ARTICLE · 993652
源码里翻了三遍,终于搞懂 Python 协程的调度机制,原来这么简单.
去年我接手一个老项目,里面有个协程池跑得特别慢。看源码看了三天,第四天我找出问题了,其实就是个循环里多了个等待。这事让我明白,协程调度没那么玄乎,说出来你可能不信,就三个字:
“交出去”
我写过不少Python,但一直不太敢碰协程。总觉得它像个黑盒子,外面套个async/await,里面到底怎么跑的,心里没底。那天被逼急了,直接打开Python标准库的`asyncio`,把核心调度器的代码翻了个底朝天。你猜怎么着?核心调度函数其实就几十行。
协程的本质是单线程里的任务切换。一个事件循环不停转,从任务队列里拿任务来跑。跑着跑着碰到await,协程就把控制权交还给事件循环。事件循环拿到控制权,再去队列里掏下一个任务。就这么简单,像学校里的值日生,一个扫地,一个擦黑板,干完了就去门口排队等着再干。
我一开始以为调度特别复杂,什么抢占式、优先级、时间片,都往上面套。看了源码才知道,根本没什么花哨的调度算法。就是最朴素的FIFO队列,先进先出。你交回来,我就把你挂回队尾,然后取队头的继续跑。谁先来谁先跑,谁也不插队。
最早让我困惑的一个细节是,协程什么时候该被让出来。看源码里,每次调用`await`,其实都在执行一个叫`__await__`的方法。这个方法返回一个迭代器,事件循环拿到这个迭代器,执行它的`__next__`或者`send`。如果碰到一个未完成的Future,协程就停在这里。事件循环把它放到一个等待列表里,等Future完成再把它塞回队列。
我用一句话总结这个过程:协程只是长得像函数,本质是个状态机。你每写一个`await`,就是状态机的一个暂停点。事件循环在这些暂停点之间调度,你根本不用管。
写代码的人最容易犯的错,是在协程里做同步阻塞操作。比如有人写`sleep`,用的是`time.sleep`而不是`asyncio.sleep`。前者一睡,整个线程都睡了,事件循环也别转了。后者会把协程挂起,让循环先干别的。这个区别我踩过坑,后来翻了源码,发现`asyncio.sleep`其实就是设置了一个定时器回调,回调里把协程塞回队列。
协程能跑多快,关键看你不做事(等待)的效率有多高。等待时间越长,切换次数越少,吞吐量越低。我调整过的那个协程池,就是把一个不必要的短暂等待去掉了。之前每次任务做完要等半秒钟,去掉后直接交回控制权,整体响应速度提升了三倍。
有同学问,协程很多会不会搞崩。会,但不会崩。事件循环有个默认的协程池大小,如果你创建一万个协程,循环会老老实实一万个挨个跑,内存占用会升高,但不至于直接崩溃。因为每个协程只占一个栈帧和少量状态。只是跑得慢,因为都是单线程。
我后来用协程写了一个爬虫,爬几百个网站,只用开一个线程。最早写的时候也是各种锁、各种信号量,后来发现根本不用。协程之间没有竞态条件,因为同一时间只有一个协程在跑。你想,跑步比赛的运动员,只有一个人在跑道上,另外的人在休息室等着,怎么会撞车。
源码翻三遍,其实就是帮自己去掉那些没必要的恐惧。协程调度跟线程调度比,简直就是神仙打架和小孩过家家的区别。线程调度要操作系统介入,要保存寄存器,要上下文切换。协程调度只需要保存一个栈帧指针和一个状态值,成本低到几乎可以忽略。
你如果现在让我用一句话给新人讲协程调度,我会说:你只管写代码碰那些await,剩下的让事件循环自己去忙活。它忙不过来,就老老实实在等待列表里排队,等排到了,自然叫你回来继续跑。