这篇整个 异步编程 系列文章的第二篇。整个系列要回答一个简单问题:什么是异步编程?
起初当我开始深入研究这个问题的时候,我以为我知道它是什么。但事实证明,我并不知道。好了,让我们了解一下吧。
整个系列的文章:
- Asynchronous programming. Blocking I/O and non-blocking I/O(翻译在 异步编程:阻塞 IO 和 非阻塞 IO)
- Asynchronous programming. Cooperative multitasking(翻译即本篇文章)
- Asynchronous programming. Await the Future(异步编程:等待未来)
- Asynchronous programming. Python3.5+(异步编程:Python3.5+)
在上篇文章中,我们讨论了如果同时处理多个请求,并且是如何用线程和进程实现。但在这篇文章里,我们讨论另外一个方案 – 协作多任务。
这个方案更困难。我们说 OS 非常酷,它可以处理进程和线程,并组织它们之间的切换,也可以处理锁等等,但依然不知道应用是如何工作的,而我们开发者知道。我们都知道程序在 CPU 上执行计算的时间很短,大部分时间花在了网络 IO 上,那如果能在每个请求之间切换就更好了。
从操作系统的观点来看,协作多任务只有一个执行线程,但深入地看,是应用程序在每个请求或者命令之间切换。还拿之前的网络例子来说,,当有数据到达时,需读数据,解析请求,比如还需要把数据发送到数据库,而这个是阻塞的操作,但如果不等待数据库的响应,就可以开始处理其他请求了。这被称为“协作”是因为所有的任务或者命令必须协作整个调度来完成工作。它们相互交错,但都在一个线程 – 也就是协同调度器 – 的控制之中,它的角色仅仅是开始处理流程,并让这些任务主动将控制权交还给它。
这比线程多任务更简单,因为开发者知道当一个任务执行的时候,其他的任务就不会执行。虽然在单核的系统中一个多线程应用也是以这种交错的模式执行,但开发者在使用线程时需要考虑多线程的陷阱,以免应用移植到多核系统上时出现问题。但一个单线程异步程序总是交错执行的,即使在一个多核系统上。
这种程序的难点在于任务切换、维护上下文、将每个任务组织为一系列较小的步骤以使能够间歇性执行,这些都落在了开发者身上。另一方面,我们获得了高效,因为不存在不必要的切换,在线程和进程之间切换处理器上下文时也没有问题。
有 2 个方法实现协作多任务 – 回调 和 绿色线程。
回调
既然所有的阻塞操作导致某些行为在以后某个时间点才会发生,而我们的执行线程应该在准备好时返回结果,那为了获得结果,我们就可以注册一个回调 – 当请求或者操作成功时,会调用回调,或者执行不成功调用其他回调。回调是一个非常明了的做法 – 开发者用这种方法开发时不需要知道回调函数什么时候被调用。
这是最常用的方法,因为它简单明了并且主流的现代语言都支持。
优缺点:
- 和多线程程序不同,没有多线程的问题
- 线程和协程对程序员都是透明的
- 回调会淹没异常
- 回调之后还有回调会让人很难理解,也不容易调试
绿色线程
第二种方法就不那么简单明了了 – 当开发人员以这样的方式编写程序时,似乎没有协作多任务。我们做一个阻塞操作时,期望立马得到结果,就像只有一个进程或者线程一样。有这么一种黑魔法 – 框架或者编程语言可以让阻塞变成非阻塞,并将控制权转移到其他执行线程上,但这里说的不是 操作系统 层面的线程,而是逻辑线程(用户层线程)。它们由一个“普通的”用户层进程来调度,而不是内核。这就叫做 绿色线程。
优缺点:
- 由应用层控制,而不是操作系统
- 跟线程很像
- 包括除 CPU 上下文切换之外的基于线程的编程的所有问题
反应器模式
在协作多任务中,总有一个处理核心来响应所有的 IO 处理。设计模式中叫做 反应器。反应器接口这样描述:给我一堆你的 socket 及对应的回调,当 socket 准备好 IO 时,我来调用你的回调函数。
反应器还有一个接口,叫做定时器 – 在 X 毫秒后调用我,这是我要调用的的回调函数。定时器在协作多任务里无处不在,有的显式,有的是隐式的。
从底层看,反应器相当简单。它有一系列定时器,根据响应时间排序。把拿到的 socket 都送给轮询准备机制。可用性轮询机制总还有一个参数 – 它说的是如果没有网络活动将要阻塞多久。阻塞时间表示最近的计时器的响应时间。于是乎,不论是将有某种网络活动,一些 socket 准备好了 IO,又或者是我们将等待下一个定时器触发,解锁并将控制权转移到一个或另一个回调,基本上是一个逻辑的执行流程。
最好的方法
实际上,前面说的方法都不理想。把它们组合起来使用最好不过了。因为通常协作多任务很有用,尤其在你的连接会挂起很久。例如有一个 web socket 是长期存活的连接。如果你分配一个进程或者一个线程来处理每一个 socket,显然你将受限于后端服务器同时能拥有的连接数量。既然这些连接存活很长时间,保持许多同时连接很重要,而每个连接上的工作却很少。
协作多任务的缺点在于只能使用一个处理器核。当然你可以在同一个机器上运行多个实例(这不是很方便,也有它的缺点),所以运行多个进程,在每个进程内部使用反应器的协作多任务挺好了。
一方面,这个组合可以在我们的系统上使用所有的处理器,另一方面,在每个核上都能有效工作,而不用为每个连接分配大量资源。
总结
使用协作多任务的难点就在于把任务切换、维护上下文放在了可怜的开发者肩上。另一方面,我们获得了高效,因为不存在不必要的切换,在线程和进程之间切换处理器上下文时也没有问题。
下一篇文章我们将讨论异步编程本身,以及跟同步编程有什么不同。从新层面解读这一旧概念。
本篇文章翻译自 luminousmen 的关于异步编程的系列文章,原文地址在文章开头列出。