一行 time.sleep(5) 为什么能拖死整个 Async 服务?从 Event Loop 到 asyncio.to_thread 的底层剖析
在 Python 异步编程中,有一类 Bug 特别隐蔽。
代码看起来完全合法:
import time
async def handler():
time.sleep(5)
return "ok"
函数使用了 async def,Web 框架也运行在 asyncio 上,请求最终还能正常返回 "ok"。
但压测一上来,服务却出现了非常诡异的现象:
接口 A 明明只做了一个 sleep,却卡住了接口 B
WebSocket 心跳突然停止
数据库异步请求延迟暴涨
定时任务不能按时运行
几十个请求像排队一样一个接一个执行
罪魁祸首竟然只是:
time.sleep(5)
为什么一个如此普通的 Python 函数,可以让 Async 服务“窒息”五秒?
更进一步:
await asyncio.sleep(5)
为什么同样是等待五秒,却几乎不会妨碍其他请求?
如果必须调用 requests、旧版数据库 SDK、文件处理库等同步代码,又应该怎么办?
什么时候应该使用:
await asyncio.to_thread(...)
什么时候反而不能用?
理解这些问题,是从“会写 async/await”迈向真正理解 Python asyncio 的关键一步。
一、先记住最重要的一句话
在 asyncio 程序中:
async def不会自动把函数里面的同步代码变成异步代码。
因此:
async def handler():
time.sleep(5)
return "ok"
虽然 handler 是 coroutine function,但:
time.sleep(5)
依然是一条彻彻底底的同步阻塞调用。
它不会因为外面套了一层:
async def
就突然拥有异步能力。
这正是很多 Python 初学者第一次开发 FastAPI、aiohttp、WebSocket 服务时最容易踩中的坑。
二、真正需要理解的是 Event Loop
Python 的 asyncio 建立在一个核心组件之上:
Event Loop
也就是事件循环。
官方文档将 Event Loop 描述为 asyncio 应用的核心,它负责运行异步 Task 和 callback、执行网络 I/O、管理子进程等。(Python documentation)
可以把一个典型异步服务简单想象成:
Event Loop
│
┌───────────┼───────────┐
│ │ │
Task A Task B Task C
HTTP请求 HTTP请求 WebSocket
关键点在于:
同一个 Event Loop 通常运行在一个线程中。
在这个线程里,同一时刻只能有一个 Task 的 Python 代码正在运行。
官方 asyncio 开发文档对此说得非常清楚:事件循环在一个线程中运行 Tasks;一个 Task 正在运行时,同一线程中的其他 Task 不能同时执行。当当前 Task 执行到能够挂起自己的 await 时,Event Loop 才能转去执行其他 Task。(Python documentation)
所以 asyncio 的并发并不是:
Task A ─────────→ CPU 1
Task B ─────────→ CPU 2
Task C ─────────→ CPU 3
而更接近:
Task A 执行
↓
A 等网络
↓
Task B 执行
↓
B 等数据库
↓
Task C 执行
↓
C 等 Socket
↓
A 的网络结果回来
↓
继续执行 A
这叫:
协作式并发。
每个 Task 都要在合适的时候主动“让出”事件循环。
三、time.sleep(5) 到底做了什么?
现在重新看:
async def handler():
time.sleep(5)
return "ok"
假设 Event Loop 开始执行这个 handler:
Event Loop
│
↓
handler()
│
↓
time.sleep(5)
问题出现了。
Python 官方文档对 time.sleep() 的描述是:
暂停调用它的线程指定的秒数。(Python documentation)
也就是说,如果 Event Loop 正好运行在:
Thread-1
那么:
time.sleep(5)
实际上是在说:
Thread-1,你休息 5 秒。
可是 Event Loop 本身就在 Thread-1 里面。
于是结果就成了:
Thread-1
└── Event Loop
└── Task A
└── time.sleep(5)
整个线程暂停
↓
Event Loop 暂停
↓
其他 Task 无法获得执行机会
不是只有当前 handler 等五秒。
而是:
这个 Event Loop 上所有等待被调度的任务,都可能跟着等。
四、为什么感觉像“整个服务死掉了”?
假设一个 Web 服务当前有三个请求:
Request A
Request B
Request C
正常异步情况下可能是:
0.00s A 开始
0.01s A 等数据库
0.01s B 开始
0.02s B 等 HTTP API
0.02s C 开始
0.03s C 等 Redis
所以三个请求能够互相穿插。
但如果 A 中出现:
time.sleep(5)
调度过程立刻变成:
0.00s A 开始
0.01s A -> time.sleep(5)
Event Loop 卡住
B 不能运行
C 不能运行
Socket 回调不能运行
Timer 不能运行
WebSocket 心跳不能运行
5.01s A 恢复
5.02s B 才获得执行机会
5.03s C 才获得执行机会
因此生产环境中常常出现一个很有迷惑性的现象:
CPU 并不一定很高,但接口延迟却突然爆炸。
原因并不是机器算不过来。
而是:
事件循环根本没有机会工作。
需要稍微严谨一点的是,多进程、多 worker 的 Web 服务不一定会因为一个 time.sleep() 就让整台服务器完全停止。
更准确地说:
它会阻塞当前 worker 中对应的 Event Loop,因此这个 Event Loop 承载的其他异步任务都会受到影响。
如果只有一个 worker,这种体验看起来就和“整个服务挂了”几乎一样。
五、亲手做一个实验
下面这个程序非常适合学习 asyncio 时亲自运行。
import asyncio
import time
async def heartbeat():
while True:
print(f"{time.strftime('%X')} heartbeat")
await asyncio.sleep(1)
async def bad_handler():
print(f"{time.strftime('%X')} handler start")
time.sleep(5)
print(f"{time.strftime('%X')} handler end")
async def main():
heartbeat_task = asyncio.create_task(heartbeat())
await asyncio.sleep(2)
await bad_handler()
await asyncio.sleep(2)
heartbeat_task.cancel()
asyncio.run(main())
你会观察到类似:
10:00:00 heartbeat
10:00:01 heartbeat
10:00:02 handler start
中间整整 5 秒没有 heartbeat
10:00:07 handler end
10:00:07 heartbeat
10:00:08 heartbeat
注意:
heartbeat()
明明是另一个 Task。
为什么它还是不能运行?
因为两个 Task 共用同一个 Event Loop,而这个 Event Loop 所在的线程被:
time.sleep(5)
阻塞了。
这就是问题的根源。
六、一个经典误区:time.sleep() 不是会释放 GIL 吗?
这是资深 Python 开发者经常会追问的问题。
没错,从 CPython 的线程执行角度看,time.sleep() 并不是一个持续占着 CPU 和 GIL 做忙循环的操作。
但这与 asyncio 是否被阻塞,是两个不同问题。
假设:
Thread A
└── asyncio Event Loop
现在 Thread A 执行:
time.sleep(5)
即使这期间其他 OS Thread 有机会执行,也改变不了一个事实:
负责 Event Loop 的 Thread A
正在 sleep。
所以:
释放 GIL
≠
Event Loop 没被阻塞
这是理解 asyncio 时非常重要的一层。
七、那么 asyncio.sleep() 为什么完全不同?
把代码改成:
async def handler():
await asyncio.sleep(5)
return "ok"
表面上同样等待五秒。
但运行机制已经完全改变。
Python 官方文档明确指出,asyncio.sleep() 会挂起当前 Task,从而允许其他 Task 继续运行。(Python documentation)
可以把:
await asyncio.sleep(5)
大致理解为:
当前 Task:
“Event Loop,我五秒以后再继续。
这五秒你先去处理其他事情。”
于是:
Task A
│
└── await asyncio.sleep(5)
│
↓
挂起 A
│
↓
控制权返回 Event Loop
│
├──运行 B
├──运行 C
├──处理网络 I/O
├──处理 WebSocket
└──处理定时器
5 秒后
│
↓
A 重新变成可运行状态
所以 asyncio.sleep() 的关键并不是“睡眠方式更高级”。
真正的区别是:
等待期间,当前 Task 把事件循环的控制权还了回去。
八、用数据直接比较两者
我们可以创建 10 个任务,每个任务“睡一秒”。
先看错误版本:
import asyncio
import time
async def worker(i):
time.sleep(1)
return i
async def main():
start = time.perf_counter()
tasks = [
asyncio.create_task(worker(i))
for i in range(10)
]
await asyncio.gather(*tasks)
print(
f"耗时:{time.perf_counter() - start:.2f}s"
)
asyncio.run(main())
直觉上创建了:
10 个 Task
似乎应该同时运行。
但实际耗时接近:
10 秒
因为每个 Task 一运行就:
time.sleep(1)
把 Event Loop 阻塞一秒。
执行过程近似:
Task 0:阻塞 1 秒
Task 1:阻塞 1 秒
Task 2:阻塞 1 秒
...
Task 9:阻塞 1 秒
总时间:
≈ 10 秒
现在改成:
async def worker(i):
await asyncio.sleep(1)
return i
再次运行。
理想情况下总耗时就接近:
≈ 1 秒
因为:
Task 0 等待 ┐
Task 1 等待 │
Task 2 等待 │
... ├── 时间重叠
Task 9 等待 ┘
这正是 asyncio 在 I/O 密集型程序中拥有巨大吞吐量优势的根本原因。
九、await 本身也不是“异步魔法”
另一个非常常见的错误认识是:
“只要前面写了 await,就不会阻塞 Event Loop。”
不对。
例如:
import asyncio
import time
async def bad():
time.sleep(5)
return 42
async def main():
result = await bad()
print(result)
asyncio.run(main())
这里虽然出现了:
await bad()
但是当 bad() 开始运行之后,它首先执行:
time.sleep(5)
在它真正遇到一个能够挂起自己的异步等待点之前,Event Loop 一样会被堵住。
因此:
async def
≠ 自动异步
await
≠ 自动非阻塞
真正重要的是:
被调用代码本身必须以协作方式把控制权交还给 Event Loop。
十、同步 library 怎么接入 async 服务?
现实项目里不可能所有库都是 async。
你可能正在使用:
同步 HTTP SDK
老数据库驱动
云厂商 SDK
文件解析库
旧业务 SDK
同步对象存储客户端
历史遗留代码
第三方 RPC 客户端
这时大致有三种处理方案。
| 场景 | 推荐方案 |
|---|---|
| 有成熟 async-native 库 | 优先使用异步版本 |
| 同步、阻塞、I/O 密集 | asyncio.to_thread() |
| 需要自行控制线程池/进程池 | run_in_executor() |
| 纯 Python CPU 密集计算 | 多进程或其他并行方案 |
原则是:
如果一项工作可能长时间阻塞当前线程,就不要直接把它塞进 Event Loop。
十一、最实用的方案:asyncio.to_thread()
例如现在有一个同步函数:
import time
def legacy_query():
time.sleep(5)
return {"name": "Python"}
错误写法:
async def handler():
result = legacy_query()
return result
因为:
legacy_query()
会直接在 Event Loop 线程执行。
正确改造:
import asyncio
async def handler():
result = await asyncio.to_thread(
legacy_query
)
return result
此时结构变成:
Event Loop Thread
│
├──提交 legacy_query
│
↓
Worker Thread
│
└──legacy_query()
│
└──阻塞 5 秒
与此同时
Event Loop Thread
│
├──处理其他请求
├──处理网络 I/O
└──执行其他 Task
Python 官方文档将 asyncio.to_thread() 定义为:在独立线程中异步执行函数,并返回一个可以等待结果的 coroutine;它主要适用于那些如果直接执行就会阻塞 Event Loop 的 I/O-bound 同步函数。当前的 contextvars.Context 也会传播到工作线程。(Python documentation)
这正是:
asyncio.to_thread()
最典型的使用场景。
十二、同步 HTTP SDK 怎么改?
假设历史代码使用一个同步客户端:
def fetch_user():
return sync_client.get_user(10001)
不要:
async def handler():
user = sync_client.get_user(10001)
return user
可以改成:
async def handler():
user = await asyncio.to_thread(
sync_client.get_user,
10001
)
return user
甚至多个互不依赖的调用可以:
async def handler():
user_task = asyncio.to_thread(
sync_client.get_user,
10001
)
order_task = asyncio.to_thread(
sync_client.get_orders,
10001
)
user, orders = await asyncio.gather(
user_task,
order_task
)
return {
"user": user,
"orders": orders,
}
这样至少可以避免同步 SDK 直接冻结 Event Loop。
十三、那为什么不把所有东西都 to_thread()?
这里开始进入 Python 最佳实践的重点。
asyncio.to_thread() 很方便,但绝不是万能胶。
首先,它会使用线程资源。
假如来了:
10000 个请求
而每一个都执行:
await asyncio.to_thread(blocking_api)
并不意味着系统会神奇地产生 10000 个无限并发的高性能线程。
线程池有容量,阻塞调用自身也有资源消耗,最终仍可能形成:
请求
↓
线程池队列
↓
等待可用线程
↓
同步 API
因此对于核心高并发链路,如果已经存在成熟的真正异步客户端:
Async HTTP Client
Async Database Driver
Async Redis Client
通常优先使用真正的异步实现。
to_thread() 更适合:
在必须兼容同步代码时,建立一道隔离 Event Loop 的桥梁。
十四、asyncio.to_thread() 适合什么任务?
最典型的是阻塞式 I/O。
例如:
await asyncio.to_thread(read_big_file)
await asyncio.to_thread(sync_http_request)
await asyncio.to_thread(legacy_database_query)
await asyncio.to_thread(cloud_sdk.upload, file)
await asyncio.to_thread(blocking_device_api)
共同特点是:
大量时间花在等待外部资源
而不是持续执行 Python CPU 运算
Python 官方文档也特别指出,受 GIL 等因素影响,to_thread() 通常主要用于把 I/O-bound 函数变成不会阻塞 Event Loop 的调用;某些会释放 GIL 的扩展模块属于例外。(Python documentation)
十五、什么时候不适合 to_thread()?
例如:
def calculate():
total = 0
for i in range(500_000_000):
total += i * i
return total
如果写成:
result = await asyncio.to_thread(calculate)
确实可以避免这段函数直接霸占 Event Loop 线程。
但不要因此得出:
“CPU 密集计算用线程就能高效并行。”
对于典型的纯 Python CPU-bound 工作,更值得考虑:
ProcessPoolExecutor
multiprocessing
独立计算服务
NumPy 等原生计算库
任务队列
因为这与:
避免阻塞 Event Loop
和:
获得 CPU 并行加速
是两个不同的问题。
这是工程实践中非常容易混淆的地方。
十六、run_in_executor() 又是什么?
如果需要更精细地控制线程池或进程池,可以使用 Event Loop 的 executor 接口。
例如:
import asyncio
from concurrent.futures import ThreadPoolExecutor
def blocking_io():
return "ok"
async def main():
loop = asyncio.get_running_loop()
with ThreadPoolExecutor(
max_workers=8
) as pool:
result = await loop.run_in_executor(
pool,
blocking_io,
)
print(result)
asyncio.run(main())
可以简单理解:
to_thread()
↓
更方便、更现代的高级接口
run_in_executor()
↓
需要显式控制 Executor 时更灵活
普通业务代码如果只是:
“我有一个同步阻塞函数,
不要让它堵住 Event Loop。”
通常:
asyncio.to_thread()
更加直观。
十七、线程安全问题千万不要忽略
假设某个同步客户端:
client
并不是线程安全的。
然后你:
await asyncio.gather(
asyncio.to_thread(client.query, 1),
asyncio.to_thread(client.query, 2),
asyncio.to_thread(client.query, 3),
)
这些调用可能进入不同线程。
如果该 SDK 内部共享了:
Socket
连接对象
状态机
缓存
游标
会话
就可能产生新的并发 Bug。
因此把同步库放进线程池之前,要确认:
它是不是 thread-safe?
一个 client 能不能被多个线程共享?
是否应该每个任务创建独立连接?
是否需要 Lock?
“不会阻塞 Event Loop”和“线程安全”是两回事。
十八、取消 to_thread() 也需要谨慎理解
假设:
task = asyncio.create_task(
asyncio.to_thread(blocking_call)
)
task.cancel()
不要理所当然地认为:
blocking_call 立刻被强制杀死
普通 Python 线程并不存在一个适合业务代码随意使用的“强制安全终止正在运行函数”的机制。
因此,对可能长期阻塞的同步 SDK,还应该优先使用它自身提供的:
timeout
连接超时
读取超时
取消机制
例如:
def query():
return client.request(
timeout=5
)
再:
result = await asyncio.to_thread(query)
这比单纯寄希望于外层 Task cancellation 更稳健。
十九、生产事故中最常见的几种 Event Loop 杀手
真正需要警惕的,不只是:
time.sleep()
任何长时间占着 Event Loop 线程不归还控制权的代码,都可能造成同样的问题。
例如同步网络请求、同步数据库访问、大文件同步读写、巨大的 JSON/压缩处理、某些图像处理、超长纯 Python 循环,以及调用未知第三方 SDK。
所以看到:
async def
时,要建立一种代码审查习惯:
检查里面所有可能耗时的函数调用,而不仅仅检查有没有
await。
这是非常重要的一条 Python 异步编程最佳实践。
二十、怎么定位“是谁阻塞了 Event Loop”?
asyncio 本身提供了 Debug Mode。
可以直接:
asyncio.run(
main(),
debug=True,
)
也可以配置 Event Loop:
async def main():
loop = asyncio.get_running_loop()
loop.set_debug(True)
loop.slow_callback_duration = 0.05
...
官方文档说明,在 Debug Mode 中,执行时间过长的 callback 会被记录,而:
loop.slow_callback_duration
可以控制多久算“慢”;默认阈值为 100 毫秒。(Python documentation)
开发环境中还可以通过:
PYTHONASYNCIODEBUG=1
启用 asyncio 调试模式。(Python documentation)
如果线上出现:
CPU 不高
↓
请求延迟突然增加
↓
WebSocket heartbeat 同时抖动
↓
多个完全不相关的 async Task 一起延迟
那么就应该高度怀疑:
Event Loop starvation
也就是事件循环长时间拿不到调度机会。
二十一、真实 Web 项目应该怎么设计?
假设现在需要实现:
async def generate_report():
...
内部有三步:
1. 异步查询数据库
2. 调用一个只能同步使用的旧 SDK
3. 调用异步对象存储
一种合理的结构是:
async def generate_report():
data = await async_database.query()
result = await asyncio.to_thread(
legacy_sdk.process,
data
)
url = await async_storage.upload(
result
)
return url
这样整个链路就是:
Async DB
│
└── await
↓
Event Loop 可以处理别人
Legacy Sync SDK
│
└── to_thread
↓
工作线程处理
Event Loop 继续服务其他请求
Async Storage
│
└── await
↓
继续协作式调度
这才是同步世界和异步世界之间比较健康的边界。
二十二、一个非常实用的选择模型
遇到一个耗时函数时,可以按下面的思路判断:
这个操作会不会阻塞?
│
├──不会
│ ↓
│ 直接执行
│
└──会
│
↓
有没有 Async API?
│
┌────┴────┐
│ │
有 没有
│ │
↓ ↓
await 它是 I/O 吗?
│
┌────┴────┐
│ │
是 CPU密集
│ │
↓ ↓
to_thread 多进程 /
专门计算方案
如果把这张图真正记住,绝大多数 asyncio 阻塞问题都会容易很多。
二十三、最后回答最开始的三个问题
第一个问题:
async def handler():
time.sleep(5)
return "ok"
为什么会拖慢整个 Async 服务?
因为:
handler 正运行在 Event Loop 所在线程
↓
time.sleep(5)
↓
调用线程暂停 5 秒
↓
Event Loop 也无法运行
↓
同一 Event Loop 上其他 Task 无法获得调度
第二个问题:
为什么:
await asyncio.sleep(5)
不一样?
因为它会:
挂起当前 Task
↓
把控制权还给 Event Loop
↓
Event Loop 去运行其他 Task
↓
时间到后再恢复当前 Task
第三个问题:
同步库怎么接入 Async 服务?
优先顺序通常是:
真正的 Async API
↓
如果没有
↓
I/O 型同步函数
↓
asyncio.to_thread()
↓
需要控制 Executor 时
↓
run_in_executor()
而对于纯 Python CPU 密集任务,通常应进一步考虑多进程或其他计算方案,而不是简单地认为线程能够解决一切。
二十四、总结:Async 服务真正怕的不是“慢”,而是“不让出控制权”
学习 asyncio 以后,我们很容易把:
async def
await
create_task
gather
当成某种性能魔法。
其实 asyncio 的思想比这朴素得多。
它依赖一种合作:
我现在需要等待,
所以我先暂停,
把线程交给别人使用。
于是一个线程可以管理大量网络连接和 I/O 工作。
但如果某段代码说:
“不,我现在要占着这个线程,
五秒以后再还给你。”
那么整个协作体系就会停下来。
time.sleep() 正是如此。
所以,真正值得记住的不是:
不要在 async 里写 time.sleep()
而是更一般的原则:
任何长期占据 Event Loop 线程、却不把控制权交还给事件循环的代码,都可能破坏异步服务的并发能力。
也正因如此,在进行 Python 实战、Web 后端开发或者高并发系统设计时,我们不应该只问:
“这个函数是不是
async def?”
而应该进一步追问:
“它里面真正执行的代码,会不会阻塞 Event Loop?”
当你开始习惯从这个角度检查代码时,才真正跨过了 Python 异步编程最重要的一道门槛。
下一次看到:
async def handler():
...
不妨沿着每一行调用继续向下看。
也许拖慢整个系统的,不是什么复杂的并发 Bug。
只是隐藏在某个角落里,一行看起来人畜无害的:
time.sleep(5)
参考资料与延伸阅读
Python 官方 asyncio 文档说明,asyncio 是使用 async/await 编写并发代码的标准库,并尤其适合 I/O-bound 和高层网络代码。(Python documentation)
Python 官方 Coroutines and Tasks 文档详细介绍了 asyncio.sleep()、Task、asyncio.to_thread() 以及线程间调度机制。(Python documentation)
Python 官方 Developing with asyncio 文档进一步解释了 Event Loop、线程、Debug Mode 和慢 callback 检测机制。(Python documentation)
推荐继续学习的主题包括 TaskGroup、asyncio.timeout()、Semaphore、Queue、线程池与进程池、异步数据库驱动、异步 HTTP 客户端,以及 Async 服务中的背压、超时和取消传播机制。
最后留两个值得讨论的问题:
你的项目中是否存在“外面是 async def,里面却偷偷调用同步 SDK”的接口?
如果一个第三方库只有同步版本,你更倾向于通过 to_thread() 兼容,还是直接替换为原生 Async Library?
很多真正有价值的 Python 最佳实践,往往就藏在这些看似不起眼的选择里。

1023

被折叠的 条评论
为什么被折叠?



