线程、事件循环、协程是三套可以单独使用、也可以组合使用的机制。线程是执行代码的上下文,事件循环负责等待并分发就绪事件,协程负责把带等待的流程写成可以挂起和恢复的顺序代码。

多线程

多线程好比多个人同时干活:把可以并行的工作拆开,交给多条线程同时做。

并行是否更快,取决于下面这些条件:

任务很短、核心数不够、锁竞争严重时,多线程可能更慢。

线程池和任务划分是两件事:

两者经常一起用。

线程可以等条件满足后再执行。定时 2 秒可以用 sleep,等下载完成可以阻塞在 I/O 或条件变量上。等待期间这条线程被占住,不能再去做别的事。要同时等很多条件,又不想为每个等待占一条线程,就使用事件循环。

事件循环

事件循环反复检查哪些事件已经就绪,然后执行对应的处理函数。

高效实现阻塞在平台的等待接口上,由内核返回已经就绪的事件。Linux 上常见的做法是 epoll 加 eventfd:epoll 等待 I/O 就绪,eventfd 让别的线程叫醒这个循环。定时器、跨线程投递和用户态任务队列也可以组成事件循环。

处理函数和对应事件先登记下来,循环处理本次已经就绪的事件。定时器到期和跨线程投递的任务可能来自另一条队列。每次空转扫描整张表会占满 CPU,实际实现不会这样做。

事件循环和多线程有三种常见配合:

  1. 每个线程一个事件循环。 事件表留在该线程内时,遍历和执行这张表不需要加锁。别的线程向这个循环投递任务时,投递队列仍然要同步。处理函数访问共享数据时,共享数据也要同步。
  2. 多个线程运行同一个事件循环。 例如多个线程同时执行同一个 io_context::run()。任务队列是共享的,处理函数直接在这些线程上执行;取任务和访问共享数据都要考虑同步。
  3. 事件循环加线程池。 一个线程专门等待事件,再把耗时工作交给另一个线程池。事件循环本身不共享,同步发生在交接队列上。

协程

这里说的是 C++20 无栈协程。编译器把这种函数改写成状态机:执行到挂起点可以停下来,协程帧保存局部变量和恢复位置,以后从该挂起点继续。可以恢复的位置只有挂起点。

函数定义中出现 co_await、co_yield 或 co_return,通常说明它是协程。注释、字符串和未求值操作数中的关键字不算。关键字写在 lambda 中时,协程是那个 lambda。返回类型还要提供合法的 promise_type。

协程还有这些限制:

三个关键字的职责不同:

协程约束的是这一条协程自己的推进顺序。操作在哪条线程上执行,由 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 条工作线程。有些小任务要等到某个时刻才能做,有些必须按先后顺序做。

三者的分工是:

一种常见配合是:

  1. 事件循环发现 I/O 或定时器就绪,恢复正在 co_await 的协程。
  2. 协程需要做重计算时,把这段工作提交给线程池。
  3. 线程池完成后,把结果投递回事件循环,再次恢复协程。