16、协程基础:协程的概念、协程与线程的区别、ucontext库的使用

协程到底是什么?

说实话,我第一次接触协程这个概念时,也觉得挺玄乎的。当时我在做一个网络代理程序,需要同时处理上千个连接。用线程吧,切换开销太大;用事件驱动吧,代码又写得像意大利面条。后来一个老同事跟我说:「你试试协程。」

协程,说白了就是用户态下的轻量级线程。它不像线程那样由操作系统内核调度,而是由程序员自己在代码里控制执行流程的切换。你可以把它理解成一个可以暂停、可以恢复的函数。

嗯,这里要注意:协程的切换完全在用户空间完成,不需要陷入内核态。这意味着什么?意味着切换成本极低——低到可以忽略不计。

核心要点:协程是协作式的,线程是抢占式的。协程主动让出CPU,线程被动被调度。

协程 vs 线程:一张表说清楚

我在项目中经常被问到:「既然线程那么好用,为什么还要搞协程?」我的回答是:场景不同,工具不同。下面这张表是我自己总结的,你感受一下:

对比维度 协程 线程
调度方式 用户态协作式 内核态抢占式
切换开销 约几十纳秒 约几微秒(含上下文切换)
内存占用 几KB栈空间 默认1MB+栈空间
并发数量 轻松上万 几百个就吃力
数据竞争 无需锁(单线程内) 需要锁保护
编程复杂度 需要手动管理yield 系统自动调度

你看,协程的优势很明显:轻、快、省。但代价是什么?你得自己控制什么时候让出执行权。线程是操作系统帮你管,协程是你自己管。

我的经验:如果你要处理大量I/O密集型任务(比如网络请求、文件读写),协程是首选。如果是CPU密集型计算,老老实实用线程。

ucontext库:C语言协程的基石

在C语言里,实现协程最经典的方式就是ucontext库。这个库提供了四个核心函数,我一个个给你讲。

核心API一览

#include <ucontext.h>

// 获取当前上下文
int getcontext(ucontext_t *ucp);

// 设置上下文(跳转到指定位置执行)
int setcontext(const ucontext_t *ucp);

// 修改上下文(创建新协程时用)
void makecontext(ucontext_t *ucp, void (*func)(), int argc, ...);

// 保存当前上下文,切换到另一个上下文
int swapcontext(ucontext_t *oucp, const ucontext_t *ucp);

这四兄弟配合使用,就能实现协程的创建、切换和销毁。我个人习惯把getcontextmakecontext搭配使用来初始化协程,用swapcontext来做切换。

一个完整的协程示例

下面这个例子,我当年在写一个爬虫框架时用过类似的模式。它创建了两个协程,互相交替执行:

#include <stdio.h>
#include <ucontext.h>

#define STACK_SIZE 8192

ucontext_t ctx_main, ctx_a, ctx_b;
char stack_a[STACK_SIZE], stack_b[STACK_SIZE];

void func_a() {
    for (int i = 0; i < 3; i++) {
        printf("协程A: 第%d次执行\n", i + 1);
        // 主动让出CPU,切换到主协程
        swapcontext(&ctx_a, &ctx_main);
    }
}

void func_b() {
    for (int i = 0; i < 3; i++) {
        printf("协程B: 第%d次执行\n", i + 1);
        swapcontext(&ctx_b, &ctx_main);
    }
}

int main() {
    // 初始化协程A
    getcontext(&ctx_a);
    ctx_a.uc_link = &ctx_main;  // 协程结束后回到主协程
    ctx_a.uc_stack.ss_sp = stack_a;
    ctx_a.uc_stack.ss_size = STACK_SIZE;
    makecontext(&ctx_a, func_a, 0);

    // 初始化协程B
    getcontext(&ctx_b);
    ctx_b.uc_link = &ctx_main;
    ctx_b.uc_stack.ss_sp = stack_b;
    ctx_b.uc_stack.ss_size = STACK_SIZE;
    makecontext(&ctx_b, func_b, 0);

    printf("主协程开始调度\n");

    // 先切换到协程A
    swapcontext(&ctx_main, &ctx_a);
    // 协程A让出后,切换到协程B
    swapcontext(&ctx_main, &ctx_b);
    // 重复调度...
    swapcontext(&ctx_main, &ctx_a);
    swapcontext(&ctx_main, &ctx_b);
    swapcontext(&ctx_main, &ctx_a);
    swapcontext(&ctx_main, &ctx_b);

    printf("主协程结束\n");
    return 0;
}

运行结果是这样的:

主协程开始调度
协程A: 第1次执行
协程B: 第1次执行
协程A: 第2次执行
协程B: 第2次执行
协程A: 第3次执行
协程B: 第3次执行
主协程结束

看到了吗?两个协程像在「打乒乓球」一样,你一下我一下。这就是协作式调度的精髓——每个协程主动让出CPU,而不是被操作系统强行打断。

注意:ucontext库在POSIX标准中已被标记为废弃(obsolescent),但在Linux上仍然可用。如果你做生产环境项目,建议用libco、libtask等更现代的协程库。不过,学习ucontext能帮你理解协程的本质。

协程的执行流程

为了让你更直观地理解,我画了一张流程图:

协程调度流程 主协程 (main) 协程A (func_a) 协程B (func_b) swapcontext yield返回 swapcontext yield返回 调度循环(重复3次) 1. 主协程调用 swapcontext(&ctx_main, &ctx_a) → 切换到协程A 2. 协程A执行一次循环,调用 swapcontext(&ctx_a, &ctx_main) → 回到主协程 3. 主协程调用 swapcontext(&ctx_main, &ctx_b) → 切换到协程B 4. 协程B执行一次循环,调用 swapcontext(&ctx_b, &ctx_main) → 回到主协程 5. 重复步骤1-4,直到两个协程都执行完毕

这张图展示了协程调度的核心逻辑。主协程就像个调度器,它负责决定下一个该谁执行。每个协程执行完自己的「一回合」后,主动把控制权交还给主协程。

避坑指南

我曾经在ucontext上踩过一个坑,说出来你可能不信——栈溢出。当时我创建协程时给的栈大小是4096字节,结果协程里调了一个递归函数,直接栈溢出导致程序崩溃。排查了半天才发现是栈不够用。

我的建议:

  • 协程栈大小至少给8KB,如果涉及递归调用,给16KB以上
  • 永远检查 makecontext 的返回值(虽然它不返回错误,但参数错了会直接崩)
  • uc_link 一定要设置,否则协程结束后程序会直接退出
  • 不要在信号处理函数里做协程切换,会出大问题

什么时候用协程?

你想想看,如果你的程序里有大量「等待」操作——等网络数据、等磁盘读写、等用户输入——那协程就是你的最佳选择。它能让你的代码看起来像同步的,但实际上是异步执行的。

举个例子,我之前写过一个HTTP代理服务器,用协程处理每个连接。代码看起来就是顺序执行的:接收请求、转发、等待响应、返回结果。但实际上,成千上万个这样的「顺序流」在交替执行,效率极高。

一句话总结:协程让你用写同步代码的方式,达到异步执行的效率。这就是它的魅力所在。


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