协程设计原理
协程设计原理
协程(Coroutine)是一种程序组件,允许在单个线程内实现多个执行流的协作式多任务。它在不涉及内核态切换的情况下,通过保存和恢复执行上下文(寄存器、栈)来实现任务的挂起与恢复,从而以同步的编程方式获得异步的高性能。
一、为什么需要协程
在传统的网络编程中,我们面临两种选择:同步阻塞模型和异步非阻塞模型。
- 同步阻塞模型:代码直观,逐行执行,但每个I/O操作都会阻塞线程,导致线程资源浪费,并发能力受限。例如,串行发送多个DNS请求,总耗时等于各请求耗时之和。
- 异步非阻塞模型:通过回调函数处理I/O就绪事件,可以在单个线程内并发处理大量I/O,性能极高。但回调嵌套容易导致“回调地狱”,代码难以维护和调试。
协程的目标:用同步的编程方式,实现异步的性能。即在协程内部,代码看起来是顺序执行的,但底层在I/O等待时会自动挂起并切换到其他就绪协程,从而充分利用CPU和I/O资源。
二、协程的基本概念
1. 协程的本质
协程是独立于语言的通用框架,不是某个语言特有的特性。它通过编译器或库的支持,实现函数的挂起与恢复。
2. 原语操作
协程的核心原语是 yield 和 resume:
- yield:主动让出CPU,保存当前执行上下文(寄存器、栈),并切换回调度器。
- resume:从调度器切换到指定协程,恢复其上下文并继续执行。
3. 有栈协程与无栈协程
- 有栈协程:每个协程拥有独立的栈空间,可以保存局部变量和函数调用链。实现复杂,但支持任意嵌套调用。
- 无栈协程:协程共享调用者栈,通过状态机实现挂起。轻量,但限制较多(如不能跨函数调用挂起)。
多数高性能协程库(如libco)采用有栈设计。
三、协程的实现方式
协程切换的本质是保存和恢复CPU寄存器(包括栈指针、指令指针等)。下面介绍三种常见的实现技术。
1. setjmp/longjmp
setjmp 保存当前函数环境(寄存器、栈指针等)到 jmp_buf 结构,longjmp 可以跳回 setjmp 的位置并恢复环境。
#include <stdio.h>
#include <setjmp.h>
jmp_buf env;
void func() {
printf("Enter func\n");
longjmp(env, 2); // 跳回setjmp处,并返回2
}
int main() {
int val = setjmp(env);
if (val == 0) {
printf("First call, val=%d\n", val);
func();
} else {
printf("Return from longjmp, val=%d\n", val);
}
return 0;
}
局限性:不管理栈空间,可能导致栈溢出;跨线程使用不安全。仅适合简单场景。
2. ucontext
ucontext 是 POSIX 提供的上下文操作接口,可以创建、切换和销毁用户级上下文。每个上下文包含完整的寄存器状态和独立的栈空间。
#include <stdio.h>
#include <ucontext.h>
ucontext_t main_ctx, ctx1, ctx2;
char stack1[8192], stack2[8192];
void func1() {
printf("In func1\n");
swapcontext(&ctx1, &main_ctx); // 切回main
}
void func2() {
printf("In func2\n");
swapcontext(&ctx2, &main_ctx);
}
int main() {
getcontext(&ctx1);
ctx1.uc_stack.ss_sp = stack1;
ctx1.uc_stack.ss_size = sizeof(stack1);
ctx1.uc_link = &main_ctx; // 结束后返回main
makecontext(&ctx1, func1, 0);
getcontext(&ctx2);
ctx2.uc_stack.ss_sp = stack2;
ctx2.uc_stack.ss_size = sizeof(stack2);
ctx2.uc_link = &main_ctx;
makecontext(&ctx2, func2, 0);
printf("Start switching\n");
swapcontext(&main_ctx, &ctx1); // 切换到ctx1
printf("Back from ctx1\n");
swapcontext(&main_ctx, &ctx2);
printf("Back from ctx2\n");
return 0;
}
ucontext 提供了完整的上下文管理,是许多协程库的基础。但它是 Linux 特有的,且性能略逊于汇编实现。
四、协程调度器设计
协程调度器负责管理多个协程的生命周期和运行顺序。通常包含以下组件:
- 就绪队列:存放可运行的协程,通常采用队列实现FCFS或优先级调度。
- 等待队列:存放因I/O或超时而阻塞的协程,常使用红黑树(以超时时间或文件描述符为键)实现高效查找。
- 事件驱动引擎:如epoll,用于监听I/O事件,当I/O就绪时将对应协程移回就绪队列。
调度器核心循环:
while (1) {
// 处理超时:将超时的协程移回就绪队列
process_timeout();
// 执行所有就绪协程
while (!ready_queue.empty()) {
Coroutine* co = ready_queue.pop();
resume(co); // 切换到该协程
if (co->state == FINISHED) {
delete co;
} else if (co->state == WAITING) {
waiting_set.insert(co);
}
}
// 等待I/O事件,设置超时时间为最近等待协程的超时时间
int timeout = calc_timeout();
int n = epoll_wait(epfd, events, maxevents, timeout);
for (int i = 0; i < n; ++i) {
int fd = events[i].data.fd;
Coroutine* co = fd_to_co[fd];
waiting_set.erase(co);
ready_queue.push(co); // I/O就绪,重新调度
}
}
协程定义示例
typedef struct Coroutine {
int id;
ucontext_t ctx;
void *stack;
int state; // READY, RUNNING, WAITING, FINISHED
int fd; // 等待的文件描述符
// 其他字段
} Coroutine;
五、Hook机制:透明接入协程
为了让现有同步代码无需修改即可运行在协程之上,需要hook系统I/O函数(如read、write、recv、send、connect等)。通过动态链接库的符号替换,将标准库函数替换为自定义版本,在自定义版本中实现自动yield。
Hook实现原理
- 定义与系统调用同名的函数,如
ssize_t read(int fd, void *buf, size_t count)。 - 在函数内部,首先判断fd是否已经设置为非阻塞。
- 调用
poll或epoll检测fd是否就绪,若未就绪则执行yield,将当前协程挂起,并将fd加入监听集合。 - 当I/O就绪后,调度器恢复协程,此时再次调用真正的
read(通过dlsym(RTLD_NEXT, "read")获取原始函数地址)即可立即返回数据。
ssize_t read(int fd, void *buf, size_t count) {
// 检查是否在协程环境中
if (!is_in_coroutine()) {
return sys_read(fd, buf, count);
}
// 等待fd可读
while (1) {
struct pollfd pfd = {fd, POLLIN, 0};
int ret = poll(&pfd, 1, 0);
if (ret > 0) break; // 已就绪
// 未就绪,将当前协程挂起,并注册fd监听
co_yield_and_add_fd(fd, POLLIN);
}
// 调用真正的read
return sys_read(fd, buf, count);
}
通过hook,像hiredis这样的客户端库无需修改即可在协程中高效运行。

浙公网安备 33010602011771号