一个词频任务已经提交,调用 run_async(store, task_id) 后查询仍是 queued;把返回值交给 asyncio.create_task,紧接着查询也还是 queued。任务究竟什么时候开始?这与 09 的生成器惰性相似,但协程由事件循环驱动,不能把 yield 和 await 混作同一种业务调度。

本篇沿用 27 的词频工作负载和 submit → run → query → cancel 状态合同,仅增加 run_async 入口。创建协程对象不运行其函数体;create_task 将其安排到事件循环运行,并返回可以等待的 Task。安排不等于当前这一行已经执行。测试紧接着查询时仍为 queued,给事件循环一次运行机会后,协程调用已有的 _start 进入 running。

能让出循环的等待

run_async 在 _start 后等待 asyncio.Event。未设置事件时,Task 暂停,另一个已经安排的心跳任务运行,词频任务仍是 running,没有结果。事件设置后它恢复,计算与提交结果走现有 app.count_words、app._complete,最终 succeeded、结果 {"one": 2, "two": 1}。await 的协作对象是同一个事件循环里的其他 Task;它本身不为计算增加线程,也没有让同一段纯 Python 计算自动并行。

反例把与线程、进程实验相同的 cpu_work(text, iterations=20000) 放在 run_async 中,却不在 CPU 循环中放等待点。计算任务先唤醒心跳,再同步完成词频;实测事件顺序为 cpu_start → cpu_end → heartbeat。心跳没有在 CPU 工作期间运行。这一条是调度顺序断言,不是耗时或吞吐基准,也不证明 CPU 工作占用时事件循环之外的线程不能运行。若真实工作主要耗费 CPU,应按负载、隔离和数据传输成本选择 29 的线程或 30 的进程;不能靠给函数添加 async 就得到加速。

1
2
3
cd examples/python-language
PYTHONPATH=src /usr/bin/python3.12 experiments/31_async.py
PYTHONPATH=src /usr/bin/python3.12 -m unittest discover -s tests -v

实际正例输出包含 before_await: queued、等待时的 heartbeat_during_wait、最终 succeeded;反例输出的顺序为 cpu_start, cpu_end, heartbeat。两条代码路径均检查结果为 {"one": 2, "two": 1}。完整命令、原始输出和 81 项累计测试保存在 examples/python-language/evidence/31/20261008-async/。这个入口当前只在单事件循环里用内存 store;任务组内故障传播、取消后的清理、deadline 留给 32,不把本篇的普通完成推断成那些场景已经通过。

预测题

coro = run_async(store, task_id, wait_event=gate),随后 task = asyncio.create_task(coro);在下一次让出事件循环之前,依次查询状态。两次查询是什么?事件循环运行 Task 到尚未设置的 gate.wait() 后又是什么?

答案:前两次都是 queued,协程体尚未执行;循环运行到 _start 后是 running,gate.wait() 尚未返回、Task 尚未完成。此时其他可运行 Task 可以执行。这里的 asyncio.sleep(0) 只作为测试中的一次交接点,不是用休眠时间保证生产调度顺序。

变更题

保持输入、迭代次数不变,将 CPU 计算直接写进 async def,并在计算前设置心跳事件。仅凭 await run_async(...) 能否保证心跳先于计算结束?如果必须保持事件循环响应,该把哪一类工作移出循环?

答案:不能;计算内部没有会挂起的等待点,实测心跳排在 cpu_end 后。阻塞循环的 CPU 工作须经独立的执行策略处理,再验证计算结果、取消与资源边界;即便使用线程,也不能从 async 或普通 GIL 构建推断 CPU 加速。后续任务组篇将把取消和清理事件单独测量。

系列导航只列已验收文章。

参考资料