当前位置: 首页 > 科技观察

说说Python协程技术的演进

时间:2023-03-21 20:18:03 科技观察

一、简介1.内存山内存山是RandalBryant在《深入理解计算机系统》一书中提出的一个概念。基于成本和效率的考虑,计算机内存被设计成多层金字塔结构。塔顶是最快最便宜的CPU内部寄存器(一般几KB)和缓存,塔底是成本最低,最慢的WAN云存储(比如百度云免费2T)的指导意义是为了揭示一个设计良好的程序的必要条件是具有优良的局部性:时间局部性:同一地址在同一时间段内被访问的次数越多,时间局部性的性能越好;空间局部性:下一次访问的内存地址与上一次访问的内存地址相邻;2.cpu时间查看我们把普通2.6GHzCPU的延迟时间放大到人类可以体验的尺度上(数据来自微信公众号巨硕代码世):在最顶层执行单个寄存器指令的时间内存为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是2004年Linux2.6引入内核的I/O事件通知机制,其作用是将大量的文件描述符寄存在内核中,内核将顶层的I/O状态变化封装为read和写事件,避免程序员主动轮询状态变化的重复工作。程序员将回调函数注册到epoll的状态,当检测到对应文件描述符的状态变化时,进行函数回调。事件循环是异步编程的底层基石。上图展示了一个简单的EventLoop的实现原理。用户创建两个socket连接,通过系统返回的两个文件描述符fd3和fd4在epoll上注册读写事件;当网卡解析一个tcp包时,内核根据五元组找到对应的文件描述符,自动触发其对应的就绪事件状态,并将该文件描述符加入就绪列表。程序调用epoll.poll()返回一个可以读写的事件集合。轮询事件集合,调用回调函数等。一轮事件循环结束,周而复始。epoll不是灵丹妙药。从图中可以观察到,如果用户关注度很低,直接运行epoll构造一个维护事件的循环。从底层到顶层的业务逻辑需要逐层回调,造成回调地狱,可读性差。因此,将注册回调和回调这个繁琐的过程封装抽象成EventLoop。EventLoop屏蔽了进行epoll系统调用的具体操作。对于用户而言,将不同的I/O状态视为事件触发,只需要在更高层次上关注不同事件的回调行为即可。libev、libevent等用C语言编写的高性能异步事件库取代了这种繁琐的工作。这几种事件循环在Python框架中普遍可见:libevent/libev:Gevent使用的网络库(前期greenlet+libevent,后期libev),应用广泛;tornado:由tornado框架本身实现的IOLOP;picoev:meinheld(greenlet+picoev使用的网络库)小巧轻便。与libevent相比,改进了数据结构和事件检测模型,因此速度更快。但是从github上看,好像年久失修了,用的人不多。uvloop:Python3时代的新人。Guido创建了asyncio库。Asyncio可以配置一个可插拔的事件循环,但是需要满足相关的API要求。uvloop继承自libuv,并用Python对象包装了一些低级结构和函数。目前的Sanic框架都是基于这个库5.协程EventLoop简化了不同平台的事件处理,但是在事件触发时处理回调还是很麻烦。响应式的异步程序编写对于程序员的脑子来说是个不小的麻烦。因此引入了协程来代替回调来简化事情。协程模型主要在以下几个方面优于回调模型:异步回调模式被类似于同步代码的编程模式所取代。真正的业务逻辑往往是同步线性推导的。因此,这种同步代码更容易编写。回调底层依然是回调地狱,但这部分脏活累活都交给了编译器和解释器来完成,程序员出错的可能性更小。异常处理更加完善,可以复用语言中的错误处理机制和回调方法。但传统的异步回调方式需要自行判断成功或失败,错误处理行为复杂。上下文管理得到简化。回调模式代码上下文管理在很大程度上依赖于闭包。不同的回调函数相互耦合,分离了相同的上下文处理逻辑。协程直接用代码的执行位置来表示状态,而回调则维护一堆数据结构来处理状态。方便处理并发行为,协程的开销成本很低,每个协程只有一个轻量级用户态栈空间。6.EventLoop与协程的发展历程2004年,事件驱动的nginx诞生并迅速传播开来。2006年以后,从俄语国家传到世界各地。同时,EventLoop也变得具体化和多样化,并被不同的编程语言实现。近十年来,后端领域古老的子程序和事件循环相结合,协程(协同子程序)得到快速发展,一些语言也被创新诞生,比如golang的goroutine,luajit的coroutine,Python的gevent,erlang的process,scala的actor等。在不同语言面向并发设计的协程实现方面,Scala和Erlang中的actor模型,Golang中的goroutine比Python成熟。不同的协程使用通信来共享内存,从而优化竞争条件、冲突和不一致等问题。但是在基本概念上没有区别,都是在用户态通过事件循环驱动实现调度。由于历史包袱较轻,后端语言上的各种异步技术除了PythonTwisted外基本没有回调地狱。其他解决方案已经将回调地狱的过程封装起来,交给库代码、编译器、解释器来解决。有了协程和事件循环库,传统的C10K问题不再是挑战,已经上升为C1M问题。2、GeventPython2时代的协程技术主要是Gevent,其他的比较少。Gevent既有赞扬也有批评。消极的观点是它的实现不够Pythonic。它独立于解释器实现了一个黑盒调度器。猴子补丁让不了解它的用户感到困惑。积极的观点是这样可以屏蔽所有细节,简化使用难度。Gevent基于Greenlet和Libev。Greenlet是一种微线程或者协程,其调度粒度比PY3更大。一个greenlet存在于一个线程容器中,其行为类似于一个线程,有自己独立的栈空间。不同greenlet的切换类似于操作系统层的线程切换。greenlet.hub也是从原来的greenlet继承而来的对象,也是其他greenlet的父节点。它主要负责任务调度。当一个greenlet协程在执行部分例程后遇到断点时,通过greenlet.switch()将控制权交给hub对象,hub执行上下文切换操作:从寄存器中备份当前greenlet的栈内容并缓存到内存中,并恢复另外一个greenlet栈数据,原来备份到寄存器中。循环对象封装在集线器对象中。loop负责封装libev的相关操作,向上提供接口。所有greenlets都被调度在由循环驱动的hub下。3.从yield到async/await1。生成器的演变Python2.2中首次引入了生成器。生成器实现了一种惰性和多值获取的方法。这时候,它们仍然是通过next构造迭代链或者next为多个值生成的。直到Python2.5,yield关键字才被添加到语法中。此时生成器具有记忆功能,下次从生成器中传出的值可以恢复到生成器上次执行yield的位置。之前的生成器都是关于如何构造迭代器的。在Python2.5中,generators还增加了send方法,与yield配合使用。我们发现,此时generator不仅可以暂停到yield的状态,还可以通过send方法传入一个值,改变其停止点的状态。举个简单的例子,主要是熟悉yield和send与外界的交互过程:defjump_range(up_to):step=0whilestep>48LOAD_CONST0(无)50RETURN_VALUE3。Python3.5引入了async和await关键字来替代asyncio.coroutine和yieldfrom,从语义上定义了原生的协程关键字,避免了用户对协程和生成器的混淆。现阶段(3.0-3.4),使用Python的人不多,历史包袱不重,可以做一些大的创新。await的行为与yieldfrom类似,但是它们异步等待的对象并不一致。Yieldfrom等待一个生成器对象,而await接收一个定义了__await__方法的可等待对象。在Python中,协程也是可等待对象,collections.abc.Coroutine对象继承自collections.abc.Awaitable。因此,将上一节的示例代码重写为:()session=aiohttp.ClientSession(looploop=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库的开发和generator的完善。从结构原理上来说,asyncio本质上扮演了一个异步框架的角色,而async/await是为异步框架提供的API,因为目前用户无法在没有asyncio或其他异步库的情况下使用async/await编写协程代码。即使用户可以避免显式实例化事件循环,例如curio,一个支持asyncio/await语法的协程网络库,但如果没有eventloop的心脏驱动效果,async/await关键字本身也是无用的。四、async/await的使用1、Future写完没有回调方法的异步代码后,为了获取异步调用的结果,引入一个Future未来对象。Future封装了与循环的交互。add_done_callback方法向epoll注册一个回调函数。当result属性得到返回值时,会运行之前注册的回调函数,向上传递给协程。但是,每个角色都有自己的职责。使用Future将结果发送给生成器以恢复工作状态是不合适的。Future对象本身的生命周期是比较短的。每注册一次回调,就会产生一个事件,触发回调过程,工作就完成了。.所以这里需要在生成器协程中引入一个新的对象Task和Future对象来管理生成器协程的状态。2.TaskTask,顾名思义,就是维护生成器协程状态处理执行逻辑的任务。Task中的_step方法负责generator协程与EventLoop交互过程的状态转换:向协程发送一个值,恢复其工作状态协程运行到断点后,获取一个新的future对象,然后回调处理future和loop的注册过程。3、Loop事件循环的工作模式与用户的设想存在一些偏差。每个线程都可以有一个独立的循环,这是理所当然的。但是在运行时,只能在主线程中通过asyncio.get_event_loop()创建新的循环,在其他线程中使用get_event_loop()时会报错。正确的做法应该是asyncio.set_event_loop()为当前线程显式绑定到loop。由于循环的运行行为不受Python代码控制,因此无法稳定地扩展协程在多线程中运行。协程在工作的时候,并不知道是哪个循环在调度它。即使调用了asyncio.get_event_loop(),也不一定能获取到真正运行的循环。因此,在各种库代码中,在实例化对象时,必须显式传递当前循环进行绑定。4.AnotherFuturePython中的另一个Future对象是concurrent.futures.Future,它与asyncio.Future不兼容,但容易造成混淆。concurrent.futures是一个线程级的Future对象,用于在使用concurrent.futures.Executor进行多线程编程时,在不同线程之间传递结果。5、现阶段asyncio生态发展困难由于这两个关键字是2014年发布的Python3.5才引入的,发展历史比较短。在Python2和Python3分离的环境下,生态环境的搭建并不完善;对于用户来说,希望的逻辑是引入一个库,然后调用它得到结果,而不用关心第三方库的内部逻辑。但是,使用协程编写异步代码需要处理与事件循环的交互。对于异步库,它们的外部封装没有同步库那么高。异步编程时,用户通常只选择一个第三方库来处理所有的HTTP逻辑。但是,不同的异步实现方式之间存在不一致、不兼容的问题,差异阻碍了社区的成长;尽管异步代码很快,但它不能被阻塞。一旦阻塞,整个程序就会失败。使用多线程或多进程将调度权交给操作系统,并不是一种自我保护;6.一些个人观点其实说了这么多。个人认为asyncio虽然更优雅,但实际上并没有表面看起来那么好。它看起来很好。首先,它不是特别快(据说是gevent的两倍),但它引入了更多的复杂性,并且更难从错误消息中进行调试。其次,这个解决方案还不成熟。在最近的3.4、3.5、3.6这三个版本中,协程也有着各种各样的细节,并且越来越复杂。程序员必须时刻注意语言的变化以保持同步。令人不解的是,为什么Python一定要坚持使用generator来实现coroutines,最后把generator和coroutines从旧到新分了,细节却不屏蔽?从目前的成熟度来看,当你写协程代码的时候,首先要了解协程和生成器的区别,future对象和task对象的作用,loop的作用。总之,目前在生产环境使用asyncio技术栈解决问题还不稳定,这个生态还需要持续发展才能成熟。作为程序员,对一门语言的深入也可以带来知识的广度。不同的语言有不同的性格,适合的工具解决适合的问题,而站在一个Python程序员的角度,没必要非要非要非要用asyncio来解决Python的性能问题,垂直理解asyncio。一套协程细节所需的时间用来横向学习Golang,寻找更合适更简单的方案,代码也可以上线。【本文为栏目组织《奇安科技》原创文章,转载请微信公众号(bigsec)联系原作者】点此查看本作者更多好文