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);
这四兄弟配合使用,就能实现协程的创建、切换和销毁。我个人习惯把getcontext和makecontext搭配使用来初始化协程,用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能帮你理解协程的本质。
协程的执行流程
为了让你更直观地理解,我画了一张流程图:
这张图展示了协程调度的核心逻辑。主协程就像个调度器,它负责决定下一个该谁执行。每个协程执行完自己的「一回合」后,主动把控制权交还给主协程。
避坑指南
我曾经在ucontext上踩过一个坑,说出来你可能不信——栈溢出。当时我创建协程时给的栈大小是4096字节,结果协程里调了一个递归函数,直接栈溢出导致程序崩溃。排查了半天才发现是栈不够用。
我的建议:
- 协程栈大小至少给8KB,如果涉及递归调用,给16KB以上
- 永远检查
makecontext的返回值(虽然它不返回错误,但参数错了会直接崩) uc_link一定要设置,否则协程结束后程序会直接退出- 不要在信号处理函数里做协程切换,会出大问题
什么时候用协程?
你想想看,如果你的程序里有大量「等待」操作——等网络数据、等磁盘读写、等用户输入——那协程就是你的最佳选择。它能让你的代码看起来像同步的,但实际上是异步执行的。
举个例子,我之前写过一个HTTP代理服务器,用协程处理每个连接。代码看起来就是顺序执行的:接收请求、转发、等待响应、返回结果。但实际上,成千上万个这样的「顺序流」在交替执行,效率极高。
一句话总结:协程让你用写同步代码的方式,达到异步执行的效率。这就是它的魅力所在。
公众号:蓝海资料掘金营,微信deep3321