最近在python3.7上用asyncio做项目,实现web的服务端,一边从GitHub和StackOverflow上抄代码,一边在看asyncio相关的源码,所学所思,姑且写在这里。
为什么会出现协程(coroutine)这种设计?
多线程(thread)也是同时执行多个任务的一种设计,为什么有了多线程,我们还要设计协程,它有什么不同呢?
首先,多线程的目的是什么?
一种说法,利用多核的性能,让代码占用尽可能的计算资源,运行快一点。这算是个原因吧,我们简称为并行(parallelism)。在这里,我还不想抠字眼讨论并发(cocurrent)和并行。
另一种说法,我们程序有些任务是cpu密集型(逻辑计算比较多),有些任务是IO密集型(读写文件或网络比较多),如果遇到IO密集型计算,比如从网站上下载一个大文件,这时候如果是阻塞下载,并且只有一个线程的话,程序的其他逻辑就无法执行,从这个程序的角度讲,他没能很好的占有cpu的资源,白白地等待下载的结束。如果开了另外一个线程的话,这个下载线程虽然阻塞掉了,但是别的线程依然可以跑别的逻辑,就可以更充分的占有cpu的资源。有人称同时执行不同的任务为并发。
(TODO? 图1 任务调度示意图)
同样针对上面第二种问题,我们换个角度表述,我们有一个IO密集型任务,还有一个cpu密集型任务,我们想有效的运行这两个任务,不让IO密集型任务阻塞了cpu密集型任务,所以我们构造了一个任务调度器,当IO密集任务开始从网站上下载大文件的时候,我们把他从调度器上暂时移开,后面再去检查他,让调度器去执行cpu密集任务,在此期间我们可能会时不时的去检查IO任务有没有下载完毕(也可能是被动通知,如软件驱动的中断),如果发现完毕了,调度器可能会暂停当前的cpu密集任务,转而继续执行IO密集任务的后续工作。在多线程环境下,这个调度器就是操作系统,两个任务是两个线程。而协程,就是这个思路,只不过设计的更加极端一点,我们在多线程环境下,几乎不可能手动地控制先执行什么线程,再执行什么线程(只是操作系统的调度工作),而如果我们专门写了一个任务调度器,我们自己实现应用层面的调度算法,就可能实现先执行什么任务,再执行什么任务,什么时候暂停一个任务,什么时候恢复运行这个任务。这个可控的调度机制,就是协程。他的初始目的就是实现任务的并发,只不过想更加精细地控制任务的暂停和继续。协程是种设计思想,线程是计算机实现多任务的一种工程机制,线程可以用于实现协程。
asyncio的模样
我们先杜撰一段代码应景。
如图2所示,我们用async def定义了两个函数,一个下载大文件,一个做一些逻辑运算。然后我们实现了一个调度函数,在调度函数里,我们分别用两个任务函数创建了两个任务,加入到asyncio的event loop里面,接着,我们运行这个event loop,这样,两个任务就开始执行起来了。注意,async with和await的时候,都是执行一个异步函数的过程,这个时候,当前任务会主动让出event loop,去后台执行一些网络IO,event loop会选择自己等待队列的任务继续执行。等原来网络IO的任务结束网络IO,他会重新加入到event loop的等待队列,等待其他任务主动让出event loop,被动等待调度。比如self_play在执行await asyncio.sleep(3)的时候会主动让出event loop。
上面我特别强调了主动让出event loop,这是协程的核心思想,如果一个任务没有任何await或async with逻辑,那么它一旦执行,别的任务再也没有机会被调度到。比如,我们如果去掉self_play的await语句,整个event loop将永远被self_play所占用,其他任务再也没有机会执行,整个输出只有左右右手慢动作了。总之,爸爸不给,你不能抢。这一点和朴素的多线程很不一样。
asyncio实现原理推测
从上面的介绍,我们可以大概猜出,asyncio主要有一个任务调度器(event loop),然后可以用async def定义异步函数作为任务逻辑,通过create_task接口把任务挂到event loop上。event loop的运行过程应该是个不停循环的过程,不停查看等待类别有没有可以执行的任务,如果有的话执行任务,直到碰到await之类的主动让出event loop的函数,如此反复。
(TODO? 图3 event loop调度示意图)
asyncio源码分析
更进一步的问,evnet loop大致是怎么实现的呢?怎么进行调度的呢?
我们顺藤摸瓜,在asyncio/base_events.py里面我们看到了create_task的源码实现,代码的关键是Task的构造,传了一个event loop(loop参数)进去,也就是在这个时候,task注册到了event loop上面。注册过程是c实现的(见文末附录1),但本质上都是通过event loop的call_soon()。
图5,是run_forever的实现,基本上是不停的在循环,然后每一个循环执行一帧(_run_once)。
图6是每一帧的代码实现,基本上是在调度队列里找到这一帧应该执行的任务(任务最终注册在event loop的结构是Handle,通过call_soon()实现),直接_run()。
event loop的call_soon,是注册任务时使用的,字面意思是下一帧执行当前注册的任务。它的本质就是把当前任务封装成Handle,放到_ready里面,如图7所示。
调度队列是event驱动形成的,这也是为什么asyncio的核心叫做event loop。这部分代码同样也在_run_once()里面,见图7,这个select就是某种多路复用机制,比如select,epoll和iocp。
图8给出了select机制下的selector.select实现,看起来是不是有点熟悉啊。消息处理相关我们后续在常用接口里会再次提到。
总结一下asyncio的实现思路
有一个任务调度器event loop,我们可以把需要执行的coroutine打包成task加入到event loop的调度列表里面(以Handle形式)。
在event loop的每个帧里面,它会检查需要执行那些task,然后运行这些task,可能拿到最终结果,也可能执行一半继续await别的任务,任务之间互相wait,通过回调来把任务串联起来(后面常用接口会继续深入介绍,实现细节见附录2)。
任务可能会依赖别的IO消息,在每一帧,event loop都会用selector处理相应的消息,执行相应的callback函数。
我们当前的介绍里,只有一个event loop,这个event loop跑在主线程里面。当然,event loop还可以开线程池处理别的任务,或者,多个线程里执行多个event loop,他们之间还有交互,我们这里不在介绍。
单个event loop跑在单个线程有个好处,只要自己不主动await,就会一直占有主线程,换句话说,同步函数一定没有数据冲突(data racing)。对比多线程方案,如果需要处理数据冲突,就需要加锁了,这在很多情况下会降低程序的性能。所以协程这种设计思路,非常适合有多个用户、但是每个用户之间没有共享数据的场景。如果需要实现并行,多开几个进程就行了。
但是实际上在工程里面,我们很难单用一个线程处理问题,asyncio也不例外,特别在集成别的同步库的时候,可能需要用到别的线程,我们后续介绍。
后续笔记
asyncio常用接口及其意义(实用主义)
如何集成asyncio和同步库(介绍executor线程池对event loop的影响)
为什么异步编程容易犯错(数据冲突)
不适宜骚年的附录
1. Task的构造的c实现
我们打开男性社交网站,这是event loop实现的核心代码。在这里我们找到了task的c实现_asyncio_Task___init___impl(L1933),它的核心代码执行ask_all_step_soon,间接调用脚本的event loop的call_soon,并且把自己加入到all_task这个全局list(通过register_task,主要是后面索引使用)。
2. task执行细节
task的执行,实现在task_step(L2878)和task_step_impl(L2540) 。其中task_step是asyncio任务执行的核心,对于一个coroutine,每次task_step得到一个结果,然后根据结果判断是否拿到了最终结果,或者需要继续计算等待别的结果,或者把结果扔给自己的waiter。
python的await都是通过generator实现的,具体的计算在genobject,主要是通过PyEval_EvalFrameEx拿计算结果。