Python 异步里混用同步库,事件循环被卡住的全过程

一个 FastAPI 接口压测时 QPS 上不去,只有 300 左右,机器 CPU 一直在 12%。单个请求处理耗时 40 毫秒,4 核理论上该跑到几千。

问题出在一行 requests.get() 上。这篇把机制、症状和六种修法讲清楚。

事件循环是怎么被卡住的

asyncio 是单线程协作式调度。所有协程跑在一个线程上,靠 await 主动让出控制权。任何不 await 的阻塞调用,都会让整个循环停在那里。

python 复制代码
import asyncio, time, requests

async def handler():
    r = requests.get('http://api.internal/user/1', timeout=5)  # 阻塞 40ms
        return r.json()
        ```
这 40 毫秒里,事件循环不能处理任何其他连接。四个人同时请求就完全线性排队,第四个看到 160ms。QPS 上限等于 1 除以阻塞时间,40ms 对应 25,实测 300 是因为还有别的协程在跑。

## 症状长什么样

四个信号,出现任意两个就该怀疑同步阻塞。

一、QPS 不随并发数增长。压测时把并发从 10 加到 100,QPS 卡在同一个数字。

二、CPU 低但延迟高。真在算东西的话 CPU 会满,这里 CPU 只有 12%,说明在等 IO。

三、延迟分布有明显台阶。P50 是 40ms,P99 是 800ms,而下游服务自己的 P99 只有 50ms。中间的 750ms 是排队时间。

四、asyncio 的 debug 模式能直接抓到。Python 3.7 之后可以开 slow callback 告警:

```python
import asyncio, logging

logging.basicConfig(level=logging.WARNING)
loop = asyncio.get_event_loop()
loop.slow_callback_duration = 0.05   # 超过 50ms 就告警
loop.set_debug(True)

运行时会打印:

text 复制代码
Executing <Task finished name='Task-3' coro=<handler() done, defined at app.py:12>
result={'id': 1} created at /usr/lib/python3.11/asyncio/tasks.py:341>
took 0.041 seconds

这一行就是罪证,coro 名和文件行号都给了。

修法一:换异步客户端

最直接的一条。requests 换成 httpx 或 aiohttp:

python 复制代码
import httpx

client = httpx.AsyncClient(timeout=5, limits=httpx.Limits(
    max_connections=100, max_keepalive_connections=20))
async def handler(uid: int):
    r = await client.get(f'http://api.internal/user/{uid}')
        return r.json()
        ```
client 要在应用生命周期内复用,每次 new 一个等于每次重建连接池和 TLS 握手,收益打对折。

## 修法二:必须用同步库时,扔到线程池

有些库没有异步版本(比如某些 SDK、boto3 的老版本),只能包一层:

```python
import asyncio
from concurrent.futures import ThreadPoolExecutor

pool = ThreadPoolExecutor(max_workers=32, thread_name_prefix='sync-io')

async def run_sync(fn, *args, **kwargs):
    loop = asyncio.get_running_loop()
        return await loop.run_in_executor(pool, lambda: fn(*args, **kwargs))
        ```
线程数按阻塞类型定:纯网络 IO 可以开到一百以上,有 CPU 计算则开到核数就够。

踩坑:默认执行器的 max_workers 是 min(32, cpu+4),对网络 IO 偏小,还和其他库共用一个池互相拖累。自己建池。另外任务被取消时异常会静默丢掉,给每个任务包 try/except 记日志。

## 修法三:CPU 密集的交出去,别在线程里做

GIL 决定了线程做 CPU 计算没用。这类必须上进程池:

```python
from concurrent.futures import ProcessPoolExecutor
import asyncio

cpu_pool = ProcessPoolExecutor(max_workers=4)

async def heavy(data: bytes) -> dict:
    loop = asyncio.get_running_loop()
        return await loop.run_in_executor(cpu_pool, compute, data)
        ```
参数必须可 pickle,传大对象时序列化开销可能超过计算本身。Windows 上 spawn 开销每个任务几十毫秒,别放在请求路径里。

## 修法四:文件 IO 也别在协程里做

```python
# 阻塞
async def read_cfg():
    with open('/etc/app/config.json') as f:
            return json.load(f)
# 不阻塞
import aiofiles
async def read_cfg():
    async with aiofiles.open('/etc/app/config.json') as f:
            return json.loads(await f.read())
            ```
小文件只有几十微秒,影响不大。但读几百 MB 的文件、或者 NFS 上的文件(延迟毫秒级)时阻塞会很明显。判断标准是耗时是否常超过 1 毫秒。

## 修法五:数据库驱动

同步的 pymysql 在 async 函数里用,是最常见的隐性阻塞。SQL 查询动辄几毫秒到几十毫秒。

换 asyncpg(PostgreSQL)或 aiomysql:

```python
import asyncpg

pool = await asyncpg.create_pool(dsn='postgresql://app@db/prod',
                                 min_size=5, max_size=20)
async def get_user(uid: int):
    async with pool.acquire() as conn:
            return await conn.fetchrow('SELECT id, name FROM users WHERE id=$1', uid)
            ```
SQLAlchemy 2.0 用 create_async_engine,URL 里必须写 postgresql+asyncpg://,写成 postgresql:// 会静默用同步驱动,然后在 async 函数里阻塞。这个坑很常见,因为连接串是照抄的。

## 修法六:time.sleep 和锁

两个小东西最容易漏。

```python
import time, asyncio

time.sleep(1)          # 卡住整个循环 1 秒
await asyncio.sleep(1) # 正确

import threading
with threading.Lock():  # 争用时阻塞线程
    ...
    lock = asyncio.Lock()   # 正确,await lock
    ```
redis-py 也是重灾区。老版本的 Redis 客户端是同步的,2.0 之后有 redis.asyncio:

```python
import redis.asyncio as aioredis

r = aioredis.from_url('redis://127.0.0.1:6379', decode_responses=True)
await r.set('k', 'v', ex=60)

如果写 import redis 然后 r.get() 而不 await,不报错,返回的是协程对象,后面 json.loads 拿到 coroutine 抛 TypeError。这种错误比阻塞还难查。

怎么量化影响

三个协程并发,一个阻塞 1 秒,看总耗时:

python 复制代码
import asyncio, time

async def blocking():
    time.sleep(1)
async def nonblocking():
    await asyncio.sleep(1)
async def main(fn):
    t = time.perf_counter()
        await asyncio.gather(fn(), fn(), fn())
            print(f'{fn.__name__}: {time.perf_counter() - t:.2f}s')
asyncio.run(main(blocking))     # 3.00s
asyncio.run(main(nonblocking))  # 1.00s

阻塞版 3 秒,异步版 1 秒。这个脚本可以在 review 时验证某个库是不是真异步。

一条检查清单

上线前过一遍:请求路径里有没有 requests、time.sleep、同步 DB 驱动、同步文件 IO、同步 Redis、同步 HTTP SDK;有的话要么换异步版,要么包线程池;CPU 计算一律进程池;线程池自己建,别用默认的;开 slow_callback_duration 告警,让它自己暴露。

相关推荐
小肝一下2 小时前
3.基础语法(3)
python
深蓝AI3 小时前
Python 子进程树:父进程被杀后,孙进程为何还在跑?
python
心祭丫丫3 小时前
【每天一个 Python 小案例17】Python 实现通讯录
开发语言·人工智能·笔记·python·学习
爱你真是太好了3 小时前
Skill 索引瘦身与渐进加载
python
贾公子3 小时前
检测链路的容量规划——用 Little's Law 算清 QPS、并发与积压
后端·python
露天赏雪3 小时前
第3篇:Sentinel熔断降级实战:大模型接口抖动时的服务兜底方案
java·开发语言·人工智能·spring boot·spring·servlet·sentinel
天天被压力3 小时前
【A股分钟级高频数据完全手册 #06】北交所分钟级数据接口:京市股票Pro版最新分时
java·人工智能·python
天天被压力4 小时前
【A股分钟级高频数据完全手册 #05】指数历史分钟数据接口:Pro版沪深指数分时回放
java·人工智能·python
ATMQuant4 小时前
量化策略开发08:Lasso因子选择 - 因子动物园的断舍离
python·量化交易·lasso·vnpy·多因子选股