谈谈Python协程技术的演进

Coding Crush Python开发工程师nginx

主要负责岂安科技业务风险情报系统redq。git

引言

1.1. 存储器山

存储器山是 Randal Bryant 在《深刻理解计算机系统》一书中提出的概念。
基于成本、效率的考量,计算机存储器被设计成多级金字塔结构,塔顶是速度最快、成本最高的 CPU 内部的寄存器(通常几 KB)与高速缓存,塔底是成本最低、速度最慢的广域网云存储(如百度云免费 2T )程序员

bigsec

存储器山的指导意义在于揭示了良好设计程序的必要条件是须要有优秀的局部性:
时间局部性:相同时间内,访问同一地址次数越多,则时间局部性表现越佳;
空间局部性:下一次访问的存储器地址与上一次的访问过的存储器地址位置邻近;github

1.2. cpu的时间观

bigsec

咱们将一个普通的 2.6GHz 的 CPU 的延迟时间放大到人能体验的尺度上(数据来自微信公众号 驹说码事):在存储器顶层执行单条寄存器指令的时间为1秒钟;从第五层磁盘读 1MB 数据却须要一年半;ping 不一样的城域网主机,网络包须要走 12.5 年。
若是程序发送了一个 HTTP 包后便阻塞在同步等待响应的过程上,计算机不得不傻等 12 年后的那个响应再处理别的事情,低下的硬件利用率必然致使低下的程序效率。golang

1.3. 同步编程

从以上数据能够看出,内存数据读写、磁盘寻道读写、网卡读写等操做都是 I/O 操做,同步程序的瓶颈在于漫长的 I/O 等待,想要提升程序效率必须减小 I/O 等待时间,从提升程序的局部性着手。编程

同步编程的改进方式有多进程、多线程,但对于 c10k 问题都不是良好的解决方案,多进程的方式存在操做系统可调度进程数量上限较低,进程间上下文切换时间过长,进程间通讯较为复杂。后端

而 Python 的多线程方式,因为存在众所周知的 GIL 锁,性能提高并不稳定,仅能知足成百上千规模的 I/O 密集型任务,多线程还有一个缺点是由操做系统进行抢占式调度存在竞态条件,可能须要引入了锁与队列等保障原子性操做的工具。api

1.4. 异步编程

说到异步非阻塞调用,目前的代名词都是 epoll 与 kqueue,select/poll 因为效率问题基本已被取代。
epoll 是04年 Linux2.6 引入内核的一种 I/O 事件通知机制,它的做用是将大量的文件描述符托管给内核,内核将最底层的 I/O 状态变化封装成读写事件,这样就避免了由程序员去主动轮询状态变化的重复工做,程序员将回调函数注册到 epoll 的状态上,当检测到相对应文件描述符产生状态变化时,就进行函数回调。
事件循环是异步编程的底层基石。缓存

bigsec

上图是简单的EventLoop的实现原理,微信

用户建立了两个socket链接,将系统返回的两个文件描述符fd三、fd4经过系统调用在epoll上注册读写事件;
当网卡解析到一个tcp包时,内核根据五元组找到相应到文件描述符,自动触发其对应的就绪事件状态,并将该文件描述符添加到就绪链表中。
程序调用epoll.poll(),返回可读写的事件集合。
对事件集合进行轮询,调用回调函数等
一轮事件循环结束,循环往复。

epoll 并不是银弹,从图中能够观察到,若是用户关注的层次很低,直接操做epoll去构造维护事件的循环,从底层到高层的业务逻辑须要层层回调,形成callback hell,而且可读性较差。因此,这个繁琐的注册回调与回调的过程得以封装,并抽象成EventLoop。EventLoop屏蔽了进行epoll系统调用的具体操做。对于用户来讲,将不一样的I/O状态考量为事件的触发,只需关注更高层次下不一样事件的回调行为。诸如libev, libevent之类的使用C编写的高性能异步事件库已经取代这部分琐碎的工做。

在Python框架里通常会见到的这几种事件循环:
libevent/libev: Gevent(greenlet+前期libevent,后期libev)使用的网络库,普遍应用;
tornado: tornado框架本身实现的IOLOOP;
picoev: meinheld(greenlet+picoev)使用的网络库,小巧轻量,相较于libevent在数据结构和事件检测模型上作了改进,因此速度更快。但从github看起来已经年久失修,用的人很少。
uvloop: Python3时代的新起之秀。Guido操刀打造了asyncio库,asyncio能够配置可插拔的event loop,但须要知足相关的API要求,uvloop继承自libuv,将一些低层的结构体和函数用Python对象包装。目前Sanic框架基于这个库

1.5. 协程

EventLoop简化了不一样平台上的事件处理,可是处理事件触发时的回调依然很麻烦,响应式的异步程序编写对程序员的心智是一项不小的麻烦。
所以,协程被引入来替代回调以简化问题。协程模型主要在在如下方面优于回调模型:

以近似同步代码的编程模式取代异步回调模式,真实的业务逻辑每每是同步线性推演的,所以,这种同步式的代码写起来更加容易。底层的回调依然是callback hell,但这部分脏活累活已经转交给编译器与解释器去完成,程序员不易出错。
异常处理更加健全,能够复用语言内的错误处理机制,回调方式。而传统异步回调模式须要本身断定成功失败,错误处理行为复杂化。
上下文管理简单化,回调方式代码上下文管理严重依赖闭包,不一样的回调函数之间相互耦合,割裂了相同的上下文处理逻辑。协程直接利用代码的执行位置来表示状态,而回调则是维护了一堆数据结构来处理状态。
方便处理并发行为,协程的开销成本很低,每个协程仅有一个轻巧的用户态栈空间。

1.6. EventLoop与协程的发展史

04年,event-driven 的 nginx 诞生并快速传播,06年之后从俄语区国家扩散到全球。同时期,EventLoop 变得具象化与多元化,相继在不一样的编程语言实现。

近十年以来,后端领域内古老的子例程与事件循环获得结合,协程(协做式子例程)快速发展,并也革新与诞生了一些语言,好比 golang 的 goroutine,luajit 的 coroutine,Python 的 gevent,erlang 的 process,scala 的 actor 等。

就不一样语言中面向并发设计的协程实现而言,Scala 与 Erlang 的 Actor 模型、Golang 中的 goroutine 都较 Python 更为成熟,不一样的协程使用通讯来共享内存,优化了竞态、冲突、不一致性等问题。然而,根本的理念没有区别,都是在用户态经过事件循环驱动实现调度。

因为历史包袱较少,后端语言上的各类异步技术除 Python Twisted 外基本也没有 callback hell 的存在。其余的方案都已经将 callback hell 的过程进行封装,交给库代码、编译器、解释器去解决。

有了协程,有了事件循环库,传统的 C10K 问题已经不是挑战并已经上升到了 C1M 问题。

2. Gevent

bigsec

Python2 时代的协程技术主要是 Gevent,另外一个 meinheld 比较小众。Gevent 有褒有贬,负面观点认为它的实现不够 Pythonic,脱离解释器独自实现了黑盒的调度器,monkey patch 让不了解的用户产生混淆。正面观点认为正是这样才得以屏蔽全部的细节,简化使用难度。

Gevent 基于 Greenlet 与 Libev,greenlet 是一种微线程或者协程,在调度粒度上比 PY3 的协程更大。greenlet 存在于线程容器中,其行为相似线程,有本身独立的栈空间,不一样的 greenlet 的切换相似操做系统层的线程切换。

greenlet.hub 也是一个继承于原生 greenlet 的对象,也是其余 greenlet 的父节点,它主要负责任务调度。当一个 greenlet 协程执行完部分例程后到达断点,经过 greenlet.switch() 向上转交控制权给 hub 对象,hub 执行上下文切换的操做:从寄存器、高速缓存中备份当前 greenlet 的栈内容到内存中,并将原来备份的另外一个 greenlet 栈数据恢复到寄存器中。

hub 对象内封装了一个 loop 对象,loop 负责封装 libev 的相关操做并向上提供接口,全部 greenlet 在经过 loop 驱动的 hub 下被调度。

3. 从yield到async/await

3.1. 生成器的进化

在 Python2.2 中,第一次引入了生成器,生成器实现了一种惰性、屡次取值的方法,此时仍是经过 next 构造生成迭代链或 next 进行屡次取值。

直到在 Python2.5 中,yield 关键字被加入到语法中,这时,生成器有了记忆功能,下一次从生成器中取值能够恢复到生成器上次 yield 执行的位置。

以前的生成器都是关于如何构造迭代器,在 Python2.5 中生成器还加入了 send 方法,与 yield 搭配使用。

咱们发现,此时,生成器不只仅能够 yield 暂停到一个状态,还能够往它中止的位置经过 send 方法传入一个值改变其状态。
举一个简单的示例,主要熟悉 yield 与 send 与外界的交互流程:

def jump_range(up_to):
    step = 0
    while step < up_to:
      jump = yield step
      print("jump", jump)
      if jump is None:
          jump = 1
          step += jump
      print("step", step)

if __name__ == '__main__':
    iterator = jump_range(10)
    print(next(iterator))  # 0
    print(iterator.send(4))  # jump4; step4; 4
    print(next(iterator))  # jump None; step5; 5
    print(iterator.send(-1)) # jump -1; step4; 4

在 Python3.3 中,生成器又引入了 yield from 关键字,yield from 实现了在生成器内调用另外生成器的功能,能够轻易的重构生成器,好比将多个生成器链接在一块儿执行。

def gen_3():
    yield 3

def gen_234():
    yield 2
    yield from gen_3()
    yield 4

def main():
    yield 1
    yield from gen_234()
    yield 5

for element in main():
    print(element)  # 1,2,3,4,5

bigsec

从图中能够看出 yield from 的特色。使用 itertools.chain 能够以生成器为最小组合子进行链式组合,使用 itertools.cycle 能够对单独一个生成器首尾相接,构造一个循环链。
使用 yield from 时能够在生成器中从其余生成器 yield 一个值,这样不一样的生成器之间能够互相通讯,这样构造出的生成链更加复杂,但生成链最小组合子的粒度却精细至单个 yield 对象。

3.2. 短暂的asynico.coroutine 与yield from

有了Python3.3中引入的yield from 这项工具,Python3.4 中新加入了asyncio库,并提供了一个默认的event loop。Python3.4有了足够的基础工具进行异步并发编程。
并发编程同时执行多条独立的逻辑流,每一个协程都有独立的栈空间,即便它们是都工做在同个线程中的。如下是一个示例代码:

import asyncio
import aiohttp

@asyncio.coroutine
def fetch_page(session, url):
    response = yield from session.get(url)
    if response.status == 200:
        text = yield from response.text()
        print(text)
loop = asyncio.get_event_loop()

session = aiohttp.ClientSession(loop=loop)

tasks = [
    asyncio.ensure_future(
       fetch_page(session, "http://bigsec.com/products/redq/")),
    asyncio.ensure_future(
       fetch_page(session, "http://bigsec.com/products/warden/"))
]

loop.run_until_complete(asyncio.wait(tasks))
session.close()
loop.close()

在 Python3.4 中,asyncio.coroutine 装饰器是用来将函数转换为协程的语法,这也是 Python 第一次提供的生成器协程 。只有经过该装饰器,生成器才能实现协程接口。使用协程时,你须要使用 yield from 关键字将一个 asyncio.Future 对象向下传递给事件循环,当这个 Future 对象还未就绪时,该协程就暂时挂起以处理其余任务。一旦 Future 对象完成,事件循环将会侦测到状态变化,会将 Future 对象的结果经过 send 方法方法返回给生成器协程,而后生成器恢复工做。

在以上的示例代码中,首先实例化一个 eventloop,并将其传递给 aiohttp.ClientSession 使用,这样 session 就不用建立本身的事件循环。

此处显式的建立了两个任务,只有当 fetch_page 取得 api.bigsec.com 两个 url 的数据并打印完成后,全部任务才能结束,而后关闭 session 与 loop,释放链接资源。

当代码运行到 response = yield from session.get(url)处,fetch_page 协程被挂起,隐式的将一个 Future 对象传递给事件循环,只有当 session.get() 完成后,该任务才算完成。

session.get() 内部也是协程,其数据传输位于在存储器山最慢的网络层。当 session.get 完成时,取得了一个 response 对象,再传递给原来的 fetch_page 生成器协程,恢复其工做状态。

为了提升速度,此处 get 方法将取得 http header 与 body 分解成两次任务,减小一次性传输的数据量。response.text() 便是异步请求 http body。

使用 dis 库查看 fetch_page 协程的字节码,GET_YIELD_FROM_ITER 是 yield from 的操做码:

In [4]: import dis

In [5]: dis.dis(fetch_page)
  0 LOAD_FAST 0 (session)
  2 LOAD_ATTR 0 (get)
  4 LOAD_FAST 1 (url)
  6 CALL_FUNCTION 1
  8 GET_YIELD_FROM_ITER
  10 LOAD_CONST 0 (None)
  12 YIELD_FROM
  14 STORE_FAST 2 (response)

  16 LOAD_FAST 2 (response)
  18 LOAD_ATTR 1 (status)
  20 LOAD_CONST 1 (200)
  22 COMPARE_OP 2 (==)
  24 POP_JUMP_IF_FALSE 48

  26 LOAD_FAST 2 (response)
  28 LOAD_ATTR 2 (text)
  30 CALL_FUNCTION 0
  32 GET_YIELD_FROM_ITER
  34 LOAD_CONST 0 (None)
  36 YIELD_FROM
  38 STORE_FAST 3 (text)

  40 LOAD_GLOBAL 3 (print)
  42 LOAD_FAST 3 (text)
  44 CALL_FUNCTION 1
  46 POP_TOP
  >> 48 LOAD_CONST 0 (None)
  50 RETURN_VALUE

3.3. async与 await关键字

Python3.5 中引入了这两个关键字用以取代 asyncio.coroutine 与 yield from,从语义上定义了原生协程关键字,避免了使用者对生成器协程与生成器的混淆。这个阶段(3.0-3.4)使用 Python 的人很少,所以历史包袱不重,能够进行一些较大的革新。
await 的行为相似 yield from,可是它们异步等待的对象并不一致,yield from 等待的是一个生成器对象,而await接收的是定义了__await__方法的 awaitable 对象。
在 Python 中,协程也是 awaitable 对象,collections.abc.Coroutine 对象继承自 collections.abc.Awaitable。
所以,将上一小节的示例代码改写成:

import asyncio
import aiohttp

async def fetch_page(session, url):
    response = await session.get(url)
    if response.status == 200:
        text = await response.text()
        print(text)

loop = asyncio.get_event_loop()
session = aiohttp.ClientSession(loop=loop)

tasks = [
    asyncio.ensure_future(
        fetch_page(session, "http://bigsec.com/products/redq/")),
    asyncio.ensure_future(
        fetch_page(session, "http://bigsec.com/products/warden/"))
]
loop.run_until_complete(asyncio.wait(tasks))
session.close()
loop.close()

从 Python 语言发展的角度来讲,async/await 并不是是多么伟大的改进,只是引进了其余语言中成熟的语义,协程的基石仍是在于 eventloop 库的发展,以及生成器的完善。从结构原理而言,asyncio 实质担当的角色是一个异步框架,async/await 是为异步框架提供的 API,由于使用者目前并不能脱离 asyncio 或其余异步库使用 async/await 编写协程代码。即便用户能够避免显式地实例化事件循环,好比支持 asyncio/await 语法的协程网络库 curio,可是脱离了 eventloop 如心脏般的驱动做用,async/await 关键字自己也毫无做用。

4. async/await的使用

bigsec

4.1. Future

不用回调方法编写异步代码后,为了获取异步调用的结果,引入一个 Future 将来对象。Future 封装了与 loop 的交互行为,add_done_callback 方法向 epoll 注册回调函数,当 result 属性获得返回值后,会运行以前注册的回调函数,向上传递给 coroutine。可是,每个角色各有本身的职责,用 Future 向生成器 send result 以恢复工做状态并不合适,Future 对象自己的生存周期比较短,每一次注册回调、产生事件、触发回调过程后工做已经完成。因此这里又须要在生成器协程与 Future 对象中引入一个新的对象 Task,对生成器协程进行状态管理。

4.2. Task

Task,顾名思义,是维护生成器协程状态处理执行逻辑的的任务,Task 内的_step 方法负责生成器协程与 EventLoop 交互过程的状态迁移:向协程 send 一个值,恢复其工做状态,协程运行到断点后,获得新的将来对象,再处理 future 与 loop 的回调注册过程。

4.3. Loop

事件循环的工做方式与用户设想存在一些误差,理所固然的认知应是每一个线程均可以有一个独立的 loop。可是在运行中,在主线程中才能经过 asyncio.get_event_loop() 建立一个新的 loop,而在其余线程时,使用 get_event_loop() 却会抛错,正确的作法应该是 asyncio.set_event_loop() 进行当前线程与 loop 的显式绑定。因为 loop 的运做行为并不受 Python 代码的控制,因此没法稳定的将协程拓展到多线程中运行。
协程在工做时,并不了解是哪一个 loop 在对其调度,即便调用 asyncio.get_event_loop() 也不必定能获取到真正运行的那个 loop。所以在各类库代码中,实例化对象时都必须显式的传递当前的 loop 以进行绑定。

4.3. 另外一个Future

Python 里另外一个 Future 对象是 concurrent.futures.Future,与 asyncio.Future 互不兼容,但容易产生混淆。concurrent.futures 是线程级的 Future 对象,当使用 concurrent.futures.Executor 进行多线程编程时用于在不一样的 thread 之间传递结果。

4.4. 现阶段asyncio生态发展的困难

因为这两个关键字在2014年发布的Python3.5中才被引入,发展历史较短,在Python2与Python3割裂的大环境下,生态环境的创建并不完善;
对于使用者来讲,但愿的逻辑是引入一个库而后调用并获取结果,并不关心第三方库的内部逻辑。然而使用协程编写异步代码时须要处理与事件循环的交互。对于异步库来讲,其对外封装性并不能达到同步库那么高。异步编程时,用户一般只会选择一个第三方库来处理全部HTTP逻辑。可是不一样的异步实现方法不一致,互不兼容,分歧阻碍了社区壮大;
异步代码虽然快,但不能阻塞,一旦阻塞整个程序失效。使用多线程或多进程的方式将调度权交给操做系统,未免不是一种自我保护;

4.5. 一些我的见解

其实说了这么多,我的以为 asyncio 虽然更加优雅,却实际使用上并非像表面看起来的那么美好。首先,它不是特别的快(听说比 gevent 快一倍),却引入了更多的复杂性,并且从错误信息 debug 更加困难。其次,这套解决方案并不成熟,最近 3.四、3.五、3.6 的三个版本,协程也有各类的细节变化,也变得愈来愈复杂,程序员必须随时关注语言的变化才能同步。使人疑惑的是为何 Python 必定要坚持用生成器来实现协程,最后又将生成器与协程进行新老划断,细节却未获得屏蔽?以目前的成熟度来看,当你写协程代码时,必须先去理解协程、生成器的区别,future 对象与 task 对象的职能,loop 的做用。总之,目前在生产环境中使用 asyncio 技术栈来解决问题并不稳定,这个生态还须要持久的发展才能成熟。

bigsec

做为程序员,在一门语言上深刻一样能够带来知识的广度。不一样语言有不一样的性格,合适的工具解决合适的问题,而以一名 Python 程序员的视角来看,大可没必要坚持寄但愿于 asyncio 解决 Python 的性能问题,把在纵向上搞懂 asyncio 和这一套协程细节所需的时间拿来横向学习 Golang,寻求更合适更简单的解决方案,代码也能够上线了。

相关文章
相关标签/搜索