谈谈Python协程技术的演进

Coding Crush Python开发工程师 主要负责岂安科技业务风险情报系统redq。

引言

1.1. 存储器山

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

bigsec

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

1.2. cpu的时间观

bigsec表格

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

1.3. 同步编程

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

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

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

1.4. 异步编程

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

bigsec

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

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

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 库查看 fetchpage 协程的字节码,GETYIELDFROMITER 是 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 的交互行为,adddonecallback 方法向 epoll 注册回调函数,当 result 属性得到返回值后,会运行之前注册的回调函数,向上传递给 coroutine。但是,每一个角色各有自己的职责,用 Future 向生成器 send result 以恢复工作状态并不合适,Future 对象本身的生存周期比较短,每一次注册回调、产生事件、触发回调过程后工作已经完成。所以这里又需要在生成器协程与 Future 对象中引入一个新的对象 Task,对生成器协程进行状态管理。

4.2. Task

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

4.3. Loop

事件循环的工作方式与用户设想存在一些偏差,理所当然的认知应是每个线程都可以有一个独立的 loop。但是在运行中,在主线程中才能通过 asyncio.geteventloop() 创建一个新的 loop,而在其他线程时,使用 geteventloop() 却会抛错,正确的做法应该是 asyncio.seteventloop() 进行当前线程与 loop 的显式绑定。由于 loop 的运作行为并不受 Python 代码的控制,所以无法稳定的将协程拓展到多线程中运行。 协程在工作时,并不了解是哪个 loop 在对其调度,即使调用 asyncio.geteventloop() 也不一定能获取到真正运行的那个 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.4、3.5、3.6 的三个版本,协程也有各种的细节变化,也变得越来越复杂,程序员必须随时关注语言的变化才能同步。令人疑惑的是为什么 Python 一定要坚持用生成器来实现协程,最后又将生成器与协程进行新老划断,细节却未得到屏蔽?以目前的成熟度来看,当你写协程代码时,必须先去理解协程、生成器的区别,future 对象与 task 对象的职能,loop 的作用。总之,目前在生产环境中使用 asyncio 技术栈来解决问题并不稳定,这个生态还需要持久的发展才能成熟。

bigsec

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