(第二次?)遇到了 Python 里的坑,特此记录。

背景是一个 Tornado 和 Asyncio 协程混用的项目,将一部分逻辑迁移到 Asyncio 后,之前好用的asyncio.wait_for突然偶尔失灵,导致里面调的协程进了死循环。当时发现的时候只是简单地看了一下,认为是 asyncio 的问题(经典臭名昭著?),但没关注具体是什么问题,只是先把里面的死循环改活了。上了线后出了些别的问题,觉得这里还是不妥,于是更进一步挖掘了下这个坑的具体成因。

话先说在前面,Python 3.8 已经修复(?)了该问题,而我这里刚好用的是 3.7。权当记录一下时代的眼泪好了。

复现

先 demo 一下现场,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import asyncio

async def f():
while True:
try:
print('processing')
await asyncio.sleep(1)
except Exception:
pass

async def main():
try:
await asyncio.wait_for(f(), timeout=1)
except asyncio.TimeoutError:
print('timeout')

asyncio.run(main())

且先不读 Python docs,这里asyncio.wait_for预期的行为就是f运行超过1秒就停止,之后打印"timeout"。然而运行的结果却是,程序一直在打印"processing"而不打印"timeout",当然也不退出。由于这里没有任何的其他逻辑,所以可以先给asyncio颁一个石锤~

而个中奥妙其实在f里的这个try-except里。如果我们去掉这个,然后再运行,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import asyncio

async def f():
while True:
print('processing')
await asyncio.sleep(1)

async def main():
try:
await asyncio.wait_for(f(), timeout=1)
except asyncio.TimeoutError:
print('timeout')

asyncio.run(main())

那么程序就是一切正常的了。

虽然这么搞有点上帝视角,但是到了这步我们只能顺理成章地把罪魁祸首揪出来,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import asyncio

async def f():
while True:
try:
print('processing')
await asyncio.sleep(1)
except Exception as e:
print(type(e))

async def main():
try:
await asyncio.wait_for(f(), timeout=1)
except asyncio.TimeoutError:
print('timeout')

asyncio.run(main())

这样一运行,我们就看到了它:asyncio.CancelledError

1
2
3
4
5
6
7
8
processing
<class 'concurrent.futures._base.CancelledError'>
processing
processing
processing
processing
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 版本了!)