开场:一次生产环境的「灵异事件」
去年12月,我接手了一个数据采集服务。技术栈很标准:Python 3.11 + aiohttp + asyncio。看代码感觉没什么问题——该用 await 的地方都用了,Semaphore 限流也加了。
但诡异的事情发生了:并发100的时候响应飞快(P99 < 500ms),一到并发500就直接跪——CPU占用不高,内存也没爆,但P99延迟从200ms飙到了30秒。老板问我怎么回事,我只能说「在查」。
三天后,我终于找到了元凶——一行藏在80行代码深处的 time.sleep(0.5)。就这一行,把整个Event Loop卡成了单线程同步。从那以后,我学到了一件事:asyncio不是银弹,用错了比同步还慢。
这篇文章记录了我从「asyncio怎么这么慢」到「排查-定位-修复-验证」的完整实战过程。没有废话,都是踩过的坑。
第一步:打开asyncio的「开发者模式」
Python的asyncio模块自带一个调试模式,但我发现90%的人不知道它的存在。启用方式极其简单,但却能救命:
# 方法1:环境变量(推荐,不侵入代码)
# PYTHONASYNCIODEBUG=1 python app.py
# 方法2:代码中启用
import asyncio
asyncio.get_event_loop().set_debug(True)
# 方法3:Python 3.11+ 更精细控制
import asyncio
loop = asyncio.new_event_loop()
loop.slow_callback_duration = 0.1 # 超过100ms的回调会被警告
启用debug模式后,asyncio会自动检测并打印三种致命问题:
- 「Executing <Handle …> took N seconds」——某个回调执行时间过长,阻塞了Event Loop。这就是我那个
time.sleep(0.5)被发现的途径。 - 「Task was destroyed but it is pending!」——有协程被GC回收时还没执行完,Task泄漏的经典信号。
- 「coroutine ‘xxx’ was never awaited」——你忘了
await一个协程,它根本没被调度执行。
📎 相关阅读:Python并发编程选型指南:ThreadPoolExecutor vs asyncio vs ProcessPoolExecutor实测对比(2026) — 同一台机器实测四种并发方案,附选型决策树。
⚡ 实战建议:开发环境永远开着
PYTHONASYNCIODEBUG=1。生产环境用slow_callback_duration替代全量debug(性能开销小得多)。
第二步:三大常见Event Loop阻塞元凶
2.1 同步I/O混入协程——最常见的坑
这是我见过最多的翻车场景。代码看起来「用了async」,但里面藏了一个同步调用:
# ❌ 你以为在异步,实际在同步阻塞
async def fetch_data(url):
import requests # 同步HTTP库!
resp = requests.get(url) # 这行卡住整个Event Loop
return resp.json()
# ✅ 正确做法
import aiohttp
async def fetch_data(url):
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
return await resp.json()
我做过测试:用 asyncio.gather() 并发100个任务,如果内部用了 requests.get()(同步),总耗时 ≈ 所有请求耗时之和,跟串行几乎一样。Event Loop被一个同步调用卡住,其他99个任务全部排队干等。
同理,文件I/O也要注意:
# ❌ open() 是同步调用
async def save_file(data):
with open('output.json', 'w') as f:
json.dump(data, f)
# ✅ 用 aiofiles 或 run_in_executor
import aiofiles
async def save_file(data):
async with aiofiles.open('output.json', 'w') as f:
await f.write(json.dumps(data))
2.2 CPU密集计算——Event Loop的隐形杀手
这个坑比同步I/O更隐蔽。你的代码里没有 requests,没有 time.sleep,全是 await——但性能还是一塌糊涂。罪魁祸首常常是藏在协程里的CPU密集操作:
# ❌ 看似异步,实际在主线程做重计算
async def process_image(data):
# 这些操作都在Event Loop的主线程里跑
img = Image.open(io.BytesIO(data))
img = img.resize((1920, 1080)) # CPU密集!
img = img.filter(ImageFilter.BLUR) # CPU密集!
return img
# ✅ 扔到线程池,不阻塞Event Loop
async def process_image(data):
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, _sync_process, data)
def _sync_process(data):
img = Image.open(io.BytesIO(data))
img = img.resize((1920, 1080))
img = img.filter(ImageFilter.BLUR)
return img
一个残酷的事实:run_in_executor 默认用的是 ThreadPoolExecutor,而Python的GIL让CPU密集任务在多线程下几乎没有加速效果。真正CPU密集的操作应该用 ProcessPoolExecutor:
from concurrent.futures import ProcessPoolExecutor
async def heavy_compute(data):
loop = asyncio.get_running_loop()
with ProcessPoolExecutor() as pool:
return await loop.run_in_executor(pool, cpu_bound_func, data)
2.3 Task泄漏与僵尸协程——内存泄漏的元凶
这是我排查过的最「阴间」的问题。服务跑了一周后内存从200MB涨到8GB,OOM被杀。用 py-spy 采样发现CPU大部分时间在空转——但内存就是不停涨。
根因是 Task泄漏:用 asyncio.create_task() 创建了协程,但没人 await 它,也没人取消它。这些「僵尸Task」一直存在于Event Loop的调度队列里,永不结束,永不GC。
# 诊断僵尸Task
import asyncio
async def diagnose_zombie_tasks():
all_tasks = asyncio.all_tasks() # Python 3.7+
for task in all_tasks:
if not task.done():
coro = task.get_coro()
print(f"ZOMBIE: {task.get_name()} | coro={coro.__qualname__} | "
f"stack={task.get_stack()[:3]}")
# 更暴力的做法:定期杀掉超时Task
async def with_timeout(coro, timeout=30):
try:
return await asyncio.wait_for(coro, timeout=timeout)
except asyncio.TimeoutError:
print(f"Task {coro} timed out, cancelling...")
raise
第三步:生产环境性能剖析工具链
debug模式在开发环境好用,但生产环境不能开(性能开销太大)。下面是我在生产环境实际用的工具:
3.1 py-spy:无侵入式CPU采样
py-spy 是我最喜欢的Python性能分析工具。它通过读取进程内存来采样调用栈,不需要修改任何代码,不需要重启进程。对生产环境几乎零影响。
# 采样一个正在运行的asyncio进程
py-spy top --pid 12345
# 生成火焰图
py-spy record -o flame.svg --pid 12345 --duration 60
# 只看原生Python栈(过滤掉C扩展)
py-spy dump --pid 12345 --native
有一次我发现CPU使用率正常但延迟飙升,py-spy火焰图显示大量时间花在 ssl.SSLSocket.read——原来是对端TLS握手慢,不是我们的代码问题。没有py-spy,我可能还在改代码。
3.2 aiomonitor:实时Event Loop监控
aiomonitor 给asyncio应用加了一个telnet监控端口,可以在线查看所有正在运行的Task:
# 安装
pip install aiomonitor
# 代码里一行接入
import aiomonitor
async def main():
with aiomonitor.start_monitor(loop=asyncio.get_running_loop()):
# 你的asyncio应用
...
# 然后 telnet localhost 50101
# 输入命令:
# ps → 列出所有正在运行的协程
# where N → 查看第N个协程的调用栈
# cancel N → 取消一个卡住的协程
# signal SIGNAL → 发送信号
有次半夜报警,我telnet进去一个 ps 发现800个协程都卡在同一个数据库查询上——数据库连接池耗尽。如果没这个监控,重启服务就是第一反应,但重启解决不了池耗尽的问题。
3.3 自定义Slow Callback Hook
不想引入额外依赖?可以自己写一个轻量监控:
import asyncio
import time
import logging
logger = logging.getLogger(__name__)
def monitor_slow_callbacks(threshold=0.5):
"""监控超过阈值的回调,记录调用栈"""
loop = asyncio.get_running_loop()
original_call_soon = loop.call_soon
def patched_call_soon(callback, *args, **kwargs):
start = time.monotonic()
def wrapper():
result = callback(*args)
elapsed = time.monotonic() - start
if elapsed > threshold:
logger.warning(
f"SLOW CALLBACK: {callback} took {elapsed:.2f}s\n"
f"Stack: {callback.__qualname__ if hasattr(callback, '__qualname__') else callback}"
)
return result
return original_call_soon(wrapper, *args, **kwargs)
loop.call_soon = patched_call_soon
第四步:优化实战——把并发上限从100拉到10000
分享一个真实案例。一个数据聚合服务,需要并发调用下游30+个微服务接口。最初设计:
# 初版:简单粗暴
async def aggregate():
tasks = [call_service(f"api_{i}") for i in range(30)]
results = await asyncio.gather(*tasks)
return merge(results)
并发30没问题。后来业务增长,需要并发到200+,问题来了——下游服务开始返回429(Too Many Requests),P50延迟从50ms涨到800ms。
问题诊断:asyncio.gather(*tasks) 是一次性把所有协程塞进Event Loop,没有流控。200个协程瞬间发出200个HTTP请求,下游直接被打爆。
优化方案:
import asyncio
async def bounded_gather(coros, concurrency=20):
"""带并发上限的gather,避免打爆下游"""
semaphore = asyncio.Semaphore(concurrency)
async def bounded(coro):
async with semaphore:
return await coro
return await asyncio.gather(*[bounded(c) for c in coros])
# 使用时
results = await bounded_gather(
[call_service(f"api_{i}") for i in range(200)],
concurrency=20 # 最多同时20个
)
效果:P50延迟从800ms降到60ms,零429错误。这个改动不到10行代码。
如果下游响应时间差异大,还可以用 asyncio.wait 的 FIRST_COMPLETED 模式做「流水线」处理——先返回的先聚合,不用等慢的:
async def pipeline_gather(coros, max_concurrent=20):
"""流水线模式:完成一个立即补充一个"""
semaphore = asyncio.Semaphore(max_concurrent)
pending = set()
results = []
async def run_one(coro):
async with semaphore:
result = await coro
results.append(result)
for coro in coros:
task = asyncio.create_task(run_one(coro))
pending.add(task)
if pending:
await asyncio.wait(pending)
return results
常见问题(FAQ)
Q: asyncio和threading到底哪个快?
I/O密集型:asyncio完胜。单线程事件循环的上下文切换开销远小于线程切换。但如果你的任务是CPU密集型(图像处理、加密计算),多线程/多进程更适合。判断标准很简单:你的代码大部分时间在等网络/磁盘/数据库 → asyncio;大部分时间在算 → ProcessPoolExecutor。
Q: 怎么判断Event Loop有没有被阻塞?
最快的方法:加一个心跳协程。每0.1秒记录当前时间,如果两次记录间隔超过0.5秒,说明Event Loop被阻塞了至少0.4秒。我在生产环境就是这么做的,效果比任何监控面板都直观。
Q: uvloop值得用吗?
值得。uvloop用libuv替换了Python原生的Event Loop,性能提升2-4倍。接入成本极低:import uvloop; uvloop.install() 就完成了。但注意uvloop的debug模式支持不如原生asyncio,排查问题时建议先切回原生loop。
📖 相关推荐:Python 并发编程深度实战:GIL 原理与最优并发策略选择(2026) — 线程池 vs 进程池 vs asyncio 决策框架
总结
回头看那个 time.sleep(0.5) 的bug,修复只花了两分钟——加上 await asyncio.sleep(0.5)。但如果没有debug模式、py-spy和aiomonitor这套工具链,我可能还在对着监控面板挠头。
三个核心原则:
- 开发环境永远开debug模式,别等上线了才发现问题
- 协程里只做I/O等待,CPU密集和同步I/O统统扔进executor
- Task要么被await,要么被cancel,别留僵尸
工具是你的朋友:PYTHONASYNCIODEBUG=1 + py-spy + aiomonitor,这三个加起来排查asyncio问题的效率能提升一个数量级。
如果你在意性能优化,可以看看这几篇:Python logging模块生产环境日志管理——排查问题第一步先把日志搞好;MySQL慢查询优化实战——排查思路跟asyncio事件循环阻塞异曲同工;Python requests库完全指南——如果你还在用同步requests,看完这篇就知道该不该换了。
📖 相关阅读:AI编程工具的Prompt Engineering实战指南 — 从五维度框架到调试专属套路,让你第一次就写出对的生产级代码。
📌 排查系列新文:Python 生产环境内存泄漏排查实战:tracemalloc + objgraph + gc 三件套从发现到修复
📖 相关阅读:C# Channel<T> 高性能生产者消费者模式 — 从传统锁到百万级吞吐的进化之路
📖 相关推荐:Redis生产环境踩坑实录:缓存穿透、雪崩、热点Key——从凌晨告警到根治
📎 推荐阅读:Python性能剖析三件套:py-spy、Scalene、memray实战对比 —— 如果asyncio不是你的瓶颈,用这三个工具找到真正的CPU和内存热点。
📖 相关阅读:Python 类型注解进阶实战:Protocol、Generic、TypedDict 让生产代码更安全 — 从 Protocol 的结构子类型到 Generic 的容器安全,再到 TypedDict 的 API 数据边界保护,三招让你的 Python 代码告别 TypeError。
