Python 协程:yield,greenlet,gevent,asyncio
进程
进程是资源分配的最小单位,拥有独立的内存空间,有寄存器信息、堆、栈、数据段、代码段、虚拟内存、文件句柄、IO 状态、信号信息等等内容,不同进程的切换开销比较大,同时进程比较独立稳定,通常不受其他进程影响
进程间的通信有管道(Pipe)、消息队列(Message Queue)、信号量(Semaphore)、共享内存(Shared Memory)、套接字(Socket)等等
线程
线程是系统调度的最小单位,只需要保存自己的栈、寄存器信息等少量内容,一个进程至少要有一个线程,不同线程的切换开销比进程切换要小很多,但线程不够独立稳定,容易受进程和其他线程的影响
由于不同线程都是共享同一段内存,线程间通信直接使用共享内存,就是使用全局定义的变量即可,另外不同的线程间通常还需要通过锁实现同步、互斥等功能
协程
进程和线程都是操作系统调度的,虽然线程切换开销比进程要小,但如果是频繁切换,依然会严重影响性能
操作系统通常在三种情况下会进行切换
- 程序运行时间比较长
- 有更高优先级的程序抢占
- 程序发生了阻塞
在很多网络应用中,会同时接受大量请求,这些请求计算量很小,主要的时间是耗在 IO 上了,并且最主要是网络的 IO 时间,导致了频繁的 IO 阻塞和线程切换,严重影响性能
协程就是为了解决以 IO 为主要开销的程序,在高并发场景下的性能问题
在一个线程内可以运行多个协程,当一个协程调用了需要 IO 阻塞的命令时,会使用异步 IO 的方式,避免触发操作系统进行切换,然后继续执行另一个协程,由于是在同一个线程内实现,切换开销非常小,性能会有很大提升
注意协程在 IO 并发量很大的情况下作用才比较明显,因为只有这种情况下才能保证随时有异步 IO 准备就绪可以执行的,如果 IO 量很小,比如 10 分钟才有一条请求,那做了异步操作后,还是得等待这个异步 IO 就绪,照样会导致线程切换
注意协程只在一种情况下会切换:IO 调用
这个功能需要由程序框架实现,对操作系统是透明的,对应用程序也是透明的,这样既避免了以 IO 为主要开销的程序在高并发时频繁地触发多线程的切换,又不增加应用程序开发的工作量
在 Go 语言中,这个功能是原生的,Go 语言本身就实现了这个功能,在语法层面上就支持
在 Python 语言中,这个功能由 gevent 包提供支持
下面主要讲 Python 的协程
yield
yield 是为了生成器使用的,比如下面的代码
def f(max):
n = 1
while n <= max:
yield n*n
n = n + 1
for i in f(5):
print(i)
如果不使用 yield,那么函数 f 就需要返回一个 list,如果 max 非常大,那么就需要创建一个很大的内存在放这个 list,而在使用了 yield 后,函数被当成迭代器,f(5) 返回的是一个迭代器,for 语句每次取值的时候触发迭代器,迭代器执行到 yield 命令时返回 n*n 并停止执行,直到 for 下一次取值,迭代器再从 n = n + 1 继续执行,这样无论 max 多大,内存的使用都是恒定的
再举一个例子
def f():
n = 1
print("f function with yield inside")
while True:
msg = yield n
print("msg: ", msg)
n = n + 1
iter = f()
print("before invoke next")
print("receive: ", next(iter))
print("after invoke next")
print("receive: ", next(iter))
返回的是
before invoke next
f function with yield inside
('receive: ', 1)
after invoke next
('msg: ', None)
('receive: ', 2)
可以看到调用 iter = f() 的时候没有打印任何信息出来,即 f() 函数其实没有被执行,而是返回了一个迭代器,当执行 next(iter) 函数时 (next 是 python 内置函数),f() 函数才被执行,并且这里只执行到 yield n 就停止继续执行并将 n 作为结果返回 (这里连 msg 的赋值都没执行,后面会进一步讲到),等下一个 next 函数时,会从 msg 的赋值开始继续执行,直到再次遇见 yield,如果迭代器已经执行完,那么 next 函数会报 StopIteration 异常
继续下一个例子
def f():
n = 1
print("f function with yield inside")
while True:
msg = yield n
print("msg: ", msg)
n = n + 1
iter = f()
print("before invoke next")
print("receive: ", next(iter))
print("after invoke next")
print("receive: ", iter.send("from outside"))
这里把第二个 next 换成调用迭代器的 send 函数
返回的是
before invoke next
f function with yield inside
('receive: ', 1)
after invoke next
('msg: ', 'from outside')
('receive: ', 2)
和上一个例子的唯一区别就是打印的 msg 不是 None 而是 send 函数的参数,send 函数和 next 一样会触发迭代器继续执行,但同时会将参数作为 yield 语句的结果赋值给 msg
下面用 yield 模拟协程
def f_0():
n = 5
while n >= 0:
print('[f_0] ' + str(n))
yield
n = n - 1
def f_1():
m = 3
while m >= 0:
print('[f_1] ' + str(m))
yield
m = m - 1
iter_list = [f_0(), f_1()]
while True:
for it in iter_list:
try:
next(it)
except:
iter_list.remove(it)
if len(iter_list) == 0:
break
返回结果为
[f_0] 5
[f_1] 3
[f_0] 4
[f_1] 2
[f_0] 3
[f_1] 1
[f_0] 2
[f_1] 0
[f_0] 1
[f_0] 0
可以看到实现了两个函数不断切换的功能,但代码写起来麻烦点
greenlet
greenlet 是底层实现了原生协程的 C 扩展库
from greenlet import greenlet
def f_0():
n = 5
while n >= 0:
print('[f_0] ' + str(n))
parent_greenlet.switch()
n = n - 1
def f_1():
m = 3
while m >= 0:
print('[f_1] ' + str(m))
parent_greenlet.switch()
m = m - 1
def parent():
while True:
for task in greenlet_list:
task.switch()
if task.dead:
greenlet_list.remove(task)
if len(greenlet_list) == 0:
break
parent_greenlet = greenlet(parent)
greenlet_list = [greenlet(f_0, parent_greenlet), greenlet(f_1, parent_greenlet)]
parent_greenlet.switch()
返回
[f_0] 5
[f_1] 3
[f_0] 4
[f_1] 2
[f_0] 3
[f_1] 1
[f_0] 2
[f_1] 0
[f_0] 1
[f_0] 0
switch 也可以传值,根据程序运行情况会传给函数参数,或是传给 switch 的返回
def test1(x, y):
z = gr2.switch(x+y)
print(z)
def test2(u):
print(u)
gr1.switch(42)
print "end"
gr1 = greenlet(test1)
gr2 = greenlet(test2)
gr1.switch("hello", " world")
返回
hello world
42
可以看到没有打印出 end,因为没指定 parent,默认有一个结束就返回 main,另一个就不会执行了,如果有指定 parent,则结束后会返回 parent
gevent
greenlet 写起来也比较复杂,并且 greenlet 只实现了协程,却没有实现捕获 IO 操作并进行切换的功能,实际上一般的计算并不需要协程的切换,性能没什么影响,只有在高并发 IO 操作时能切换程序,其性能才会有较大提升
gevent 基于 greenlet,使用了包括 linux 的 epoll 事件监听机制在内的许多优化措施,以提升高并发 IO 的性能,比如当一个 greenlet 程序需要做网络 IO 操作时,就将其注册为异步监听,并切换到其他 greenlet 程序,等 IO 完成,在适当的时候会再切回来继续执行,这样当 IO 很高时,可以让程序一直在运行,而不是把时间耗在 IO 等待上,同时又能避免线程的切换开销
import gevent
def f_0(param):
n = param
while n >= 0:
print('[f_0] ' + str(n))
gevent.sleep(0.1)
n = n - 1
def f_1(param):
m = param
while m >= 0:
print('[f_1] ' + str(m))
gevent.sleep(0.1)
m = m - 1
g1 = gevent.spawn(f_0, 5)
g2 = gevent.spawn(f_1, 3)
gevent.joinall([g1, g2])
返回
[f_0] 5
[f_1] 3
[f_0] 4
[f_1] 2
[f_0] 3
[f_1] 1
[f_0] 2
[f_1] 0
[f_0] 1
[f_0] 0
可以看到代码简洁清楚,和正常程序相比,就是用 gevent.sleep() 替换了 time.sleep() 是 gevent 能在需要阻塞的地方做协程的切换
实际上还可以更简单
import time
import gevent
from gevent import monkey
monkey.patch_all()
def f_0(param):
n = param
while n >= 0:
print('[f_0] ' + str(n))
time.sleep(0.1)
n = n - 1
def f_1(param):
m = param
while m >= 0:
print('[f_1] ' + str(m))
time.sleep(0.1)
m = m - 1
g1 = gevent.spawn(f_0, 5)
g2 = gevent.spawn(f_1, 3)
gevent.joinall([g1, g2])
通过 monkey.patch_all() 打补丁,可以拦截到大量 IO 操作,比如 time sleep,http request 等,对其做异步执行,并切换协程,这种做法允许原函数不用修改就能直接使用,对程序开发人员而言,协程就是透明的,不用特意修改代码,交给 gevent 打理就可以
asyncio
Python 3.6 中正式引入了 asyncio 库作为 python 标准库
最主要是 async 和 await 关键字
async 用来声明一个函数为异步函数,可以被挂起
await 用来用来声明程序被挂起,await 后面只能跟异步程序或有 __await__ 属性的对象
import asyncio
import aiohttp
async def f_0(param):
n = param
while n >= 0:
print('[f_0] ' + str(n))
await asyncio.sleep(0.1)
n = n - 1
async def f_1(param):
m = param
while m >= 0:
print('[f_1] ' + str(m))
await asyncio.sleep(0.1)
m = m - 1
loop = asyncio.get_event_loop()
tasks = [
f_0(5),
f_1(3)
]
loop.run_until_complete(asyncio.wait(tasks))
loop.close()
返回
[f_0] 5
[f_1] 3
[f_0] 4
[f_1] 2
[f_0] 3
[f_1] 1
[f_0] 2
[f_1] 0
[f_0] 1
[f_0] 0
另一个例子
import asyncio
import aiohttp
async def request(session, url):
async with session.get(url) as response:
return await response.read()
async def fetch(url):
await asyncio.sleep(1)
async with aiohttp.ClientSession() as session:
html = await request(session, url)
print(html)
url_list = [
"http://www.qq.com",
"http://www.jianshu.com",
"http://www.cnblogs.com"
]
tasks = [fetch(url) for url in url_list]
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))
loop.close()
可以看到需要加上 async 表示支持异步调用,并且要用 await 指定被挂起的地方
如果 await 指定的代码无法被挂起的话,是会出错的
并且需要使用特定的异步方法,或是类
相比较而言 gevent 则可以做到对程序透明
一个正常的同步程序,不需要任何修改就可以通过 gevent 实现异步
但 gevent 是借助三方包,asyncio 则是 python 标准库,在语法层面提供支持