5、基于 ucontext 的协程框架设计:协程结构体定义、调度器设计
好,我们直接进入正题。上一章我们聊了 ucontext 这套底层 API 怎么用,说白了就是让你能手动保存和切换执行流。但光有 API 还不够,你得把它封装成一个像样的协程框架。这一章,我就带你手搭一个最小可用的协程骨架。
我个人习惯,设计任何框架都先从数据结构入手。数据结构定好了,代码怎么写都不会跑偏。协程框架的核心就两块:协程结构体和调度器。咱们一个一个来。
5.1 协程结构体定义
一个协程,本质上就是一个可以暂停和恢复的执行单元。它需要保存哪些信息?你想想看:
- 它自己的栈空间(每个协程独立栈,不然数据会乱)
- 它的上下文(就是 ucontext_t,保存寄存器、栈指针等)
- 它的状态(运行中、就绪、阻塞、已结束)
- 它的入口函数和参数
- 可能还有协程 ID、返回值等辅助信息
我在项目中遇到过一种情况:有人把协程栈设得特别大,结果创建几千个协程直接 OOM。所以栈大小一定要可配置,别写死。
下面是我常用的协程结构体定义:
/* 协程状态枚举 */
typedef enum {
COROUTINE_READY, /* 就绪,还没开始跑 */
COROUTINE_RUNNING, /* 正在运行 */
COROUTINE_SUSPENDED,/* 主动让出,等待恢复 */
COROUTINE_DONE /* 执行完毕 */
} coroutine_state_t;
/* 协程控制块 */
typedef struct coroutine {
uint64_t id; /* 协程唯一标识 */
coroutine_state_t state; /* 当前状态 */
ucontext_t ctx; /* 上下文,用于 swapcontext */
void* stack; /* 独立栈空间指针 */
size_t stack_size; /* 栈大小,默认 64KB */
void* (*func)(void*); /* 协程入口函数 */
void* arg; /* 入口函数参数 */
void* retval; /* 返回值指针 */
struct coroutine* next; /* 调度器链表用 */
} coroutine_t;
嗯,这里要注意:next 指针是为了把协程串成链表,调度器遍历起来方便。你也可以用数组,但我个人觉得链表更灵活,动态增删协程不用搬内存。
5.2 调度器设计
调度器是协程框架的大脑。它的职责很简单:决定下一个该跑哪个协程,然后切换过去。但实现起来,细节不少。
我设计的调度器包含以下要素:
- 主上下文:调度器自身的 ucontext_t,用于从协程切回调度器
- 就绪队列:所有可以运行的协程链表
- 当前协程指针:正在运行的协程是哪个
- 调度策略:最简单的就是轮转(Round-Robin),每个协程跑一次就切
说白了,调度器就是一个循环:从就绪队列头部拿一个协程,切换过去让它跑;协程主动 yield 或者执行完毕,再切回调度器,继续拿下一个。
/* 调度器结构体 */
typedef struct scheduler {
ucontext_t main_ctx; /* 调度器主上下文 */
coroutine_t* ready_head; /* 就绪队列链表头 */
coroutine_t* running; /* 当前正在运行的协程 */
uint64_t next_id; /* 用于分配协程 ID */
size_t default_stack_size; /* 默认栈大小 */
} scheduler_t;
main_ctx 其实就是一个“锚点”。每次协程 yield 或者结束,都会通过 swapcontext 回到这个上下文。你可以把它理解成调度器的“大本营”。
5.3 核心调度流程
调度器的核心函数就两个:schedule_run() 和 coroutine_yield()。前者启动调度循环,后者让协程主动让出 CPU。
我画了一张流程图,帮你理清整个调度逻辑:
你看这个流程,其实就是一个“取-切-等-回”的循环。调度器把协程从就绪队列拿出来,用 swapcontext 切过去跑。协程要么主动 yield 回来,要么执行完自动回来。调度器接着取下一个。
5.4 关键函数实现
光说不练假把式。我直接贴核心代码,你感受一下:
/* 创建协程 */
coroutine_t* coroutine_create(scheduler_t* sched,
void* (*func)(void*),
void* arg) {
coroutine_t* co = (coroutine_t*)malloc(sizeof(coroutine_t));
if (!co) return NULL;
co->id = sched->next_id++;
co->state = COROUTINE_READY;
co->func = func;
co->arg = arg;
co->retval = NULL;
co->stack_size = sched->default_stack_size;
co->stack = malloc(co->stack_size);
if (!co->stack) {
free(co);
return NULL;
}
/* 初始化协程上下文 */
getcontext(&co->ctx);
co->ctx.uc_stack.ss_sp = co->stack;
co->ctx.uc_stack.ss_size = co->stack_size;
co->ctx.uc_link = &sched->main_ctx; /* 协程结束后自动切回调度器 */
/* 设置协程入口,这里用了一个包装函数 */
makecontext(&co->ctx, (void (*)(void))coroutine_entry, 1, co);
/* 插入就绪队列尾部 */
/* ... 链表操作略 ... */
return co;
}
/* 调度器主循环 */
void schedule_run(scheduler_t* sched) {
while (sched->ready_head != NULL) {
/* 取出队首协程 */
coroutine_t* co = sched->ready_head;
/* ... 从链表移除 ... */
sched->running = co;
co->state = COROUTINE_RUNNING;
/* 切换到协程 */
swapcontext(&sched->main_ctx, &co->ctx);
/* 从协程切回来后,检查状态 */
if (co->state == COROUTINE_DONE) {
/* 协程已结束,清理资源 */
free(co->stack);
free(co);
} else if (co->state == COROUTINE_SUSPENDED) {
/* 协程主动 yield,重新入队 */
/* ... 插入就绪队列尾部 ... */
}
sched->running = NULL;
}
}
/* 协程主动让出 */
void coroutine_yield(scheduler_t* sched) {
coroutine_t* co = sched->running;
co->state = COROUTINE_SUSPENDED;
/* 切回调度器 */
swapcontext(&co->ctx, &sched->main_ctx);
}
makecontext 的参数传递上栽过跟头。注意 makecontext 的第三个参数是入口函数的参数个数,后面才是真正的参数。如果你传的参数类型不是 int,需要自己包装。另外,uc_link 一定要设置,否则协程结束后程序会直接退出,不会回到调度器。
5.5 调度策略的取舍
上面我用的轮转调度,简单可靠。但实际项目中,你可能需要更复杂的策略:
| 调度策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 轮转 (Round-Robin) | 实现简单,公平 | 无优先级,I/O 密集型协程可能空转 | 通用场景,学习阶段 |
| 优先级调度 | 高优先级协程优先执行 | 可能产生饥饿 | 实时性要求高的场景 |
| 协作式 (Cooperative) | 协程主动让出,无抢占 | 某个协程死循环会阻塞所有协程 | 协程数量可控,行为可预测 |
我个人建议,初期先用轮转。等你对协程的切换开销、栈管理有了手感,再考虑优化调度策略。别一上来就搞复杂,容易把自己绕进去。
好了,这一章的核心内容就是这些。协程结构体定义好,调度器跑起来,你的协程框架就有了骨架。下一章我们会在骨架上填肉——实现协程的创建、销毁、yield 和 resume 这些具体操作。到时候你就能真正跑起一个协程了。
公众号:蓝海资料掘金营,微信deep3321