深入异步与无栈协程通用概念
协程是一个简单的概念,但它同时又没那么简单,因为其所代表的异步编程的思想具有一定的历史发展过程,而在这里,我们试图从异步最早的历史对协程的工作原理以及协程库要做到什么。
前言: 本文试图将 I/O 多路复用、异步 I/O、事件循环、协程、async/await 等概念串联起来,其中涉及到的 syscall 以 Linux 为主
在异步之旅开始之前
相信任何一个朋友在一开始接触编程的时候都会编写类似下面这样的 C++ 代码
1 |
|
这样的一段程序的行为非常的显然,在第五行会阻塞起来等待输入,实质上在这一段代码背后实际上是调用了阻塞的 syscall read()
,当你通过标准库具体的实现调用了这个系统调用时,会从用户态陷入内核态,等待内核将输入交给程序,才会继续下去。这是程序最简单的形态,以至于如果你参加任何算法竞赛的时候,要写的程序都是这样的形态。而这种系统调用称为阻塞的,阻塞IO
但这太简单了,初学者一定会好奇,这样一些从上到下顺序执行的代码,如何拼凑出一个带 UI 的程序。比如他们可能会使用 Qt 去做一个贪吃蛇程序,他们做到了,但始终觉得隔靴搔痒,因为他们会听说,他们所做的 Qt 程序运行在一个事件循环上,而这个事件循环,完全是一个单线程程序,单线程怎么可能做到这个事情呢,它怎么能做到等待你输入的时候还能保证软件被渲染,这个程序不使用多线程简直是个魔法嘛
是的,我当年就是这么想的,在当时的我看来,阻塞完全是一个 debuff,应该需要一个不阻塞的接口,如果没有按下,也能正常执行后面的程序。那么我们就可以说同操作系统实际做的事情一样创建一个巨大的 while
循环,不断询问操作系统,对应的按钮是否被按下,这些询问本身并不花时间,只是问一问操作系统当前这个 IO 任务是否被完成,如果完成,我程序就可以从操作系统里取,如果没完成,那我就不管了,等到 while 下一次询问。这种类型的系统调用被称为非阻塞的,非阻塞IO,就像下面这样:
1 | // 假设这里的isClickedButtonX()就是一个不阻塞的询问 |
要说的是,这样,配以一个合理编排的 handler 其实是最高效的处理这些 IO 事件的方式,事实上某些单片机程序就是这么做的,这种循环查询某个事件是否就绪的模式称为轮询(polling)。
这样的方式可以进行的原因是:我们必须理解程序的运行,程序的信息来自于两处,一个是来自操作系统,一个是程序自身已经编入的信息,它在运行时的额外信息完全来自于操作系统,而操作系统的信息来自于硬件的交互。操作系统本身是异步的,因为硬件是并行处理的,操作系统通过中断以及各种调度算法处理并存储来自硬件的各种信息,本身 OS 就作为一个巨大 Buffer 为程序提供信息,所以程序本身无需在意数据是怎么被具体准备出来的,你只需要向操作系统提起任务,操作系统就为你在缓冲区里准备好数据,你自己去取,因此数据其实短期内不会丢失的,只等你的应用去响应并消费。
但是这种方式最大的问题是,当裸的 while(1)
出现在你的程序里,导致死循环,会大量占用 CPU 资源,CPU 占用率高。在这里我们需要知道这个值的具体定义:CPU 占用率是通过对比忙碌时间和总时间得出的百分比,而总时间减去忙碌时间是空闲时间,其实操作系统完成了对 CPU 的调度,操作系统在运行时可以告诉 CPU,你要在某一个时间周期里工作多久,然后这个时间周期里的 CPU 还能休息一会,停止工作,而这段时间就是空闲时间。
对于死循环最大的问题是,死循环程序会导致它试图最大程度请求 CPU 去做无用的轮询,操作系统为了最大满足它索要的计算资源,不允许 CPU 停止休息,因此占用率便提高了。
所以完全阻塞等待某个资源不行,开个循环询问所有任务的就绪状态也有问题,那应该怎么办呢?
破局之法之多路复用
上文提到的阻塞 IO 并没有死循环的问题,你哪怕在一个 while
循环里写一个 std::cin
也不会出现占用超多 CPU 资源的问题,因为在你运行到的时候,系统就已经定在那了,程序被挂起了,不会走新的一轮 while
循环,但问题就在于程序此时此刻就只能做一件事情了,也许你会同早期的我一样,认为这个关键就在于 syscall 阻塞了线程的活动,应该彻底抛开可能导致阻塞的系统调用,也许存在一个可以天降信息的超级接口可以传递信息,那你就绕进了这个局中,很不幸,事实证明这样的接口并不存在。
让我们回来看看我们所需要做到的事情:如果我们要做一个单线程的贪吃蛇的小程序,其实最大的点是,我们可能需要程序以一个固定的帧率去绘制,移动屏幕上的这条小蛇,那么我们就需要设定一个计时器来定时做一些事情,但是我们忽略了一个事情,那就是计时器事件本身就也是一个IO事件,从更大的一个层次上,他和你等待一个按钮按下没有什么区别。
如果在 while
里将任务轮询式能解决问题,但是没有阻塞导致占用率高,但阻塞的情况只会响应一个任务,那又不行。但如果我们能把这些个 if
扔给操作系统去考虑呢?于是 IO 多路复用出现了,它的核心原理是,在一个线程里通过有且仅有一个接口去获取所有IO任务,如果查询的时候存在准备好的数据,则立即返回并处理,如果查询时没有准备好的数据,则立即阻塞,等待操作系统准备好数据立即将其唤醒,这一类接口,比较常见的就是 Linux 下的 select/poll/epoll,将这样的接口置于while循环下,如果你处理IO数据的速度高于IO数据产生的速度,你就可以及时解决所有IO任务
看到了么,我特意强调了阻塞的字眼,为什么?需要强调的是,这里接口的阻塞并不是程序处理事件意义上的阻塞,接口的阻塞是因为至少目前为止要想释放 CPU 的利用率,必须要让用户态应用程序挂起,所以从这个意义上来说,阻塞是必然的,所有这类要谦让资源的程序都应当是阻塞的程序。而只要有一个接口访问 IO 事件,等事件一出现,就立即响应,消费掉任务,然后挂起,就不算程序处理事件意义上的阻塞,因为你的程序仍然会高效的处理事件。
是的,此阻塞非彼阻塞,所谓阻塞亦能实现非阻塞
所以,我们的程序需要高效的处理或者说消费掉任务,这是保证任务不会堆起来,所有任务都能得到处理的前提,从具体参数上看就是处理IO数据的速度高于IO数据产生的速度,所以这个条件成立的程序,是为 IO 密集型程序,适合这种模式。而不满足这个条件的程序,一种是计算量特别大的程序,可能是执行某些科学计算,求解微分方程,处理图像,甚至是深度学习任务,这些程序是为计算密集型任务,一般选择多线程方式,还有一种是现代设备IO速度越来越快,此时可能直接轮询,试图达到速率上限能获得更高的收益(比如 io_uring 就是在做这里的极致优化)。
事件循环
所以最根本的问题解决了,接下来是要为这个简陋的 while(1)
循环加工一下了,我们需要一个在这个循环体进行一次时能够彻底消费掉这些操作系统里出现的数据的代码,其实这是一个 handler,是一个调度器。在绝大部分的实现中,它都以一些我们耳熟能详的名字出现:事件驱动的模式,或者说事件循环。
来看下面这段给出的伪代码:
1 | auto *task_list = TaskList.getInstance()->add(InitTask); |
首先,既然要监听事件,必须有一个监听器,在这里是 EventFetcher
,你需要在 initTask
这个初始任务里完成注册,紧接着是事件发生后分发任务的函数 EventDispatcher
,它将事件按照注册时留下的函数,将发生事件后处理事件的任务扔给 taskList
,并一个一个跟着处理,taskList
这样的容器可能是按照优先级优先弹出的优先队列或者其他什么算法,用以高效的分配任务,这样的任务我们给它一个名字,叫做回调函数
要注意的是,输入 taskList
的是一个闭包,如果假使说这个任务是一个计时器,也许你会需要循环往复的计时,此时你必须在回调函数里将计时器重新启动,启动的过程就是向 EventFetcher
再注册一遍计时器,向 EventDispatcher
再把自己的任务和自己当前正在执行的这个回调组成的一对数据再次传入,以此实现无限计时的效果
回调式的写法在现代的 UI 框架里相当常见,例如 Qt 的信号槽机制,本质上就是这样的东西,我们来看看下面 Dart 的事件循环实现
哎哎,这不就是一模一样,所以说自 IO 多路复用出现后,事件循环也很自然的就出现了
然而大量使用回调引发了回调地狱的问题,异步最大的问题就是难以阅读,当代码量一旦庞大起来,很有可能函数要成为一等公民在函数里作为参数反复传递,最终放到一个对象某个行为的回调中。同时异步的行为导致无法定位事件的来源,所有的任务的调用栈将都被污染为事件循环所执行的,形成了可悲的厚障壁—— asynchronous gap 为程序员调试带来了极大的困难。为了缓解回调地狱,Javascript 带头引入了 promise 的概念,但仍然未脱离回调的意图,只是让回调写成链式调用更好看一点罢了,在此处我们按下不表,有兴趣的可以自行阅读 Promise - JavaScript | MDN
横空出世的协程,async/await 的秘法
未完待续 TODO
问题:await 是等待某个任务结束后运行下去,那这不就是阻塞么,实则不然
大纲:async 函数是状态机。await 可以等待多个,可以是多个里有一个结束就 await 结束,可以是完全等待多个一起结束而结束,因此 async/await 构成一棵可以被可视化的树,其所有叶子节点都是 await 函数的尽头,即具体等待一个任务,树的下方是调度器,当事件发生时,调度器将会在事件列表中挑选一个事件,poll 这个事件对应的叶子节点,是一个 async 函数/协程,让它执行至挂起或结束,控制权回到调度器,处理下一个事件,如果事件消费完毕,则继续挂起,这样的循环构成了异步模式下,无栈协程组成程序的图景。其实 async/await 的一种实现是在事件循环里将状态机监听的事件的回调统一化成推动状态机运行的 poll
小点:异步模式与同步模式具有鸿沟,异步模式是基于同步模式的一种特殊环境,所以 async 函数具有传染性,这是异步模式本身的性质所决定的
目前我们讨论的话题局限于单线程,实则多线程条件下协程也能发挥其它作用,以及有一些别的应用,要说的是,协程从不与异步强绑定,实际协程只是一个代码块而已,代表了程序本身最本质的概念:状态机(jyy os课)