自从 PyCon 2011 协程成为热点话题以来,我一直对此有着浓厚的兴趣。为了异步,我们曾使用多线程编程。然而线程在有着 GIL 的 Python 中带来的性能瓶颈和多线程编程的高出错风险,“协程 + 多进程”的组合渐渐被认为是未来发展的方向。技术容易更新,思维转变却需要一个过渡。我之前在异步事件处理方面已经习惯了回调 + 多线程的思维方式,转换到协程还非常的不适应。这几天我非常艰难地查阅了一些资料并思考,得出了一个可能并不可靠的总结。尽管这个总结的可靠性很值得怀疑,但是我还是决定记录下来,因为我觉得既然是学习者,就不应该怕无知。如果读者发现我的看法有偏差并指出来,我将非常感激。
多线程下异步编程的方式
线程的出现,为开发者带来了除多进程之外另一种实现并发的方式。比起多进程,多线程有另一些优势,比如可以访问进程内的变量,也就是共享资源。还有的说法说线程创建比进程创建开销低,考虑到这个问题在 Windows 一类进程创建机制很蹩脚的系统才存在,故先忽略。总的来说,线程除了可以实现进程实现的“并发执行”之外,还有另一个功能,就是管理应用程序内部的“事件”。我不知道把这种事件处理分类到异步中是不是合适,但事件处理一定是基于共享进程内资源才能实现的,所以这是多线程可以做到而多进程做不到的一点。
异步处理基于两个前提。第一个前提是支持并发,当然这是基本前提。这里的并发并不一定要是并行,也就是说允许逻辑上异步,实现上串行;第二个前提是支持回调(callback),因为并发的、异步的处理不会阻塞当前正在被执行的流程,所以“任务完成后”要执行的步骤应该写在回调中,绝大多数回调是通过函数来实现。
多线程之所以适合异步编程,是因为它同时支持并发和回调。无论是系统级的线程还是用户级的线程,逻辑上都能并发执行不同的控制流;同时因为能共享进程内资源,所以回调只需要通过简单的回调函数。
出于回调函数的处理比较杂乱,一般异步程序都引入了事件机制。也就是说把一系列的回调函数注册到某个命名的事件,当这个事件被触发的时候,执行这些回调函数。例如在 ECMAScript 中,需要在访问了远程网址之后,要把响应的结果填充到页面中,在同步(阻塞)的情况下是这么做的:
处理起来非常简单,因为 XMLHttpRequest 的 send 方法会阻塞主线程,所以我们去读取 http.response 的时候一定已经完成了远程访问。如果使用基于多线程和回调函数的异步方式呢?问题会变得麻烦很多:
由于使用异步方式之后 send 方法不再阻塞主线程,所以必须设置 onreadystatechange 回调函数。XMLHttpRequest 有多种加载状态,每次状态改变会调用一次用户设置的回调函数。现在编程变得麻烦,但是用户体验变得更好,因为不再阻塞主线程,用户可以看到“正在加载”的提示,并且在此期间还可以异步做其他事情。为了简化回调函数的使用,一般采取两种方式改进回调,第一种方式是对于简单的回调,直接在参数中将回调函数传入,这种方式对有匿名函数的语言来说方便了很多(比如 ECMAScript 和 Ruby,显然 C 语言和 Python 不在此列);第二种方式是对于复杂的回调,以事件管理器替代。仍然是 ajax 请求的例子,jquery 提供的封装就采取了第一种方式:
而 W3C 规定的浏览器 window 对象,则采取了事件管理器的方式管理更为复杂的异步支持:
采取事件管理器的本质还是使用回调,不过这种方式提出了“事件”的概念,将回调函数统一注册到一个管理器中,并对应到各自的“事件”,需要调用这一系列回调函数的时候,就“触发”这一个“事件”,管理器会调用注册进来的回调函数。这种做法解除了调用者和被调用者的耦合,其实就是 GoF 观察者模式 [0] 的具体应用。
用多线程实现异步的弊病
“我们仍然认为,如果在连 a=a+1 都没有确定结果的语言中,无人可以写出正确的程序。” —— 《编程之魂》 [1]
用多线程来实现异步最大的弊病,是它真的是并发的。采用线程实现的异步,即使不存在多核并行,线程执行的先后仍然是不可预知的。操作系统课程上我们也学到过,称之为不可再现性。究其原因,线程的调度毕竟是调度器来完成的,无论是系统级的调度还是用户级的调度,调度器都会因为 IO 操作、时间片用完等诸多的原因,而强制夺取某个线程的控制权。这种不可再现性给线程编程带来了极大的麻烦。如果是上段中的简单代码还没什么,若是情况更加复杂一些,在单独的线程中操作了某共享资源,那么这个共享资源就会成为危险的临界资源,一时疏忽忘记加锁就会带来数据不一致问题。而加锁本身是把对资源的并行访问串行化,所以锁往往又是拖慢系统效率的罪魁祸首,由此又发展出了多种复杂的锁机制。
Unix 编程哲学强调 Simple is better,有时跳出来想想,有些复杂性是不是走了弯路导致的呢?首先,多线程编程以并发和事件机制来实现异步,并发可以带来性能的提升,同时能给我们非阻塞工作方式。对于临界资源的访问,我们又必须使之串行化,甚至诞生了管道、消息队列这种绝对串行化的通讯方式。为何不干脆就让所有的操作串行化,以此换取资源的安全,多核资源的利用则交给多进程实现呢?Python 的做法就是这样。Python 的线程是系统级线程,由内核调度,却不是真正的并发执行。因为 Python 有一个全局解释器锁(GIL),它导致 Python 内部的线程执行实质上是串行的。
串行的线程无法充分利用多核资源,但是换来了线程安全,看上去是比较明智的选择,但 Python 的线程却有个很大的缺点 —— 这些线程是系统级的。系统级线程由内核来调度,调度的开销会比想象的要大,而很多情况下这些调度开销是付出的很没有价值的。比如一次异步的远程网址获取,本来只需要在开始访问网络的时候释放主线程控制权,得到响应之后返回主线程控制权,使用系统级线程之后调度全部委托给了系统内核,简单问题往往就复杂化了。协程(Coroutine) [2] 提供了不同于线程的另一种方式,它首先是串行化的。其次,在串行化的过程中,协程允许用户显式释放控制权,将控制权转移另一个过程。释放控制权之后,原过程的状态得以保留,直到控制权恢复的时候,可以继续执行下去。所以协程的控制权转移也称为“挂起”和“唤醒”。
Python 中的协程
其实 Python 语言内置了协程的支持,也就是我们一般用来制作迭代期的“生成器”(Generator)。生成器本身不是一个完整的协程实现,所以此外 Python 的第三方库中还有一个优秀的替代品 greenlet [3] 。
使用生成器作为协程支持,可以实现简单的事件调度模型:
测试运行可以看到,打印出“waiting click”之后,暂停了三秒,也就是协程被挂起,控制权回到主控制流上,之后触发“click”事件,协程被唤醒。协程的这种“挂起”和“唤醒”机制实质上是将一个过程切分成了若干个子过程,给了我们一种以扁平的方式来使用事件回调模型。
用 greenlet 实现简单事件框架
用生成器实现的协程有些繁琐,同时生成器本身也不是完整的协程实现,因此经常有人批评 Python 的协程比 Lua 弱。其实 Python 中只要放下生成器,使用第三方库 greenlet,就可以媲美 Lua 的原生协程了。greenlet 提供了在协程中直接切换控制权的方式,比生成器更加灵活、简洁。
基于把协程看成“切开了的回调”的视角,我使用 greenlet 制作了一个简单的事件框架。
使用这个事件框架,可以很容易的完成挂起过程 -> 转移控制权 -> 事件触发 -> 唤醒过程的步骤。还是上文生成器协程中使用的例子,用基于 greenlet 的事件框架实现出来是这样的:
同样,运行结果如下:
================================================== micro-thread waiting click do many other works... done... now trigger click event. clicked !!
在“do may other works”打印出来之后,控制权从协程切出,暂停了三秒,直到事件 click 被触发才重新切入协程中。
非 Python 领域,有一个叫 Jscex [4] 的库在没有协程的 ECMAScript 中实现了类似协程的功能,并以之控制事件。
总结
总的来说,我个人感觉协程给了我们一种更加轻量的异步编程方式。在这种方式中没有调度复杂的系统级线程,没有容易出错的临界资源,反而走了一条更加透明的路 —— 显式的切换控制权代替调度器充满“猜测”的调度算法,放弃进程内并发使用清晰明了的串行方式。结合多进程,我想协程在异步编程尤其是 Python 异步编程中的应用将会越来越广。
附加更新
在 SlideShare 上看到了一个介绍 node.js 异步工作原理和缺陷的 presentation [5] ,感觉作者所说的 node.js 缺陷正是多线程异步的缺陷,而协程擅长的正是这样的领域。
[0] | 维基百科中文站上的“观察者模式”词条 |
[1] | 云风翻译《编程之魂》中采访 Lua 创始人选段 |
[2] | 维基百科中文站上的“协程”词条 |
[3] | Python 协程支持库 greenlet |
[4] | Jscex 项目在 github 上的主页 |
[5] | Nodejs异步的原理和缺陷 |