Python 第三方模块之 greenlet - 协程

一句话来说明 greenlet 的实现原理:通过栈的复制切换来实现不同协程之间的切换

greenlet初体验

Greenlet 是 Python 的一个 C 扩展,来源于Stackless python,旨在提供可自行调度的‘微线程’,即协程。generator 实现的协程在 yield value 时只能将 value 返回给调用者(caller)。而在 greenlet 中,target.switch(value) 可以切换到指定的协程(target),然后 yield value。greenlet 用 switch 来表示协程的切换,从一个协程切换到另一个协程需要显式指定。

greenlet 安装

1
pip install greenlet

安装好了之后我们来看一个官方的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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()

输出为:

1
12 56 34

当创建一个 greenlet 时,首先初始化一个空的栈,switch 到这个栈的时候,会运行在 greenlet 构造时传入的函数(首先在test1中打印 12),如果在这个函数(test1)中 switch 到其他协程(到了 test2 打印 34),那么该协程会被挂起,等到切换回来(在 test2 中切换回来打印 34)。当这个协程对应函数执行完毕,那么这个协程就变成 dead 状态。

注意: 上面没有打印 test2 的最后一行输出 78,因为在 test2 中切换到 gr1 之后挂起,但是没有地方再切换回来。这个可能造成泄漏,后面细说。

greenlet module 与 class

我们首先看一下 greenlet 这个 module 里面的属性

1
2
3
import greenlet
dir(greenlet)
# ['GREENLET_USE_GC', 'GREENLET_USE_TRACING', 'GreenletExit', '_C_API', '__doc__', '__file__', '__name__', '__package__', '__version__', 'error', 'getcurrent', 'gettrace', 'greenlet', 'settrace']

其中,比较重要的是 getcurrent()类 greenlet异常类 GreenletExit

  • getcurrent():返回当前的 greenlet 实例;
  • GreenletExit:是一个特殊的异常,当触发了这个异常的时候,即使不处理,也不会抛到其parent(后面会提到协程中对返回值或者异常的处理)

然后我们再来看看 greenlet.greenlet 这个类:

1
2
3
4
import greenlet
dir(greenlet.greenlet)

# ['GreenletExit', '__class__', '__delattr__', '__dict__', '__doc__', '__format__', '__getattribute__', '__getstate__', '__hash__', '__init__', '__new__', '__nonzero__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '_stack_saved', 'dead', 'error', 'getcurrent', 'gettrace', 'gr_frame', 'parent', 'run', 'settrace','switch', 'throw']

比较重要的几个属性:

  • run:当 greenlet 启动的时候会调用到这个 callable,如果我们需要继承 greenlet.greenlet 时,需要重写该方法
  • switch:前面已经介绍过了,在 greenlet 之间切换
  • parent:可读写属性,后面介绍
  • dead:如果 greenlet 执行结束,那么该属性为 true
  • throw:切换到指定 greenlet 后立即跑出异常

文章后面提到的 greenlet 大多都是指 greenlet.greenlet 这个 class,请注意区别 

Switch not call

对于 greenlet,最常用的写法是 x = gr.switch(y)。 这句话的意思是切换到gr,传入参数y。当从其他协程(_不一定是这个gr_)切换回来的时候,将值付给x。

1
2
3
4
5
6
7
8
9
10
11
12
import greenlet
def test1(x, y):
z = gr2.switch(x+y)
print('test1 ', z)

def test2(u):
print('test2 ', u)
gr1.switch(10)

gr1 = greenlet.greenlet(test1)
gr2 = greenlet.greenlet(test2)
print(gr1.switch("hello", " world"))

输出:

1
2
3
('test2 ', 'hello world')  
('test1 ', 10)
None

上面的例子,第 12 行从 main greenlet 切换到了 gr1,test1 第 3 行切换到了 gs2,然后 gr1 挂起,第 8 行从 gr2 切回 gr1 时,将值(10)返回值给了 z。 

每一个 Greenlet 都有一个 parent,一个新的 greenlet 在哪里创生,_当前环境的 greenlet 就是这个新 greenlet 的 parent_。所有的 greenlet 构成一棵树,其跟节点就是还没有手动创建 greenlet 时候的”main” greenlet(事实上,在首次 import greenlet 的时候实例化)。当一个协程正常结束,执行流程回到其对应的 parent;或者在一个协程中抛出未被捕获的异常,该异常也是传递到其 parent。学习 Python 的时候,有一句话会被无数次重复”everything is oblect”, 在学习 greenlet 的调用中,同样有一句话应该深刻理解,“switch not call”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import greenlet
def test1(x, y):
print(id(greenlet.getcurrent()), id(greenlet.getcurrent().parent)) # 40240272 40239952
z = gr2.switch(x+y)
print('back z', z)

def test2(u):
print(id(greenlet.getcurrent()), id(greenlet.getcurrent().parent)) # 40240352 40239952
return 'hehe'

gr1 = greenlet.greenlet(test1)
gr2 = greenlet.greenlet(test2)
print(id(greenlet.getcurrent()), id(gr1), id(gr2)) # 40239952, 40240272, 40240352
print(gr1.switch("hello", " world"), 'back to main') # hehe back to main

上述例子可以看到,尽量是从 test1 所在的协程 gr1 切换到了 gr2,但 gr2 的 parent 还是’main’ greenlet,因为默认的 parent 取决于 greenlet 的创生环境。另外在 test2 中 return 之后整个返回值返回到了其 parent,而不是 switch 到该协程的地方(即不是test1),这个跟我们平时的函数调用不一样,记住“switch not call”。对于异常 也是展开至parent

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import greenlet
def test1(x, y):
try:
z = gr2.switch(x+y)
except Exception:
print('catch Exception in test1')

def test2(u):
assert False

gr1 = greenlet.greenlet(test1)
gr2 = greenlet.greenlet(test2)
try:
gr1.switch("hello", " world")
except:
print('catch Exception in main')

输出为:

1
catch Exception in main

greenlet 生命周期

文章开始的地方提到第一个例子中的 gr2 其实并没有正常结束,我们可以借用 greenlet.dead 这个属性来查看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from greenlet import greenlet
def test1():
gr2.switch(1)
print('test1 finished')

def test2(x):
print('test2 first', x)
z = gr1.switch()
print('test2 back', z)

gr1 = greenlet(test1)
gr2 = greenlet(test2)
gr1.switch()
print('gr1 is dead?: %s, gr2 is dead?: %s' % (gr1.dead, gr2.dead))
gr2.switch()
print('gr1 is dead?: %s, gr2 is dead?: %s' % (gr1.dead, gr2.dead))
print(gr2.switch(10))

输出:

1
2
3
4
5
6
test2 first 1  
test1 finished
_gr1 is dead?: True, gr2 is dead?: False_
test2 back ()
_gr1 is dead?: True, gr2 is dead?: True_
_10_

从这个例子可以看出

  • 只有当协 程对应的函数执行完毕,协程才会die,所以第一次 Check 的时候 gr2 并没有 die,因为第9行切换出去了就没切回来。在 main 中再 switch 到 gr2 的时候,执行后面的逻辑,gr2 die
  • 如果试图再次 switch 到一个已经是 dead 状态的 greenlet 会怎么样呢,事实上会切换到其 parent greenlet

greenlet Traceing

Greenlet 也提供了接口使得程序员可以监控 greenlet 的整个调度流程。主要是 gettrace 和 settrace(callback) 函数。下面看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import greenlet
def test_greenlet_tracing():
def callback(event, args):
print(event, 'from', id(args[0]), 'to', id(args[1]))

def dummy():
g2.switch()

def dummyexception():
raise Exception('excep in coroutine')

main = greenlet.getcurrent()
g1 = greenlet.greenlet(dummy)
g2 = greenlet.greenlet(dummyexception)
print('main id %s, gr1 id %s, gr2 id %s' % (id(main), id(g1), id(g2)))
oldtrace = greenlet.settrace(callback)
try:
g1.switch()
except:
print('Exception')
finally:
greenlet.settrace(oldtrace)

test_greenlet_tracing()

输出:

1
2
3
4
5
main id 40604416, gr1 id 40604736, gr2 id 40604816  
switch from 40604416 to 40604736
switch from 40604736 to 40604816
throw from 40604816 to 40604416
Exception

其中 callback 函数 event 是 switch 或者 throw 之一,表明是正常调度还是异常跑出;args 是二元组,表示是从协程 args[0] 切换到了协程 args[1]。上面的输出展示了切换流程:从 main 到 gr1,然后到 gr2,最后回到 main。

greenlet 使用建议

使用 greenlet 需要注意一下三点:

  • 第一:greenlet 创生之后,一定要结束,不能 switch 出去就不回来了,否则容易造成内存泄露
  • 第二:Python 中每个线程都有自己的 main greenlet 及其对应的 sub-greenlet,不同线程之间的 greenlet 是不能相互切换的
  • 第三:不能存在循环引用,这个是官方文档明确说明

    Greenlets do not participate in garbage collection; cycles involving data that is present in a greenlet’s frames will not be detected.

对于第一点,我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from greenlet import greenlet, GreenletExit
huge = []
def show_leak():
def test1():
gr2.switch()

def test2():
huge.extend([x* x for x in range(100)])
gr1.switch()
print('finish switch del huge')
del huge[:]

gr1 = greenlet(test1)
gr2 = greenlet(test2)
gr1.switch()
gr1 = gr2 = None
print('length of huge is zero ? %s' % len(huge))

if __name__ == '__main__':
show_leak() # output: length of huge is zero ? 100

在 test2 函数中第 11 行,我们将 huge 清空,然后在第 16 行将 gr1、gr2 的引用计数降到了 0。但运行结果告诉我们,第 11 行并没有执行,所以如果一个协程没有正常结束是很危险的,往往不符合程序员的预期。greenlet 提供了解决这个问题的办法,官网文档提到:如果一个 greenlet 实例的引用计数变成 0,那么会在上次挂起的地方抛出 GreenletExit 异常,这就使得我们可以通过 try ... finally 处理资源泄露的情况。如下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from greenlet import greenlet, GreenletExit
huge = []
def show_leak():
def test1():
gr2.switch()

def test2():
huge.extend([x* x for x in range(100)])
try:
gr1.switch()
finally:
print('finish switch del huge')
del huge[:]

gr1 = greenlet(test1)
gr2 = greenlet(test2)
gr1.switch()
gr1 = gr2 = None
print('length of huge is zero ? %s' % len(huge))

if __name__ == '__main__':
show_leak()
# output :
# finish switch del huge
# length of huge is zero ? 0

上述代码的 switch 流程:main greenlet --> gr1 --> gr2 --> gr1 --> main greenlet, 很明显 gr2 没有正常结束(在第10行刮起了)。第 18 行之后 gr1,gr2 的引用计数都变成 0,那么会在第 10 行抛出 GreenletExit 异常,因此 finally 语句有机会执行。同时,在文章开始介绍 Greenlet module 的时候也提到了,GreenletExit 这个异常并不会抛出到 parent,所以 main greenlet 也不会出异常。

看上去貌似解决了问题,但这对程序员要求太高了,百密一疏。所以最好的办法还是保证协程的正常结束。

总结

之前的文章其实已经提到提到了 coroutine 协程的强大之处,对于异步非阻塞,而且还需要保留上下文的场景非常适用。greenlet 跟强大,可以从一个协程切换到任意其他协程,这是 generator 做不到的,但这种能力其实也是双刃剑,前面的注意事项也提到了,必须保证 greenlet 的正常结束,在协程之间任意的切换很容易出问题。

比如对于服务之间异步请求的例子,简化为服务A的一个函数 foo 需要异步访问服务B,可以这样封装 greenlet:用 decorator 装饰函数 foo,当调用这个 foo 的时候建立一个 greenlet 实例,并为这个 greenley 对应一个唯一的 gid,在foo方法发出异步请求(写到gid)之后,switch到parent,这个时候这个新的协程处于挂起状态。当请求返回之后,通过gid找到之前被挂起的协程,恢复该协程即可。More simple More safety,保证旨在main和一级子协程之间切换。需要注意的是处理各种异常 以及请求超时的情况,避免内存泄露,gvent对greenlet的使用大致也是这样的。

references


Python 第三方模块之 greenlet - 协程
https://flepeng.github.io/021-Python-33-Python-第三方模块-01-进程、线程、协程-相关-Python-第三方模块之-greenlet-协程/
作者
Lepeng
发布于
2016年8月3日
许可协议