将Python生成器迁移至async/await:await asyncio.sleep(0)是否为正确挂起方式?
最初想学习用async/await这种现代方式编写类生成器协程,之前尝试在asyncio事件循环里并发使用生成器被指出过于繁琐,于是简化成这个问题:await asyncio.sleep(0) 是否是挂起现代协程的正确方式?既然生成器已经有yield关键字实现挂起,依赖asyncio来做这件事感觉不太合理。
以下是用子进程作为可并发执行的顺序代码替代示例:
import asyncio import subprocess from time import time async def proc(*args): print(*args) p = subprocess.Popen(args) await asyncio.sleep(0) p.wait() async def main(): queue = [proc("sleep", str(i)) for i in range(3)] async with asyncio.TaskGroup() as tg: for job in queue: tg.create_task(job) t = time() asyncio.run(main()) print("time", round(time() - t, 2))
运行结果:
sleep 0 sleep 1 sleep 2 time 2.01
解答
首先明确:await asyncio.sleep(0) 确实能触发协程挂起、让事件循环切换到其他任务,但这绝不是推荐的正确方式,本质是一种hack手段。
为什么不推荐这么做?
- 语义模糊:
sleep(0)字面意思是"休眠0秒",完全体现不出"主动让渡执行权"的意图,代码可读性极差,其他开发者看到会一脸困惑。 - 依赖实现细节:这种方式能生效,是因为asyncio事件循环处理
sleep(0)时会直接把协程重新排入任务队列,触发切换。但这属于事件循环的内部逻辑,并非标准API承诺的行为,未来版本可能会改变。
正确的挂起方式
如果你需要主动让协程暂停并让渡执行权,Python 3.10+ 可以用asyncio.current_task().yield(),它的语义非常明确:告诉事件循环"我现在可以暂停,先去处理别的任务"。
另外,你的示例代码还有个核心问题:用subprocess.Popen()加p.wait()是同步阻塞调用,会直接卡住整个事件循环!这也是为什么你必须加await asyncio.sleep(0)才能看到三个sleep命令几乎同时打印——但本质上三个子进程并没有真正并发,p.wait()会阻塞当前线程,事件循环根本没法正常切换。
正确的异步子进程写法应该用asyncio自带的asyncio.create_subprocess_exec(),它是异步非阻塞的,不需要手动挂起协程:
import asyncio from time import time async def proc(*args): print(*args) process = await asyncio.create_subprocess_exec(*args) await process.wait() async def main(): queue = [proc("sleep", str(i)) for i in range(3)] async with asyncio.TaskGroup() as tg: for job in queue: tg.create_task(job) t = time() asyncio.run(main()) print("time", round(time() - t, 2))
这段代码运行结果同样耗时约2秒,但逻辑更合理:事件循环可以在等待子进程结束的间隙,自由切换到其他任务,不需要手动插入sleep(0)触发切换。
关于yield和async/await的区别
生成器的yield是用于同步迭代场景,而async/await是用于异步任务调度,两者设计目标完全不同:
yield让生成器暂停并返回值,需要调用方主动迭代触发后续执行;await让协程暂停,把执行权交还给事件循环,由事件循环负责调度其他任务,等异步操作完成后再回来继续执行当前协程。
所以在asyncio的异步编程模型里,不需要用yield来挂起协程,而是通过await异步操作(比如IO等待、任务切换)来实现并发。
内容的提问来源于stack exchange,提问作者Geo

