(第二次?)遇到了 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 版本了!)