4. ucontext 系列函数:getcontext, setcontext, makecontext, swapcontext 详解
好,咱们今天来啃一块硬骨头——ucontext 系列函数。说实话,这组 API 在 Linux 系统里存在了二十多年,用的人却不多。为什么?因为它的接口设计确实有点「反人类」。但恰恰是这套函数,构成了 C 语言协程实现的基础设施。我当年第一次接触它时,也被那四个函数的调用关系绕晕过。
先给你一个全景图。ucontext 系列一共四个函数:getcontext、setcontext、makecontext、swapcontext。它们操作的对象是 ucontext_t 结构体。说白了,这组函数让你能手动保存和恢复 CPU 的执行上下文——寄存器、栈指针、程序计数器等等。这不就是协程切换的核心吗?
核心概念:ucontext 本质上是一个「用户态线程控制块」。它保存了 CPU 寄存器的完整快照,让你可以在用户态自由跳转到任意一个保存过的执行点。
4.1 ucontext_t 结构体:协程的「身份证」
先看看这个结构体长什么样。嗯,不同系统略有差异,但核心字段差不多:
typedef struct ucontext_t {
struct ucontext_t *uc_link; // 指向下一个上下文(协程退出后自动切换到这里)
sigset_t uc_sigmask; // 信号屏蔽集
stack_t uc_stack; // 协程使用的栈空间
mcontext_t uc_mcontext; // 机器相关的寄存器上下文(核心!)
} ucontext_t;
这里我要强调一下 uc_link 字段。我在项目中见过不少新手在这上面栽跟头——如果你创建了一个协程,但没有设置 uc_link,那这个协程执行完毕后就会直接返回,程序可能崩溃。我曾经调试过一个诡异的段错误,查了两天才发现是 uc_link 没设置,协程执行完跳到了随机地址。
个人习惯:我每次创建协程时,都会把 uc_link 指向主协程的上下文。这样任何子协程执行完毕,都能安全回到主调度器。
4.2 getcontext:拍一张「快照」
getcontext 的作用很简单——把当前 CPU 寄存器的值全部保存到 ucontext_t 结构体中。你可以把它理解成给程序的执行状态拍了一张照片。
#include <ucontext.h>
ucontext_t ctx;
if (getcontext(&ctx) == -1) {
perror("getcontext");
exit(EXIT_FAILURE);
}
// 此时 ctx 保存了当前执行点的完整上下文
调用 getcontext 后,ctx 里就包含了当前栈指针、程序计数器、通用寄存器等所有信息。你想想看,这意味着什么?意味着你可以在任意时刻用 setcontext 跳回到这个保存点。
注意:getcontext 成功时返回 0。但如果后续通过 setcontext 或 swapcontext 跳转回来,它会返回一个非零值(通常是 1)。这个特性可以用来区分「首次执行」和「被恢复执行」——很多协程库利用这个机制做初始化。
4.3 setcontext:跳转到「任意时刻」
setcontext 是这四个函数里最「暴力」的一个。它直接恢复一个 ucontext_t 中保存的上下文,然后 CPU 就会跳转到那个位置继续执行。注意,setcontext 不会返回——因为它已经改变了执行流。
ucontext_t ctx;
getcontext(&ctx); // 保存当前点
// ... 做一些事情 ...
setcontext(&ctx); // 跳回之前保存的点
// 程序会从 getcontext 之后继续执行
你可能会问:这有什么用?嗯,最简单的用法就是实现「跳转」。但实际项目中很少单独用 setcontext,因为它太粗暴了——跳过去就回不来了。真正有用的是 swapcontext。
4.4 makecontext:创建「新协程」
这是最核心的函数。makecontext 让你能创建一个全新的执行上下文——说白了就是创建一个新协程。你需要给它分配栈空间,指定入口函数。
#define STACK_SIZE 8192
ucontext_t ctx;
char stack[STACK_SIZE];
// 1. 先获取当前上下文作为模板
getcontext(&ctx);
// 2. 设置新栈
ctx.uc_stack.ss_sp = stack;
ctx.uc_stack.ss_size = STACK_SIZE;
ctx.uc_link = NULL; // 注意:这里应该指向主上下文
// 3. 创建新协程,入口函数是 my_coroutine
makecontext(&ctx, (void (*)(void))my_coroutine, 1, 42);
这里有个细节我要提醒你。makecontext 的参数传递方式很特殊——它用的是可变参数,但参数类型必须是 int。为什么?因为底层实现里,这些参数是通过寄存器传递的,而寄存器只能放整数。如果你想传指针,得用 uintptr_t 做类型转换。
避坑指南:我曾经在 64 位系统上直接传了一个 char* 指针给 makecontext,结果高 32 位被截断了。正确的做法是:makecontext(&ctx, func, 1, (uintptr_t)ptr)。
4.5 swapcontext:协程切换的「原子操作」
swapcontext 是协程调度的心脏。它同时做两件事:保存当前上下文,恢复另一个上下文。相当于 getcontext + setcontext 的组合,但它是原子操作——中间不会被中断。
ucontext_t main_ctx, coro_ctx;
// 创建协程(省略栈分配等步骤)
makecontext(&coro_ctx, my_coroutine, 0);
// 从主协程切换到 coro_ctx
swapcontext(&main_ctx, &coro_ctx);
// 当协程 yield 回来时,程序会从这里继续执行
你想想看,这个函数就是协程切换的「开关」。调用一次,CPU 就跳到了另一个协程里执行。等那个协程调用 swapcontext 切回来时,又回到了这里。
4.6 完整示例:两个协程交替执行
光说不练假把式。咱们写一个完整的例子,看看这四个函数怎么配合工作:
#include <stdio.h>
#include <ucontext.h>
#include <stdlib.h>
ucontext_t main_ctx, coro_ctx;
char coro_stack[8192];
void my_coroutine(void) {
printf("协程:第一次执行\n");
swapcontext(&coro_ctx, &main_ctx); // 切回主协程
printf("协程:第二次执行\n");
swapcontext(&coro_ctx, &main_ctx); // 再切回去
printf("协程:执行完毕\n");
// 没有显式切换,通过 uc_link 自动返回
}
int main(void) {
// 创建协程
getcontext(&coro_ctx);
coro_ctx.uc_stack.ss_sp = coro_stack;
coro_ctx.uc_stack.ss_size = sizeof(coro_stack);
coro_ctx.uc_link = &main_ctx; // 协程结束后回到 main_ctx
makecontext(&coro_ctx, my_coroutine, 0);
printf("主协程:第一次切换\n");
swapcontext(&main_ctx, &coro_ctx);
printf("主协程:第二次切换\n");
swapcontext(&main_ctx, &coro_ctx);
printf("主协程:结束\n");
return 0;
}
运行结果:
主协程:第一次切换
协程:第一次执行
主协程:第二次切换
协程:第二次执行
主协程:结束
协程:执行完毕
注意最后一行——协程执行完 printf 后,没有显式调用 swapcontext,而是通过 uc_link 自动回到了 main_ctx。但此时 main 函数已经执行完了,所以程序正常退出。
4.7 核心流程图
下面这张图展示了四个函数之间的关系,以及协程切换的完整流程:
4.8 常见陷阱与最佳实践
最后,我总结几个实际开发中容易踩的坑:
- 栈空间不足:默认 8KB 栈可能不够用。如果协程里调用了深度递归或大数组,记得加大栈空间。我一般用 64KB 起步。
- 信号安全:
swapcontext不是信号安全的。如果在信号处理函数里调用它,行为未定义。我曾经因为这个 bug 排查了整整一个下午。 - 线程安全:ucontext 系列函数不是线程安全的。多线程环境下使用需要自己加锁,或者干脆用
makecontext的线程安全变体(如果有的话)。 - uc_link 必须设置:再次强调,创建协程时一定要设置
uc_link,否则协程执行完毕会跳转到随机地址,导致段错误。
我的建议:在实际项目中,不要直接裸用 ucontext。把它封装成协程调度库,提供 coro_create、coro_yield、coro_resume 等接口。这样既安全又易用。我们后面的章节就会一步步实现这样一个库。
好了,ucontext 系列函数就讲到这里。这四个函数虽然看起来简单,但组合起来能实现非常强大的协程调度机制。下一节我们会用它们实现一个完整的协程调度器——到时候你就知道这些基础 API 的威力了。