协程入门:用 ucontext 实现生产者和消费者

什么是协程

协程是一段可以暂停、随后继续执行的程序。普通函数通常在完成后返回;协程可以在执行到一半时让出控制权,之后从暂停的位置接着运行,局部变量和调用状态也会保留下来。

例如,生产者生成一个数据后暂停,让消费者处理;消费者处理完,再恢复生产者,生成下一个数据。两段代码交替执行,却不需要创建两个线程。

进程提供资源和地址空间,线程由操作系统调度,协程则在线程上运行,由程序或运行时安排切换。下面的例子只用一个线程,同一时刻只有一个协程在执行。

Windows 提供了类似的机制——Fiber(纤程)。可以用 ConvertThreadToFiber() 转换当前执行流,用 CreateFiber() 创建纤程,再通过 SwitchToFiber() 主动切换。Windows Fiber 文档

ucontext 的接口和结构体

ucontext 是 Unix 系统中的一组上下文操作接口,头文件为 <ucontext.h>。下面的代码以 Linux/glibc 为例;这些接口已从 POSIX.1-2008 移除,并非所有 Unix 平台都支持。

接口 作用
getcontext(&ctx) 把当前执行上下文保存到 ctx
makecontext(&ctx, func, argc, ...) 设置上下文的入口函数及参数,激活时从 func 开始执行
swapcontext(&from, &to) 保存当前上下文到 from,切换到 to;再次恢复 from 时从本次调用后继续
setcontext(&ctx) 恢复 ctx,不保存当前上下文;成功时不沿原调用路径返回

ucontext_t 的主要字段如下:

字段 作用
uc_mcontext 保存寄存器等机器状态,具体内容与平台有关
uc_stack.ss_sp 协程栈内存区域的起始地址
uc_stack.ss_size 栈内存区域的大小
uc_stack.ss_flags 栈的标志,本例设为 0
uc_link 入口函数正常返回后,要恢复的上下文
uc_sigmask 运行该上下文时使用的信号屏蔽集合

创建上下文时,先调用 getcontext(),再分配栈并设置 uc_stackuc_link,最后调用 makecontext()makecontext() 只设置入口,不会立即执行函数。getcontext 文档makecontext 文档

生产者消费者示例

这里用一个整数 buffer 存放数据。生产者依次写入 1~10,每写入一个就切换到消费者;消费者读取后切回生产者。因此,下一次写入之前,上一个数据已经被消费。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
#include <stdio.h>
#include <stdlib.h>
#include <ucontext.h>

static ucontext_t p_ctx, c_ctx, main_ctx;
static constexpr int kMaxCount = 10;
static constexpr size_t kStackSize = 64 * 1024;
static int cnt = 0;
static int buffer = 0;

void check(int result) {
if (result == -1) {
perror("ucontext");
exit(EXIT_FAILURE);
}
}

void producer() {
while (cnt < kMaxCount) {
buffer = ++cnt;
printf("[producer] produce: %d\n", buffer);
check(swapcontext(&p_ctx, &c_ctx));
}
printf("[producer] finished\n");
}

void consumer() {
for (int i = 0; i < kMaxCount; ++i) {
printf("[consumer] consume: %d\n", buffer);
check(swapcontext(&c_ctx, &p_ctx));
}
printf("[consumer] finished\n");
}

void init_context(ucontext_t& ctx, void (*func)()) {
check(getcontext(&ctx));
ctx.uc_stack.ss_sp = malloc(kStackSize);
if (ctx.uc_stack.ss_sp == nullptr) {
perror("malloc");
exit(EXIT_FAILURE);
}
ctx.uc_stack.ss_size = kStackSize;
ctx.uc_stack.ss_flags = 0;
ctx.uc_link = &main_ctx;
makecontext(&ctx, func, 0);
}

int main() {
init_context(p_ctx, producer);
init_context(c_ctx, consumer);

// 先运行生产者,之后双方直接切换。
check(swapcontext(&main_ctx, &p_ctx));

// 生产者已结束,让消费者从最后一次切换后继续,退出循环。
check(swapcontext(&main_ctx, &c_ctx));

printf("[main] finished\n");
free(p_ctx.uc_stack.ss_sp);
free(c_ctx.uc_stack.ss_sp);
return 0;
}

保存为 ucontext_demo.cpp,在 Linux/glibc 环境中编译运行:

1
2
g++ -std=c++17 -Wall -Wextra ucontext_demo.cpp -o ucontext_demo
./ucontext_demo

预期输出如下,中间几组数据省略:

1
2
3
4
5
6
7
8
9
10
[producer] produce: 1
[consumer] consume: 1
[producer] produce: 2
[consumer] consume: 2
...
[producer] produce: 10
[consumer] consume: 10
[producer] finished
[consumer] finished
[main] finished

producer-consumer

每次调用 swapcontext(),当前函数都还没有结束。消费者循环中的 i 会保留,恢复后继续递增,不会重新从 0 开始。两个协程各自使用一块栈,切换时栈内存留在原处。

第 10 个数据消费完后,消费者仍切回生产者。生产者退出循环,通过 uc_link 回到 mainmain 再恢复消费者,让它也退出循环。两边都结束后,才释放栈内存。

这个例子靠主动切换安排执行顺序。如果其中一方调用阻塞的 read()sleep(),整个线程仍会等待,另一个协程不会自动接着运行。

对称协程与非对称协程

对称协程

对称协程可以直接把控制权交给指定协程。A 可以切换到 B,B 也可以切换到 A,切换目标由当前协程决定。

上面的生产者消费者就采用这种方式:生产者执行 swapcontext(&p_ctx, &c_ctx),消费者执行 swapcontext(&c_ctx, &p_ctx)。处理数据时,双方直接交替,不需要每次回到 main

非对称协程

非对称协程通常使用 resume()yield():调用者通过 resume() 恢复协程,协程通过 yield() 把控制权交回恢复它的调用者。

例如,消费者恢复生产者,生产者生成一个数据后让出,回到消费者;下次需要数据时,消费者再恢复生产者。生产者只需要执行 yield(),不必指定消费者的上下文。两种协程模式的说明

symmetric-vs-asymmetric

常见方案

  • Boost.Coroutine2:提供有栈、非对称协程,常用于生成器和数据流。
  • libco:C/C++ 有栈协程库,结合事件循环和部分 I/O Hook,支持用同步风格组织网络处理逻辑。
  • Boost.Asio:异步 I/O 库,可以配合 C++20 的 co_await 编写网络代码。

C++20 也提供了 co_await 等语言级协程支持,C++26对写成进行了完善。