线程、事件循环、协程是三套可以单独使用、也可以组合使用的机制。线程是执行代码的上下文,事件循环负责等待并分发就绪事件,协程负责把带等待的流程写成可以挂起和恢复的顺序代码。
多线程
多线程好比多个人同时干活:把可以并行的工作拆开,交给多条线程同时做。
并行是否更快,取决于下面这些条件:
- 有空闲的 CPU 核心。
- 每个任务足够重,创建、切换和同步的开销不会盖过收益。
- 共享数据的竞争不严重。
任务很短、核心数不够、锁竞争严重时,多线程可能更慢。
线程池和任务划分是两件事:
- 任务划分是把 1000 次独立调用分成 10 份并行。
- 线程池是预先创建一组工作线程,让它们反复从队列领取任务,避免每次任务都创建和销毁线程。
两者经常一起用。
线程可以等条件满足后再执行。定时 2 秒可以用 sleep,等下载完成可以阻塞在 I/O 或条件变量上。等待期间这条线程被占住,不能再去做别的事。要同时等很多条件,又不想为每个等待占一条线程,就使用事件循环。
事件循环
事件循环反复检查哪些事件已经就绪,然后执行对应的处理函数。
高效实现阻塞在平台的等待接口上,由内核返回已经就绪的事件。Linux 上常见的做法是 epoll 加 eventfd:epoll 等待 I/O 就绪,eventfd 让别的线程叫醒这个循环。定时器、跨线程投递和用户态任务队列也可以组成事件循环。
处理函数和对应事件先登记下来,循环处理本次已经就绪的事件。定时器到期和跨线程投递的任务可能来自另一条队列。每次空转扫描整张表会占满 CPU,实际实现不会这样做。
事件循环和多线程有三种常见配合:
- 每个线程一个事件循环。 事件表留在该线程内时,遍历和执行这张表不需要加锁。别的线程向这个循环投递任务时,投递队列仍然要同步。处理函数访问共享数据时,共享数据也要同步。
- 多个线程运行同一个事件循环。 例如多个线程同时执行同一个
io_context::run()。任务队列是共享的,处理函数直接在这些线程上执行;取任务和访问共享数据都要考虑同步。 - 事件循环加线程池。 一个线程专门等待事件,再把耗时工作交给另一个线程池。事件循环本身不共享,同步发生在交接队列上。
协程
这里说的是 C++20 无栈协程。编译器把这种函数改写成状态机:执行到挂起点可以停下来,协程帧保存局部变量和恢复位置,以后从该挂起点继续。可以恢复的位置只有挂起点。
函数定义中出现 co_await、co_yield 或 co_return,通常说明它是协程。注释、字符串和未求值操作数中的关键字不算。关键字写在 lambda 中时,协程是那个 lambda。返回类型还要提供合法的 promise_type。
协程还有这些限制:
- 不能使用普通
return语句。 - 不能使用 C 风格可变参数。
- 不能使用
auto或约束占位返回类型。 - 构造函数、析构函数、
main、constexpr函数和consteval函数不能是协程。
三个关键字的职责不同:
co_await:挂起当前协程,直到对应的 awaiter 决定恢复它。awaiter 也可以让它不挂起而继续执行。co_yield:挂起并产出一个值,常用于生成器。co_return:结束协程并提交结果。函数体不会在这一句之后继续执行。
协程约束的是这一条协程自己的推进顺序。操作在哪条线程上执行,由 awaiter 和执行器决定。
顺序等待时,后一个操作在前一个恢复之后才开始:
co_await A();
co_await B();两个操作只有在调用时已经启动,才会同时进行。后面的 co_await 只按这个顺序取结果:
auto a = A();
auto b = B();
co_await a;
co_await b;如果 A()、B() 是惰性协程,只调用并不会启动。后面的 co_await 仍会按顺序启动并等待。
when_all 这类等待体可以同时等待多个已经启动的操作。协程挂起后,原来的线程可以执行其他协程或任务。协程适合在少量线程上并发处理大量等待,局部变量可以跨等待保留。
普通语句同样按源码顺序执行。要并行,需要另外启动并发任务。协程把“等待某个结果再继续”写成顺序代码,从而少写回调和手工状态机。哪些任务必须串联、哪些可以同时启动,由依赖关系和启动方式决定。
三者共同使用
假设一个大任务由 10 个小任务组成,有 2 条工作线程。有些小任务要等到某个时刻才能做,有些必须按先后顺序做。
三者的分工是:
- 线程: 2 条工作线程负责执行计算或阻塞工作。
- 线程池: 复用这 2 条线程,并从队列领取任务。
- 事件循环: 发现某个条件已经满足,然后调用对应的处理函数,或把就绪任务交到工作队列。它不规定这 10 个小任务的业务先后。
- 协程: 把一条有等待的逻辑写成看起来顺序的代码。挂起时让出线程,恢复时带着原来的局部变量继续。哪些小任务串联、哪些同时启动,要在代码里显式写出来。回调、future 和手工状态机也能表达同样的依赖。
一种常见配合是:
- 事件循环发现 I/O 或定时器就绪,恢复正在
co_await的协程。 - 协程需要做重计算时,把这段工作提交给线程池。
- 线程池完成后,把结果投递回事件循环,再次恢复协程。