无知的 tonyseek

Yet Another Seeker

用 greenlet 协程处理异步事件

自从 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 中的协程

其实 Python 语言内置了协程的支持,也就是我们一般用来制作迭代期的“生成器”(Generator)。生成器本身不是一个完整的协程实现,所以此外 Python 的第三方库中还有一个优秀的替代品 greenlet [3]

使用生成器作为协程支持,可以实现简单的事件调度模型:

from time import sleep

# Event Manager

event_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_event
def 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”事件,协程被唤醒。协程的这种“挂起”和“唤醒”机制实质上是将一个过程切分成了若干个子过程,给了我们一种以扁平的方式来使用事件回调模型。

用 greenlet 实现简单事件框架

用生成器实现的协程有些繁琐,同时生成器本身也不是完整的协程实现,因此经常有人批评 Python 的协程比 Lua 弱。其实 Python 中只要放下生成器,使用第三方库 greenlet,就可以媲美 Lua 的原生协程了。greenlet 提供了在协程中直接切换控制权的方式,比生成器更加灵活、简洁。

基于把协程看成“切开了的回调”的视角,我使用 greenlet 制作了一个简单的事件框架。

from greenlet import greenlet, getcurrent


class 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 sleep
from event import EventManager

event = EventManager()
event.register("click")

@event.use
def 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 异步编程中的应用将会越来越广。

附加更新

在 SlideShare 上看到了一个介绍 node.js 异步工作原理和缺陷的 presentation [5] ,感觉作者所说的 node.js 缺陷正是多线程异步的缺陷,而协程擅长的正是这样的领域。

[0]维基百科中文站上的“观察者模式”词条
[1]云风翻译《编程之魂》中采访 Lua 创始人选段
[2]维基百科中文站上的“协程”词条
[3]Python 协程支持库 greenlet
[4]Jscex 项目在 github 上的主页
[5]Nodejs异步的原理和缺陷

Comments