asyncio.gather behaves inconsistently when handling KeyboardInterruption/SystemExit
还没有人认领这个 Issue。
- 主要语言
- Python
- 星标
- 77.2k
- 派生
- 36k
- PR 合并指标
- PR 指标待抓取
描述
Bug report
Here is a minimal example:
import asyncio
e = KeyboardInterrupt # or SystemExit
async def main_task():
await asyncio.gather(
sub_task(),
)
async def sub_task():
raise e
if __name__ == '__main__':
try:
asyncio.run(main_task())
except e:
print(f'Handle {e}')
This code handles the Interrupt normally as I expected.
Handle <class 'KeyboardInterrupt'>
Process finished with exit code 0
But when I add the asyncio.sleep(0) (can be replaced by other task, not important) into main_task's asyncio.gather
import asyncio
e = KeyboardInterrupt # or SystemExit
async def main_task():
await asyncio.gather(
sub_task(),
asyncio.sleep(0)
)
async def sub_task():
raise e
if __name__ == '__main__':
try:
asyncio.run(main_task())
except e:
print(f'Handle {e}')
There is an unexpected traceback print out which is really confusing 💦, this traceback indicates that there is
another KeyboardInterrupt raised.
Full traceback
Traceback (most recent call last):
File "/Users/huanghuiling/PycharmProjects/Lighting-bilibili-download/tests/log_test.py", line 45, in <module>
asyncio.run(main_task())
File "/opt/homebrew/Caskroom/miniforge/base/envs/test9/lib/python3.9/asyncio/runners.py", line 47, in run
_cancel_all_tasks(loop)
File "/opt/homebrew/Caskroom/miniforge/base/envs/test9/lib/python3.9/asyncio/runners.py", line 63, in _cancel_all_tasks
loop.run_until_complete(
File "/opt/homebrew/Caskroom/miniforge/base/envs/test9/lib/python3.9/asyncio/base_events.py", line 629, in run_until_complete
self.run_forever()
File "/opt/homebrew/Caskroom/miniforge/base/envs/test9/lib/python3.9/asyncio/base_events.py", line 596, in run_forever
self._run_once()
File "/opt/homebrew/Caskroom/miniforge/base/envs/test9/lib/python3.9/asyncio/base_events.py", line 1890, in _run_once
handle._run()
File "/opt/homebrew/Caskroom/miniforge/base/envs/test9/lib/python3.9/asyncio/events.py", line 80, in _run
self._context.run(self._callback, *self._args)
File "/Users/huanghuiling/PycharmProjects/Lighting-bilibili-download/tests/log_test.py", line 7, in main_task
await asyncio.gather(
File "/opt/homebrew/Caskroom/miniforge/base/envs/test9/lib/python3.9/asyncio/runners.py", line 44, in run
return loop.run_until_complete(main)
File "/opt/homebrew/Caskroom/miniforge/base/envs/test9/lib/python3.9/asyncio/base_events.py", line 629, in run_until_complete
self.run_forever()
File "/opt/homebrew/Caskroom/miniforge/base/envs/test9/lib/python3.9/asyncio/base_events.py", line 596, in run_forever
self._run_once()
File "/opt/homebrew/Caskroom/miniforge/base/envs/test9/lib/python3.9/asyncio/base_events.py", line 1890, in _run_once
handle._run()
File "/opt/homebrew/Caskroom/miniforge/base/envs/test9/lib/python3.9/asyncio/events.py", line 80, in _run
self._context.run(self._callback, *self._args)
File "/Users/huanghuiling/PycharmProjects/Lighting-bilibili-download/tests/log_test.py", line 14, in sub_task
raise e
KeyboardInterrupt
Process finished with exit code 0
So I go deeply into the asyncio.run, and write code (a simplified asyncio.run) below to figure out what happen.
import asyncio
e = KeyboardInterrupt # or SystemExit
async def main_task():
await asyncio.gather(
sub_task(),
asyncio.sleep(0)
)
async def sub_task():
raise e
if __name__ == '__main__':
loop = asyncio.get_event_loop()
try:
loop.run_until_complete(main_task())
except e:
print(f'Expected {e}')
finally:
try:
tasks = asyncio.all_tasks(loop)
for t in tasks:
t.cancel()
# ⬇️ this line will raise another KeyboardInterrupt which is unexpected ⬇️
loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
except e:
print(f'Unexpected {e} !!!!')
This line will raise another KeyboardInterrupt which is unexpected.
loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
Expected <class 'KeyboardInterrupt'>
Unexpected <class 'KeyboardInterrupt'> !!!!
Process finished with exit code 0
Note that this line is used to cancel all tasks during gracefully shutdown (also in asyncio.run), and when I change
e to other error like IndexError(any BaseException), this code works fine without unexpected another exception.
I believe this is related to the asyncio treats SystemExit and KeyboardInterrupt in different way. For example
in events.py
def _run(self):
try:
self._context.run(self._callback, *self._args)
except (SystemExit, KeyboardInterrupt):
raise
except BaseException as exc:
cb = format_helpers._format_callback_source(
self._callback, self._args)
msg = f'Exception in callback {cb}'
context = {
'message': msg,
'exception': exc,
'handle': self,
}
if self._source_traceback:
context['source_traceback'] = self._source_traceback
self._loop.call_exception_handler(context)
self = None # Needed to break cycles when an exception occurs.
My question is:
- Why
asyncio.gatherbehaves inconsistently. - Is there any reason to treat
KeyboardInterruptdifferently, since the simplest way
to solve this bug is to handle it same asBaseException.
I think user would like to handle all error consistently during the running of a task whether
it's KeyboardInterrupt or BaseException.
Even asyncio treats them in different way (incase really necessary)
asyncio.gather(sub_task())
and
asyncio.gather(sub_task(), asyncio.sleep(0))
should behave consistently, so I think this is a bug in asyncio.
Your environment
- CPython versions tested on: 3.8, 3.9, 3.10
- Operating system and architecture: both macOS and windows
贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
调研方向
首先复现两个 asyncio.gather 示例和简化的 shutdown 流程。阅读 asyncio.gather、asyncio/events.py 和 asyncio/runners.py,追踪 KeyboardInterrupt 和 SystemExit 在任务取消期间的传播方式;完成意味着预期行为已定义、实现一致,并由回归测试覆盖。
由索引模型根据 Issue 内容生成。
评估
- 技术栈
- python
- 领域
- backend
- Issue 类型
- 缺陷
- 难度
- 4/5
- 预计耗时
- 3-5 天
- 活跃度
- 停滞
- 描述清晰度
- 基本清楚
- 新手友好度
- 35/100