Coroutines and Cooperative Multitasking in C
Coroutines are program components that generalize subroutines for cooperative multitasking, allowing execution to be suspended and resumed at specific points while preserving state. In this article, you will learn the core concepts behind coroutines, how cooperative scheduling contrasts with preemptive thread management, and practical techniques to implement stackless coroutines in the C programming language without relying on operating system threads.
Understanding Coroutines and Cooperative Multitasking
In conventional preemptive multitasking, the operating system's scheduler forcibly interrupts running threads via timer interrupts to allocate CPU time to other tasks. While this guarantees that no single task starves the CPU, it introduces overhead from context switching, kernel transitions, and the necessity of synchronization primitives like mutexes to prevent race conditions.
Coroutines, by contrast, facilitate cooperative multitasking. A coroutine executes until it explicitly yields control back to the caller or scheduler. Because context switches occur only at well-defined yield points:
- Data races are significantly reduced, often eliminating the need for locking mechanisms.
- Memory consumption drops sharply, as tasks do not require large, dedicated OS-allocated thread stacks.
- Execution speed improves due to the absence of kernel-mode context switches.
Simulating Coroutines in C
C does not natively support coroutines as first-class language constructs like C++20, Python, or Go. However, cooperative multitasking can be simulated in pure C using several distinct approaches.
1. The Switch-Case State Machine (Duff's Device Technique)
The most lightweight and portable way to simulate stackless
coroutines is by leveraging C's switch statement and Duff's
Device. By saving an instruction pointer state variable between calls, a
function can jump directly to where it previously returned.
#include <stdio.h>
#define cr_begin(state) switch(state) { case 0:
#define cr_yield(state, val) do { state = __LINE__; return val; case __LINE__:; } while (0)
#define cr_end() }
int counter_task(void) {
static int state = 0;
static int i = 0;
cr_begin(state);
for (i = 1; i <= 3; i++) {
cr_yield(state, i);
}
cr_end();
return -1; // Indicates completion
}
int main(void) {
int value;
while ((value = counter_task()) != -1) {
printf("Task yielded: %d\n", value);
}
return 0;
}In this implementation, __LINE__ generates unique
numeric states. The cr_yield macro records the line number
into state and returns execution to the caller. When the
function is called again, switch(state) jumps directly to
the matching case, resuming execution where it paused.
Local variables that must survive yields must be defined as
static or maintained in a context structure passed into the
function.
2. Manual State Structs
For complex applications, relying on static variables prevents running multiple instances of the same task. The cleaner design encapsulates the coroutine state within a dedicated structure:
typedef struct {
int state;
int current_val;
int max_val;
} TaskContext;
int process_task(TaskContext *ctx) {
switch (ctx->state) {
case 0:
for (ctx->current_val = 0; ctx->current_val < ctx->max_val; ctx->current_val++) {
ctx->state = 1;
return ctx->current_val;
case 1:; // Resumes inside the loop
}
}
return -1;
}Passing a TaskContext pointer allows an arbitrary number
of coroutine instances to be multiplexed concurrently.
3.
Standard Library Context Switching (setjmp /
longjmp)
For scenarios where stackful coroutines are required (where yielding
can occur deep within nested function calls), C provides
<setjmp.h>.
setjmp(jmp_buf env)saves the current stack environment, including registers and instruction pointer.longjmp(jmp_buf env, int val)restores the environment saved bysetjmp, transferring execution back to that point.
While setjmp and longjmp do not switch the
call stack by default, they can be combined with a round-robin scheduler
to implement non-local jumps between control points, creating
cooperative control transfer without OS-managed threads.
The Cooperative Scheduler
To run multiple coroutines, a simple non-preemptive scheduler maintains a queue of active task contexts. In an event loop, the scheduler iterates through the queue, calling each coroutine function until it yields. If a task returns a completion code, the scheduler removes it from the queue. This pattern forms the underlying architecture of embedded firmware, high-performance network poll loops, and game logic engines where predictable latency and minimal memory overhead are critical.