一行 `time.sleep(5)` 为什么能拖死整个 Async 服务?从 Event Loop 到 `asyncio.to_thread` 的底层剖析

一行 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)

推荐继续学习的主题包括 TaskGroupasyncio.timeout()、Semaphore、Queue、线程池与进程池、异步数据库驱动、异步 HTTP 客户端,以及 Async 服务中的背压、超时和取消传播机制。

最后留两个值得讨论的问题:

你的项目中是否存在“外面是 async def,里面却偷偷调用同步 SDK”的接口?

如果一个第三方库只有同步版本,你更倾向于通过 to_thread() 兼容,还是直接替换为原生 Async Library?

很多真正有价值的 Python 最佳实践,往往就藏在这些看似不起眼的选择里。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

铭渊老黄

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值