深入理解异步IO

Posted on December 13, 2024

前言

干了那么多年码农,遇到 IO 从来都是写异步代码。从最初直面裸 epoll 写,到后来基本上依赖 asio 写。 最近研究 io_uring 后,试着用 io_uring 把 IOCP 那套 API 实现了。

以前呆在 asio 的舒适区,根本没关注过其他程序员对异步的理解。但是 windows 程序员是最多的,当我研究IOCP的时候, 以前被我忽略掉的那群写 IOCP 的程序员,突然进入了我的视线。

当我研究了他们对异步的一些吐槽后,才发现了一个真相:大部程序员并不懂异步。

尤其是,我由于不熟悉 IOCP,故而为了给 iocp4linux 找测试用例,而翻遍网络,寻找使用 IOCP 写成的一些网络代码示例。 能跑通它们,才算 iocp4linux 功能完成了。对吧。

结果,大部分 IOCP 的例子,虽然能跑。但是我研究了他们的代码后发现,统统都是错误的。完全没有领会到异步IO的精髓。根本就 没有真正的理解异步IO。

所以,我打算写数篇最深入剖析异步IO的引导文章,足够教会大家 真正的异步思维。

线程的由来

现在的程序员大部分都是新世纪后出生的。所以早就忘记了线程的设计初衷了。我看到大部分新世纪程序员都说线程是为了并发,为了压榨cpu的性能。

其实多线程压榨 cpu 的性能,是最近十几年才有的。而线程的历史,和操作系统一样古老。 计算机诞生的前半个世纪,CPU都是单核的。那时候就已经有了多线程。

所以,线程的最初真正作用,实际上出乎现在的程序员的意料:是为了实现异步IO。

DOS 下的 IO

在个人电脑的上古时代,一个打字员的日常工作,是打字,然后 按下键盘上的 Ctrl-S。接着去泡杯茶去喝。

当 wps 要将文档存入软盘,意味着这段时间,整台电脑都处于“不可操作”的状态。 直到软驱磁头吭呲吭呲的干完活,当前的应用才会复活,能继续执行其他任务了。

为啥会这样呢?因为 DOS 是一个单任务的操作系统,不支持多线程。这就意味着,DOS 不支持异步 IO。WPS要写磁盘,就只能等待 DOS 完成文件写入。 期间整个电脑都无法干其他工作。

这个问题,直到“多任务系统”的出现才解决。当然,多任务系统,出现的比 DOS 早多了,只不过,引入 PC 领域要晚的多。一直到 windows 出现。

在 windows 下,操作系统有了多任务的能力。什么是多任务的能力?简单的来说,就是多线程。虽然那时候,cpu 还是只有一个核心。 多线程,其实并不是真正的并行运行。而是轮流使用 cpu 时间。

为什么要支持多任务? 你想,当你的 WPS 忙着写磁盘文件,软驱磁头在吭呲吭呲的干活的时候,你是不是可以切换任务,去运行别的软件了?

这就是多线程带来的 “异步”。当这个任务,在等待 IO 完成的时候,宝贵的CPU时间,可以释放出来运行另一个任务。

IO 和任务调度 - 操作系统内核级

当一个任务要进行 IO ,意味着接下来它已经无法干活了。它一定要等 IO完成才能继续干活。于是,操作系统会很贴心的将任务挂起。 然后调度 CPU 运行别的任务。 当 IO 操作完成了,操作系统就会把那个发起IO的任务给“唤醒”,然后调度它继续运行。

所以,在操作系统的内部,有一个“事件循环”,还有一票“任务列队”。事件循环不断的获取 事件(可以是硬件发的,也可以是软件发的), 然后 调度 完成事件了的任务去运行。

如果同一时间有大量事件完成,则这些任务就得按一定的优先级去竞争 CPU 时间。

而如果同一时间要发出大量IO操作,则需要创建大量的“线程”。

没错,在古代,发起多个 IO操作就必须要创建多个线程。

这就是很多下载软件说自己是 “多线程下载” 的由来。

随着 IO 的性能提高,一台计算机里,同时能支持的 并发IO数量越来越多。这也就意味着,线程的数量会越来越多。。

然而,CPU 的数量,并没有跟着一起增长出来。在古代, “10k client problem” 是一个研究热门。一万个并发IO,得一万个线程。 想想古代计算机那体系下,创建一万个线程,那操作系统的调度压力会有多大。

因此,业界迫切需要一种替代线程的异步IO手段。

单线程异步IO

我们现在所说的异步IO,其实指的是单线程异步IO。意思是一个线程就可以发起多个IO操作。 而过去多线程实现的异步IO,在单线程的视角下,就变成了同步IO。

单线程实现异步IO,其实就是把内核里的 IO和任务调度 的做法,给复制了一份到用户层。 当然因为有内核打底,到用户层这边,这部分的代码就简单多了。并不像内核里那般复杂。

因此,要支持用户层可以单线程异步IO,内核需要提供几个关键设施

  1. 事件通知接口

     这个事件通知,必须得是多路复用的。一个接口,要负责通知所有的事件。任何遗漏掉的事件,都意味着那部分就得额外使用线程工作。
    
  2. IO 发起操作 或 非阻塞 IO操作

     所谓 IO发起操作,意思是,调用这个API后,这个 IO 操就可以认为已经进入后台开始执行了。
     执行结果“成功/失败”会通过事件通知接口汇报。
     而 非阻塞IO操作,意味着IO操作要么立即完成,要么无法完成。
     如果立即完成,那么即便是同步IO,也不会阻塞线程,算是解决了最初的问题“IO不能卡电脑啊”。
     如果是无法完成,那么必须可以支持某种“什么时候操作IO不会失败的时候告诉我,我到时候重试”这样的通知机制。
    

那么基于内核提供的这些接口,应用软件就可以构造出自己的“任务调度器”。

IO 和任务调度 - 应用软件级

细心的人已经注意到了,这个小节使用了相同的标题。于是只好加了副标题进行区分。

既然单线程异步IO实际上就是把内核的工作给复刻一部分到应用层。那么实际上这部分的编码逻辑,就应该参考内核而来。

如果大家有研究过操作系统的代码,就应该知道,操作系统实际上是有和 cpu 核心数量一样多的调度器。

每个调度器执行的代码,都是如下所示的伪代码:

for(;;) // 调度器是个死循环
{
    if (runable_task_list.empty())
    {
        hlt(); // 将 cpu 放入 可中断的低功耗睡眠模式
    }
    else
    {
        cur = runable_task_list.pop();
        switch_to(cur);
    }
}

那么,自然,在应用软件里,也应该开启和线程数一样多的“事件循环”。 对系统内核来说,它的任务调度器的数量,是受硬件控制的。自身是不能为所欲为的。 而对于应用软件来说,线程数量倒是可以自己控制。想开几个线程就开几个线程。

那么,在应用软件里,事件循环应该和内核一样,和具体任务无关,只保留调度代码。用伪代码表示如下:

for(;!quit;) // 和内核不一样,这里可停止循环
{
    // 从内核获取 事件.
    event = get_os_event();
    // 运行 事件所“依附”的 任务代码.
    run_task(event.task);
}

然后,就可以基于这个事件循环,逐步构建自己的应用代码。

异步IO下的 API 设计

之所以前面长篇大论,其实最终还是为了这段内容服务的。

从前面的历史,线程概念,异步IO概念,就是为了写现在那句话:

内核调度的多任务是线程。而用户线程调度的多任务,就是协程。

既然异步IO,实质就是把内核的任务调度,复刻一份到用户层。那么复刻“线程”的概念到用户层,就非常有用,而且也是必须的。

在最初计算机刚刚发明,开始分 “操作系统”和“应用软件”的时候,系统给应用软件提供的就不是直接基于硬件底层的 API,而是在 API 上面封装一层后的 “同步IO”,并且软件使用多线程进行并发IO。 后来操作系统为了提高工作效率,避免陷入百万线程调度的困境,而选择提供机制让应用层自己调度任务。 那么应用层自己给自己暴露的API,就应该是,也必须是,基于协程的。

因为事实已经无可辩驳的证明了,程序员在编写逻辑的时候,更倾向于使用“同步IO”的思维模式。因此,程序员使用的IO接口,必然是要么 “阻塞线程” 的 API, 要么是 “阻塞协程” 的 API。

如果操作系统本身就提供“阻塞协程”的 API,那么皆大欢喜。否则,应用层应该首先就操作系统提供的非阻塞API+多路复用器构建出一套 “阻塞协程” 的 API。

就拿 “接受 tcp 链接” 这个代码来说,在古代 多线程模式下,代码这么写:


void client_thread(socket client_fd);

void accept_thread(socket listen_fd)
{
    for (;;)
    {
        sockaddr new_client_addr;
        int addr_len;

        socket new_client = accept(listen_fd, &new_client_addr, &addr_len);

        create_thread(client_thread, new_client);
    }
}

而使用异步IO,必须,也必然要这么写,才能最大化的提高代码可读性,可维护性。

awaitable<void> client_coro(socket client_fd);

awaitable<void> accept_coro(socket listen_fd)
{
    for (;;)
    {
        sockaddr new_client_addr;
        int addr_len;

        socket new_client = co_await async_accept(listen_fd, &new_client_addr, &addr_len);

        create_coro(client_coro(new_client));
    }
}

但是,co_await 是 c++20 才有的新设施。难道c++20以前,程序员就不配写异步代码吗?

必然是可以的。因为,co_await 代表的协程,是一种叫 “无栈协程” 的技术路线。 在 c++20 以前,乃至现在和遥远的将来,程序员都可以使用一种叫 “有栈协程” 的技术。 事实上,线程本身就是被内核调度的“有栈协程”。

因此,在我看来,异步IO必须设计为使用协程并 “阻塞协程” 的同步IO。它只阻塞协程,并不阻塞线程。 线程还是要继续运行,然后到 获取内核的事件通知的时候,才能被阻塞。 正如内核的调度器,要一直运行,直到无事件才调用 hlt 指令让 cpu 进入低功耗待机模式。

由于 c++20 并不是“随处可得”的普及状态。因此如果设计一个IO库,则必须使用”proactor” 模型。 因为只有 proactor 模型,才能完美适配协程。

也就是架构如下图所示:

img

而用户实际编写代码的时候,要么使用 co_await 协程。要么使用 有栈协程。

有栈协程并不依赖编译器实现。因此可以视为编译器能力受限情况下的最优解。

在这个意义下,asio 提供了如何写一个多范式 IO 库的完美例子。

asio 里的IO对象,其 async_xxx 系列函数,同时提供了 “co_await 协程”,“有栈协程”,“回调” 三种体系。

如果你自己设计的 IO 库,可以不需要像 asio 那样“考虑周全”。 只需要提供 无栈 或者 有栈 二者之一即可。

不过,最好 协程是构建在 回调 的基础上。并同时暴露出 基于回调的接口。

以便用户可以自行扩展。

扩展阅读

为了更好的理解本文,推荐一些扩展阅读。

愚蠢的 on(‘data’, cb) 接口设计

网络库常见的糟糕设计有哪些

重叠IO(proactor)是最理想的IO模型

Comments