一个 time.sleep,为什么让所有 Task 一起安静了?¶
学习助手一边等待旧式审计 client,一边运行心跳。我们原以为心跳属于另一个 Task, 应该照常打印;实际日志却出现一整段空白。问题不在心跳,而在 event loop 的线程 被同步调用占住了。
运行环境¶
Python 3.12、asyncio 标准库。time.sleep 仅模拟无法立即替换的阻塞 I/O。
1. 看见 event loop 被冻结¶
import asyncio
import time
async def heartbeat() -> None:
for tick in range(5):
print("tick", tick)
await asyncio.sleep(0.05)
async def bad_adapter() -> str:
time.sleep(0.2) # 同步阻塞,没有向 event loop 让出
return "audit:ok"
async def main() -> None:
await asyncio.gather(heartbeat(), bad_adapter())
asyncio.run(main())
bad_adapter 虽然写成 async def,函数体里的 time.sleep 仍是同步调用。event loop
通常在一个线程中执行回调和 Task;当前 Task 不返回、也不到可挂起点时,同线程的
heartbeat 无法推进。async 关键字没有改变底层调用的行为。
2. 同步不等于阻塞¶
短字符串清洗、字段校验或小字典组合也是同步代码,但通常立即完成。把每个普通 函数都搬到线程,会增加调度、理解和资源成本。需要重点识别的是:
- 同步 I/O:线程在等文件、网络或遗留 client;
- 长时间 CPU 计算:线程持续计算,没有让出点;
- 短本地计算:快速完成,通常直接保留。
前两类都可能拖慢 event loop,但解决方式不同。CPU 密集工作不会因为换成 async 或普通线程就自动获得理想并行;本课只识别它应使用不同执行模型。
3. 方案优先级:替换、隔离、换模型¶
若依赖提供原生 async API,优先使用它。真正的 awaitable I/O 能让 event loop 直接管理等待:
如果遗留阻塞 I/O 暂时无法替换,可以建立最小隔离边界:
def legacy_audit(topic: str) -> str:
time.sleep(0.2)
return f"audit:{topic}"
async def audit(topic: str) -> str:
return await asyncio.to_thread(legacy_audit, topic)
to_thread 在线程中调用同步函数,当前 Task await 结果时 event loop 仍可调度心跳。
它没有把 legacy_audit 变成 coroutine,只是把阻塞执行移出了 loop 线程。
4. 移出去,不等于问题消失¶
线程是一种边界,不是无限资源。大量 to_thread 调用仍会受到线程、内存、下游
连接和远端容量限制。更重要的是:取消等待 to_thread 的 Task,不代表已经运行
的同步函数能被强制终止。底层函数持有的文件或连接仍要自己正确释放。
因此提出线程隔离方案时,至少同时说明:它适用于无法替换的阻塞 I/O;调用量必须 受容量约束;取消语义有限;资源生命周期仍存在。完整限流、取消和清理将在后续 课程展开。
边界与常见误区¶
async def不会自动把内部同步调用变成非阻塞。asyncio.sleep是可等待的定时器;time.sleep会阻塞当前线程。- 不要把每个短同步函数都放进线程。
to_thread主要用于阻塞 I/O,不是本课的 CPU 并行方案。- 移出 event loop 只恢复响应性,不会消除容量、取消和资源问题。
本章小结¶
event loop 依赖任务在等待点协作让出。同步阻塞 I/O 或长计算会占住它的线程, 拖住所有其他 Task。优先改用原生 async API;确需保留的阻塞 I/O 可用线程隔离; CPU 密集工作考虑其他模型。任何隔离方案都要保留剩余限制说明。
练习¶
- 分类:Pydantic 小模型校验、同步 HTTP client、图片压缩、大量文件读取。
- 为什么把同步 client 包在
async def里仍会阻塞? await to_thread(legacy_call)被取消后,能否断言底层调用已经停止?
练习解析¶
- 小校验通常直接保留;同步 HTTP 和文件读取是阻塞 I/O,优先换 async API,无法 替换时可隔离;图片压缩偏 CPU 密集,应评估其他执行模型。
- 函数实际仍在 event loop 线程同步执行,没有遇到可挂起的 await。
- 不能。等待方可以停止等待,但运行中的线程函数通常不能被强制终止;仍需处理 容量、外部副作用和资源释放。