(第二次?)遇到了 Python 里的坑,特此记录。
背景是一个 Tornado 和 Asyncio 协程混用的项目,将一部分逻辑迁移到 Asyncio 后,之前好用的asyncio.wait_for突然偶尔失灵,导致里面调的协程进了死循环。当时发现的时候只是简单地看了一下,认为是 asyncio 的问题(经典臭名昭著?),但没关注具体是什么问题,只是先把里面的死循环改活了。上了线后出了些别的问题,觉得这里还是不妥,于是更进一步挖掘了下这个坑的具体成因。
话先说在前面,Python 3.8 已经修复(?)了该问题,而我这里刚好用的是 3.7。权当记录一下时代的眼泪好了。
复现
先 demo 一下现场,代码如下
1 | import asyncio |
且先不读 Python docs,这里asyncio.wait_for预期的行为就是f运行超过1秒就停止,之后打印"timeout"。然而运行的结果却是,程序一直在打印"processing"而不打印"timeout",当然也不退出。由于这里没有任何的其他逻辑,所以可以先给asyncio颁一个石锤~
而个中奥妙其实在f里的这个try-except里。如果我们去掉这个,然后再运行,如下
1 | import asyncio |
那么程序就是一切正常的了。
虽然这么搞有点上帝视角,但是到了这步我们只能顺理成章地把罪魁祸首揪出来,如下
1 | import asyncio |
这样一运行,我们就看到了它:asyncio.CancelledError
1 | processing |
检讨
按上面的流程走,这其实是一个很初级的问题。不过为什么花了精力排查呢?
首先是「偶尔失灵」,也就是并非必然复现。为什么呢,其实也很简单了,只有是在try-except里遇到了取消,才会出现这个问题,而实际上并不是整段协程的执行都被try-except包裹。
在实际的代码里,try-except是在里面好几层的,不过捕获的确实是Exception,而且打日志的时候其实也没有把详细的错误打出来。
这个事情告诉我们,一方面抓异常要抓的精细一点,另一方面日志尽量不要把异常吞下去。
探究
起手不如先看个文档,其实timeout的实现就是到点了做一个cancellation。其中有对cancel的说明,最初是这个给了我排查问题的启发。当然现在来看,这个文档就足够解释一切了。
再进一步,cancel的说明里其实给了CancelledError的跳转入口,其中「Changed in version 3.8: CancelledError is now a subclass of BaseException.」正好解释了为什么我一开始说 Python 3.8 已经修复了该问题。
还是回到cancel的说明上来。看这段话
This arranges for a CancelledError exception to be thrown into the wrapped coroutine on the next cycle of the event loop.
The coroutine then has a chance to clean up or even deny the request by suppressing the exception with a try … … except CancelledError … finally block. Therefore, unlike Future.cancel(), Task.cancel() does not guarantee that the Task will be cancelled, although suppressing cancellation completely is not common and is actively discouraged.
这段实际上告诉了我们CancelledError是一个可被捕获的异常。那么在 Python 3.8 之前,CancelledError继承自Exception,因此我们捕获Exception就带来了悲剧。Python 3.8 以后改继承自BaseException,我们就可以在这个 case 里安心使用except Exception了。而说明后给的示例代码,则是显示地捕获了CancelledError,并再raise出去,使得整个异常链路可控。
至于最后的解决办法,似乎不需要赘述了(当然不是升级 Python 版本了!)


