文章目录
引言
话说上一篇,我们聊了一些使用多进程,多线程,I/O多路复用的编程技巧来提升socket应用的性能。本篇,我们介绍异步编程界的另一个主角——协程。
为什么要使用协程
执行效率极高
相较于多线程机制,协程的调度是由程序自身控制的,因此没有象多线程一样切换的开销。多线程场景下,线程的数量越多,协程的性能优势越明显。
无锁
由于协程的运行都是在一个线程中,所以不存在多线程的线程安全问题,也就是说,不需要考虑线程加锁的问题,贡献资源的读写只需要考虑状态问题就可以了(效率也会提高)
代码更加优(魔)雅(幻)?
避免了链式调用的回调地狱,解耦了复杂的调用链。降低了程序维护的难度。
协程允许我们在例程
(编程语言定义的可被调用的代码段,通常为函数或方法)中定义多个入口点用来确定代码片段的位置,以便控制程序的暂停与恢复执行,听上去有那么一丝丝的魔幻~
基于生成器的协程
python中存在一个特殊的对象——生成器(generator)
我们再是用Python中的列表,字典推导时,如果使用这样的语法
(i for i in range(10) if i % 2 == 0)
程序将会返回给我们一个生成器(PS:可以大大减少内存的消耗),我们可以通过next()
去调用它,调用一次,便会给我们返回下一个值。
通过描述,我们发现这个对象的特点跟我们即将要讨论的协程很象,每次调用,都会中断执行,继续下一次调用的时候,还会保留上一次的执行状态。
事实上生成器真的能够支持简单的协程
为了支持用生成器做简单的协程,Python 2.5 对生成器进行了增强(PEP 342),该增强提案的标题是 “Coroutines via Enhanced Generators”。有了PEP 342的加持,生成器可以通过yield 暂停执行和向外返回数据,也可以通过send()向生成器内发送数据,还可以通过throw()向生成器内抛出异常以便随时终止生成器的运行。
来看一个简单的例子:
def coro():
hello = yield 'hello' # yield作为在=右边作为表达式,可以被send值
yield hello
c = coro()
# 输出'hello',这里调用 next 产出第一个值 'hello', 之后函数暂停
print(next(c))
# 再次调用 send 发送值,此时 hello 变量赋值为 'world', 然后 yield 产出 hello 变量的值 'world'
print(c.send('world'))
# 之后协程结束,在使用 send 发送值会报 stopIteration 错误
我们在以一个简单的生产者-消费者模型来看看基于Python 生成器的协程。
def consumer():
r = ''
while True:
# 入口 / 中断点
n = yield r
if not n:
return
print('[CONSUMER] Consuming %s...' % n)
r = '200 OK'
def produce(c_):
# 启动生成器
c_.send(None)
n = 0
while n < 5:
n = n + 1
print('[PRODUCER] Producing %s...' % n)
# 向 yield 发送数据,切换到consumer执行
r = c_.send(n)
print('[PRODUCER] Consumer return: %s' % r)
c_.close()
if __name__ == '__main__':
c = consumer()
produce(c)
我们先说说传统的生产-消费者模型,两个线程,一个线程写数据,一个线程读数据,通过锁机制控制队列和等待(可能会出现死锁)
而基于协程的例子,通过yield
和send
在程序件切换和传递参数,当消费者消费完成后,切换到生产者继续生产;生产者生产完成后,在通知消费者进行消费。
整个由一个单独的线程,produce
和consumer
相互协作完成,所以我们管它叫做协程,而非线程的抢占式多任务。
让协程干掉回调(大雾)
在上面一篇,我们使用了多种方式编写了socket应用,其中基于I/O多路复用的socket已经有了让人满意的效果,但或多或少存在一些让人难受的点(比如回调地狱),而现在我们有了协程,可以进一步的继续改进代码,下面看看实现
import socket
import time
from selectors import DefaultSelector, EVENT_READ, EVENT_WRITE
from tech_share.time_deco import TimeLogger
# 根据环境选择最佳模块
selector = DefaultSelector()
stopped = False
count = 10
class Feature:
def __init__(self):
self.result = ''
self.dispatches = []
def coroutine_dispatch(self, func):
self.dispatches.append(func)
def set_result(self, result):
self.result = result
# 触发任务调度
for func in self.dispatches:
func(self)
class Creeper:
def __init__(self, task):
# 初始化
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.response = b''
self.task = task
# 设置非阻塞
self.sock.setblocking(False)
def fetch(self):
# 建立TCP连接
try:
self.sock.connect(('www.baidu.com', 443))
except BlockingIOError:
pass
# 初始化未来对象
fe = Feature()
# 注册
def on_connect():
fe.set_result(None)
selector.register(self.sock.fileno(), EVENT_WRITE, on_connect)
yield fe # 出入口
# 发送数据
selector.unregister(self.sock.fileno())
request = b'GET / HTTP/1.0\r\n Host: www.baidu.com\r\n\r\n'
self.sock.send(request)
global stopped, count
while True:
fe = Feature()
# 注册
def on_readable():
fe.set_result(self.sock.recv(4096))
selector.register(self.sock.fileno(), EVENT_READ, on_readable)
chunk = yield fe # 第二阶段,另一个出入口
selector.unregister(self.sock.fileno())
if chunk:
self.response += chunk
else:
print('task {} end time: {}'.format(self.task, time.time()))
count -= 1
if count == 0:
stopped = True
break
class Task:
def __init__(self, coro):
self.coro = coro
fe = Feature()
fe.set_result(None)
self.step(fe)
def step(self, feature):
try:
# 切换到下一个代码段
next_feature = self.coro.send(feature.result)
except StopIteration:
return
next_feature.coroutine_dispatch(self.step)
@TimeLogger()
def loop():
while not stopped:
# 阻塞,直到一个事件发生
event = selector.select()
for event_key, event_mask in event:
callback = event_key.data
# 此时callback的作用就是保存状态/结果以及触发协程的调度
callback()
def run():
for task_id in range(count):
creeper = Creeper(task_id)
Task(creeper.fetch())
if __name__ == '__main__':
# 启动10个socket应用
run()
# 事件循环
loop()
这段代码有4个主要的部分,下面我们来一个一个分析,看看代码是如何利用I/O多路复用驱动协程进行协作的。
class Feature
这是一个用来保存协程执行状态的类,因为我们要干掉程序中大部分(注意大部分,不是所有)的回调,所以我们需要让协程的每一步管理自己状态。
其中,self.dispatches
用来保存我们的协程调度方法(这个方法会在下面给出,不着急),通过coroutine_dispatch
方法添加
set_result()
方法有两个作用,第一,保存后面我们代码执行的结果;第二,也是比较重要的一个功能,触发协程调度,这个待会儿再说。
class Creeper
这个就是我们协程的主体了,如果不看改动的部分,主体代码的结构和同步的代码结构是十分的相似的,这是个好现象,因为它意味着我们的代码结构更加的清晰了。
那么我们再看看改动的几个地方:
fetch()
主体中添加了两个yield
,这是程序的另外两个“出入口”,回顾一下yield的功能
- 暂停程序的执行(相当于临时的return)
- 向外返回数据(return xxx)
- 接收
send()
发送的数据(另外调用send之后,会立即切换到生成器内部继续执行)
由于socket在设置为False
之后已经不再阻塞,这段代码我们需要以yield为节点,分成三个部分来看;
第一段(第一个yield之前):初始化socket实例,建立TCP连接,这个过程是立即返回的
第二段(第一个yield之后,while循环之前):发送请求数据,也是立即返回的
第三段(while 循环):接收数据
主体有了,我们需要去启动它,并且在在不通过回调的情况下,调度协程的执行。
这个启动工作由谁来完成?
答案是代码片段中的Task
类,我们来看看Task
都干了哪些事情
其实例化的过程中传入了一个生成器,并生成了一个Feature
实例,将Feature
的result
置为None
(这个过程在该例的每个任中只会发生一次)
比较有趣的是这个step
方法,这个方法在Task
实例化的过程中就被运行了,它的主体部分就两行代码,主要干了两件事情:
- 通过生成器的
send
方法向协程发送数据,并切换到中断的协程处继续执行 - 将自己加入到了
Feature
的dispatches
(协程调度方法)中去
这个step
方法就是后面我们进行线程调度的主要手段,可以看到,首次调用,我们只会给协程传递一个None
,这意味着启动这个协程。
协程的调度
好了,启动工作已经完成了,接下来我们还需要不断的调度程序协作,才能算完整的运行,这个艰巨的任务还是由我们的万能的事件循环loop
来完成。相比之前的事件循环,代码上并没有过多的变化,这里也包含了代码中唯一的回调(不是不用回调吗喂)。但是这里的回调和之前相比又存在着本质上的差异。
差异主要体现在回调的含义不同,之前的回调函数包含了业务逻辑,也就是说回调和业务是紧紧耦合在一起的。
此外,callback()
方法仅仅作为触发调度,对于触发的对象并不关心。也没有必要再回调之间传递状态了,正如上面所说的,每个协程能够自己保存自己的状态。
我们来完整的看一遍代码执行的流程:
- 执行
run()
,启动10个协程 - 调用
loop()
事件循环,监听socket状态 self.coro.send(feature.result)
(首次发送None
)启动协程,并将自身加入调度程序fetch()
执行直到第一个yield中断- 当
loop()
监听到socket状态变化(EVENT_WRITE
)时,调用on_connect()
,返回到第一个中断的位置继续执行发送请求的操作。 - 之后进入循环,重新实例化
feature
对象用于保存结果,注册调度函数后将feature
实例返回给调度任务step
,同样在feature
的dispatches
中加入step
,进入第二次中断。 - 当
loop()
监听到socket状态变化(EVENT_READ
)时,调用on_readable()
,接收数据,并执行协程调度,通过next_feature = self.coro.send(feature.result)
将结果返回给fetch
, fetch
拿到结果后将其添加至result
。- 重复步骤6~8,直到拿到全部的返回数据,程序终止。
Ok,我们来看看代码的执行效率如何
task 0 end time: 1566056013.435203
task 1 end time: 1566056013.439032
task 2 end time: 1566056013.4390998
task 7 end time: 1566056013.4454038
task 4 end time: 1566056013.445444
task 3 end time: 1566056013.445656
task 9 end time: 1566056013.445724
task 8 end time: 1566056013.4457421
task 6 end time: 1566056013.4491572
task 5 end time: 1566056013.44926
use time: 0.049172163009643555
这是一个符合预期的结果 : )
继续改进 yield from
上面的程序依然不够好,主要体现在fetch()
部分的代码没有完全分割出业务,我尝试着将这部分业务代码分离出来,然而这几段代码都存在中断(yield)节点,如果分离出俩,它们也将会是一个生成器,我们会面临一个很蛋疼的问题,生成器中嵌套生成器,代码会变的难以阅读。
好在python设计者们早就想到了这个问题,在PEP380
中引入了新的语法yield from
,
这个语法接受一个可迭代的对象,假设这个对象是一个列表,yield from
会把这个列表中的元素一个一个迭代出来,如果是一个生成器,我们就可以轻而易举的实现生成器的嵌套。
下面,我们吧上面那段代码重新改写一下(代码中相同的部分使用省略号代替):
import socket
import time
from selectors import DefaultSelector, EVENT_READ, EVENT_WRITE
from tech_share.time_deco import TimeLogger
# 根据环境选择最佳模块
selector = DefaultSelector()
stopped = False
count = 10
class Feature:
def __init__(self):
self.result = ''
self.dispatches = []
def coroutine_dispatch(self, func):
self.dispatches.append(func)
def set_result(self, result):
self.result = result
# 触发任务调度
for func in self.dispatches:
func(self)
class Creeper:
def __init__(self, task):
...
def connect(self):
# 建立TCP连接
try:
self.sock.connect(('www.baidu.com', 443))
except BlockingIOError:
pass
# 初始化未来对象
fe = Feature()
# 注册
def on_connect():
fe.set_result(None)
selector.register(self.sock.fileno(), EVENT_WRITE, on_connect)
yield fe # 出入口
selector.unregister(self.sock.fileno())
def send_request(self):
request = b'GET / HTTP/1.0\r\n Host: www.baidu.com\r\n\r\n'
self.sock.send(request)
def read(self):
fe = Feature()
# 注册
def on_readable():
fe.set_result(self.sock.recv(4096))
selector.register(self.sock.fileno(), EVENT_READ, on_readable)
chunk = yield fe # 第二阶段,另一个出入口
selector.unregister(self.sock.fileno())
return chunk
def read_all(self):
result = []
chunk = yield from self.read()
while chunk:
result.append(chunk)
chunk = yield from self.read()
return b''.join(result)
def fetch(self):
global stopped, count
yield from self.connect()
self.send_request()
self.response = yield from self.read_all()
print('task {} end time: {}'.format(self.task, time.time()))
count -= 1
if count == 0:
stopped = True
class Task:
...
@TimeLogger()
def loop():
...
def run():
...
if __name__ == '__main__':
# 启动10个socket应用
run()
# 事件循环
loop()
执行一下看看:
task 0 end time: 1566136683.677768
task 1 end time: 1566136683.677956
task 2 end time: 1566136683.67798
task 3 end time: 1566136683.677997
task 7 end time: 1566136683.6910799
task 6 end time: 1566136683.6911201
task 8 end time: 1566136683.691138
task 5 end time: 1566136683.6921751
task 9 end time: 1566136683.6922612
task 4 end time: 1566136683.692322
use time: 0.053611040115356445
其中最大的改进在于业务逻辑的分离,以及使用yield from
解决了生成器嵌套的问题。
这归功于yield from
的双向通道功能
,使得协程之间能够随心所欲的传递数据。如果只使用yield,代码的复杂度将会大大提升。
至于什么是双向通道
,这里给出一个简单的解释:
调用函数
可以通过send()直接发送消息给子生成器
,而子生成器yield的值,也是直接
返回给调用方。这帮助我们省去了大量处理嵌套协程调度的问题。