跳转至

一个 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 直接管理等待:

async def audit_async() -> str:
    await asyncio.sleep(0.2)  # 代表原生异步 client 的等待
    return "audit:ok"

如果遗留阻塞 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 密集工作考虑其他模型。任何隔离方案都要保留剩余限制说明。

练习

  1. 分类:Pydantic 小模型校验、同步 HTTP client、图片压缩、大量文件读取。
  2. 为什么把同步 client 包在 async def 里仍会阻塞?
  3. await to_thread(legacy_call) 被取消后,能否断言底层调用已经停止?

练习解析

  1. 小校验通常直接保留;同步 HTTP 和文件读取是阻塞 I/O,优先换 async API,无法 替换时可隔离;图片压缩偏 CPU 密集,应评估其他执行模型。
  2. 函数实际仍在 event loop 线程同步执行,没有遇到可挂起的 await。
  3. 不能。等待方可以停止等待,但运行中的线程函数通常不能被强制终止;仍需处理 容量、外部副作用和资源释放。

参考资料