Designtarget

Choosing a Concurrency Model

Threads and locks, async/await, actors, CSP channels and data parallelism are five language design decisions, and each one hands the compiler a different job: a memory model to obey, a state machine to build, an isolation rule to check, a scheduler to emit calls into, or a loop to prove independent.

The question

Which concurrency model should the language have, and what does each one force the compiler to do?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

The definition's answer to what may be shared, what may run at once, and what synchronisation is available. That answer becomes two compiler artifacts: a memory model that constrains which reorderings the optimizer may perform, and a lowering for whatever concurrency construct the surface syntax provides — a state machine, a closure handed to a scheduler, or a message send.

What this phase may assume or do

The memory model is the precondition on every optimization in a language with shared state. A transformation is legal only if it preserves the orderings the model guarantees: an ordinary load may be hoisted out of a loop, an acquire load may not be moved after a subsequent access, and no access may be introduced where the program had none, because an invented write to a location another thread reads creates a data race the source did not have. For async lowering the precondition is different: the state machine must preserve the observable order of effects and must keep every value live across a suspension point in the state object rather than on the stack, because the stack frame does not survive the suspension.

Key points

  • Permitting shared mutable state across threads obliges the language to define a memory model, and that model is a constraint on the optimizer rather than a runtime feature.
  • The prohibition on introducing writes that the source did not perform is a real, costly optimizer restriction that concurrency imposed on all code.
  • Async/await is a compiler transformation: a state machine per function, with everything live across a suspension moved off the stack into a heap object.
  • Actors and CSP move the sharing problem into the type system or the runtime; Erlang escapes the memory model entirely by forbidding sharing, Go does not because it permits it.
  • Data parallelism is a legality problem — no loop-carried dependence, no aliasing, and permission to reassociate — and the design question is who supplies the evidence.

Five models, five compiler jobs

The models are usually compared on programmer experience. Compared on what they oblige the compiler to do, they separate much more sharply, and the obligations explain several things about the languages that adopted them.

The model, what the language must define, and what the compiler must build
ModelThe definition must fixThe compiler must produceWhere it shows up in the output
Threads and shared memorytargetA memory model: which reorderings are visible, what atomics mean, what a data race isAn optimizer that respects the model, and atomic instructions or fences per targetFences and load-acquire/store-release forms around synchronised accesses, and optimizations that decline to fire near them
Async/awaitWhere suspension points are, and what is guaranteed about resumptionA state machine per async function, with every value live across a suspension moved into a heap-allocated state object — [[async-lowering]]One struct per async function, a generated resume function, and a switch on a state field
ActorsWhat isolation means: which values may cross an actor boundary and what happens if they doA checker that rejects programs sharing mutable state across actors, and a lowering of message sends to queue operationsCompile-time errors on non-sendable values, plus send/receive calls into a runtime
CSP channels and lightweight tasksimplementationThe scheduling guarantees: preemption, blocking semantics, what happens when a task blocks in native codeCalls into a scheduler at spawn, send, receive and every preemption point, plus growable or segmented stacksA runtime call at every channel operation, and stack-growth checks in function prologues
Data parallelismtargetWhether iterations may be assumed independent, and what floating-point reassociation is permittedA vectorizer, an independence proof or a programmer assertion, and a decision about reduction order — [[compiler-vectorization]]Vector instructions, or a missed-optimization remark explaining why not

Shared memory buys you a memory model whether you wanted one or not

targetWhich fences are needed for a given ordering is a target property. x86-64 has a strong model where ordinary loads and stores already provide acquire and release semantics, so a release store compiles to a plain mov; AArch64 has a weaker model and needs stlr or an explicit barrier for the same guarantee. The language-level guarantee is identical and the instructions are not, which is the entire reason the model is expressed abstractly.

If two threads may access the same location and at least one writes, the language must say what the second thread can observe. There is no way to leave this open: the hardware will answer it, differently on x86 and on AArch64, and the optimizer will answer it again by reordering. A language that does not define the answer has defined it as "whatever happens", which is unusable.

The consequence for this domain is that the memory model is a constraint on the optimizer, not a runtime feature. It is what forbids hoisting a load out of a loop when another thread may write to it, what forbids introducing a speculative write to a location the program never wrote, and what makes an acquire load a barrier that certain code motion may not cross. Those prohibitions are stated in the model and enforced in every pass — see [[semantics-drive-optimization]].

The invented-write prohibition is the one most worth knowing, because it is a real optimization that compilers used to perform and now may not. Speculatively storing to a variable inside a conditional — writing it unconditionally and then correcting — is legal in a single-threaded program and creates a data race in a concurrent one. C++11 and Java both had to forbid it explicitly, and that prohibition costs real optimization opportunities in code that has nothing to do with threads.

Async/await is a compiler transformation with a syntax

Async/await is the model whose implementation is most purely a compiler job. There is no new execution mechanism: the compiler rewrites each async function into a state machine, so that a function which suspends and resumes becomes an object holding its own local state plus a resume function containing a jump table over suspension points.

The consequence engineers actually hit is that everything live across an await moves into that state object, which is allocated on the heap. That is why an async function holding a large buffer across a suspension has a large state object, why async recursion needs indirection, and why the size of a future is a thing Rust programmers profile. It is also why holding a lock across an await is a hazard: the lock guard is now a field in a heap object that may be resumed on a different thread. See [[coroutine-lowering]].

The design decision inside the model is where suspension may occur and whether it is visible. Explicit await makes every suspension point syntactically apparent, so the reader knows where interleaving can happen and the compiler knows where to split the state machine. Implicit suspension — green threads that yield anywhere — removes the annotation burden and removes the reader's ability to see the points, and forces the runtime rather than the compiler to do the work. Go took the second path, which is why Go has no async keyword and does have a scheduler with stack-growth checks in function prologues.

Isolation models push the work into the type system

implementationGo's goroutine scheduling has changed materially: preemption was cooperative until 1.14, so a tight loop with no function calls could not be preempted and could stall a collection. Asynchronous preemption via signals changed that. Any claim about scheduling guarantees needs a version, and the same is true of Swift's actor isolation, which became strictly enforced only in Swift 6 language mode.

Actors and CSP both answer the sharing question by restricting it, which converts a runtime problem into a checking problem — the same move ownership makes for memory, and it is not a coincidence that the languages doing one often do the other.

Erlang restricts absolutely: processes share nothing, messages are copied, and the compiler needs no aliasing analysis across processes because there is no aliasing across processes. The cost is copying, which is why the model works so well for the message sizes telecom switching involves and less well for large shared datasets. Swift's actors restrict by type: a value may cross an actor boundary only if it is Sendable, and the compiler checks it, producing compile-time errors where another language would have produced a data race.

Go's answer is the interesting middle. Channels are the recommended mechanism and shared memory is not forbidden, which means Go still needs a memory model and still has data races — and ships a race detector as a consequence. That is a coherent design choice rather than an oversight: it buys the ability to use shared memory where copying would be too expensive, and it pays by keeping the whole memory-model obligation that Erlang escaped.

Data parallelism is a legality problem, not a scheduling one

The fifth model is the one where the compiler does the concurrency itself. Vectorizing a loop means executing several iterations at once in one instruction, and the entire difficulty is establishing that this preserves the program's meaning.

The preconditions are strict: no loop-carried dependence, no aliasing between the arrays written and the arrays read, a trip count the compiler can reason about, and — for floating-point reductions — permission to reassociate, which changes results and is therefore not granted by default. Most disappointing vectorization outcomes are one of these failing rather than the compiler being unwilling, and the missed-optimization remarks say which. See [[compiler-vectorization]] and [[alias-analysis]].

The language design decision is how much the programmer may assert. C has restrict, C++ has #pragma omp simd, Rust has iterator abstractions whose structure makes the independence apparent, and several array languages make the parallelism part of the semantics so no proof is needed at all. Each is a different answer to "who supplies the evidence", and the last one is the only answer that does not depend on an analysis succeeding.

How it works

The steps, in the order the compiler takes them.

  • The definition fixes what may be shared and what synchronisation exists, which determines whether a memory model is required.
  • The memory model is encoded in the IR as ordering attributes on atomic operations, and every optimization pass must respect them or decline to fire.
  • Async functions are rewritten into a state object plus a resume function with a jump table, with liveness across suspension points deciding what is stored.
  • Isolation models add a checking pass over the type system — sendability, ownership transfer on send — and lower message operations to runtime calls.
  • Lightweight-task models emit scheduler calls at spawn and at blocking operations, plus stack-growth or preemption checks the programmer never wrote.
  • Vectorization runs a dependence analysis over the loop, then either transforms it or emits a remark naming the precondition that failed.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • A program is correct on x86-64 and produces impossible values on AArch64, because it relied on ordering the hardware happened to provide and the language never guaranteed.
  • An async function holds a mutex guard across an await, and the program deadlocks or corrupts state when the continuation resumes on a different thread than the one that acquired it.
  • Memory usage in an async service is dominated by future sizes rather than by data, because large buffers are live across suspension points and therefore live in heap-allocated state objects.
  • A goroutine or task in a tight computational loop prevents the runtime from preempting it, and unrelated latency across the whole process degrades.
  • A loop that looks trivially parallel is not vectorized, and the missed-optimization remark says the compiler could not prove two pointers do not alias.
  • A data race is found in production by a race detector months after the code shipped, because the model permitted sharing and nothing checked this instance of it.

When it helps

  • Deciding a concurrency model for a new language, where the memory-model obligation is the single largest hidden cost and is worth pricing explicitly.
  • Explaining an async performance or correctness surprise, nearly all of which follow from the state-machine lowering rather than from the scheduler.
  • Reading a language's restrictions as design rather than as arbitrariness: Sendable, Send, Sync and copying semantics are all the same decision made in different places.

When it hurts

  • Choosing a model on aesthetics. Every one of the five is in production in a major language, and the differences that matter are the obligations they create, not the syntax they present.
  • Assuming an isolation model removes concurrency bugs. It removes data races. Deadlock, livelock, message-ordering assumptions and unbounded queues survive all five models intact.

What it costs

Every one of these is paid by something.

  • Shared memory buys the ability to work on large data without copying, and costs a memory model — the hardest document a language committee writes — plus a permanent restriction on the optimizer for all code, concurrent or not.
  • Async/await buys explicit, readable suspension points and no thread per task, and costs a state-machine transformation in the compiler, heap-allocated state objects sized by what is live across suspensions, and a function-colouring split that divides the library ecosystem in two.
  • Actor isolation buys compile-time elimination of data races, and costs copying or a sendability discipline that rejects programs, plus an interoperability problem at every boundary with code that does not have it.
  • Lightweight tasks with a scheduler buy an ordinary blocking programming model with cheap concurrency, and cost a runtime in every binary, preemption machinery, stack-growth checks in prologues, and a much harder story for calling into native code that blocks.
  • Compiler-driven data parallelism buys speedup with no source change, and costs an analysis that fails silently and often, plus results that can differ from the scalar version when reassociation was permitted.

What else you could do

What a different compiler or language does instead, and when that is better.

  • Forbid sharing entirely and copy every message, which is Erlang's answer and removes the memory model obligation completely at the cost of copying and of a per-process heap.
  • Structured concurrency: constrain task lifetimes to lexical scopes so that cancellation and error propagation are compositional. It is orthogonal to the five models and improves all of them.
  • Provide no concurrency in the language at all and expose the platform's, which is what C did until C11 and what several DSLs do deliberately.
  • Make parallelism part of the semantics rather than an optimization, as array and dataflow languages do, so no independence proof is required because independence is what the construct means.

See it for yourself

The flag, dump or tool that shows you this directly.

  • What async lowering produced: rustc -Z unpretty=mir on nightly for the state machine, or in C# use a decompiler on the generated class to see the switch on the state field directly.
  • Future sizes: std::mem::size_of_val on a future in Rust, which is frequently much larger than expected and is exactly the liveness-across-suspension cost.
  • What ordering the target actually needed: compile an atomic release store for x86-64 and for AArch64 in Compiler Explorer and compare — one is a mov, the other is not.
  • Why a loop did not vectorize: clang -O2 -Rpass-analysis=loop-vectorize names the failing precondition, and it is almost always aliasing or a dependence.
  • Whether races exist in a language that permits them: go test -race, or ThreadSanitizer via -fsanitize=thread. Both find real races that testing does not.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "Async makes code concurrent." Async makes suspension explicit and lets one thread interleave many tasks. Whether anything runs in parallel is a scheduler question, and in a single-threaded executor the answer is no.
  • "Message passing eliminates concurrency bugs." It eliminates data races. Deadlock, ordering assumptions and queue growth are untouched, and the last one is how message-passing systems usually fail.
  • "The memory model only matters if I write lock-free code." It decides what the optimizer may do to ordinary code near synchronisation, which is why an optimizing compiler had to stop performing transformations that were legal before threads existed.
  • "The compiler will vectorize it." It may, if it can prove independence and non-aliasing and the trip count works out. The remark flags will tell you which of those failed, and usually one has.

Misconceptions

The claim, and what is actually true.

The concurrency model is a library concern.
Sharing rules constrain the optimizer, async needs a compiler transformation, and isolation needs a type-system check. Only the thread-pool part is a library.
A memory model is only needed for lock-free programming.
It is needed the moment two threads can touch the same location. Its main practical effect is on what the compiler may do to code that is not obviously concurrent at all.
Green threads and async/await are the same thing with different syntax.
Green threads put the work in the runtime — a scheduler, growable stacks, preemption. Async/await puts it in the compiler — a state machine per function. The observable differences are stack traces, function colouring and where the memory goes.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

Five models: threads sharing memory, async functions that suspend and resume, actors that own their state, tasks talking over channels, and loops the compiler runs in parallel by itself. Each hands a different job to the compiler — obey a memory model, build a state machine, check an isolation rule, call a scheduler, or prove a loop is independent.

practical

In an async codebase, treat the state machine as the mental model: what is live across an await lives on the heap, and a lock guard held across one will be resumed on a thread that did not acquire it. In a shared-memory codebase, run the race detector in CI rather than hoping — it finds real races that no test reproduces. And when a loop does not vectorize, ask the compiler why with the remark flags before restructuring anything.

advanced

The models line up on a single axis: where the sharing rule is enforced. Threads and locks enforce it nowhere, so the compiler must be conservative everywhere and the programmer must be right everywhere. Actors and ownership enforce it in the type system, so the compiler can be aggressive and the programmer is rejected rather than raced. Erlang enforces it structurally by making sharing impossible, which is the strongest and the most expensive. What is striking is how closely this parallels the memory-management axis in [[memory-management-choice]]: both are questions about aliasing, both can be answered by a runtime, a type system or a structural prohibition, and languages that answer one with a type system almost always answer the other the same way — because it is the same analysis, applied to the same information, for two different purposes.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

targetWhich instructions implement a given memory ordering is entirely a target property. x86-64's strong model makes acquire loads and release stores ordinary instructions; AArch64 and RISC-V need explicit acquire/release forms or barriers. A program that appears correct on one is not evidence about the other, which is the single most common way memory-model bugs escape testing.
implementationGo's preemption, Swift's actor isolation enforcement and Rust's async state-machine layout have all changed in recent versions. Go 1.14 introduced asynchronous preemption; Swift 6 made strict concurrency checking the default rather than an opt-in. Version the claim before relying on it.
typicalThe claim that vectorization commonly fails on aliasing describes ordinary pointer-based C and C++ loops. Languages whose types carry non-aliasing information — Fortran arrays, Rust slices — give the analysis much more to work with, which is a large part of why Fortran retained a reputation for numerical performance.

If you were asked this in an interview

  • Why does permitting shared mutable state force a language to define a memory model, and what does that model forbid the optimizer from doing?
  • What does the compiler do to an async function, and why does that make holding a lock across an await dangerous?
  • Erlang and Go both promote message passing. Why does one of them still need a race detector?

Connections

Computer Architecturesimdcache-lines
Performancecpu-profiling
Domains that do not exist yet
  • Programming Languages & Runtime Internals — Schedulers, work stealing, goroutine and task runtimes, and what a blocking call does to them
    This lesson stops at what the compiler must emit — state machines, scheduler calls, preemption checks. How the scheduler then behaves under load is theirs, and it is where most operational concurrency problems actually live.