当前位置: 首页 > 后端技术 > Python

Python协程的本质?原来

时间:2023-03-26 18:58:51 Python

都是单线程的。为什么原本低效的代码要用async、await和加一些异步库来变得更高效呢?如果你在进行基于Python的网络或Web开发时曾疑惑过这个问题,本文试图给出答案。0x00开始之前,首先本文不带大家浏览源码,然后跟大家说一下Python标准对照原码的实现。相反,我们会从实际问题出发,思考问题的解决方案,一步步体验解决方案的演化路径。最重要的是,我们希望在这个过程中获得系统的知识提升。??本文仅提供独立思考方向,不追溯历史和实际实现细节。其次,阅读本文需要你熟悉Python,至少了解Python中生成器的概念。0x01IO多路复用这是性能的关键。但我们这里只解释概念,实现细节不是重点。这足以让我们了解Python协程。如果我们有足够的了解,请转到0x02。首先,你要知道所有的网络服务程序都是一个巨大的死循环,你的业务逻辑在这个循环中的某个时刻被调用:theloop:#获取一个新的请求request=accept()#根据路由映射获取用户编写的业务逻辑函数handler=get_handler(request)#运行用户的handler并处理请求handler(request)想象一下你的webservice的处理程序,在收到请求后需要调用API来响应结果。对于大多数传统的Web应用程序,您的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)inevents:ifio_type==READ:data=read_data(io_id)elifio_type==WRITE:write_data(io_id,data)将IO复用逻辑集成到我们的服务器中,大概像这样:call_backs={}defhandler(req):#在这里做作业io_register(io_id,io_type)defcall_back(result):#使用返回的结果来完成剩下的工作...call_backs[io_id]=call_back#newloopwhileTrue:#获取完成的io事件events=io_get_finished()for(io_id,io_type)inevents:ifio_type==READ:#读取数据=read(io_id)call_back=call_backs[io_id]call_back(data)else:#其他类型的处理ofioeventspass#获取一个新的请求request=accept()#获取用户编写的业务逻辑函数根据路由映射handler=get_handler(request)#运行用户的handler,处理请求handler(request)our的IO操作,handler注册回调后会立即返回,每次迭代都会对完成的IO执行回调,网络请求将不再被阻塞整个服务器上的伪代码只是简单易懂,具体实现细节比较复杂。而且,接受新的连接请求也是在从操作系统获取监听端口的IO事件之后进行的。如果我们将loop部分和call_backs字典拆分成单独的模块,就可以得到一个EventLoop,也就是Python标准库的asyncio包中提供的ioloop。0x02使用生成器消除回调。关注我们业务中经常写的handler函数。有了独立的ioloop之后,现在是这样的:defhandler(request):#业务逻辑代码...#需要执行一次APIRequestdefcall_back(result):#使用API??返回的结果来完成remainingworkprint(result)#没有io_call方法,这里只是提示,说明这里注册了一个IO操作asyncio.get_event_loop().io_call(api,call_back),性能问题已经解决:我们不再需要多线程不断接受新的请求,我们不关心API响应有多慢取决于。但是我们也引入了一个新的问题。原本流畅的业务逻辑代码现在拆分成了两部分。请求API前的代码还是正常的,请求API后的代码只能写在回调函数中。这里我们的业务逻辑只有一个API调用。如果有多个API,再加上对Redis或者MySQL的调用(本质上也是网络请求),整个逻辑就会分裂分散,对业务发展是一种负担。对于一些带有匿名函数的语言(没错,JavaScript),也可能造成所谓的“回调地狱”。接下来,我们就想办法解决这个问题。我们很容易想到:如果函数能在运行到网络IO操作后暂停,完成后在断点处唤醒就好了。如果你熟悉Python的“生成器”,你应该会发现它正是有这个功能:defexample():value=yield2print("get",value)returnvalueg=example()#启动生成器,我们将get2got=g.send(None)print(got)#2try:#重启会显示“get4”,也就是我们传入的值got=g.send(got*2)exceptStopIterationase:#之后generator运行完毕,会打印(4),e.value是generator返回的值。print(e.value)函数中有一个yield关键字。调用函数会得到一个生成器,生成器的一个关键方法是send()可以和生成器进行交互。g.send(None)会运行generator中的代码,直到遇到yield,返回后面的对象,也就是2,generator代码会停在这里,直到我们再次执行g.send(got2),会把22就是4.给yield之前的变量value赋值,然后继续运行generator代码。yield在这里就像一扇门,你可以从这里发送一个东西出去,也可以把另一个东西拿进来。如果send让生成器在下一次yield之前运行到结束,send调用会触发一个特殊的异常StopIteration,它有一个属性值,就是生成器返回的值。如果我们用yield关键字将我们的handler转换成generator,运行它返回IO操作的具体内容,IO完成后将IO结果放回回调函数中,恢复generator操作,那么业务就是解决了代码不流畅:defhandler(request):#业务逻辑代码...#需要执行一个API请求,直接yieldIO请求信息result=yieldio_info#使用API??返回的结果完成remainingworkprint(result)#该函数注册在ioloop中,当有新请求时回调defon_request(request):#根据路由映射获取用户编写的业务逻辑函数handler=get_handler(request)g=handler(request)#第一次开始获取io_infoio_info=g.send(None)#io完成回调函数defcall_back(result):#重启生成器g.send(result)asyncio.get_event_loop().io_call(io_info,call_back)上面的例子,user的written处理程序代码不会分散到回调中。on_request函数使用回调与ioloop进行交互,但它会在web框架中实现,并且不会对用户可见。上面的代码足以给我们提供了生成器淘汰回调的启发,但是有两个局限性:业务逻辑中只发起了一次网络IO,但在实践中,更多的业务逻辑并没有调用其他异步函数(coroutines),但是在实践中,我们经常调用其??他协程0x03来解决完整的调用链。我们来看一个更复杂的例子:request进行真正的IO,只调用func1和func2。显然我们的代码只能这样写:deffunc1():ret=yieldrequest("http://test.com/foo")ret=yieldfunc2(ret)returnretdeffunc2(data):result=yieldrequest("http://test.com/"+data)returnresultdefrequest(url):#这里模拟返回一个io操作,包含了io操作的所有信息,这里是string的简化,而不是result=yield"iojobof%s"%urlreturnresult对于request,我们通过yield将IO操作暴露给框架。对于func1和func2,显然在调用request时要加上yield关键字,否则返回一个generator后request调用不会暂停,继续执行后续逻辑显然会报错。在没有yieldfrom、aysnc、await的时代,我们在tornado框架中写异步代码的方式基本就是这样。要运行整个调用堆栈,一般过程如下:调用func1()获取生成器callsend(None)启动它并获取request("http://test.com/foo")的结果,或者generatorobjectsend(None)启动request()生成的generator,获取IO操作。框架注册到ioloop,指定IO完成后唤醒requestgenerator的回调函数。generator会到return语句的末尾捕获异常,获取生成的requestgenerator的返回值会唤醒上层的func1,同时获取func2()generator继续执行...熟悉算法和数据结构的朋友遇到这种前向后向遍历逻辑可以用递归或者栈。因为目前还不能递归使用生成器,所以我们可以使用栈,这其实就是“调用栈”这个词的由来。借助栈,我们可以将整个调用链上所有串联的生成器对表示为一个生成器,通过不断向其发送,可以不断获取所有IO操作信息,推动调用链向前推进。实现方法如下:第一个生成器进入栈调用send。如果获取到生成器,则将其压入栈中,进入下一轮迭代。当遇到IO请求yield时,框架将注册到ioloopIO。操作完成后会被唤醒,将结果缓存并出栈,进入下一轮迭代。上层函数使用IO结果恢复操作。如果一个generator运行结束,需要恢复上层函数,如4,实现的话,代码不长,但是信息量比较大。它将整个调用链变成了一个生成器,在它上面调用send可以完成整个调用链中的IO,完成这些IO,继续推动调用链中的逻辑执行,直到整体逻辑结束:defwrapper(gen):#第一层调用入栈stack=Stack()stack.push(gen)#开始逐层调用whileTrue:#获取栈顶元素item=stack.peak()result=None#Generatorifisgenerator(item):try:#尝试获取下层调用并入栈child=item.send(result)stack.push(child)#result使用完后恢复为Noneresult=None#入栈直接进入下一个循环,继续探索continueexceptStopIterationase:#如果操作结束,结果暂存,下一步出栈result=e.valueelse:#IOoperation#遇到IO操作时yield出来,IO操作后会用到IO结果completed唤醒并暂存到resultresult=yielditem#到这里,本层执行完毕,出栈,下一次迭代为调用链上的层stack.pop()#如果有就是没有前面一层,整个调用链全部执行完毕,返回ifstack.empty():print("finished")returnresult这大概是最复杂的部分,如果看起来很难,其实只要明白上面例子中的调用链,它能实现的效果如下:w=wrapper(func1())#willget"iojobofhttp://test.com/富”w。send(None)#传入最后一个iojobfoo的结果"bar",继续运行,得到"iojobofhttp://test.com/bar"w.send("bar")#最后一个iojobbar完成传入结构体“barz”后,继续运行,结束w.send("barz")这部分,框架将添加支持代码:#维护一个就绪列表,用于存储所有已完成的IO事件,格式为(wrapper,result)ready=[]defon_request(request):handler=get_handler(request)#用wrapper包装后,IO只能通过send处理g=wrapper(func1())#把start状态直接当成ready状态,result为Noneready.append((g,None))#让ioloop每个周期都执行这个函数来处理就绪的IOdefprocess_ready(self):defcall_back(g,result):ready.append((g,result))#遍历所有就绪的generators,将其下推为g,resultinself.ready:#使用result唤醒生成器,获取下一个io操作io_job=g.send(result)#注册io操作完成后,将生成器添加到就绪列表中,等待下一个roundofprocessingasyncio.get_event_loop().io_call(io_job,lambdaresult:ready.append((g,result)这里的核心思想是维护一个readylist,ioloop每轮迭代都会扫描它,将就绪状态的生成器Rundown,并注册新的IO操作。IO完成后,会再次准备就绪。经过几轮ioloop迭代,最终会执行一个handler。至此,我们已经使用生成器编写方式编写了业务逻辑,并且可以正常运行。0x04ImprovementScalability如果能看懂这里,就基本能理解Python协程的原理了。我们已经实现了一个微型协程框架。标准库的实现细节和这里看起来差别很大,但是具体思路是一样的。我们的协程框架有一个限制,我们只能异步IO操作,虽然在网络编程和web编程的世界里,基本上只有IO操作是阻塞的,但是也有一些例外,比如我想让当前操作休眠一段时间几秒钟,使用time.sleep()将阻塞整个线程,因此需要特殊的实现。再比如,一些CPU密集型的操作可以通过多线程异步化,另外一个线程通知事件自己已经完成了再执行后续。因此,最好将协程与网络解耦,让等待网络IO只是提高可扩展性的场景之一。Python官方的解决方案是让用户自己处理阻塞代码。至于是用ioloop注册IO事件,还是开线程,完全由你决定,提供了一个标准的“占位符”Future,表示它的结果要等到未来才有。是的,它的一些原型如下:classFuture:#设置结果defset_result(result):pass#获取结果defresult():pass#表示是否设置了future对象。Resultdefdone():pass#Set设置结果时应该执行的回调函数,可以设置多个defadd_done_callback(callback):pass我们稍加修改就可以支持Future,扩展性更强。对于用户代码中的网络请求函数request:#现在请求函数不是生成器,它返回futuredefrequest(url):#future理解为占位符fut=Future()defcallback(result):#当networkIO回调完成后,给占位符赋值fut.set_result(result)asyncio.get_event_loop().io_call(url,callback)#返回占位符returnfuture现在request不再是generator,而是直接return未来。而对于框架中处理就绪列表的函数:defprocess_ready(self):defcallback(fut):#future已设置,结果将放入就绪列表ready.append((g,fut.result()))#遍历所有准备就绪的生成器,将它们下推g,resultinself.ready:#使用result唤醒生成器,得到的不再是io操作,而是一个futurefut=g.send(result)#futureisset当result到达时,会调用回调fut.add_done_callback(callback)0x05发展变化很多年前使用tornado的时候,大概只有一个yield关键字可用。如果要实现协程,就是这个思路,连yield关键字和return关键字都不能出现在一个函数中。如果你想在生成器完成运行后返回一个值,你需要手动引发异常。虽然效果和现在的return一样,但是写起来还是别扭,不够优雅。后来是表达的产量。它能做什么?通俗地说,就是做了上面generatorwrapper所做的事情:通过栈实现调用链遍历,也就是wrapper逻辑的语法糖。使用它,您可以像这样编写相同的示例:deffunc1():#Noteyieldfromret=yieldfromrequest("http://test.com/foo")#Noteyieldfromret=yieldfromfunc2(ret)returnretdeffunc2(data):#注意yieldfromresult=yieldfromrequest("http://test.com/"+data)returnresult#现在请求函数不是生成器,它返回futuredefrequest(url):#和基于future实现的request一样,那就不需要那个烧脑的wrapper函数了:g=func1()#返回第一个请求的futureg.send(None)#继续运行,自动进入func2,得到第一个里面的futureg.send("bar")#继续运行,完成调用链剩下的逻辑,抛出StopIteration异常g.send("barz")yieldfrom直接opensuptheentirecallchain,这已经是一个很大的进步了,但是用于异步编程还是很别扭。其他语言有特殊的协程async和await关键字。直到后来的版本将这些内容用特殊的async和await关键字打包,才变得更加优雅。看。0x06总结与对比Python的原生协程一般从两个方面来实现:基于IO多路复用技术,整个应用在IO上是非阻塞的,实现了高效率。分散的回调代码变成Synchronize代码,减少业务编写难度generator这种有对象的语言,IO协程的实现大致相同。JavaScript协程的演进基本相同,关键字相同,Future类比与Promise本质相同。但是以协程着称的Go的协程实现与此不同,它并没有显式地基于生成器。以此类推,Python的gevent可以算作一个类别。它实现自己的运行时,修补系统调用以访问自己的运行时,并自行调度协程。gevent专注于网络相关的,基于网络的IO调度,比较简单。而Go则实现了完善的多核支持,使得调度更加复杂和完善,开创了一种新的基于通道的编程范式。你是一个零基础的初学者,热爱编程,想学习python,但是没有门路怎么办?关注“Python编程学习圈”,送“J”免费领取大量python干货资料,让你轻松搞定python