co_context[1]: 易用性设计

co_context 是最近开发的 C++ 异步协程框架,基于 Linux io_uring。「易用性」和「性能」是我们最重视的属性,这篇文章,我们介绍在 co_context 中有关易用性的设计。

I/O:同步风格,业务专注

任何有意义的计算机程序都需要做 I/O,为了提高性能,我们不得不追求更好的 I/O 模型。很多网络文章将 I/O 模型分成阻塞、非阻塞、多路复用等多种,但本人不太喜欢这种分类(阻塞+非阻塞≠全集,违反直觉)。从代码风格上,我将 I/O 模型分成两种:同步式,函数回调式。

以一个简单需求为例:等待五秒钟后,打印“Hello, world!”。我们对比一下同步式和函数回调的写法:

1
2
3
4
5
6
7
8
9
// 同步式,使用C++标准库
void delay_print() {
this_thread::sleep_for(5s);
printf(“Hello, world!\n”);
}

// 在业务代码中使用
delay_print(); // 导致当前线程会阻塞5秒
std::thread t{delay_print}; // 生成新线程,令其阻塞5秒
1
2
3
4
5
6
7
8
9
// 同步式,使用co_context
task<void> delay_print() {
co_await timeout(5s);
printf(“Hello, world!\n”);
}

// 在业务代码中使用
co_await delay_print(); // 当前协程暂停5秒后被继续执行;当前线程不阻塞(转而执行其他协程)
co_spawn(delay_print()); // 生成一个新协程,5秒后被执行;当前协程继续执行
1
2
3
4
5
6
7
8
9
10
11
12
// 函数回调,使用Boost.Asio
void print(const error_code & /*e*/) {
cout << "Hello, world!" << endl;
}

int main() {
asio::io_context io;
asio::steady_timer t(io, 5s);
t.async_wait(&print); // 总是不会造成阻塞
io.run();
return 0;
}
同步阻塞式 函数回调式 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
2
3
4
5
6
7
8
9
// Linux 系统调用
int n = read(fd, buf, count);
int n = writev(fd, iov, iovcnt);
int fd = accept4(sockfd, addr, addrlen, flags); // man7.org/linux/man-pages/man2/accept.2.html

// co_context
int n = co_await read(fd, buf, count);
int n = co_await writev(fd, iov, iovcnt);
int fd = co_await accept(sockfd, addr, addrlen, flags);

从实现的角度上说,因为底层的 io_uring 也是面向系统调用的,所以提供这些接口的实现并不困难。从用户的角度看,用这些系统调用来实现基本的功能实在是太简单了(相比用函数回调)。例如,实现一个 netcat 基础功能,将 TCP 连接上接收到的任何信息打印到 stdout:

1
2
3
4
5
6
7
8
9
10
11
task<int> hear(co_context::socket peer) {
char buf[8192];
int nr = co_await peer.recv(buf);
while (nr > 0) {
co_await lazy::write(STDOUT_FILENO, {buf, nr}, /*offset*/0);
nr = co_await peer.recv(buf);
}
// handle_error(-nr); // 当场处理错误码
// throw std::system_error{-nr, std::system_category(), "hear"}; // 或者,抛出异常
co_return -nr; // 或者,返回错误码。
}

然后,上层可以同步地调用hear协程:

1
2
3
4
5
6
task<void> run(int some_para) {
// ...
int err = co_await hear(sockfd);
if (err != 0) handle_error(err);
// ...
}

task<void>(或者缩写task<>)是一种特殊的协程类型,可以独立地被生成并运行,例如,开启 10000 个 run() 协程:

1
2
3
for (int i=0; i<10000; ++i)
co_spawn(run(i));
// 是不是很像 std::thread{run, i}.detach() ?

库+调度器=应用

上面的例子演示了如何用协程式 API 实现自己的业务库,但是,光有库是不能做成应用的,我们需要一个调度器来运行这些库函数。调度器负责管理所有的协程,在协程因等待 I/O 而暂停时,调度器寻找下一个就绪的协程,并恢复其执行,如此往复。调度器可以掌控一个或多个线程。

co_context 的调度器名叫io_context,这是从 Boost.Asio 借鉴而来的。 与 Asio 需要经常指定 io_context 不同,co_context 是隐式指定的,默认沿用当前的调度器,所以调度器的存在感非常低。通常,只有为了更好的负载均衡,将任务分配到特定的线程组上,才需要显式指定调度器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
using namespace co_context;

task<> a() {
printf("a\n");
co_return;
}

task<> b() {
co_spawn(a()); // 不显式指定,则使用当前的调度器
printf("b\n");
co_return;
}

task<> c(io_context& io) {
io.co_spawn(a()); // 显式指定调度器
printf("c\n");
co_return;
}

int main() {
io_context io{32768};
io.co_spawn(b()); // 根源的co_spawn必须指定调度器
io.co_spawn(c(io));
io.run(); // 调度器开始工作
}

高阶 API

很多人,包括我的毕设答辩考官,可能会忽视协程式 API 的威力。“你是不是仅仅对系统调用做了一个迁移(简单包装)?”当然不是,co_context 提供的惰性求值 API 具有可组合的能力,在易用性性能上都是大杀器。

高阶API

如上图所示,用户可以将两个原始的一阶 API 组合,形成二阶 API。只要有意义,用户要写成三四五六七八阶都不是问题。如下图所示,在运行时,co_context 将整个高
阶 API 转化为链表,整个链表只进入一次调度器,只进入一次 io_uring,显然可以节省调度开销。

高阶API原理

总结

「面向系统调用」和「高阶 API 」是一套组合拳,从底层开始为用户提供了易用性(和性能)。因为使用了同步的代码风格,我们不难将已有的同步库改写成 co_context 的形式,从而获得强大的并发性能;如果原有库是基于系统调用,那么……可能加一次班就能完成迁移了。

co_context 在易用性上还有其他设计,例如:C++协程帮你托管了堆内存,你不再需要用shared_ptr了;当你忘记用co_await时, 编译器会向你道歉;等等,只不过这些内容有点喧宾夺主,我就不多写了。在未来的文章中,我可能会介绍这些易用性是怎样实现的。下一章,打算先介绍 co_context 的性能设计,看看它用了哪些 trick 来骗取性能。


代码实时同步于 Github,求 star~