文档章节

python 高性能编程之协程

passionfly
 passionfly
发布于 2015/05/07 17:06
字数 3165
阅读 723
收藏 28

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

用 greenlet 实现简单事件框架

用生成器实现的协程有些繁琐,同时生成器本身也不是完整的协程实现,因此经常有人批评 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 异步编程中的应用将会越来越广


本文转载自:https://blog.tonyseek.com/post/event-manage-with-greenlet/

passionfly
粉丝 15
博文 106
码字总数 76465
作品 0
西安
私信 提问
Python 协程:协程才是未来 / 被线程替代 / 推与拉

协程三篇之一(协程初接触) 协程虽然如此之好,看是很长时间以来,因为受到基于堆栈的子例程实现的限制,并没有多少语言在其实语言或库中支持协程,所以线程作为一个替代者(当然,线程也有...

岭南六少
2011/08/10
2.9K
1
《Python分布式计算》第2章 异步编程 (Distributed Computing with Python)

序言 第1章 并行和分布式计算介绍 第2章 异步编程 第3章 Python的并行计算 第4章 Celery分布式应用 第5章 云平台部署Python 第6章 超级计算机群使用Python 第7章 测试和调试分布式应用 第8章...

seancheney
2017/10/11
0
0
Python 3.5 协程究竟是个啥

原文链接 : How the heck does async/await work in Python 3.5? 原文作者 : Brett Cannon 译文出自 : 掘金翻译计划 译者 : @Yushneng 校对者: @L9m,@iThreeKing 作者是 Python 语言的核心开...

好铁
2017/10/23
91
0
Databot —— 高性能 Python 数据驱动编程框架

Databot 是用于 Web 爬虫、ETL、数据管道任务开发的高性能 Python 数据驱动编程框架。特性:数据驱动编程框架 ;基于协程的并行;基于类型和内容的函数路由。

王练
2018/08/31
83
0
python --- 协程编程(第三方库gevent的使用)

1. 什么是协程?   协程(coroutine),又称微线程。协程不是线程也不是进程,它的上下文关系切换不是由CPU控制,一个协程由当前任务切换到其他任务由当前任务来控制。一个线程可以包含多个...

码农47
2017/11/19
0
0

没有更多内容

加载失败,请刷新页面

加载更多

OpenStack 简介和几种安装方式总结

OpenStack :是一个由NASA和Rackspace合作研发并发起的,以Apache许可证授权的自由软件和开放源代码项目。项目目标是提供实施简单、可大规模扩展、丰富、标准统一的云计算管理平台。OpenSta...

小海bug
昨天
5
0
DDD(五)

1、引言 之前学习了解了DDD中实体这一概念,那么接下来需要了解的就是值对象、唯一标识。值对象,值就是数字1、2、3,字符串“1”,“2”,“3”,值时对象的特征,对象是一个事物的具体描述...

MrYuZixian
昨天
6
0
数据库中间件MyCat

什么是MyCat? 查看官网的介绍是这样说的 一个彻底开源的,面向企业应用开发的大数据库集群 支持事务、ACID、可以替代MySQL的加强版数据库 一个可以视为MySQL集群的企业级数据库,用来替代昂贵...

沉浮_
昨天
6
0
解决Mac下VSCode打开zsh乱码

1.乱码问题 iTerm2终端使用Zsh,并且配置Zsh主题,该主题主题需要安装字体来支持箭头效果,在iTerm2中设置这个字体,但是VSCode里这个箭头还是显示乱码。 iTerm2展示如下: VSCode展示如下: 2...

HelloDeveloper
昨天
7
0
常用物流快递单号查询接口种类及对接方法

目前快递查询接口有两种方式可以对接,一是和顺丰、圆通、中通、天天、韵达、德邦这些快递公司一一对接接口,二是和快递鸟这样第三方集成接口一次性对接多家常用快递。第一种耗费时间长,但是...

程序的小猿
昨天
10
0

没有更多内容

加载失败,请刷新页面

加载更多

返回顶部
顶部