co_context[1]: 易用性设计
co_context 是最近开发的 C++ 异步协程框架,基于 Linux io_uring。「易用性」和「性能」是我们最重视的属性,这篇文章,我们介绍在 co_context 中有关易用性的设计。
I/O:同步风格,业务专注
任何有意义的计算机程序都需要做 I/O,为了提高性能,我们不得不追求更好的 I/O 模型。很多网络文章将 I/O 模型分成阻塞、非阻塞、多路复用等多种,但本人不太喜欢这种分类(阻塞+非阻塞≠全集,违反直觉)。从代码风格上,我将 I/O 模型分成两种:同步式,函数回调式。
以一个简单需求为例:等待五秒钟后,打印“Hello, world!”。我们对比一下同步式和函数回调的写法:
1 | // 同步式,使用C++标准库 |
1 | // 同步式,使用co_context |
1 | // 函数回调,使用Boost.Asio |
同步阻塞式 | 函数回调式 | co_context(同步式) | |
---|---|---|---|
学习难度 | 易 | 难 | 易 |
编程难度 | 易 | 难 | 易 |
阅读难度 | 易 | 非常难 | 易 |
调度者 | OS(线程调度) | 用户(显式) | 用户(隐式) |
并发容量参考 | 千级 | 十万级 | 十万级 |
典型场景 | 磁盘;客户端 | 网络;服务器 | 全场景 |
从业务逻辑的角度看,同步式的表达更加流畅,语言噪音更小,简单易懂;而函数回调写的业务比较零散,仅因一个计时器就需要将业务分割成两部分(等待 5 秒,打印 Helloworld),当业务越来越复杂,代码分裂割据的现象就越严重。只需看 Boost.Asio 的 echo server 例子就能知道,回调不是一般人能喜欢写的。所以,co_context 选择了同步写法。
抽象层次:从系统调用出发
在众多 C++ 异步框架中,抽象层次有高有低。Asio 对 Proactor 模式的应用登峰造极,做出 Executor,Strand,Buffer,Stream 等偏向编程的抽象;最近比较活跃的搜狗 Workflow 面向任务流抽象,直接内置了通用网络协议,用户将基础任务和协议串联或并联成自己的应用;中文互联网教科书级别的 Muduo 针对网络层和传输层抽象,对熟练计算机网络的同学比较友好;还有 libevent、libev、libuv 等等……任何一种抽象都在学习成本和开发效率上有所优劣。而 co_context 的做法是,直接对同步阻塞系统调用抽象(走操作系统的路,让操作系统无路可走)。偏底层的开发者基于 co_context 完成高级业务库后,偏上层的开发者同样是以同步编程的风格来描述业务逻辑。
用系统调用构建你的库
在 Linux 中,同步阻塞 API 直截了当地表达了程序员的意图,而 co_context 提供的 API 极力地继承了这种特性。
1 | // Linux 系统调用 |
从实现的角度上说,因为底层的 io_uring 也是面向系统调用的,所以提供这些接口的实现并不困难。从用户的角度看,用这些系统调用来实现基本的功能实在是太简单了(相比用函数回调)。例如,实现一个 netcat 基础功能,将 TCP 连接上接收到的任何信息打印到 stdout:
1 | task<int> hear(co_context::socket peer) { |
然后,上层可以同步地调用hear
协程:
1 | task<void> run(int some_para) { |
而task<void>
(或者缩写task<>
)是一种特殊的协程类型,可以独立地被生成并运行,例如,开启 10000 个 run()
协程:
1 | for (int i=0; i<10000; ++i) |
库+调度器=应用
上面的例子演示了如何用协程式 API 实现自己的业务库,但是,光有库是不能做成应用的,我们需要一个调度器来运行这些库函数。调度器负责管理所有的协程,在协程因等待 I/O 而暂停时,调度器寻找下一个就绪的协程,并恢复其执行,如此往复。调度器可以掌控一个或多个线程。
co_context 的调度器名叫io_context
,这是从 Boost.Asio 借鉴而来的。 与 Asio 需要经常指定 io_context
不同,co_context 是隐式指定的,默认沿用当前的调度器,所以调度器的存在感非常低。通常,只有为了更好的负载均衡,将任务分配到特定的线程组上,才需要显式指定调度器。
1 | using namespace co_context; |
高阶 API
很多人,包括我的毕设答辩考官,可能会忽视协程式 API 的威力。“你是不是仅仅对系统调用做了一个迁移(简单包装)?”当然不是,co_context 提供的惰性求值 API 具有可组合的能力,在易用性和性能上都是大杀器。
如上图所示,用户可以将两个原始的一阶 API 组合,形成二阶 API。只要有意义,用户要写成三四五六七八阶都不是问题。如下图所示,在运行时,co_context 将整个高
阶 API 转化为链表,整个链表只进入一次调度器,只进入一次 io_uring,显然可以节省调度开销。
总结
「面向系统调用」和「高阶 API 」是一套组合拳,从底层开始为用户提供了易用性(和性能)。因为使用了同步的代码风格,我们不难将已有的同步库改写成 co_context 的形式,从而获得强大的并发性能;如果原有库是基于系统调用,那么……可能加一次班就能完成迁移了。
co_context 在易用性上还有其他设计,例如:C++协程帮你托管了堆内存,你不再需要用shared_ptr
了;当你忘记用co_await
时, 编译器会向你道歉;等等,只不过这些内容有点喧宾夺主,我就不多写了。在未来的文章中,我可能会介绍这些易用性是怎样实现的。下一章,打算先介绍 co_context 的性能设计,看看它用了哪些 trick 来骗取性能。
代码实时同步于 Github,求 star~