Coroutine

On this page 6

A coroutine is a function that can suspend its execution at defined points and later resume from the same place with its local state intact. In concurrency, coroutines let one thread interleave many tasks cooperatively, which makes them the foundation of async/await in Python, JavaScript, C#, Rust and Kotlin, and of goroutine-style runtimes.

Where an ordinary subroutine runs from entry to return in one go, a coroutine has multiple entry and exit points. It hands control back voluntarily, usually while waiting for input or output, so the runtime can run something else in the meantime without the cost of an operating system thread switch.

How a coroutine suspends and resumes

The essential operation is the suspension point, written as await, yield or a call to a suspending function. At that point the coroutine saves where it stopped and its live variables, then returns control to a caller or to a scheduler, often an event loop. When the awaited result is ready, the scheduler resumes the coroutine at the saved point.

Because a coroutine gives up control only at these explicit points, scheduling is cooperative rather than preemptive. Code between two suspension points runs without interruption from other coroutines on the same thread, which removes many race conditions but also means a long computation blocks every other task on that thread.

Implementations differ in how they store the suspended state, as the following table shows.

ModelHow state is keptCan suspend from nested callsExamples
StacklessCompiler-generated state machine objectOnly through awaited callsC# async, Rust futures, C++20 coroutines, Python async def
StackfulSeparate small stack per coroutineYes, from any depthLua coroutines, Go goroutines, Java virtual threads
Generator-basedFrame object kept by the runtimeOnly at the yield in the generatorPython generators, JavaScript generators
Main coroutine implementation models

Stackless coroutines are very cheap, often a few hundred bytes each, but every function on the path must be marked async, which spreads the keyword through the codebase. Stackful coroutines avoid this so-called function coloring problem at the cost of more memory per task.

Coroutines solve a similar problem to threads, running many activities at once, but with a different cost and control model. A thread is scheduled by the operating system and can be interrupted at any instruction, while a coroutine is scheduled by a language runtime or library and switches only where the code allows it.

UnitSchedulingTypical memory per unitSwitch cost
OS threadPreemptive, by the kernelStack of about 1 to 8 MB reservedKernel context switch, microseconds
Green threadPreemptive or cooperative, by a runtimeSmall growable stackUser-space switch
Stackless coroutineCooperative, at await pointsHundreds of bytes to a few KBFunction return and call, nanoseconds
ActorRuntime processes one message at a timeMailbox plus stateMessage dispatch
Coroutines versus neighboring concurrency units

Coroutines provide concurrency, not parallelism. On a single thread they interleave tasks but never run two at the same instant. Runtimes such as Kotlin dispatchers, Tokio in Rust or the Go scheduler distribute coroutines over a thread pool to add parallelism, which brings back the need for thread safety where coroutines share data.

Where coroutines fit best

Coroutines excel at I/O-bound workloads in which tasks spend most of their time waiting: network servers, API clients, database calls, chat systems and crawlers. A single process can keep tens of thousands of connections open with modest memory, because each waiting connection costs a small coroutine object instead of a full thread.

WorkloadGood fitReason
Many concurrent network requestsCoroutines on an event loopWaiting dominates, switches are cheap
CPU-heavy computationThreads or processesNeeds parallel cores, not interleaving
UI event handlingCoroutines on the main threadKeeps the interface responsive while awaiting
Blocking legacy libraryThread pool offloadBlocking calls would freeze the event loop
Lazy sequences and pipelinesGenerator coroutinesProduce values on demand
Choosing coroutines or another approach

Modern libraries add structured concurrency, in which child coroutines live inside a scope that waits for them and cancels them together. Kotlin coroutine scopes, Python task groups and Swift task groups all follow this idea, which prevents orphaned tasks and makes error propagation predictable.

Pitfalls and common mistakes

Most coroutine bugs come from forgetting that the scheduler cannot interrupt a running coroutine. The table below lists frequent symptoms and their causes.

SymptomCauseFix
Whole service stallsBlocking call inside a coroutineUse async libraries or offload to a thread
Task silently never runsCoroutine created but not awaitedAwait it or schedule it as a task
Lost exceptionsFire-and-forget tasks without a parentUse structured concurrency scopes
Inconsistent shared stateMutation across an await pointUse locks or keep state local
Deadlock on resumeWaiting synchronously for async work on the same loopAwait instead of blocking
Typical coroutine problems

Another misconception is that code without threads is automatically free of races. Between two await points another coroutine can change shared data, so a check followed by an await and then an update is still a check-then-act race.

The idea in brief

A coroutine is a task that can pause itself and continue later. Think of a cook with several pots on the stove. While one pot heats up, the cook chops vegetables for another dish. Nothing is lost when the cook switches. Each dish continues exactly where it stopped.

One cook can handle many dishes this way because most of the time is spent waiting. Pausing and switching is quick and cheap. It only fails if the cook gets stuck on one long task and never looks at the other pots.

A practical scenario

A price comparison service must query 300 supplier APIs for each search, and each API takes between 100 and 800 milliseconds to answer. Calling them one after another would take minutes, and starting 300 operating system threads per search would waste memory under load.

The developers write the fetcher as async coroutines on an event loop, start all 300 requests inside a task group with a two-second timeout, and limit simultaneous connections per supplier with a semaphore. A search now completes in roughly the time of the slowest single response, and one server process handles hundreds of searches at once with a few hundred megabytes of memory.