关于Python异步编程的一些心得(二)

引言

话说上一篇,我们聊了一些使用多进程,多线程,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)

我们先说说传统的生产-消费者模型,两个线程,一个线程写数据,一个线程读数据,通过锁机制控制队列和等待(可能会出现死锁)

而基于协程的例子,通过yieldsend在程序件切换和传递参数,当消费者消费完成后,切换到生产者继续生产;生产者生产完成后,在通知消费者进行消费。

整个由一个单独的线程,produceconsumer相互协作完成,所以我们管它叫做协程,而非线程的抢占式多任务。

让协程干掉回调(大雾)

在上面一篇,我们使用了多种方式编写了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的功能

  1. 暂停程序的执行(相当于临时的return)
  2. 向外返回数据(return xxx)
  3. 接收send()发送的数据(另外调用send之后,会立即切换到生成器内部继续执行)

由于socket在设置为False之后已经不再阻塞,这段代码我们需要以yield为节点,分成三个部分来看;
第一段(第一个yield之前):初始化socket实例,建立TCP连接,这个过程是立即返回的
第二段(第一个yield之后,while循环之前):发送请求数据,也是立即返回的
第三段(while 循环):接收数据

主体有了,我们需要去启动它,并且在在不通过回调的情况下,调度协程的执行。

这个启动工作由谁来完成?

答案是代码片段中的Task类,我们来看看Task都干了哪些事情

其实例化的过程中传入了一个生成器,并生成了一个Feature实例,将Featureresult置为None(这个过程在该例的每个任中只会发生一次)

比较有趣的是这个step方法,这个方法在Task实例化的过程中就被运行了,它的主体部分就两行代码,主要干了两件事情:

  1. 通过生成器的send方法向协程发送数据,并切换到中断的协程处继续执行
  2. 将自己加入到了Featuredispatches(协程调度方法)中去

这个step方法就是后面我们进行线程调度的主要手段,可以看到,首次调用,我们只会给协程传递一个None,这意味着启动这个协程。

协程的调度

好了,启动工作已经完成了,接下来我们还需要不断的调度程序协作,才能算完整的运行,这个艰巨的任务还是由我们的万能的事件循环loop来完成。相比之前的事件循环,代码上并没有过多的变化,这里也包含了代码中唯一的回调(不是不用回调吗喂)。但是这里的回调和之前相比又存在着本质上的差异。

差异主要体现在回调的含义不同,之前的回调函数包含了业务逻辑,也就是说回调和业务是紧紧耦合在一起的。

此外,callback()方法仅仅作为触发调度,对于触发的对象并不关心。也没有必要再回调之间传递状态了,正如上面所说的,每个协程能够自己保存自己的状态。

我们来完整的看一遍代码执行的流程:

  1. 执行run(),启动10个协程
  2. 调用loop()事件循环,监听socket状态
  3. self.coro.send(feature.result)(首次发送None)启动协程,并将自身加入调度程序
  4. fetch()执行直到第一个yield中断
  5. loop()监听到socket状态变化(EVENT_WRITE)时,调用on_connect(),返回到第一个中断的位置继续执行发送请求的操作。
  6. 之后进入循环,重新实例化feature对象用于保存结果,注册调度函数后将feature实例返回给调度任务step,同样在featuredispatches中加入step,进入第二次中断。
  7. loop()监听到socket状态变化(EVENT_READ)时,调用on_readable(),接收数据,并执行协程调度,通过next_feature = self.coro.send(feature.result)将结果返回给fetch
  8. fetch拿到结果后将其添加至result
  9. 重复步骤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的值,也是直接返回给调用方。这帮助我们省去了大量处理嵌套协程调度的问题。

参考

廖雪峰的python教程:协程
深入理解yield from语法
深入理解Python异步编程(上)

在这里插入图片描述

发布了19 篇原创文章 · 获赞 17 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/breavo_raw/article/details/99647350
今日推荐