第二十九章:多线程高级话题:协程与线程对比、用户态线程实现、C++20协程与C线程结合
说实话,搞了十几年系统编程,我越来越觉得「线程」不是万能的。早年间我在一个高并发网关项目里,开了上千个线程处理连接,结果光上下文切换就把CPU吃掉了40%。后来我换了个思路——用协程。嗯,这一章我们就来聊聊协程和线程的那些事儿。
一、协程 vs 线程:本质区别在哪?
很多人问我:「协程不就是轻量级线程吗?」我的回答是:像,但完全不是一回事。
线程是操作系统内核管理的。你创建线程,内核要分配栈空间、调度器要参与切换。而协程呢?它是用户态自己控制的。说白了,协程就是一段可以暂停、可以恢复执行的函数。
| 对比维度 | 线程 | 协程 |
|---|---|---|
| 调度方式 | 内核态抢占式 | 用户态协作式 |
| 切换开销 | 约1~10微秒(系统调用+上下文切换) | 约几十纳秒(仅保存寄存器) |
| 栈大小 | 默认1~8MB | 可自定义,通常4~64KB |
| 并发数量 | 几千个就吃力了 | 轻松几十万甚至百万 |
| 数据竞争 | 需要锁/原子操作 | 同一线程内协程无需锁 |
核心结论:线程适合CPU密集型任务,协程适合I/O密集型任务。我个人的习惯是——计算密集用线程,等待密集用协程。
二、用户态线程实现:自己动手写个协程
你想想看,操作系统怎么实现线程切换?无非就是保存寄存器、切换栈指针、恢复寄存器。这些事在用户态也能干。
我在一个嵌入式项目中就自己撸过一个协程库。核心就三个东西:
- 上下文结构体:保存寄存器状态(RSP、RBP、RIP等)
- 栈空间:每个协程独立分配一块内存当栈
- 调度器:决定下一个运行哪个协程
下面是一个极简的用户态协程切换示例(基于ucontext.h,Linux下可用):
#include <ucontext.h>
#include <stdio.h>
#define STACK_SIZE 8192
ucontext_t main_ctx, co_ctx;
void co_function() {
printf("协程开始执行\n");
// 主动让出CPU
swapcontext(&co_ctx, &main_ctx);
printf("协程恢复执行\n");
}
int main() {
char stack[STACK_SIZE];
getcontext(&co_ctx);
co_ctx.uc_stack.ss_sp = stack;
co_ctx.uc_stack.ss_size = STACK_SIZE;
co_ctx.uc_link = &main_ctx; // 协程结束后回到主上下文
makecontext(&co_ctx, co_function, 0);
printf("主程序:切换到协程\n");
swapcontext(&main_ctx, &co_ctx);
printf("主程序:从协程返回\n");
return 0;
}
避坑指南:我曾经在用户态协程里犯过一个低级错误——栈空间分配在局部变量里,函数返回后栈被回收,协程再切回来直接段错误。记住:协程的栈必须是静态分配或者堆分配的,不能是局部自动变量。
三、C++20协程与C线程结合:混搭的艺术
现实项目里,很少只用一种并发模型。我最近在做一个网络框架,底层用C线程池处理连接,上层用C++20协程处理业务逻辑。为什么这么搞?因为C++20的协程语法太香了。
C++20协程有几个关键概念:
- promise_type:定义协程的行为(返回值、暂停、恢复)
- co_await:挂起当前协程,等待某个操作完成
- co_return:返回结果并结束协程
- co_yield:产生一个值并暂停
下面是一个C++20协程与C线程池结合的简化示例:
#include <coroutine>
#include <thread>
#include <queue>
#include <functional>
// 一个简单的C风格线程池(伪代码)
typedef void (*task_func)(void*);
void thread_pool_submit(task_func f, void* arg);
// C++20协程的promise类型
struct Task {
struct promise_type {
Task get_return_object() { return {}; }
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() {}
};
};
// 将协程提交到C线程池执行
Task run_on_thread_pool() {
printf("协程在C线程池中运行,线程ID: %d\n",
std::this_thread::get_id());
co_return;
}
// 桥接函数:C线程池回调中恢复协程
void bridge_func(void* arg) {
// 这里可以恢复被挂起的协程
// 实际项目中会配合std::coroutine_handle使用
}
int main() {
// 提交协程到C线程池
// 实际实现需要将协程句柄传递给C线程池
printf("主线程提交协程任务\n");
return 0;
}
注意:C++20协程默认是栈无关的(stackless),这意味着你不能在协程内部调用一个会阻塞的函数,否则整个线程都会被阻塞。我见过有人把sleep()写在协程里,结果线程池全部卡死。正确的做法是用co_await配合异步I/O。
四、知识体系总览
为了让你更直观地理解本章的知识结构,我画了一张图:
五、实际项目中的选择建议
说了这么多,到底什么时候用协程,什么时候用线程?我根据多年经验总结了几条:
- 网络I/O密集(HTTP服务器、WebSocket网关):首选协程。我在一个WebSocket项目中用协程替代线程,连接数从5000提升到了8万。
- CPU计算密集(图像处理、科学计算):用线程。协程在计算密集场景下没有优势,反而因为协作式调度可能造成不公平。
- 混合场景:用C线程池 + C++20协程。线程池负责并行计算,协程负责异步等待。
- 遗留系统:如果项目已经是纯C写的,用ucontext或自己实现简单的协程切换,不要强行引入C++。
我的一个小技巧:在协程里做I/O操作时,永远设置超时。我曾经在生产环境遇到一个协程因为网络闪断卡住了3分钟,导致整个调度器停摆。加个超时机制,几行代码就能避免这种灾难。
嗯,这一章的内容就到这里。协程和线程不是非此即彼的关系,而是互补的。你掌握了这两种武器,才能在并发编程的战场上游刃有余。