谈谈Python协程技术的演进

简介:

一、引言

1. 存储器山

存储器山是 Randal Bryant 在《深入理解计算机系统》一书中提出的概念。

基于成本、效率的考量,计算机存储器被设计成多级金字塔结构,塔顶是速度最快、成本最高的 CPU 内部的寄存器(一般几 KB)与高速缓存,塔底是成本最低、速度最慢的广域网云存储(如百度云免费 2T )

存储器山的指导意义在于揭示了良好设计程序的必要条件是需要有优秀的局部性:

  • 时间局部性:相同时间内,访问同一地址次数越多,则时间局部性表现越佳;
  • 空间局部性:下一次访问的存储器地址与上一次的访问过的存储器地址位置邻近;

2. cpu的时间观

cpu的时间观

我们将一个普通的 2.6GHz 的 CPU 的延迟时间放大到人能体验的尺度上(数据来自微信公众号 驹说码事):在存储器顶层执行单条寄存器指令的时间为1秒钟;从第五层磁盘读 1MB 数据却需要一年半;ping 不同的城域网主机,网络包需要走 12.5 年。

如果程序发送了一个 HTTP 包后便阻塞在同步等待响应的过程上,计算机不得不傻等 12 年后的那个响应再处理别的事情,低下的硬件利用率必然导致低下的程序效率。

3. 同步编程

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

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

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

4. 异步编程

说到异步非阻塞调用,目前的代名词都是 epoll 与 kqueue,select/poll 由于效率问题基本已被取代。

epoll 是04年 Linux2.6 引入内核的一种 I/O 事件通知机制,它的作用是将大量的文件描述符托管给内核,内核将最底层的 I/O 状态变化封装成读写事件,这样就避免了由程序员去主动轮询状态变化的重复工作,程序员将回调函数注册到 epoll 的状态上,当检测到相对应文件描述符产生状态变化时,就进行函数回调。

事件循环是异步编程的底层基石。

简单的EventLoop的实现原理

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

  • 用户创建了两个socket连接,将系统返回的两个文件描述符fd3、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框架基于这个库

5. 协程

EventLoop简化了不同平台上的事件处理,但是处理事件触发时的回调依然很麻烦,响应式的异步程序编写对程序员的心智是一项不小的麻烦。

因此,协程被引入来替代回调以简化问题。协程模型主要在在以下方面优于回调模型:

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

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 问题。

二、Gevent

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 下被调度。

三、从yield到async/await

1. 生成器的进化

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

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

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

我们发现,此时,生成器不仅仅可以 yield 暂停到一个状态,还可以往它停止的位置通过 send 方法传入一个值改变其状态。

举一个简单的示例,主要熟悉 yield 与 send 与外界的交互流程:

 
  1. def jump_range(up_to): 
  2.     step = 0 
  3.     while step < up_to: 
  4.       jump = yield step 
  5.       print("jump", jump) 
  6.       if jump is None: 
  7.           jump = 1 
  8.           step += jump 
  9.       print("step", step) 
  10.  
  11. if __name__ == '__main__': 
  12.     iterator = jump_range(10) 
  13.     print(next(iterator))  # 0 
  14.     print(iterator.send(4))  # jump4; step4; 4 
  15.     print(next(iterator))  # jump None; step5; 5 
  16.     print(iterator.send(-1)) # jump -1; step4; 4 

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

 
  1. def gen_3(): 
  2.     yield 3 
  3.  
  4. def gen_234(): 
  5.     yield 2 
  6.     yield from gen_3() 
  7.     yield 4 
  8.  
  9. def main(): 
  10.     yield 1 
  11.     yield from gen_234() 
  12.     yield 5 
  13.  
  14. for element in main(): 
  15.     print(element)  # 1,2,3,4,5 

从图中可以看出 yield from 的特点。使用 itertools.chain 可以以生成器为最小组合子进行链式组合,使用 itertools.cycle 可以对单独一个生成器首尾相接,构造一个循环链。

使用 yield from 时可以在生成器中从其他生成器 yield 一个值,这样不同的生成器之间可以互相通信,这样构造出的生成链更加复杂,但生成链最小组合子的粒度却精细至单个 yield 对象。

2. 短暂的asynico.coroutine 与yield from

有了Python3.3中引入的yield from 这项工具,Python3.4 中新加入了asyncio库,并提供了一个默认的event loop。Python3.4有了足够的基础工具进行异步并发编程。

并发编程同时执行多条独立的逻辑流,每个协程都有独立的栈空间,即使它们是都工作在同个线程中的。以下是一个示例代码:

 
  1. import asyncio 
  2. import aiohttp 
  3.  
  4. @asyncio.coroutine 
  5. def fetch_page(session, url): 
  6.     response = yield from session.get(url) 
  7.     if response.status == 200: 
  8.         text = yield from response.text() 
  9.         print(text) 
  10. loop = asyncio.get_event_loop() 
  11.  
  12. session = aiohttp.ClientSession(looploop=loop) 
  13.  
  14. tasks = [ 
  15.     asyncio.ensure_future( 
  16.        fetch_page(session, "http://bigsec.com/products/redq/")), 
  17.     asyncio.ensure_future( 
  18.        fetch_page(session, "http://bigsec.com/products/warden/")) 
  19.  
  20. loop.run_until_complete(asyncio.wait(tasks)) 
  21. session.close() 
  22. 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 的操作码:

 
  1. In [4]: import dis 
  2.  
  3. In [5]: dis.dis(fetch_page) 
  4.   0 LOAD_FAST 0 (session) 
  5.   2 LOAD_ATTR 0 (get) 
  6.   4 LOAD_FAST 1 (url) 
  7.   6 CALL_FUNCTION 1 
  8.   8 GET_YIELD_FROM_ITER 
  9.   10 LOAD_CONST 0 (None) 
  10.   12 YIELD_FROM 
  11.   14 STORE_FAST 2 (response) 
  12.  
  13.   16 LOAD_FAST 2 (response) 
  14.   18 LOAD_ATTR 1 (status) 
  15.   20 LOAD_CONST 1 (200) 
  16.   22 COMPARE_OP 2 (==) 
  17.   24 POP_JUMP_IF_FALSE 48 
  18.  
  19.   26 LOAD_FAST 2 (response) 
  20.   28 LOAD_ATTR 2 (text) 
  21.   30 CALL_FUNCTION 0 
  22.   32 GET_YIELD_FROM_ITER 
  23.   34 LOAD_CONST 0 (None) 
  24.   36 YIELD_FROM 
  25.   38 STORE_FAST 3 (text) 
  26.  
  27.   40 LOAD_GLOBAL 3 (print) 
  28.   42 LOAD_FAST 3 (text) 
  29.   44 CALL_FUNCTION 1 
  30.   46 POP_TOP 
  31.   >> 48 LOAD_CONST 0 (None) 
  32.   50 RETURN_VALUE 

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。

因此,将上一小节的示例代码改写成:

 
  1. import asyncio 
  2. import aiohttp 
  3.  
  4. async def fetch_page(session, url): 
  5.     response = await session.get(url) 
  6.     if response.status == 200: 
  7.         text = await response.text() 
  8.         print(text) 
  9.  
  10. loop = asyncio.get_event_loop() 
  11. session = aiohttp.ClientSession(looploop=loop) 
  12.  
  13. tasks = [ 
  14.     asyncio.ensure_future( 
  15.         fetch_page(session, "http://bigsec.com/products/redq/")), 
  16.     asyncio.ensure_future( 
  17.         fetch_page(session, "http://bigsec.com/products/warden/")) 
  18. loop.run_until_complete(asyncio.wait(tasks)) 
  19. session.close() 
  20. loop.close() 

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

四、async/await的使用

async/await的使用

1. Future

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

2. Task

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

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. 另一个Future

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

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

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

6. 一些个人看法

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

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


本文作者:Coding Crush

来源:51CTO

相关文章
|
30天前
|
存储 并行计算 Java
Python读取.nc文件的方法与技术详解
本文介绍了Python中读取.nc(NetCDF)文件的两种方法:使用netCDF4和xarray库。netCDF4库通过`Dataset`函数打开文件,`variables`属性获取变量,再通过字典键读取数据。xarray库利用`open_dataset`打开文件,直接通过变量名访问数据。文中还涉及性能优化,如分块读取、使用Dask进行并行计算以及仅加载所需变量。注意文件路径、变量命名和数据类型,读取后记得关闭文件(netCDF4需显式关闭)。随着科学数据的增长,掌握高效处理.nc文件的技能至关重要。
109 0
|
26天前
|
安全 Python
Python中的并发编程:多线程与多进程技术探究
本文将深入探讨Python中的并发编程技术,重点介绍多线程和多进程两种并发处理方式的原理、应用场景及优缺点,并结合实例分析如何在Python中实现并发编程,以提高程序的性能和效率。
|
7天前
|
分布式计算 Hadoop 大数据
大数据技术与Python:结合Spark和Hadoop进行分布式计算
【4月更文挑战第12天】本文介绍了大数据技术及其4V特性,阐述了Hadoop和Spark在大数据处理中的作用。Hadoop提供分布式文件系统和MapReduce,Spark则为内存计算提供快速处理能力。通过Python结合Spark和Hadoop,可在分布式环境中进行数据处理和分析。文章详细讲解了如何配置Python环境、安装Spark和Hadoop,以及使用Python编写和提交代码到集群进行计算。掌握这些技能有助于应对大数据挑战。
|
1月前
|
数据可视化 数据挖掘 数据处理
Python中的数据可视化技术及应用
数据可视化是数据分析领域中至关重要的一环,Python作为一种流行的编程语言,拥有丰富的数据可视化库和工具。本文将介绍Python中常用的数据可视化技术及其在实际应用中的案例,帮助读者更好地理解和运用数据可视化技术。
|
2天前
|
调度 Python
Python多线程、多进程与协程面试题解析
【4月更文挑战第14天】Python并发编程涉及多线程、多进程和协程。面试中,对这些概念的理解和应用是评估候选人的重要标准。本文介绍了它们的基础知识、常见问题和应对策略。多线程在同一进程中并发执行,多进程通过进程间通信实现并发,协程则使用`asyncio`进行轻量级线程控制。面试常遇到的问题包括并发并行混淆、GIL影响多线程性能、进程间通信不当和协程异步IO理解不清。要掌握并发模型,需明确其适用场景,理解GIL、进程间通信和协程调度机制。
17 0
|
3天前
|
数据采集 NoSQL 搜索推荐
五一假期畅游指南:Python技术构建的热门景点分析系统解读
五一假期畅游指南:Python技术构建的热门景点分析系统解读
|
21天前
|
机器学习/深度学习 人工智能 数据可视化
基于Python的数据可视化技术在大数据分析中的应用
传统的大数据分析往往注重数据处理和计算,然而数据可视化作为一种重要的技术手段,在大数据分析中扮演着至关重要的角色。本文将介绍如何利用Python语言中丰富的数据可视化工具,结合大数据分析,实现更直观、高效的数据展示与分析。
|
23天前
|
数据采集 XML 程序员
揭秘YouTube视频世界:利用Python和Beautiful Soup的独特技术
本文介绍了如何使用Python和Beautiful Soup库抓取YouTube视频数据,包括标题、观看次数和点赞、踩的数量。通过亿牛云爬虫代理IP服务避免被网站屏蔽,提供代理服务器配置和请求头设置示例。代码可能需根据YouTube页面更新进行调整。
揭秘YouTube视频世界:利用Python和Beautiful Soup的独特技术
|
23天前
|
API 数据处理 调度
Python中的异步编程与协程应用
传统的Python编程在处理IO密集型任务时常常面临效率低下的问题,而异步编程和协程技术的引入为解决这一问题提供了有效的途径。本文将介绍Python中异步编程的基本概念,深入探讨asyncio库的使用以及协程在实际项目中的应用,旨在帮助开发者更好地理解和运用异步编程技术。
|
25天前
|
UED 开发者 Python
Python中的并发编程技术探究
本文将深入探讨Python中的并发编程技术,介绍多线程、多进程、协程等概念及其在实际项目中的运用,帮助读者更好地理解并发编程的重要性和应用场景。