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