4. ucontext 系列函数:getcontext, setcontext, makecontext, swapcontext 详解

好,咱们今天来啃一块硬骨头——ucontext 系列函数。说实话,这组 API 在 Linux 系统里存在了二十多年,用的人却不多。为什么?因为它的接口设计确实有点「反人类」。但恰恰是这套函数,构成了 C 语言协程实现的基础设施。我当年第一次接触它时,也被那四个函数的调用关系绕晕过。

先给你一个全景图。ucontext 系列一共四个函数:getcontextsetcontextmakecontextswapcontext。它们操作的对象是 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。但如果后续通过 setcontextswapcontext 跳转回来,它会返回一个非零值(通常是 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 核心流程图

下面这张图展示了四个函数之间的关系,以及协程切换的完整流程:

ucontext 系列函数核心流程 主协程 (main_ctx) 保存当前执行状态 子协程 (coro_ctx) 新创建的执行上下文 getcontext() makecontext() swapcontext(&main_ctx, &coro_ctx) swapcontext(&coro_ctx, &main_ctx) uc_link 自动跳回主协程 getcontext/swapcontext swapcontext(返回) makecontext/uc_link

4.8 常见陷阱与最佳实践

最后,我总结几个实际开发中容易踩的坑:

  • 栈空间不足:默认 8KB 栈可能不够用。如果协程里调用了深度递归或大数组,记得加大栈空间。我一般用 64KB 起步。
  • 信号安全:swapcontext 不是信号安全的。如果在信号处理函数里调用它,行为未定义。我曾经因为这个 bug 排查了整整一个下午。
  • 线程安全:ucontext 系列函数不是线程安全的。多线程环境下使用需要自己加锁,或者干脆用 makecontext 的线程安全变体(如果有的话)。
  • uc_link 必须设置:再次强调,创建协程时一定要设置 uc_link,否则协程执行完毕会跳转到随机地址,导致段错误。

我的建议:在实际项目中,不要直接裸用 ucontext。把它封装成协程调度库,提供 coro_createcoro_yieldcoro_resume 等接口。这样既安全又易用。我们后面的章节就会一步步实现这样一个库。

好了,ucontext 系列函数就讲到这里。这四个函数虽然看起来简单,但组合起来能实现非常强大的协程调度机制。下一节我们会用它们实现一个完整的协程调度器——到时候你就知道这些基础 API 的威力了。

公众号:蓝海资料掘金营,微信 deep3321