【C/C++】从 setjmp 到 ucontext 再到 hook:C 语言协程是怎么跑起来的?

【C/C++】从 setjmp 到 ucontext 再到 hook:C 语言协程是怎么跑起来的?

在这里插入图片描述

1. 为什么需要协程?

网络服务里经常有一个矛盾:

  • 同步阻塞代码好写:send() 之后 recv(),业务逻辑顺着往下走;
  • 异步事件模型性能好:epoll_wait() 拿到事件,再按 fd 分发处理;
  • 但纯异步代码容易变成回调、状态机、上下文传递,一旦多个请求之间有依赖,代码很快变复杂。

课程笔记里对这个问题的概括很直接:

func() {
    async_send();
    async_recv();
}

async_xxx() {
    if (1 == poll(fd, 0)) {
        switch();  // 判断 IO,然后切换出去
    }
}

协程要解决的就是这个问题:业务代码看起来像同步代码,底层遇到 IO 不就绪时主动让出 CPU;等 fd 就绪后,调度器再恢复它。

所以协程不是为了“炫技式切栈”,而是为高并发 IO 服务:

  • 写法上接近同步;
  • 执行上接近异步;
  • 调度发生在用户态,切换成本比线程更轻;
  • epoll 组合后,可以用少量线程管理大量连接。

2. 协程的本质:保存现场,再恢复现场

一个函数普通调用时,执行权从调用者进入被调用者;函数返回后,栈帧销毁,不能随便回到中间某一行继续执行。

协程不一样。它要支持两个基本动作:

  • yield:当前协程主动让出执行权,保存自己的执行现场;
  • resume:调度器恢复某个协程,让它从上次暂停的位置继续跑。

课程里列了三种常见实现路线:

1. setjmp / longjmp
2. ucontext
3. asm 汇编

这三种方法的核心都一样:保存 CPU 寄存器、栈信息和下一条执行位置;恢复时再把这些状态装回去。

3. 第一站:setjmp / longjmp 认识“跳回保存点”

先看最小示例 jmp.c

#include <setjmp.h>
#include <stdio.h>

jmp_buf env;

void func(int val)
{
    printf("In func, about to longjmp: %d\n", val);
    longjmp(env, ++val);
}

int main()
{
    int ret = setjmp(env);
    if (ret == 0) {
        func(ret);
    } else if (ret == 1) {
        func(ret);
    } else if (ret == 2) {
        func(ret);
    } else if (ret == 3) {
        func(ret);
    }
}

setjmp(env) 做了两件事:

  1. 第一次调用时,保存当前执行现场,然后返回 0
  2. 后续如果有人 longjmp(env, value),程序会跳回这个保存点,并让 setjmp 返回 value

运行结果类似:

In func, about to longjmp: 0
In func, about to longjmp: 1
In func, about to longjmp: 2
In func, about to longjmp: 3

这已经有一点“切换”的味道了:程序不是按普通调用栈返回,而是跳回了之前保存的点。

setjmp/longjmp 还不够像完整协程,因为它默认没有给每个协程准备独立栈。真正的协程需要多个执行流各自拥有自己的栈,否则很难让多个任务都停在自己的调用链中间。

4. 第二站:ucontext 做出真正的用户态切换

ucontext.c 就是一个非常适合入门的协程雏形。它创建了三个上下文 ctx[0]ctx[1]ctx[2],再用 main_ctx 充当最小调度器。

在这里插入图片描述

关键初始化代码如下:

ucontext_t ctx[3];
ucontext_t main_ctx;

getcontext(&ctx[0]);

char *stack1 = malloc(SIGSTKSZ);
ctx[0].uc_stack.ss_sp = stack1;
ctx[0].uc_stack.ss_size = SIGSTKSZ;
ctx[0].uc_link = &main_ctx;
makecontext(&ctx[0], func1, 0);

这里有四个关键点:

  • getcontext(&ctx[0]):初始化一个上下文结构;
  • uc_stack:给这个上下文配置独立栈;
  • uc_link = &main_ctx:当 func1 执行结束时回到哪里;
  • makecontext(&ctx[0], func1, 0):指定这个上下文第一次被恢复时执行 func1

协程函数里主动切回 main_ctx

void func1()
{
    while (count++ < 30) {
        printf("In func1\n");
        swapcontext(&ctx[0], &main_ctx);
        printf("Back in func1\n");
    }
}

调度器再按顺序恢复某个协程:

while (count < 30) {
    printf("In main\n");
    swapcontext(&main_ctx, &ctx[count % 3]);
    printf("Back in main\n");
}

这两句 swapcontext 分别对应:

  • swapcontext(&ctx[0], &main_ctx):当前协程 yield,保存 ctx[0],切回调度器;
  • swapcontext(&main_ctx, &ctx[i]):调度器 resume 某个协程,从它上次暂停的位置继续。

我在 Linux/WSL 下重新编译运行过,输出开头如下:

In main
In func1
Back in main
In main
In func2
Back in main
In main
In func3
Back in main
In main
Back in func1
In func1

注意 Back in func1:它不是重新调用 func1,而是从上一次 swapcontext(&ctx[0], &main_ctx) 后面继续执行。这就是协程“暂停后恢复”的直观证据。

5. 从 demo 到协程库:需要 coroutine 和 scheduler

上面的 ucontext.c 只有三个固定函数,还不是完整协程库。课程笔记里给出了更接近工程实现的抽象。

协程对象大致需要保存:

struct coroutine {
    int fd;
    ucontext_t ctx; // stack, stack_size, func
    void *arg;

    queue_node(coroutine, ) ready_queue;
    rbtree_node(coroutine, ) wait_rb;
    rbtree_node(coroutine, ) sleep_rb;
};

调度器则需要管理:

struct scheduler {
    int epfd;
    struct epoll_event events[];

    queue_node(coroutine, ) ready_head;
    rbtree_root(coroutine, ) wait;
    rbtree_root(coroutine, ) sleep;
};

这两个结构说明了一件事:协程库不是只有“切换上下文”这么简单。真正跑起来时,调度器至少要维护三类协程:

  • ready:已经可以运行,等待被恢复;
  • wait:正在等待某个 fd 的 IO 事件;
  • sleep:等待定时器到期。

一个典型调度循环可以理解成这样:

while (1) {
    // 1. 运行 ready 队列里的协程
    while (!queue_empty(ready)) {
        co = pop_ready();
        resume(co);
    }

    // 2. 计算最近的 sleep 超时时间
    timeout = nearest_timer_timeout();

    // 3. 等待 IO 事件
    n = epoll_wait(epfd, events, maxevents, timeout);

    // 4. fd 就绪:把等待该 fd 的协程放回 ready
    for (i = 0; i < n; i++) {
        co = events[i].data.ptr;
        push_ready(co);
    }

    // 5. 定时器到期:sleep 协程也放回 ready
    expire_sleep_coroutines();
}

这样,yield/resume 就和 epoll_wait 接上了:协程不是随机切换,而是根据“可运行、IO 就绪、定时器到期”来调度。

6. 第三站:hook,把阻塞 IO 变成可调度 IO

如果业务代码里写的是:

while (1) {
    recv(fd, buffer, sizeof(buffer), 0);
    parser(buffer);
    send(fd, response, response_len, 0);
}

问题来了:recv 如果真的阻塞,整个线程都会卡住,调度器也没机会运行其他协程。

所以协程库通常会 hook 常见阻塞调用,例如:

read / write / recv / send / accept / connect / sleep

目录下的 hook.c 是一个最小 hook 示例,它用 dlsym(RTLD_NEXT, "...") 找到真正的 libc 函数:

#define _GNU_SOURCE
#include <dlfcn.h>

typedef ssize_t (*read_t)(int fd, void *buf, size_t count);
read_t read_f = NULL;

typedef ssize_t (*write_t)(int fd, const void *buf, size_t count);
write_t write_f = NULL;

ssize_t read(int fd, void *buf, size_t count)
{
    ssize_t ret = read_f(fd, buf, count);
    return ret;
}

ssize_t write(int fd, const void *buf, size_t count)
{
    return write_f(fd, buf, count);
}

void init_hook()
{
    read_f = dlsym(RTLD_NEXT, "read");
    write_f = dlsym(RTLD_NEXT, "write");
}

这段代码本身还没有调度逻辑,但它证明了一个关键机制:我们可以拦截业务代码里的 read/write,在自己的函数里决定什么时候调用真实系统调用。

真正接入协程调度后,hook read 的逻辑可以画成这样:

在这里插入图片描述

伪代码如下:

ssize_t read(int fd, void *buf, size_t count)
{
    struct pollfd pfd = {
        .fd = fd,
        .events = POLLIN,
    };

    int ready = poll(&pfd, 1, 0);
    if (ready <= 0) {
        // fd 暂时不可读,把当前协程挂到 wait 结构里
        epoll_ctl(scheduler->epfd, EPOLL_CTL_ADD, fd, &ev);

        // 当前协程 yield,调度器去跑别的协程
        coroutine_yield();
    }

    // 被 resume 回来时,fd 已经可读,再调用真实 read
    return read_f(fd, buf, count);
}

这就是“同步写法、异步执行”的核心。

业务层仍然写:

n = read(fd, buf, sizeof(buf));

但底层实际发生的是:

  1. fd 可读:直接调用真实 read_f
  2. fd 不可读:注册到 epoll,当前协程 yield
  3. epoll_wait 发现 fd 就绪;
  4. 调度器把对应协程放回 ready 队列;
  5. 协程 resume,再次进入 hook,最终完成真实读取。

7. 异步 DNS 和 epoll 服务端:协程要解决的真实场景

扩展目录里还有两个很有代表性的文件。

async_dns_client_noblock.c 展示了“纯异步”的写法:创建非阻塞 UDP socket,发送 DNS 请求,把 fd 注册到 epoll,等响应回来后回调处理结果。

核心结构很简单:

struct async_context {
    int epfd;
};

struct ep_arg {
    int sockfd;
    async_result_cb cb;
};

事件线程里等待 DNS 响应:

int nready = epoll_wait(epfd, events, ASYNC_CLIENT_NUM, -1);
for (i = 0; i < nready; i++) {
    struct ep_arg *data = (struct ep_arg*)events[i].data.ptr;
    int sockfd = data->sockfd;

    int n = recvfrom(sockfd, buffer, sizeof(buffer), 0,
                     (struct sockaddr*)&addr, (socklen_t*)&addr_len);

    struct dns_item *domain_list = NULL;
    int count = dns_parse_response(buffer, &domain_list);

    data->cb(domain_list, count);
}

这种写法性能很好,但业务逻辑会被拆成“提交请求”和“回调处理结果”。协程库想做的,就是把这种事件驱动能力藏到 hook 和 scheduler 下面,让上层代码重新变回顺序逻辑。

server_mulport_epoll.c 则展示了高并发网络服务常见结构:

  • 多端口监听;
  • epoll_wait 接收大量连接事件;
  • 客户端 fd 设置非阻塞;
  • 事件到来后可以直接处理,也可以投递到线程池。

课程截图里可以看到大量客户端回显输出:

在这里插入图片描述

这类场景正是协程库的用武之地:每个连接可以对应一个协程,业务代码像处理单连接一样顺序执行;调度器在背后负责把不可读、不可写、睡眠中的协程挂起。

8. 把整条链路串起来

到这里,可以把协程实现拆成四层:

第 1 层:上下文切换
setjmp/longjmp、ucontext、汇编保存和恢复 CPU/栈状态。

第 2 层:协程对象
每个 coroutine 保存 ctx、stack、入口函数、参数、状态。

第 3 层:调度器
维护 ready / wait / sleep,使用 epoll_wait 驱动 IO 协程恢复。

第 4 层:系统调用 hook
拦截 read/write/recv/send/sleep,把阻塞点改造成 yield 点。

执行流程可以概括为:

create coroutine
    -> makecontext / 初始化栈
    -> 放入 ready 队列
    -> scheduler resume
    -> 业务代码执行
    -> 遇到 IO 不就绪
    -> hook 注册 epoll 并 yield
    -> scheduler 跑其他协程
    -> epoll_wait 返回事件
    -> 对应协程回到 ready
    -> resume 后继续执行

这也是 ntyco、libco、go runtime 等协程/轻量线程系统的共同味道:把“等待”从线程阻塞变成任务挂起,把“恢复”交给调度器。

9. 编译运行本文示例

这些示例依赖 Linux API,建议在 Linux 或 WSL 下运行。

gcc -Wall -Wextra -O0 -g -o jmp jmp.c
./jmp

gcc -Wall -Wextra -O0 -g -o ucontext ucontext.c
./ucontext

gcc -Wall -Wextra -O0 -g -o hook hook.c -ldl
./hook

我本地验证到的 hook 输出为:

buffer: 1234567890

这说明顶层 hook.c 已经成功通过自定义 read/write 包装函数调用到了真实 libc 系统调用。

10. 小结

协程可以用一句话理解:

协程是在用户态保存和恢复执行现场,并把 IO 等待交给调度器管理的一种并发执行单元。

本文从目录里的代码出发,走了一条从浅到深的路线:

  • jmp.c:理解“跳回保存点”;
  • ucontext.c:理解“独立栈 + yield/resume”;
  • hook.c:理解“拦截系统调用”;
  • server_mulport_epoll.c / async_dns_client_noblock.c:理解协程为什么适合高并发 IO。

如果继续往下实现一个完整协程库,下一步就可以补齐:

  • coroutine_create():分配协程对象和栈;
  • coroutine_yield() / coroutine_resume():封装上下文切换;
  • scheduler_run():实现 ready/wait/sleep 三类任务调度;
  • read/recv/send/sleep hook:把阻塞点接入 epoll;
  • 多线程多核模式:每个线程一个 scheduler,连接按线程分片。

做到这里,一个“看起来同步、跑起来异步”的 C 协程网络库就有了骨架。

学习链接: https://github.com/0voice

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值