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。

我画了一张流程图,帮你理清整个调度逻辑:

协程调度核心流程 schedule_run() 启动 就绪队列为空? (ready_head == NULL) 调度结束 取出队首协程 设置 state = RUNNING sched->running = co swapcontext 切换到协程 协程执行 func(arg) 协程 yield 或执行完毕,切回调度器 调度器动作 协程动作

你看这个流程,其实就是一个“取-切-等-回”的循环。调度器把协程从就绪队列拿出来,用 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) 协程主动让出,无抢占 某个协程死循环会阻塞所有协程 协程数量可控,行为可预测

我个人建议,初期先用轮转。等你对协程的切换开销、栈管理有了手感,再考虑优化调度策略。别一上来就搞复杂,容易把自己绕进去。

一个小技巧:你可以在调度器里加一个统计计数器,记录每个协程被调度的次数。调试时打印出来,能直观看到调度是否均衡。我在做网络框架时就用这招抓到了一个协程“饿死”的 bug。

好了,这一章的核心内容就是这些。协程结构体定义好,调度器跑起来,你的协程框架就有了骨架。下一章我们会在骨架上填肉——实现协程的创建、销毁、yield 和 resume 这些具体操作。到时候你就能真正跑起一个协程了。


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