什么是协程

线程(进程)在执行一段程序过程中,遇到了大量 IO 操作,是继续等还是切走去干别的活?如果等着,那么是同步编程,这个线程相当于不干活了,“发呆”直到 IO 操作完成。如果切走去干别的活,那么是异步编程,等 IO 操作完成了,线程可以回来接着干活。

协程(Coroutine)是 C++ 20 实现异步编程的基础工具之一 。正常来说,函数不能执行到一半停下来,但是协程函数可以。协程函数实现的本质是回调函数,一段函数执行到一半遇到耗时较久的 IO 时,可以让线程切走,等 IO 完成后再调用回调函数让线程回来干活。

简单地,我们把程序分成两个类型:计算和 IO。考虑如下场景:有四个协程函数分别为 ABCD,其中 AB 中间有 IO 操作,CD 没有:

  • A:计算 -> IO -> 计算
  • B:计算 -> IO -> 计算
  • C:计算 -> 计算
  • D:计算 -> 计算

此时有两个线程分别是 t1 和 t2,如果是同步编程:t1 在 A 中做完计算等待 IO,t2 在 B 中做完计算等待 IO,那么系统就停在这里了,能够直观地看到 CPU 利用率降了下来,因为两个线程在等 IO。 如果是异步编程:t1 在 A 中做完计算可以去 C 里面做计算,t2 在 B 中做完计算可以去 D 做计算,等到 A 和 B 的 IO 完成后,t1 和 t2 都把 C 和 D 的计算都干完了,再切回 A 和 B 中继续计算,这期间 CPU 利用率一直很高,做到充分利用 CPU 资源,活干得也快。

协程的思想本质是线程复用。在上述同步编程的场景中,为了达到异步编程 CPU 打满的效果,不得不在 t1 和 t2 等待 IO 的时候多开两个线程去干 C 和 D 的计算,异步编程则充分利用 t1 和 t2 线程,在它们不干活的时候拉出去干活。

异步编程当然不是完美无缺,首先是编码更难,对象和指针生命周期管理更复杂,Debug 也会更困难,给编程人员带来挑战。所以选择异步编程就必须要有性能上的收益,否则只会让程序难写难维护。 另一个显而易见的缺点是协程的上下文切换带来的开销,A 中干活到一半切去 C 需要保留和恢复现场,但不同于线程或进程的上下文切换,协程的上下文切换通常比较轻量,因为协程的上下文信息比线程或进程的要少。协程的上下文切换通常是在用户空间中完成的,不需要进入内核态,因此开销比较小。异步编程适合吞吐量大、数据量大的场景,它能够帮助平衡计算和 IO。

有了协程后就解决异步编程的问题了吗?协程函数和线程一般是绑定的(取决于对协程的实现),也就是说一旦把协程函数分配给线程后,无论线程多繁忙,它就只能在那个线程上了。以上面的场景为例子:如果协程函数 ABC 都分配给 t1,D 分配给 t2 会发生什么?t1 非常繁忙,但是 t2 完成 D 的计算后也开始“发呆”了,也就是说没有负载均衡,这样子 CPU 也是打不满的,但编程人员在写代码的时候不可能知道分配的最佳策略。读者可以思考一下有什么解决办法。

什么是 Seastar

Seastar 是一个基于现代硬件的高性能 C++ 服务应用框架,用 C++ 14 实现了异步编程(future / promise)接口是特点之一,它还有另外三个特点:Shared-nothing(一个核心一个线程)架构,高性能网络,核心之间的高效信息传递。后两个就不展开介绍了,可以去官网看详细的描述。

这里的 Shared-nothing 架构是指一个核心一个线程,如果服务器有 96 核那么只开 96 个线程,并且核心和线程一一对应,俗称线程绑核,这么做的根本原因是硬件发展问题。一个老生常谈的情况是,目前处理器单核的频率已经达到瓶颈,性能的提升主要来自于多核的堆叠,服务器上百核的处理器已经见怪不怪,通过堆叠核心数来提升处理器性能的路还没走到头。 这种硬件发展现状造成的问题是,核心数越来越多,并发数越来越高,高并发程序在上下文切换、Cache 污染和锁争抢的损耗变得不可忽视。在磁盘 IO、网卡传输性能都还不强的年代,处理器在这些方面的损耗也有,但相比于磁盘和网络的开销都可以忽略不计,现在高性能 SSD 和万兆网卡越来越成熟,处理器的问题逐渐暴露。

当然,不开那么多线程行不行?自然是可以的。但是开多少线程最合适是个难以抉择的问题,开得少了 CPU 利用率上不来(如上面例子),开得多了上下文切换问题又出现,因此这不是最佳解决方案。Seastar 的解决方案是 Shared-nothing + 异步编程。

这张狗狗吃饭图相信读者在其他地方也看过,它形象地表达了 Seastar 选择绑核的理由,线程与核心绑定后不会切走,解决线程在不同核心之间切换的开销,线程之间不共享内存,解决缓存污染和锁争抢问题。线程数被限定后,就要使用异步编程来复用线程,充分利用 CPU 资源。Seastar 实现的异步编程接口是 future / promise 形式,感兴趣的读者可以去看具体用法。