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 by setjmp, 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.