关于python:Python-协程的本质原来也不过如此

3次阅读

共计 8284 个字符,预计需要花费 21 分钟才能阅读完成。

都是单线程,为什么原来低效率的代码用了 async、await 加一些异步库就变得效率高了?

如果在做基于 Python 的网络或者 Web 开发时,对于这个问题曾感到纳闷,这篇文章尝试给一个答案。

0x00 开始之前

首先,本文不是带你浏览源代码,而后对照原始代码给你讲 Python 规范的实现。相同,咱们会从理论问题登程,思考解决问题的计划,一步步领会解决方案的演进门路,最重要的,心愿能在过程中取得常识系统性晋升。
⚠️ 本文仅是提供了一个独立的思考方向,并未遵循历史和现有理论具体的实现细节。
其次,浏览这篇文章须要你对 Python 比拟相熟,至多理解 Python 中的生成器 generator 的概念。

0x01 IO 多路复用
这是性能的要害。但咱们这里只解释概念,其实现细节不是重点,这对咱们了解 Python 的协程曾经足够了,如已足够理解,后退到 0x02。
首先,你要晓得所有的网络服务程序都是一个微小的死循环,你的业务逻辑都在这个循环的某个时刻被调用:

def handler(request):
    # 解决申请
    pass

# 你的 handler 运行在 while 循环中
while True:
    # 获取一个新申请
    request = accept()
    # 依据路由映射获取到用户写的业务逻辑函数
    handler = get_handler(request)
    # 运行用户的 handler,解决申请
    handler(request)

构想你的 Web 服务的某个 handler,在接管到申请后须要一个 API 调用能力响应后果。
对于最传统的网络应用,你的 API 申请收回去后在期待响应,此时程序进行运行,甚至新的申请也得在响应完结后才进得来。如果你依赖的 API 申请网络丢包重大,响应特地慢呢?那利用的吞吐量将非常低。
很多传统 Web 服务器应用多线程技术解决这个问题:把 handler 的运行放到其余线程上,每个线程解决一个申请,本线程阻塞不影响新申请进入。这能肯定水平上解决问题,但对于并发比拟大的零碎,过多线程调度会带来很大的性能开销。
IO 多路复用能够做到不应用线程解决问题,它是由操作系统内核提供的性能,能够说专门为这类场景而生。简略来讲,你的程序遇到网络 IO 时,通知操作系统帮你盯着,同时操作系统提供给你一个办法,让你能够随时获取到有哪些 IO 操作曾经实现。就像这样:

# 操作系统的 IO 复用示例伪代码
# 向操作系统 IO 注册本人关注的 IO 操作的 id 和类型
io_register(io_id, io_type)
io_register(io_id, io_type)

# 获取实现的 IO 操作
events = io_get_finished()

for (io_id, io_type) in events:
    if io_type == READ:
        data = read_data(io_id) 
    elif io_type == WRITE:
        write_data(io_id,data)

把 IO 复用逻辑交融到咱们的服务器中,大略会像这样:

call_backs = {}

def handler(req):
    # do jobs here
    io_register(io_id, io_type)
    def call_back(result):
        # 应用返回的 result 实现残余工作...
    call_backs[io_id] = call_back

# 新的循环
while True:# 获取曾经实现的 io 事件
    events = io_get_finished()
    for (io_id, io_type) in events:
        if io_type == READ: # 读取
            data = read(io_id) 
            call_back = call_backs[io_id]
            call_back(data)
        else:
            # 其余类型 io 事件的解决
            pass

    # 获取一个新申请
    request = accept()
    # 依据路由映射获取到用户写的业务逻辑函数
    handler = get_handler(request)
    # 运行用户的 handler,解决申请
    handler(request)

咱们的 handler 对于 IO 操作,注册了回调就立即返回,同时每次迭代都会对已实现的 IO 执行回调,网络申请不再阻塞整个服务器。
下面的伪代码仅便于了解,具体实现细节更简单。而且就连承受新申请也是在从操作系统失去监听端口的 IO 事件后进行的。
咱们如果把循环局部还有 call_backs 字典拆分到独自模块,就能失去一个 EventLoop,也就是 Python 规范库 asyncio 包中提供的 ioloop。

0x02 用生成器打消 callback
着重看下咱们业务中常常写的 handler 函数,在有独立的 ioloop 后,它当初变成相似这样:

def handler(request):
    # 业务逻辑代码...
    # 须要执行一次 API 申请
    def call_back(result):
        # 应用 API 返回的 result 实现残余工作
        print(result)
    # 没有 io_call 这个办法,这里只是示意,示意注册一个 IO 操作
    asyncio.get_event_loop().io_call(api, call_back)

到这里,性能问题曾经解决了:咱们不再须要多线程就能源源不断承受新申请,而且不必 care 依赖的 API 响应有多慢。

然而咱们也引入了一个新问题,原来晦涩的业务逻辑代码当初被拆成了两局部,申请 API 之前的代码还失常,申请 API 之后的代码只能写在回调函数外面了。

这里咱们业务逻辑只有一个 API 调用,如果有多个 API,再加上对 Redis 或者 MySQL 的调用(它们实质也是网络申请),整个逻辑会被拆分的更散,这对业务开发是一笔累赘。

对于有匿名函数的一些语言(没错就是 JavaScript),还可能会引发所谓的「回调天堂」。

接下来咱们想方法解决这个问题。

咱们很容易会想到:如果函数在运行到网络 IO 操作处后可能暂停,实现后又能在断点处唤醒就好了。
如果你对 Python 的「生成器」相熟,你应该会发现,它恰好具备这个性能:

def example():
    value = yield 2
    print("get", value)
    return value

g = example()
# 启动生成器,咱们会失去 2
got = g.send(None)
print(got)  # 2

try:
    # 再次启动 会显示 "get 4", 就是咱们传入的值
    got = g.send(got*2)
except StopIteration as e:
    # 生成器运行实现,将会 print(4),e.value 是生成器 return 的值
    print(e.value)

函数中有 yield 关键字,调用函数将会失去一个生成器,生成器一个要害的办法 send() 能够跟生成器交互。

g.send(None) 会运行生成器内代码直到遇到 yield,并返回其后的对象,也就是 2,生成器代码就停在这里了,直到咱们再次执行 g.send(got2),会把 22 也就是 4 赋值给 yield 后面的变量 value,而后持续运行生成器代码。

yield 在这里就像一扇门,能够把一件货色从这里送出去,也能够把另一件货色拿进来。

如果 send 让生成器运行到下一个 yield 前就完结了,send 调用会引发一个非凡的异样 StopIteration,这个异样自带一个属性 value,为生成器 return 的值。

如果咱们把咱们的 handler 用 yield 关键字转换成一个生成器,运行它来把 IO 操作的具体内容返回,IO 实现后的回调函数中把 IO 后果放回并复原生成器运行,那就解决了业务代码不晦涩的问题了:

def handler(request):
    # 业务逻辑代码...
    # 须要执行一次 API 申请,间接把 IO 申请信息 yield 进来
    result = yield io_info
    # 应用 API 返回的 result 实现残余工作
    print(result)

# 这个函数注册到 ioloop 中,用来当有新申请的时候回调
def on_request(request):
    # 依据路由映射获取到用户写的业务逻辑函数
    handler = get_handler(request)
    g = handler(request)
    # 首次启动取得 io_info
    io_info = g.send(None)

    # io 实现回调函数
    def call_back(result):
        # 重新启动生成器
        g.send(result)

    asyncio.get_event_loop().io_call(io_info, call_back)

下面的例子,用户写的 handler 代码曾经不会被打散到 callback 中,on_request 函数应用 callback 和 ioloop 交互,但它会被实现在 Web 框架中,对用户不可见。
下面代码足以给咱们提供用生成器毁灭的 callback 的启发,但局限性有两点:
业务逻辑中仅发动一次网络 IO,但理论中往往更多
业务逻辑没有调用其余异步函数(协程),但理论中咱们往往会调用其余协程

0x03 解决残缺调用链

咱们来看一个更简单的例子:
其中 request 执行真正的 IO,func1、func2 仅调用。显然咱们的代码只能写成这样:

def func1():
    ret = yield request("http://test.com/foo")
    ret = yield func2(ret)
    return ret

def func2(data):
    result = yield request("http://test.com/"+data)
    return result

def request(url):
    # 这里模仿返回一个 io 操作,蕴含 io 操作的所有信息,这里用字符串简化代替
    result = yield "iojob of %s" % url
    return result

对于 request,咱们把 IO 操作通过 yield 裸露给框架。

对于 func1 和 func2,调用 request 显然也要加 yield 关键字,否则 request 调用返回一个生成器后不会暂停,继续执行后续逻辑显然会出错。
这根本就是咱们在没有 yield from、aysnc、await 时代,在 tornado 框架中写异步代码的样子。

要运行整个调用栈,大略流程如下:

  • 调用 func1() 失去生成器
  • 调用 send(None) 启动它失去会失去 request(“http://test.com/foo”) 的后果,还是生成器对象
  • send(None) 启动由 request() 产生的生成器,会失去 IO 操作,由框架注册到 ioloop 并指定回调
  • IO 实现后的回调函数内唤醒 request 生成器,生成器会走到 return 语句完结
  • 捕捉异样失去 request 生成器的返回值,将上一层 func1 唤醒,同时又失去 func2() 生成器
  • 继续执行 …

对算法和数据结构相熟的敌人遇到这种后退后退的遍历逻辑,能够递归也能够用栈,因为递归应用生成器还做不到,咱们能够应用栈,其实这就是「调用栈」一词的由来。
借助栈,咱们能够把整个调用链上串联的所有生成器对体现为一个生成器,对其一直 send 就能一直失去所有 IO 操作信息并推动调用链后退,实现办法如下:

  • 第一个生成器入栈
  • 调用 send,如果失去生成器就入栈并进入下一轮迭代
  • 遇到到 IO 申请 yield 进去,让框架注册到 ioloop
  • IO 操作实现后被唤醒,缓存后果并出栈,进入下一轮迭代,目标让下层函数应用 IO 后果复原运行
  • 如果一个生成器运行结束,也须要和 4 一样让下层函数复原运行

如果实现进去,代码不长但信息量比拟大。

它把整个调用链对外变成一个生成器,对其调用 send,就能整个调用链中的 IO,实现这些 IO,持续推动调用链内的逻辑执行,直到整体逻辑完结:

def wrapper(gen):
    # 第一层调用 入栈
    stack = Stack()
    stack.push(gen)

    # 开始逐层调用
    while True:
        # 获取栈顶元素
        item = stack.peak()

        result = None
        # 生成器
        if isgenerator(item):
            try:
                # 尝试获取上层调用并入栈
                child = item.send(result)
                stack.push(child)
                # result 应用过后就还原为 None
                result = None
                # 入栈后间接进入下次循环,持续向下摸索
                continue
            except StopIteration as e:
                # 如果本人运行完结了,就暂存 result,下一步让本人出栈
                result = e.value
        else:  # IO 操作
            # 遇到了 IO 操作,yield 进来,IO 实现后会被用 IO 后果唤醒并暂存到 result
            result = yield item

        # 走到这里则本层曾经执行结束,出栈,下次迭代将是调用链上一层
        stack.pop()
        # 没有上一层的话,那整个调用链都执行实现了,return        
        if stack.empty():
            print("finished")
            return result

这可能是最简单的局部,如果看起来吃力的话,其实只有明确,对于下面示例中的调用链,它能够实现的成果如下就好了:

w = wrapper(func1())
# 将会失去 "iojob of http://test.com/foo"
w.send(None)
# 上个 iojob foo 实现后的后果 "bar" 传入,持续运行,失去 "iojob of http://test.com/bar"
w.send("bar")
# 上个 iojob bar 实现后的构造 "barz" 传入,持续运行,完结。w.send("barz")

有了这部分当前框架再加上配套的代码:

# 保护一个就绪列表,寄存所有实现的 IO 事件,格局为(wrapper,result)ready = []

def on_request(request):
    handler = get_handler(request)
    # 应用 wrapper 包装后,能够只通过 send 解决 IO 了
    g = wrapper(func1())
    # 把开始状态间接视为后果为 None 的就绪状态
    ready.append((g, None))

# 让 ioloop 每轮循环都执行此函数,用来解决的就绪的 IO
def process_ready(self):
    def call_back(g, result):
        ready.append((g, result)) 

    # 遍历所有曾经就绪生成器,将其向下推动
    for g, result in self.ready:  
        # 用 result 唤醒生成器,并失去下一个 io 操作
        io_job = g.send(result)
        # 注册 io 操作 实现后把生成器退出就绪列表,期待下一轮解决
        asyncio.get_event_loop().io_call(io_job, lambda result: ready.append((g, result)

这里核心思想是保护一个就绪列表,ioloop 每轮迭代都来扫一遍,推动就绪的状态的生成器向下运行,并把新的 IO 操作注册,IO 实现后再次退出就绪,通过几轮 ioloop 的迭代一个 handler 最终会被执行实现。
至此,咱们应用生成器写法写业务逻辑曾经能够失常运行。

0x04 进步扩展性
如果到这里能读懂,Python 的协程原理根本就明确了。
咱们曾经实现了一个微型的协程框架,规范库的实现细节跟这里看起来大不一样,但具体的思维是统一的。

咱们的协程框架有一个限度,咱们只能把 IO 操作异步化,尽管在网络编程和 Web 编程的世界里,阻塞的根本只有 IO 操作,但也有一些例外,比方我想让以后操作 sleep 几秒,用 time.sleep() 又会让整个线程阻塞住,就须要非凡实现。再比方,能够把一些 CPU 密集的操作通过多线程异步化,让另一个线程告诉事件曾经实现后再执行后续。

所以,协程最好能与网络解耦开,让期待网络 IO 只是其中一种场景,进步扩展性。
Python 官网的解决方案是让用户本人解决阻塞代码,至于是向 ioloop 来注册 IO 事件还是开一个线程齐全由你本人,并提供了一个规范「占位符」Future,示意他的后果等到将来才会有,其局部原型如下:

class Future:# 设置后果
    def set_result(result): pass
    # 获取后果
    def result():  pass
    #  示意这个 future 对象是不是曾经被设置过后果了
    def done(): pass
    # 设置在他被设置后果时应该执行的回调函数,能够设置多个
    def add_done_callback(callback):  pass

咱们的稍加改变就能反对 Future,让扩展性变得更强。对于用户代码的中的网络申请函数 request:

# 当初 request 函数,不是生成器,它返回 future
def request(url):
    # future 了解为占位符
    fut = Future()

    def callback(result):
        # 当网络 IO 实现回调的时候给占位符赋值
        fut.set_result(result)
    asyncio.get_event_loop().io_call(url, callback)

    # 返回占位符
    return future

当初,request 不再是一个生成器,而是间接返回 future。
而对于位于框架中解决就绪列表的函数:

def process_ready(self):
    def callback(fut):
        # future 被设置后果会被放入就绪列表
        ready.append((g, fut.result()))

    # 遍历所有曾经就绪生成器,将其向下推动
    for g, result in self.ready:  
        # 用 result 唤醒生成器,失去的不再是 io 操作,而是 future
        fut = g.send(result)
        # future 被设置后果的时候会调用 callback
        fut.add_done_callback(callback)

0x05 倒退和改革
许多年前用 tornado 的时候,大略只有一个 yield 关键字可用,协程要想实现,就是这么个思路,甚至 yield 关键字和 return 关键字不能一个函数外面呈现,你要想在生成器运行完后返回一个值,须要手动 raise 一个异样,尽管成果跟当初 return 一样,但写起来还是很顺当,不优雅。
起初有了 yield from 表达式。它能够做什么?
艰深地说,它就是做了下面那个生成器 wrapper 所做的事:通过栈实现调用链遍历的,它是 wrapper 逻辑的语法糖。
有了它,同一个例子你能够这么写:

def func1():
    # 留神 yield from
    ret = yield from request("http://test.com/foo")
    # 留神 yield from
    ret = yield from func2(ret) 
    return ret

def func2(data):
    # 留神 yield from
    result = yield from request("http://test.com/"+data)
    return result

# 当初 request 函数,不是生成器,它返回 future
def request(url):
    # 同上基于 future 实现的 request

而后你就不再须要那个烧脑的 wrapper 函数了:

g = func1()
# 返回第一个申请的 future 
g.send(None)
# 持续运行,主动进入 func2 并失去第它外面的那个 future
g.send("bar")
# 持续运行,实现调用链残余逻辑,抛出 StopIteration 异样
g.send("barz")

yield from 间接买通了整个调用链,曾经是很大的提高了,然而用来异步编程看着还是顺当,其余语言都有专门的协程 async、await 关键字了,直到再起初的版本把这些内容用专用的 async、await 关键字包装,才成为明天比拟优雅的样子。

0x06 总结和比拟
总的来说,Python 的原生的协程从两方面实现:

  • 基于 IO 多路复用技术,让整个利用在 IO 上非阻塞,实现高效率
  • 通过生成器让扩散的 callback 代码变成同步代码,缩小业务编写艰难

有生成器这种对象的语言,其 IO 协程实现大抵如此,JavaScript 协程的演进根本截然不同,关键字雷同,Future 类比 Promise 实质雷同。

然而对于以协程闻名的 Go 的协程实现跟这个就不同了,并不显式地基于生成器。

如果类比的话,能够 Python 的 gevent 算作一类,都是本人实现 runtime,并 patch 掉零碎调用接入本人的 runtime,本人来调度协程,gevent 专一于网络相干,基于网络 IO 调度,比较简单,而 Go 实现了欠缺的多核反对,调度更加简单和欠缺,而且发明了基于 channel 新编程范式。

零根底小白的你,酷爱编程,想要学习 python,然而无门路怎么办?关注“Python 编程学习圈”,发送“J”即可收费取得泛滥 python 干货与材料,让你轻松搞定 python

正文完
 0