Python asyncio的一个坑
作者:灵剑 发布时间:2022-04-06 20:58:28
我们先从一个常见的Python
编程错误开始说起,我已经见过非常多的程序员犯过这种错误了:
def do_not_raise(user_defined_logic):
try:
user_defined_logic()
except:
logger.warning("User defined logic raises an exception", exc_info=True)
# ignore
这段代码的错误之处在哪里呢?
我们从Python
的异常结构开始说起。Python
中的异常基类有两个,最基础的是BaseException
,第二个是Exception
(继承BaseException
)。这两者有什么区别呢?
Exception
代表大部分我们经常会在业务逻辑中处理到的异常,也包括一部分运行出错例如NameError
、AttributeError
等等。但是并不是所有的异常都是Exception
类的子类,少数几个异常是继承于BaseException
的:
GeneratorExit
SystemExit
KeyboardInterrupt
第一个代表生成器被close()
方法关闭,第二个代表系统退出(例如使用sys.exit
),第三个代表程序被Ctrl+C
中断。之所以它们并不继承于Exception
,是因为:它们一般情况下绝不应当被捕获,或者被捕获之后应当立即reraise
(通过不带参数的raise语句)。
如果写出上面那样的语句,就可能会出现程序无法退出的情况:从外部发送SIGTERM信号到程序,触发了SystemExit,然而SystemExit被捕获然后忽略了,这样程序就没有正常退出,而是继续执行下去。像SystemExit、KeyboardInterrupt、GeneratorExit这样的异常,因为没有固定的抛出位置,所以如果乱捕获的话非常危险,很可能产生隐含的bug,而且测试中会很难发现。这就是为什么Python官方文档上会强调,如果使用无参数的except
,一定要配合raise重新将异常抛出。而正确的忽略执行异常的方法应该是:
def do_not_raise(user_defined_logic):
try:
user_defined_logic()
except Exception: ### <= Notice here ###
logger.warning("User defined logic raises an exception", exc_info=True)
# ignore
那么说了这么多,跟asyncio有什么联系呢?
在asyncio
当中,一个异步过程可以通过asyncio.Task
作为一个独立执行的单元启动,这个Task对象有一个cancel()方法,可以将它从中途强制停止。类似的,异步生成器也可以通过aclose()
方法强制结束。当一个异步过程或者异步生成器被从外部强制中止的时候,会从当前的await
或者yield
语句抛出asyncio.CancelledError
。
问题就出在这个CancelledError上!
asyncio也许是为了偷懒,也许是为了和concurrent
一致,这个异常实际上是concurrent.futures.CancelledError
。它的基类是Exception
,而不是BaseException
。要知道,在concurrent
库当中,CancelledError
是不会抛到已经开始了的子过程中的,它只会从future对象里抛出;而asyncio中,当使用了cancel()方法的时候,这个异常会从Task的当前堆栈位置抛出来。
这个事情就尴尬了,如果前面的do_not_raise
是个异步方法,用 except Exception
来捕获了用户自定义方法中的异常,那CancelledError
也会被捕获到。结果就是CancelledError
被错误地忽略掉,导致cancel()方法没有成功终止掉一个Task。
更尴尬的事情在于这个CancelledError
的抛出机制。asyncio
内部使用了Python的生成器和yield from
机制,yield from可以自动代理异常,
为了说明这一点我们考虑下面的代码:
import traceback
import asyncio
async def func1():
try:
return await func2()
except Exception:
traceback.print_exc()
raise
async def func2():
try:
await asyncio.sleep(2)
except Exception:
traceback.print_exc()
raise
async def func3():
t1 = asyncio.ensure_future(func1())
await asyncio.sleep(1)
t1.cancel()
try:
await t1
except CancelledError:
pass
在t1.cancel()
这里,会发生什么呢?实际上异常会从最内层的func2开始抛出,从func2抛出到func1,再到func3的await t1,所以可以看到两次traceback
打印。
这就是异步方法中await的异常代理机制,它像同步调用一样,有完整的堆栈,并且异常从最内层抛出。这本身是一个很好的设计,很方便调试,但是一旦CancelledError
抛出,你是无法确定它具体从哪条语句抛出的,这样在写异步逻辑的时候,实际上必须假设所有的await语句都有可能抛出CancelledError。如果在外面加上了前面的do_not_raise
这样的机制,就会错误地忽略掉CancelledError
。
所以异步逻辑中的忽略异常必须写成:
async def do_not_raise(user_defined_coroutine):
try:
await user_defined_coroutine
except CancelledError:
raise
except Exception:
logger.warning("User defined logic raises an exception", exc_info=True)
# ignore
这样才能保证CancelledError
不被错误捕获。
从这个结果上来看,CancelledError
从一开始就不应该继承自Exception
,它应该是一个BaseException
,这样就可以减少很多异步编程中的错误。
并不是自己不调用cancel()就不会出现这样的问题。一些会触发cancel()过程的常见例子包括:
asyncio.wait_for
在执行超时的时候会自动cancel内部的过程,这是一个很常用的实现超时逻辑的方法
aiohttp的handler
,如果没有处理完成之前用户就关闭了HTTP连接(比如强制点了浏览器的停止按钮),会对handler的异步过程调用cancel()
……
还有更尴尬的事情,许多时候我们不得不捕获CancelledError
。刚才的一段代码,我故意没有提,读者们是否发现问题了呢?
t1.cancel()
try:
await t1
except CancelledError:
pass
在asyncio中,cancel()方法并不会立即结束一个异步Task,它只会抛出CancelledError
,但是异步过程有机会使用except
或者finally,在退出之前执行一些清理过程。这里的await的本意也是等待t1完全退出再继续。但是t1会抛出CancelledError
,所以捕获这个异常,不让它再抛出。(而且如果不这么做,asyncio
会打印一行warning
,表示一个异步Task失败没有被处理)
那么问题就来了:如果func3()在执行到这里的时候,又被外部代码cancel()
了呢?下面的except CancelledError
就会变成问题,它会错误捕获外部的CancelledError
。另外,t1也会再次被cancel一遍(没错,await一个Task的时候,如果await所在过程被cancel,Task也会被cancel,需要使用asyncio.shield
来规避)
正确的写法应该是:
t1.cancel()
await asyncio.wait([t1])
try:
await t1
except CancelledError:
pass
asyncio.wait
等待Task执行结束,但并不收集结果,因此内层的CancelledError
不会在这里抛出来,而且如果此时取消func3,CancelledError
并不会被忽略。第二个await t1时,t1可以保证已经结束,这里内部没有其他异步等待过程,因此CancelledError
不会抛出在这里。也可以用t1.exception()
之类代替。
来源:https://zhuanlan.zhihu.com/p/31253104
猜你喜欢
- 本文实例为大家分享了python实现简易五子棋游戏的具体代码,供大家参考,具体内容如下运行效果: 完整代码+注释: fi
- 下面是代码,如果看不懂,建议先把表格的一些<tr><td>的表格原理弄清楚了,就可以了代码如下:<table&
- 前面介绍过vSQLAlchemy中的 Engine 和 Connection,这两个对象用在row SQL (原生的sql语句)上操作,而
- 本文实例讲述了PHP实现网页内容html标签补全和过滤的方法。分享给大家供大家参考,具体如下:如果你的网页内容的html标签显示不全,有些表
- Sql Server的存储过程是一个被命名的存储在服务器上的Transacation-Sql语句集合,是封装重复性工作的一种方法,它支持用户
- Python编程中raise可以实现报出错误的功能,而报错的条件可以由程序员自己去定制。在面向对象编程中,可以先预留一个方法接口不实现,在其
- 恭喜您,您中奖了,你的中奖码是(请牢记,领奖需要):XXXXXXXXXXX然后用户输入XXXXXXXXXXX,简单验证后就可以领奖了。你使用
- 常用的四种SQL命令:1.查询数据记录(Select)语法:Select 字段串行 From table Where 字段=内容例
- 我有个MM在网上面安了家,想做一个关于特效的网站。她虽然懂一点网页制作,但是她的机器配置比较低,有时为了反复试验页面上一些特殊效果,而打开D
- 实体有五种预定义的XML实体,HTML编码者应该熟悉。XML文档中的字符&、<、>、"和'被分别表示为
- ul: unordered lists ol: ordered lists li: Listsol 有序列表:<ol>
- 以下的文章主要介绍的是MySQL 查询缓存的实际应用代码以及查看MySQL 查询缓存的大小 ,碎片整理,清除缓存以及监视MySQL 查询缓存
- 首先是最常规的方法:<p id="para" title="cssrain demo!" on
- 一、前言容器使用沙箱机制,互相隔离,优势在于让各个部署在容器的里的应用互不影响,独立运行,提供更高的安全性。本文主要介绍python应用(d
- 我就废话不多说了,大家还是直接看代码吧!a1 = raw_input("please input a number")a
- PyCharm 具备一般 IDE 的功能,比如,调试、语法高亮、项目管理、代码跳转、智能提示、自动完成、单元测试、版本控制…另外,PyCha
- 1.因为oracle 10g暂时没有与win7兼容的版本,我们可以通过对安装软件中某些文件的修改达到安装的目地。 a)打开“\ORACLE1
- 变量类型ECMAScript变量可能包含两种不同类型的数据值:基本类型和引用类型。基本类型基本类型指的是简单的数据段,5种基本数据类型:un
- 遍历指定文件夹下的文件,根据文件后缀名,获取指定类型的文件列表;根据文件列表里的文件路径,逐个获取文件属性里的“修改时间”,如果“修改时间”
- 在VBScript中,有一个On Error Resume Next语句,它使脚本解释器忽略运行期错误并继续脚本代码的执行。接着该脚本可以检