Coroutines in C: A Generator-Consumer Example
09/02/2024
Asynchronous programming is a method that allows a program to perform tasks in parallel, improving efficiency and responsiveness. It's especially useful in I/O-bound operations, network requests, or any operation where waiting is involved. Coroutines, a concept in computer science, play a crucial role in implementing asynchronous programming by allowing functions to pause and resume their execution, facilitating non-blocking operations.
In this article, we'll delve into the concept of coroutines and demonstrate their usage through a generator-consumer example written in C. We'll also introduce a dump_stack function to inspect the stack state, enhancing our understanding of coroutine behavior.
The Coroutine Example: Generator and Consumer
Let's start with a coroutine example that implements a generator and a consumer. This example illustrates how coroutines can communicate and switch execution contexts to perform asynchronous operations.
#include <stdio.h>
#include <stdlib.h>
#include <threads.h>
#include <ucontext.h>
struct context_t;
typedef void (*coroutine_t)(struct context_t *);
typedef struct context_t {
ucontext_t loop_ctx;
ucontext_t co_ctx;
void *value;
} context_t;
#define CO_LOOP(ctx, ...) \
getcontext(&(ctx->loop_ctx)); \
__VA_ARGS__ \
setcontext(&(ctx->loop_ctx));
void co_yield(context_t *ctx, void *value) {
ctx->value = value;
swapcontext(&(ctx->co_ctx), ctx->co_ctx.uc_link);
}
void *co_await(context_t *ctx) {
while (ctx->value == NULL) {
swapcontext(&(ctx->co_ctx), ctx->co_ctx.uc_link);
}
void *value = ctx->value;
ctx->value = NULL;
return value;
}
context_t *make_coroutine(ucontext_t *back, const size_t stack_size, coroutine_t fn) {
char *stack = malloc(stack_size);
context_t *ctx = malloc(sizeof(context_t));
ctx->loop_ctx = *(ucontext_t *)malloc(sizeof(ucontext_t));
ctx->co_ctx = *(ucontext_t *)malloc(sizeof(ucontext_t));
ctx->value = NULL;
getcontext(&(ctx->co_ctx));
ctx->co_ctx.uc_stack.ss_sp = stack;
ctx->co_ctx.uc_stack.ss_size = stack_size;
ctx->co_ctx.uc_link = back;
makecontext(&(ctx->co_ctx), (void (*)())fn, 1, ctx);
return ctx;
}
void generator(context_t *ctx) {
int i = 0;
CO_LOOP(ctx, {
if (i >= 10) {
puts("generator is done");
co_yield(ctx, NULL);
return;
}
printf("generated #%d \r\n", ++i);
co_yield(ctx, &i);
});
}
void consumer(context_t *ctx) {
CO_LOOP(ctx, {
int *i_ptr = (int *)co_await(ctx);
if (i_ptr) {
printf("consumed #%d \r\n", *i_ptr);
}
});
puts("finish consumer");
}
This code defines two coroutines: a generator and a consumer. The generator produces numbers from 1 to 10, yielding control back to the main context after each number is generated. The consumer awaits the values generated by the generator and consumes them. This interaction showcases the asynchronous nature of coroutines, as the generator and consumer work cooperatively, pausing and resuming their executions.
Inspecting the Stack with dump_stack
To further our understanding, let's introduce a function, dump_stack, designed to inspect and dump the stack's current state. This function is invaluable for debugging and understanding the internal workings of coroutines.
void dump_stack(void *stack_ptr, int size, int amount) {
int bytes_to_dump = (amount > size) ? size : amount;
unsigned char *start_ptr = ((unsigned char *)stack_ptr) + size - bytes_to_dump;
printf("stack dump:\n");
for (int i = 0; i < bytes_to_dump; i++) {
printf("%02x ", start_ptr[i]);
if ((i + 1) % 16 == 0) {
printf("\n");
}
}
printf("\n");
}
The dump_stack function takes a pointer to the stack, the total size of the stack, and the amount of the stack to dump. It prints the specified portion of the stack in a hexadecimal format, facilitating a clear view into the stack's contents during coroutine execution.
Final Program Execution and Output
When the program is executed, the generator and consumer coroutines interact as expected, with the dump_stack function providing insights into the stack state at various points:
generated #1
consumed #2
stack dump:
00 00 00 00 00 00 00 00 01 00 00 00 00 00 00 00
generated #2
consumed #2
stack dump:
00 00 00 00 00 00 00 00 02 00 00 00 00 00 00 00
generated #3
consumed #3
stack dump:
00 00 00 00 00 00 00 00 03 00 00 00 00 00 00 00
generated #4
consumed #4
stack dump:
00 00 00 00 00 00 00 00 04 00 00 00 00 00 00 00
generated #5
consumed #5
stack dump:
00 00 00 00 00 00 00 00 05 00 00 00 00 00 00 00
generated #6
consumed #6
stack dump:
00 00 00 00 00 00 00 00 06 00 00 00 00 00 00 00
generated #7
consumed #7
stack dump:
00 00 00 00 00 00 00 00 07 00 00 00 00 00 00 00
generated #8
consumed #8
stack dump:
00 00 00 00 00 00 00 00 08 00 00 00 00 00 00 00
generated #9
consumed #9
stack dump:
00 00 00 00 00 00 00 00 09 00 00 00 00 00 00 00
generated #10
consumed #10
stack dump:
00 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00
generator is done
we are done here
The output shows the generator producing values, the consumer consuming them, and the program completing execution. The stack dump output, though omitted for brevity, reveals the stack's state, illustrating how coroutine switching affects the stack.
Conclusion
Coroutines offer a powerful model for asynchronous programming, enabling functions to pause and resume execution, facilitating cooperative multitasking. Through this example, we've explored the basics of coroutine implementation in C, highlighting their potential in creating efficient, non-blocking applications. The dump_stack function further aids in understanding the underlying mechanics, making it an invaluable tool for debugging and learning.