协程设计原理

张开发
2026/4/7 6:51:17 15 分钟阅读

分享文章

协程设计原理
一、为什么需要协程在高并发网络编程中我们常常面临一个矛盾同步编程简单直观但性能差异步编程性能高但代码复杂、难以维护。同步方式一个请求发出后线程阻塞等待结果返回期间无法处理其他任务。虽然逻辑顺序清晰但 CPU 利用率低。异步方式请求发出后立即返回当结果准备好时通过回调通知。性能高但回调地狱callback hell让代码难以阅读和维护例如HTTP 请求中嵌套高德位置信息、商品信息等多层回调。// 异步回调示例伪代码 async_request(url, [](response) { async_request(another_url, [](response2) { // 嵌套继续... }); });而协程的出现恰恰是为了兼顾“同步的编程方式”与“异步的高性能”。它允许我们像写同步代码一样顺序编写业务逻辑底层却以非阻塞方式调度 I/O遇到等待时自动让出 CPU待 I/O 就绪后再恢复执行。简单对比同步是串行等待异步是并行通知协程让你用串行的写法获得并行的效率。二、协程的实现原理协程的核心是函数间的可暂停/恢复机制。传统的函数调用是“一次调用必须执行完毕才返回”而协程可以在执行到某个位置时主动让出yield稍后再从让出点恢复resume。2.1 原语操作——函数如何跳转实现协程切换有三种主流方案方案优点缺点setjmp/longjmp标准 C 库简单逻辑复杂多线程下栈混乱ucontext较标准支持用户态上下文切换不可控需要调度器辅助汇编实现性能最高完全可控与架构相关实现复杂2.1.1setjmp/longjmp#include setjmp.h jmp_buf env; if (setjmp(env) 0) { // 第一次调用保存当前上下文 longjmp(env, 1); // 跳转到 setjmp 处返回值为 1 } else { // 跳转回来的逻辑 }但该方式在多线程环境下容易出错因为每个线程的栈独立jmp_buf不能跨线程使用。2.1.2ucontextucontext提供了getcontext、makecontext、swapcontext等函数可以显式创建和切换协程上下文。#include ucontext.h ucontext_t main_ctx, co_ctx; char stack[8192]; void func() { printf(in coroutine\n); // 主动让出回到 main } // 初始化协程上下文 getcontext(co_ctx); co_ctx.uc_stack.ss_sp stack; co_ctx.uc_stack.ss_size sizeof(stack); co_ctx.uc_link main_ctx; // 协程结束后自动回到 main makecontext(co_ctx, func, 0); // 切换到协程 swapcontext(main_ctx, co_ctx);这种方式需要一个调度器scheduler来管理多个协程的切换协程主动yield回到调度器调度器再resume另一个协程。否则协程之间互相乱切会非常不可控。2.1.3 汇编实现以 NtyCo 为例NtyCo 采用汇编实现协程切换性能最佳。核心是保存和恢复寄存器如rsp、rbp、rip以及自定义栈空间。这部分代码通常与 CPU 架构相关x86_64/ARM。三、数据结构定义参照 NtyCo 的设计我们需要两个核心结构体协程控制块coroutine和调度器schedule。3.1 协程结构体struct coroutinestruct coroutine { void (*func)(void *); // 协程入口函数 void *arg; // 函数参数 int status; // 状态就绪、运行、等待、死亡 int64_t id; // 唯一ID char *stack; // 私有栈空间 size_t stack_size; // 栈大小 void *ctx; // 上下文寄存器组 // ... 其他成员如定时器节点、等待的fd等 };3.2 调度器结构体struct schedulestruct schedule { int max_coroutine; // 最大协程数 int running_id; // 当前运行的协程ID struct coroutine **coroutines; // 协程池数组 // 就绪队列、等待队列等 // epoll/poller 句柄用于I/O事件监听 int epfd; struct epoll_event *events; // ... };四、调度器的执行策略调度器是整个协程库的心脏。典型的调度流程如下初始化调度器创建 epoll 或 poller。创建协程加入就绪队列。主循环从就绪队列取出一个协程resume运行它。协程内部执行 I/O 操作时若资源未就绪如recv无数据则注册等待事件主动yield回到调度器。调度器调用epoll_wait等待任意协程的 I/O 就绪。将有事件发生的协程重新放入就绪队列。循环上述过程。这种策略实现了同步非阻塞协程的代码看起来是顺序的recv→send实际底层是异步轮询。五、与 POSIX API 的兼容为了让用户无感知切换优秀的协程库会hook系统调用如read、write、connect、accept将其替换为异步版本。例如ssize_t read(int fd, void *buf, size_t count) { // 原始 read 会被替换为 // 1. 如果 fd 可读直接调用真实 read // 2. 如果不可读将当前协程注册到 epollyield // 3. 待可读时 resume再次调用真实 read }NtyCo 通过动态链接器拦截libc函数实现了这一特性。这样现有同步代码几乎不需要修改就能跑在协程上。六、协程的执行流程关键原语协程库对外提供两个核心原语resume(coro)恢复协程coro的执行。yield()让出当前协程回到调度器。执行流程示意图main (调度器) ---- resume ---- coroutine A ^ | | | (I/O等待) | yield v ------------ (暂停) | | | ---- resume after I/O ---- coroutine A 继续每个 I/O 操作可能触发多次“等待-切换-恢复”的过程直到数据接收完毕。这种切换是被动的——协程主动检测到poll(fd, 0) 0时主动让出。七、多核模式单调度器模型只能利用一个 CPU 核心。为了扩展多核常见做法是每个 CPU 核心一个独立的调度器即 N:1 模型线程绑定核心。协程创建时可以通过负载均衡策略分配到某个调度器上。另一种模式是 M:N多个调度器线程每个线程管理多个协程但需要处理协程迁移和锁竞争复杂度较高。NtyCo 采用单线程调度器 多线程 worker 模式每个线程独立调度避免锁竞争性能接近原生 epoll。八、如何测试协程性能性能测试需要关注以下指标切换开销测量 1000 万次yield/resume的耗时对比ucontext和汇编实现的差异。并发连接数使用wrk或ab工具测试 HTTP 服务器在相同硬件下能支撑的最大 QPS。内存占用每个协程栈大小通常 8KB64KB与创建数量。阻塞 I/O 模拟用多个协程同时 sleep观察调度器是否被阻塞。典型测试代码// 创建 10 万个协程每个协程 sleep 1ms 后退出统计总时间 for (int i 0; i 100000; i) { coroutine_create(sched, task_sleep, arg); } while (coroutine_status(sched) 0) { coroutine_yield(sched); }九、用协程构建网络库——实现 HTTP 协议协程最常见的应用是编写高性能网络服务比如Web 服务器静态文件 CGIKey-Value 存储对标 Redis图床服务图片上传/下载以 HTTP 请求处理为例协程版本的代码非常简洁void handle_http(void *arg) { int client_fd (intptr_t)arg; char buf[4096]; // 同步读请求底层非阻塞 recv(client_fd, buf, sizeof(buf), 0); // 解析请求构造响应 send(client_fd, response, strlen(response), 0); close(client_fd); } int main() { struct schedule *sched coroutine_open(); int listen_fd tcp_server(8080); while (1) { int client_fd accept(listen_fd, ...); coroutine_new(sched, handle_http, (void*)(intptr_t)client_fd); } }每个请求都是一个独立的协程创建开销远小于线程因此可以轻松处理数万并发。十、总结协程通过用户态切换和异步 I/O 挂钩实现了“同步编程异步性能”的编程模型。NtyCo 作为轻量级实现展示了从汇编原语到调度器、再到 Hook 系统调用的完整路径。理解协程的设计不仅能帮助我们写出更高性能的网络程序也为深入理解 Go、Rust 等语言的并发模型打下基础。

更多文章