返回介绍

建议67:基于生成器的协程及 greenlet

发布于 2024-01-30 22:19:09 字数 4564 浏览 0 评论 0 收藏 0

在前文中,对生成器实现协程卖了个小关子,在这一节,让我们来揭开谜底。不过在此之前,需要先重温一下协程的概念,以及它的意义。

协程,又称微线程和纤程等,据说源于Simula和Modula-2语言,现代编程语言基本上都支持这个特性,比如Lua和ruby都有类似的概念。协程往往实现在语言的运行时库或虚拟机中,操作系统对其存在一无所知,所以又被称为用户空间线程或绿色线程。又因为大部分协程的实现是协作式而非抢占式的,需要用户自己去调度,所以通常无法利用多核,但用来执行协作式多任务非常合适。用协程来做的东西,用线程或进程通常也是一样可以做的,但往往多了许多加锁和通信的操作。下面基于生产者消费者模型,对抢占式多线程编程实现和协程编程实现进行对比。首先来看使用以下线程的实现(伪代码):

// 
队列容器
var q := new queue
// 
消费者线程
loop
  lock(q)
  get item from q
  unlock(q)
  if item
  use this item
  else
  sleep
// 
生产者线程
loop
  create some new items
  lock(q)
  add the items to q
  unlock(q)

由以上代码可以看到,线程实现至少有两点硬伤:

对队列的操作需要有显式/隐式(使用线程安全的队列)的加锁操作。

消费者线程还要通过sleep把CPU资源适时地“谦让”给生产者线程使用,其中的适时是多久,基本上只能静态地使用经验值,效果往往不尽如人意。

而使用协程可以比较好地解决这个问题。看以下基于协程的生产者消费者模型实现(伪代码):

// 
队列容器
var q := new queue
// 
生产者协程
loop
  while q is not full
  create some new items
  add the items to q
  yield to consume
// 
消费者协程
loop
  while q is not empty
  remove some items from q
  use the items
  yield to produce

可以从以上代码看到之前的加锁和谦让CPU的硬伤不复存在,但也损失了利用多核CPU的能力。所以选择线程还是协程,就要看应用场合了。

好,回到主题:协程这东西关Python的生成器什么事?如果你仔细看上面的伪代码,应该留意到其中出现了两个yield!是的,因为yield能够中止当前代码的执行,相当于“让出”CPU资源,跟协程的“协作式”理念不谋而合,所以能够实现协程。

def consumer():
  while True:
    line = yield
    print line.upper()
def producter():
  with open('/var/log/apache2/error_log', 'r') as f:
    for i, line in enumerate(f):
      yield line
      print 'processed line %d' % i
c = consumer()
c.next()
for line in producter():
  c.send(line)

依照上文的理念,编写了这些代码,可以看到consumer()是一个生成器函数,它接收yield表达式的返回值,转换为全大写,并输出到标准输出,然后再次执行yield把CPU交给主程序。它的执行结果如下(根据内容会有点不同):

[THU OCT 31 17:49:08 2013] [WARN] INIT: SESSION CACHE IS NOT CONFIGURED [HINT: 
  SSLSESSIONCACHE]
processed line 0
HTTPD: COULD NOT RELIABLY DETERMINE THE SERVER'S FULLY QUALIFIED DOMAIN NAME, 
  USING APPLETEKIMACBOOK-PRO.LOCAL FOR SERVERNAME
processed line 1
[THU OCT 31 17:49:08 2013] [NOTICE] DIGEST: GENERATING SECRET FOR DIGEST 
  AUTHENTICATION ...
processed line 2
[THU OCT 31 17:49:08 2013] [NOTICE] DIGEST: DONE
processed line 3
... 
略

可以从输出中看到,每输出一行大写的文字后都有一行来自主程序的处理信息,绝不会像抢占式的多线程程序那样“乱序”,这就是协程的“协”字之由来。Python 2.x版本的生成器无法实现所有的协程特性,是因为缺乏对协程之间复杂关系的支持。比如一个yield协程依赖另一个yield协程,且需要由最外层往最内层进行传值的时候,就没有解决办法。下面就是一个例子:为班级编写一个程序,计算每一个学生的各科总分,并计算班级总分。先尝试编写以下函数:

>>> def accumulate():
...   tally = 0
...   while 1:
...     tally += (yield tally)
...

考虑到不同的班级有不同数量的科目,不同的班级有不同数量的学生,所以编写一个生成器进行计算,它能根据接收到的数值进行计算,无须预先知道数量。现在想象一下你拿到了学生的各科成绩表,可以想象出它是一个二维表,那么代码大概如下:

>>> l = []
>>> for s in students:
...   t = 0
...   a = accumulate ()
...   a.next()
...   for c in s:
...       t = a.send(c)
...   l.append(t)
... 
>>> t = 0
>>> a = accumulate ()
>>> a.next()
>>> for s in l:
...   t = a.send(s)
... 
>>> t
325

可以看到无端多出来的对t和a的初始化操作非常刺眼,不过代码总算是可以正常工作。如果你尝试想把它封装成一个用以计算一个学生总分的函数,会更加别扭(想象一下在accumulate()中调用其自身,递归生成器?)。这个问题直到Python 3.3增加了yield from表达式以后才得以解决,通过yield from,外层的生成器在接收到send()或throw()调用时,能够把实参直接传入内层生成器。应用到本例当中,就不需要定义临时容器l来保存每一个学生的成绩,代码复杂性下降许多。下面是假定accumulate使用了yield from后的代码:

>>> a = accumulate ()
>>> a.next()
>>> t = 0
>>> for s in students:
...   for klass in s:
...       t += a.send(klass)
... 

看这个嵌套循环的代码是不是简单了许多?回到协程这个主题,因为Python 2.x版本对协程的支持有限,而协程又是非常有用的特性,所以很多Pythonista就开始寻求语言之外的解决方案,并编写了一系列的程序库,其中最受欢迎的莫过于greenlet。

greenlet是一个C语言编写的程序库,它与yield关键字没有密切的关系。greenlet这个库里最为关键的一个类型就是PyGreenlet对象,它是一个C结构体,每一个PyGreenlet都可以看到一个调用栈,从它的入口函数开始,所有的代码都在这个调用栈上运行。它能够随时记录代码运行现场,并随时中止,以及恢复。看到这里,可以发现它跟yield所能够做到的相似,但更好的是它提供从一个PyGreenlet切换到另一个PyGreenlet的机制。最后看一下来自它帮助文件的一个例子,以便对它有个直观的印象。

from greenlet import greenlet
def test1():
  print 12
  gr2.switch()
  print 34
def test2():
  print 56
  gr1.switch()
  print 78
gr1 = greenlet(test1)
gr2 = greenlet(test2)
gr1.switch()

最后一行跳到test1,输出12,跳到test2,输出56,跳回test1,输出34;然后test1执行完,gr1就死了。然后,最初的gr1.switch()调用返回,所以永远也不会输出78。

协程虽然不能充分利用多核,但它跟异步I/O结合起来以后编写I/O密集型应用非常容易,能够在同步的代码表面下实现异步的执行,其中的代表当属将greenlet与libevent/libev结合起来的gevent程序库,它是当下最受欢迎的Python网络编程库。最后,以使用gevent并发查询DNS的例子作为结束,使用它进行并发查询n个域名,能够获得几乎n倍的性能提升。

>>> import gevent
>>> from gevent import socket
>>> urls = ['www.google.com', 'www.example.com', 'www.python.org']
>>> jobs = [gevent.spawn(socket.gethostbyname, url) for url in urls]
>>> gevent.joinall(jobs, timeout=2)
>>> [job.value for job in jobs]
['74.125.79.106', '208.77.188.166', '82.94.164.162']

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文