>>> from env_helper import info; info()
页面更新时间: 2023-12-27 10:45:37
运行环境:
    Linux发行版本: Debian GNU/Linux 12 (bookworm)
    操作系统内核: Linux-6.1.0-16-amd64-x86_64-with-glibc2.36
    Python版本: 3.11.2

6.13. 基于生成器的协程及greenlet

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

6.13.1. 协程的概念

协程,又称微线程和纤程等,据说源于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的能力。所以选择线程还是协程,就要看应用场合了。

6.13.2. 基于生成器的协程

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

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

processed line 0
 "CELLS": [

processed line 1
  {

processed line 2
   "CELL_TYPE": "CODE",

processed line 3
   "EXECUTION_COUNT": NULL,

processed line 4
   "METADATA": {},

processed line 5
   "OUTPUTS": [],

processed line 6
   "SOURCE": []

processed line 7
  }

processed line 8
 ],

processed line 9
 "METADATA": {

processed line 10
  "KERNELSPEC": {

processed line 11
   "DISPLAY_NAME": "PYTHON 3",

processed line 12
   "LANGUAGE": "PYTHON",

processed line 13
   "NAME": "PYTHON3"

processed line 14
  },

processed line 15
  "LANGUAGE_INFO": {

processed line 16
   "CODEMIRROR_MODE": {

processed line 17
    "NAME": "IPYTHON",

processed line 18
    "VERSION": 3

processed line 19
   },

processed line 20
   "FILE_EXTENSION": ".PY",

processed line 21
   "MIMETYPE": "TEXT/X-PYTHON",

processed line 22
   "NAME": "PYTHON",

processed line 23
   "NBCONVERT_EXPORTER": "PYTHON",

processed line 24
   "PYGMENTS_LEXER": "IPYTHON3",

processed line 25
   "VERSION": "3.7.3"

processed line 26
  },

processed line 27
  "TOC": {

processed line 28
   "BASE_NUMBERING": 1,

processed line 29
   "NAV_MENU": {},

processed line 30
   "NUMBER_SECTIONS": TRUE,

processed line 31
   "SIDEBAR": TRUE,

processed line 32
   "SKIP_H1_TITLE": FALSE,

processed line 33
   "TITLE_CELL": "TABLE OF CONTENTS",

processed line 34
   "TITLE_SIDEBAR": "CONTENTS",

processed line 35
   "TOC_CELL": FALSE,

processed line 36
   "TOC_POSITION": {},

processed line 37
   "TOC_SECTION_DISPLAY": TRUE,

processed line 38
   "TOC_WINDOW_DISPLAY": FALSE

processed line 39
  }

processed line 40
 },

processed line 41
 "NBFORMAT": 4,

processed line 42
 "NBFORMAT_MINOR": 2

processed line 43
}

processed line 44

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

[THU OCT 31 17:49:03 2013] [WARN] INIT: SESSION CACHE IS NOT CONFIGURED [HINT:SSLSESSI0NC.ACHE1

processed line 0 HTTPD: COULD NOT RELIABLY DETERMINE THE SERVER'$ 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() next(a) for s in l:

t = a.send(s)

t

325

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

a= accumulate() next(a) for s in students:

for klass in s:

t += s.send(klass)

看这个嵌套循环的代码是不是简单了许多?

6.13.3. 使用 greenlet

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

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

sudo apt install python3-greenlet

pip3 install greenlet

>>> 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()
12
56
34

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

6.13.4. 使用 gevent 的实例

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

安装:

sudo apt install -y python3-gevent

pip3 install gevent

>>> 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]
['128.242.240.212', '93.184.216.34', '146.75.112.223']