The problem with a bag of loose tasks
By now you can do a lot. Guide 1 gave you a thread pool and a task queue; guide 2 made the pool sharp with work stealing; guide 3 handed you futures and promises and async/await; guide 4 explained what an await actually suspends. So you write `spawn(work)` and the runtime takes it from there. The trouble is what "from there" means: a spawned task is a free-floating thing with no parent, no scope, no one obliged to wait for it or to stop it. It is the concurrent twin of `malloc()` without a matching `free()` — easy to start, nobody clearly responsible for the end.
Watch what goes wrong. A request handler spawns three tasks to fetch from three services, then returns after collecting two — and forgets the third, which keeps running, holding a connection, logging into the void, a tiny leaked task. Or one fetch fails: you want to abandon the other two, but you have no handle to them, so they grind on uselessly. Or the handler itself is cancelled by a timeout, yet its children sail on, orphaned, because nobody propagated the cancel downward. Each of these is the same shape of bug — a task whose lifetime escaped the logic that created it. Unstructured concurrency makes this the default.
Structured concurrency: tasks that nest like function calls
Structured concurrency is one rule with enormous consequences: a task may not outlive the lexical block that started it. You open a scope (a "nursery" in Smith's coinage, a `TaskGroup` in Swift, a `Scope` in Kotlin), you spawn child tasks into it, and the block does not return until every child has finished. The children's lifetimes are strictly nested inside the parent's, exactly the way a callee's stack frame is nested inside its caller's. That nesting is the whole idea — it turns a flat bag of tasks back into a tree with clear edges.
async with open_nursery() as n: // open a scope
n.start_soon(fetch, url_a) // spawn child 1
n.start_soon(fetch, url_b) // spawn child 2
n.start_soon(fetch, url_c) // spawn child 3
// <-- the 'with' block does NOT exit here until ALL three finish.
// If any child raises, the others are cancelled, and the
// error propagates out of the block like a normal exception.
// No child can leak past this line. Ever.Three guarantees fall out of that single rule, and they are precisely the three bugs from the last section, fixed. First, no leaks: the scope cannot close while a child runs, so a forgotten task is impossible — the block would simply hang waiting, which surfaces the mistake instead of hiding it. Second, error propagation that works: if a child fails, the scope cancels its siblings and re-raises the error to the parent, so a failure can no longer vanish into a detached task. Third, scoped cancellation flows the right way: cancel the parent and the cancel propagates down the tree to every descendant, the same way an exception unwinds down the call stack. Concurrency finally composes the way ordinary code does.
Actors and channels: share by communicating
Structuring lifetimes is half the battle; the other half is structuring how tasks talk. The whole synchronization rung was about the dangers of shared mutable state — two threads touching one variable, the race conditions and mutexes and deadlocks that follow. The actor model sidesteps that class of bug by a single change of stance: don't share the state at all. An actor is a task that owns a private chunk of state that no other task can reach, plus a mailbox — an incoming queue of messages. The actor loops forever: take the next message, update its own state, maybe send messages to other actors, repeat. Because the state is touched by exactly one task, there is no data race to guard against and no lock to take.
The conduit between tasks is a channel: a typed, thread-safe queue with a sending end and a receiving end. The slogan, from Go's designers borrowing from Hoare's CSP, is "do not communicate by sharing memory; share memory by communicating." Concretely, instead of two tasks locking a shared counter, one task owns the counter and others send it increment messages down a channel; the value is moved, not aliased. In Rust this is enforced by the type system — a value sent down a channel is moved, so the sender provably cannot touch it afterward, which is why a channel of owned values has no data race by construction. A `select` lets one task wait on several channels at once and act on whichever is ready first.
Be honest, though: message passing trades one set of problems for another — it does not delete the hard parts of concurrency, it relocates them. You no longer fight data races, but you can still build a deadlock out of actors waiting on each other's messages in a cycle, and bounded mailboxes raise backpressure: if a producer sends faster than a consumer drains, the channel fills, and now you must choose to block the sender, drop messages, or grow memory without bound. None of those is free. Actors make a large family of bugs structurally impossible and make the survivors easier to localize to one mailbox — but "easier to reason about" is the honest claim, not "bug-free."
Parallel patterns: fork-join and the pipeline
With structure and channels in hand, two shapes do most of the real work. The first is fork-join, which is data parallelism in its purest form: take a job, split it into independent sub-jobs (fork), run them in parallel, and wait for all of them before combining the results (join). Notice this is structured concurrency under another name — a nursery whose children all finish before the block returns is exactly a join. It is the natural pattern for divide-and-conquer: sorting half an array on each of two tasks, summing slices of a vector, mapping a function over a million elements. The work-stealing pool from guide 2 was built precisely to make fork-join cheap, since forked sub-jobs are the steal-able tasks.
The second shape is the pipeline, which is task parallelism over a stream: a sequence of stages, each its own task, connected by channels, so stage 1's output is stage 2's input and so on. Think of a Unix shell pipeline `cat f | grep x | sort` — the same idea, now inside one program. A stream of items flows through, and all stages run at once on different items, like a factory line where every station is busy on a different car. This is where backpressure earns its keep: a bounded channel between stages means a slow stage automatically throttles the fast stage feeding it, so the pipeline self-regulates instead of one stage drowning the next in unbounded queued work.
- Is the work one big job over a collection you can split into independent pieces? Reach for fork-join — split, run in parallel, join, combine.
- Is the work a continuous stream of items each needing the same sequence of transforms? Reach for a pipeline of channel-connected stages.
- Wrap whichever you choose in a scope so cancellation and errors propagate, and bound every channel so a slow stage applies backpressure instead of leaking memory.
Putting the rung together
Step back and see the whole rung as one stack of decisions. At the bottom sits a thread pool driven by a work-stealing scheduler — the engine that turns ready tasks into CPU time. On top of it, futures and async/await let you write code that suspends and resumes without blocking a thread, and coroutines are the machinery that makes that suspension cheap. This guide added the missing organizing layer: structured concurrency gives those tasks lifetimes that nest, and actors with channels give them a disciplined way to communicate. Each layer assumes the one beneath it; none replaces honest care about ordering and shared state.
And keep one honest caveat front of mind: none of this makes a program faster on its own. Structure, actors, and patterns make concurrency manageable and correct, but concurrency is not parallelism, and a pipeline whose slowest stage is the whole story buys you nothing — Amdahl's law from the synchronization rung still rules. The point of this rung is not raw speed; it is letting you express a concurrent program whose lifetimes, failures, and cancellations you can actually reason about. A correct slow program you understand beats a fast one whose tasks leak in ways you cannot see. That is the discipline this whole ladder has been building toward.