python 高性能编程之协程

独自空忆成欢 提交于 2020-01-27 08:24:38

用 greenlet 协程处理异步事件    
自从 PyCon 2011 协程成为热点话题以来,我一直对此有着浓厚的兴趣。为了异步,我们曾使用多线程编程。然而线程在有着 GIL 的 Python 中带来的性能瓶颈和多线程编程的高出错风险,“协程 + 多进程”的组合渐渐被认为是未来发展的方向。技术容易更新,思维转变却需要一个过渡。我之前在异步事件处理方面已经习惯了回调 + 多线程的思维方式,转换到协程还非常的不适应。这几天我非常艰难地查阅了一些资料并思考,得出了一个可能并不可靠的总结。尽管这个总结的可靠性很值得怀疑,但是我还是决定记录下来,因为我觉得既然是学习者,就不应该怕无知。如果读者发现我的看法有偏差并指出来,我将非常感激。


多线程下异步编程的方式

线程的出现,为开发者带来了除多进程之外另一种实现并发的方式。比起多进程,多线程有另一些优势,比如可以访问进程内的变量,也就是共享资源。还有的说法说线程创建比进程创建开销低,考虑到这个问题在 Windows 一类进程创建机制很蹩脚的系统才存在,故先忽略。总的来说,线程除了可以实现进程实现的“并发执行”之外,还有另一个功能,就是管理应用程序内部的“事件”。我不知道把这种事件处理分类到异步中是不是合适,但事件处理一定是基于共享进程内资源才能实现的,所以这是多线程可以做到而多进程做不到的一点。
异步处理基于两个前提。第一个前提是支持并发,当然这是基本前提。这里的并发并不一定要是并行,也就是说允许逻辑上异步,实现上串行;第二个前提是支持回调(callback),因为并发的、异步的处理不会阻塞当前正在被执行的流程,所以“任务完成后”要执行的步骤应该写在回调中,绝大多数回调是通过函数来实现。
多线程之所以适合异步编程,是因为它同时支持并发和回调。无论是系统级的线程还是用户级的线程,逻辑上都能并发执行不同的控制流;同时因为能共享进程内资源,所以回调只需要通过简单的回调函数。
出于回调函数的处理比较杂乱,一般异步程序都引入了事件机制。也就是说把一系列的回调函数注册到某个命名的事件,当这个事件被触发的时候,执行这些回调函数。例如在 ECMAScript 中,需要在访问了远程网址之后,要把响应的结果填充到页面中,在同步(阻塞)的情况下是这么做的:
[AppleScript] 纯文本查看 复制代码
1
// 在打开了豆瓣首页的标签页// 打开了一个 firebut/chrome console 测试var http = new XMLHttpRequest();// 第三个参数为 false 代表不使用异步http.open("GET", "/site", false);// 发送请求http.send();// 填充响应,一秒钟变页面document.write(http.response);
处理起来非常简单,因为 XMLHttpRequest 的 send 方法会阻塞主线程,所以我们去读取 http.response 的时候一定已经完成了远程访问。如果使用基于多线程和回调函数的异步方式呢?问题会变得麻烦很多:
[JavaScript] 纯文本查看 复制代码
1
2
3
4
5
6
7
8
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 提供的封装就采取了第一种方式:
[JavaScript] 纯文本查看 复制代码
1
2
$.get("/site", function(response){
    document.write(http.response);});
而 W3C 规定的浏览器 window 对象,则采取了事件管理器的方式管理更为复杂的异步支持:
[JavaScript] 纯文本查看 复制代码
1
2
// 别在 IE 下试,IE 的函数名不一样。window.addEventListener("load", function(){
    // do something}, false);
采取事件管理器的本质还是使用回调,不过这种方式提出了“事件”的概念,将回调函数统一注册到一个管理器中,并对应到各自的“事件”,需要调用这一系列回调函数的时候,就“触发”这一个“事件”,管理器会调用注册进来的回调函数。这种做法解除了调用者和被调用者的耦合,其实就是 GoF 观察者模式 [0]的具体应用

用多线程实现异步的弊病

用多线程来实现异步最大的弊病,是它真的是并发的。采用线程实现的异步,即使不存在多核并行,线程执行的先后仍然是不可预知的。操作系统课程上我们也学到过,称之为不可再现性。究其原因,线程的调度毕竟是调度器来完成的,无论是系统级的调度还是用户级的调度,调度器都会因为 IO 操作、时间片用完等诸多的原因,而强制夺取某个线程的控制权。这种不可再现性给线程编程带来了极大的麻烦。如果是上段中的简单代码还没什么,若是情况更加复杂一些,在单独的线程中操作了某共享资源,那么这个共享资源就会成为危险的临界资源,一时疏忽忘记加锁就会带来数据不一致问题。而加锁本身是把对资源的并行访问串行化,所以锁往往又是拖慢系统效率的罪魁祸首,由此又发展出了多种复杂的锁机制。
Unix 编程哲学强调 Simple is better,有时跳出来想想,有些复杂性是不是走了弯路导致的呢?首先,多线程编程以并发和事件机制来实现异步,并发可以带来性能的提升,同时能给我们非阻塞工作方式。对于临界资源的访问,我们又必须使之串行化,甚至诞生了管道、消息队列这种绝对串行化的通讯方式。为何不干脆就让所有的操作串行化,以此换取资源的安全,多核资源的利用则交给多进程实现呢?Python 的做法就是这样。Python 的线程是系统级线程,由内核调度,却不是真正的并发执行。因为 Python 有一个全局解释器锁(GIL),它导致 Python 内部的线程执行实质上是串行的。
串行的线程无法充分利用多核资源,但是换来了线程安全,看上去是比较明智的选择,但 Python 的线程却有个很大的缺点 —— 这些线程是系统级的。系统级线程由内核来调度,调度的开销会比想象的要大,而很多情况下这些调度开销是付出的很没有价值的。比如一次异步的远程网址获取,本来只需要在开始访问网络的时候释放主线程控制权,得到响应之后返回主线程控制权,使用系统级线程之后调度全部委托给了系统内核,简单问题往往就复杂化了。协程(Coroutine) [2] 提供了不同于线程的另一种方式,它首先是串行化的。其次,在串行化的过程中,协程允许用户显式释放控制权,将控制权转移另一个过程。释放控制权之后,原过程的状态得以保留,直到控制权恢复的时候,可以继续执行下去。所以协程的控制权转移也称为“挂起”和“唤醒”。

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


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

[Python] 纯文本查看 复制代码
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
from time import sleep# Event Managerevent_listeners = {}def fire_event(name):[/color]
[color=#000000]    event_listeners[name]()def use_event(func):[/color]
[color=#000000]    def call(*args, **kwargs):[/color]
[color=#000000]        generator = func(*args, **kwargs)[/color]
[color=#000000]        # 执行到挂起[/color]
[color=#000000]        event_name = next(generator)[/color]
[color=#000000]        # 将“唤醒挂起的协程”注册到事件管理器中[/color]
[color=#000000]        def resume():[/color]
[color=#000000]            try:[/color]
[color=#000000]                next(generator)[/color]
[color=#000000]            except StopIteration:[/color]
[color=#000000]                pass[/color]
[color=#000000]        event_listeners[event_name] = resume[/color]
[color=#000000]    return call# Test@use_eventdef test_work():[/color]
[color=#000000]    print("=" * 50)[/color]
[color=#000000]    print("waiting click")[/color]
[color=#000000]    yield "click"  # 挂起当前协程, 等待事件[/color]
[color=#000000]    print("clicked !!")if __name__ == "__main__":[/color]
[color=#000000]    test_work()[/color]
[color=#000000]    sleep(3)  # 做了很多其他事情[/color]
[color=#000000]    fire_event("click")  # 触发了 click 事件


测试运行可以看到,打印出“waiting click”之后,暂停了三秒,也就是协程被挂起,控制权回到主控制流上,之后触发“click”事件,协程被唤醒。协程的这种“挂起”和“唤醒”机制实质上是将一个过程切分成了若干个子过程,给了我们一种以扁平的方式来使用事件回调模型。


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


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

[Python] 纯文本查看 复制代码
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from greenlet import greenlet, getcurrentclass Event(object):[/color]
[color=#000000]    def __init__(self, name):[/color]
[color=#000000]        self.name = name[/color]
[color=#000000]        self.listeners = set()[/color]
 
[color=#000000]    def listen(self, listener):[/color]
[color=#000000]        self.listeners.add(listener)[/color]
 
[color=#000000]    def fire(self):[/color]
[color=#000000]        for listener in self.listeners:[/color]
[color=#000000]            listener()class EventManager(object):[/color]
[color=#000000]    def __init__(self):[/color]
[color=#000000]        self.events = {}[/color]
 
[color=#000000]    def register(self, name):[/color]
[color=#000000]        self.events[name] = Event(name)[/color]
 
[color=#000000]    def fire(self, name):[/color]
[color=#000000]        self.events[name].fire()[/color]
 
[color=#000000]    def await(self, event_name):[/color]
[color=#000000]        self.events[event_name].listen(getcurrent().switch)[/color]
[color=#000000]        getcurrent().parent.switch()[/color]
 
[color=#000000]    def use(self, func):[/color]
[color=#000000]        return greenlet(func).switch




使用这个事件框架,可以很容易的完成挂起过程 -> 转移控制权 -> 事件触发 -> 唤醒过程的步骤。还是上文生成器协程中使用的例子,用基于 greenlet 的事件框架实现出来是这样的:

[Python] 纯文本查看 复制代码
01
02
03
04
05
06
07
08
09
10
from time import sleepfrom event import EventManagerevent = EventManager()event.register("click")@event.usedef test(name):[/color]
[color=#000000]    print "=" * 50[/color]
[color=#000000]    print "%s waiting click" % name[/color]
[color=#000000]    event.await("click")[/color]
[color=#000000]    print "clicked !!"if __name__ == "__main__":[/color]
[color=#000000]    test("micro-thread")[/color]
[color=#000000]    print "do many other works..."[/color]
[color=#000000]    sleep(3)  # do many other works[/color]
[color=#000000]    print "done... now trigger click event."[/color]
[color=#000000]    manager.fire("click")



 

同样,运行结果如下:
[AppleScript] 纯文本查看 复制代码
1
2
3
4
micro-thread waiting click
do many other works...
done... now trigger click event.
clicked !!
在“do may other works”打印出来之后,控制权从协程切出,暂停了三秒,直到事件 click 被触发才重新切入协程中。
非 Python 领域,有一个叫 Jscex [4] 的库在没有协程的 ECMAScript 中实现了类似协程的功能,并以之控制事件。


总结    总的来说,我个人感觉协程给了我们一种更加轻量的异步编程方式。在这种方式中没有调度复杂的系统级线程,没有容易出错的临界资源,反而走了一条更加透明的路 —— 显式的切换控制权代替调度器充满“猜测”的调度算法,放弃进程内并发使用清晰明了的串行方式。结合多进程,我想协程在异步编程尤其是 Python 异步编程中的应用将会越来越广

源于:https://my.oschina.net/u/2260265/blog/411907

 

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!