协程:从并发编程说开去

期末赋闲,网上冲浪,花了点时间了解了协程这么一个奇妙的玩意,没想到就一路从并发查到了python的生成器,索性写一篇随笔,讲讲自己的所见所得。

说说并发

进程、线程、协程,都是为了解决并发问题而存在的,所以,不如在讨论前先把并发说道清楚。

有计算机基础的同学一定熟悉这张图。

没错,就是现代计算机的基础——冯诺依曼架构。

上面的Memory对应内存,中间的控制单元与计算单元对应CPU、GPU,Input与Output则是硬盘、网卡等IO设备。

在最朴素的冯诺依曼架构中,我们将数据存入/读出内存是需要CPU参与协调的,所以程序执行到输入,是必然要阻塞的。此时的并发:

  1. 没有多核,并发并不能做到并行,但并发仍旧是有意义的,我们可以时分复用,使多个程序表现得像同时执行一样
  2. 而有多核之后,更是能够真正的并行,充分利用CPU

进程有了,那么怎么调度呢?我们知道,贸然打断一个进程,执行其他进程,是有可能产生数据访问冲突的,进程A正在读写磁盘的某一数据呢,B横插一脚,改了改数据,这还聊得,因而我们需要给资源上锁来保证自己的资源不会在自己被挂起时随意访问。当然,最初的处理方案可没这么费时费力的方案,而是一种更简单明了的思路——既然“贸”然打断会造成问题,我等到你觉得可以被打断再打断不就好了?这就是协作式多任务处理,也就是如今协程的雏形。

协作式多任务(Cooperative Multitasking),是一种多任务方式,多任务是使电脑能同时处理多个程序的技术,相对于抢占式多任务(Preemptive multitasking),协作式多任务要求每一个运行中的程序,定时放弃自己的执行权利,告知操作系统可让下一个程序执行。

既然协作式多任务处理可行,为什么还需要发展出抢占式多任务处理呢?

抢占式多任务处理(Preemption)是计算机操作系统中,一种实现多任务处理(multi task)的方式,相对于协作式多任务处理而言。协作式环境下,下一个进程被调度的前提是当前进程主动放弃时间片;抢占式环境下,操作系统完全决定进程调度方案,操作系统可以剥夺耗时长的进程的时间片,提供给其它进程。

  1. 每个任务赋予唯一的一个优先级(有些操作系统可以动态地改变任务的优先级)
  2. 假如有几个任务同时处于就绪状态,优先级最高的那个将被运行;
  3. 只要有一个优先级更高的任务就绪,它就可以中断当前优先级较低的任务的执行;

这很好解释。如果把进程使用CPU比作如厕,那么抢占就像是随时把人拉出来,抢个坑位;而非抢占则是等一个人觉得这一阶段差不多了,先出来,让给后边人,之后再补上后边的——假设一个人上厕所可以分几个阶段进行(喷射)。抢占环境下,虽然大家都不痛快,但是好歹都能上厕所;那非抢占式呢?假如来个不文明的,占着茅坑不拉屎,后边的人不就憋死了吗。当然,人还是讲文明的,但是写的烂的程序可不管,自顾自地死循环。

于是,虽然需要进行上锁等额外步骤,抢占式多任务处理仍然成为了操作系统的主流设计思路。

Windows 3.x 支持的是协作式多任务,但从Win95开始,抢占式多任务处理成为普遍的设计。

说说IO

上述讨论中,我们了解到:一个CPU核心同时仅能执行一个任务(进程/线程),并发的多任务在多个核上通过调度策略——协作式/抢占式多任务处理进行调度,分享计算资源。

CPU的资源分配是解决了,那么其他资源呢?内存早就由页表分配给了每个程序,剩下的就是IO了。

IO指输入输出,泛指一切输入输出设备,包括硬盘、网卡、外设等等。

我们知道,早年的IO操作是需要CPU全程参与的,但数据传输其实本身是较为简单的,导致CPU大部分时间还是在看着数据慢慢地从IO移动到内存,又或移出,太浪费CPU的时间了。索性给个够用就行的芯片吧!

于是,有了DMA技术,即直接内存访问(Direct Memory Access)

直接内存访问(Direct Memory Access,DMA)是计算机科学中的一种内存访问技术。它允许某些电脑内部的硬件子系统(电脑外设),可以独立地直接读写系统内存,而不需中央处理器(CPU)介入处理 。在同等程度的处理器负担下,DMA是一种快速的数据传送方式。很多硬件的系统会使用DMA,包含硬盘控制器、绘图显卡、网卡和声卡。

通过DMA控制器,处理数据移动的计算任务交给了DMA Controller,CPU则可以在这个时间做别的事了;而且,由于IO不需要CPU参与,我们甚至可以并发IO,而不用担心CPU核不够用。

此时,某任务进入IO操作了,且需要数据进行后续处理,操作系统就可以将其挂起,先去执行别的任务,待其IO完毕再调度它;更进一步,这就使得异步成为可能,任务(进程/线程)可以启动读取IO,然后忙自己的,等到数据到位,再进行处理——即事件驱动模型——可以参考笔者的网络编程IO模型小记。

回到进程、线程与协程

进程自不必多说,是操作系统对程序运行时的程序、占有的资源的抽象,是讨论任务调度的基础。

但是,进程的调度需要涉及进程的上下文切换,耗时耗力,很容易成为性能瓶颈,且进程间通信(IPC)耗费亦大;于是,更符合现代软件开发,共用内存内容、文件描述符等资源的线程就成为了进程的补充,在一个程序内更轻量地并行任务。

进程与线程的调度都是操作系统提供支持的,换句话说,抢占式的多任务处理,这也符合谈谈并行中的论述。

那么协程呢?先看看wiki定义。

协程(英语:coroutine)是计算机程序的一类组件,推广了协作式多任务的子例程,允许执行被挂起与被恢复。相对子例程而言,协程更为一般和灵活,但在实践中使用没有子例程那样广泛。协程更适合于用来实现彼此熟悉的程序组件,如协作式多任务、异常处理、事件循环、迭代器、无限列表和管道。

我再对现在广泛采用的协程给出一个定义:由用户自行支持的协作式多任务调度机制。

没错,在抢占式当道的现在,协程是不能被操作系统看见的,不然就被强行抢占了,故仅能存在于用户空间,比如执行在某一线程里。

等等?前面不是说协作式就像上厕所啥的,有人占着茅坑就全完蛋吗?怎么现在又行了?

别急,听我慢慢讲。

行不行是分场合的,在整个计算机上,运行的程序千奇百怪,很可能一个老鼠屎坏了一锅粥,而且由于程序是用户安装执行的,我们没法要求用户鉴别垃圾软件,因此使用抢占式有利于操作系统处理垃圾进程。但在一个进程内呢?程序是一个团队开发的,各个线程就像一家人,就不能有点基本信任吗?何必害的每个人都不爽呢。因此,在协程角度,我们将摒除垃圾协程的任务交给开发者,也就不需要抢占了,另外,由于每个协程都能在自己认为合适的时刻被中断,许多用于保证事务完整性的锁也就不必要了,降低了切换开销。

作为一种任务调度机制,不能为操作系统所支持,也就无法享受多核并发的优势,可以说是糟透了,但是我们仍然可以使程序表现得像一起执行一样——此话怎讲?这就与现在的异步IO脱不开关系了。

DMA的异步IO下,IO与任务流解绑,不仅是CPU可以从阻塞的任务中抽身,连任务本身都不需要为IO所阻塞,先启动IO任务,然后忙别的就行。

这种机制下,一个线程中的多个协程,便可以先开启自己所需的IO,然后接力到下一个协程,让他开始IO,最后,待到自己的IO完成,再进行处理即可,完全不涉及到操作系统中的上下文切换,在一个线程内就完成了任务——尤其适合IO密集型任务,如代理服务器等,计算仅涉及协议检验,大部分时间都在将数据从In网卡搬运到Out网卡;但是对于计算密集型,由于缺乏多核调度能力,还是需要依赖多线程执行。

总结

  1. 无法控制的进程行为导致协作式多任务处理不可行
  2. 同一程序内的协程受到开发者的管理,为协作式多任务处理提供基础
  3. 缺乏操作系统支持,无法充分利用CPU资源,但在DMA与异步IO支持下,IO并行成为可能,为协程提供了应用场景。

协程在某种程度上将控制流串行化,避免了多线程异步中繁琐的回调,使得控制逻辑更为清晰,之后,有机会的话,我还想从控制流与逻辑流的角度来讲一讲协程。

参考文献

  1. https://zhuanlan.zhihu.com/p/147608872
  2. https://zh.wikipedia.org/zh-cn/%E7%9B%B4%E6%8E%A5%E8%A8%98%E6%86%B6%E9%AB%94%E5%AD%98%E5%8F%96
  3. https://zh.wikipedia.org/zh-cn/%E6%8A%A2%E5%8D%A0%E5%BC%8F%E5%A4%9A%E4%BB%BB%E5%8A%A1%E5%A4%84%E7%90%86
  4. https://zh.m.wikipedia.org/zh-hans/%E5%86%AF%C2%B7%E8%AF%BA%E4%BC%8A%E6%9B%BC%E7%BB%93%E6%9E%84
  5. https://zh.wikipedia.org/zh-cn/%E5%8D%8F%E4%BD%9C%E5%BC%8F%E5%A4%9A%E4%BB%BB%E5%8A%A1