上个月我负责的那个数据中台,突然接到一个需求:要对下游 100+ 个服务做健康检查,每个接口平均耗时 200ms,要求 5 秒内出结果。我二话不说开了 100 个线程,结果线程切换开销直接把 CPU 打满,响应时间飙到 8 秒多。运维同事在群里连发三个“?”。
那一刻我才认真审视 asyncio。以前总觉得异步编程“门槛高、容易写出 bug”,但真正跑完一轮压测后,我只想说:在 IO 密集型场景下,asyncio 和多线程根本不是一个量级。下面是我把同一个任务用三种方案——同步、多线程、asyncio——放在一起硬刚的完整复盘。
实测场景:100 个 HTTP 请求,每个 200ms 延迟
我们用 FastAPI 搭了一个模拟下游服务,/health 接口固定 sleep 200ms 再返回 {"status": "ok"}。客户端分别用三种策略发起 100 个并发请求,统计总耗时和资源占用。
方案一:同步串行,慢得理所当然
# sync_demo.py — 同步请求,一个接一个
import time
import requests
URLS = [f"http://localhost:8000/health" for _ in range(100)]
def check_sync():
results = []
for url in URLS:
resp = requests.get(url, timeout=5)
results.append(resp.json())
return results
if __name__ == "__main__":
start = time.perf_counter()
check_sync()
elapsed = time.perf_counter() - start
print(f"同步耗时: {elapsed:.2f}s") # 20.3s 左右
毫无悬念,100 × 200ms = 20 秒,线程全程在等网络 IO,CPU 几乎空转。这是 100 个请求,如果是 1000 个,系统直接假死 3 分钟,任何并发的幻想都没有。
方案二:多线程,看似并发实则陷阱
# thread_demo.py — 100 个线程并发
import time
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
URLS = [f"http://localhost:8000/health" for _ in range(100)]
def fetch(url):
return requests.get(url, timeout=5).json()
def check_thread():
results = []
with ThreadPoolExecutor(max_workers=100) as executor:
futures = {executor.submit(fetch, url): url for url in URLS}
for future in as_completed(futures):
results.append(future.result())
return results
if __name__ == "__main__":
start = time.perf_counter()
check_thread()
elapsed = time.perf_counter() - start
print(f"多线程耗时: {elapsed:.2f}s") # 第一次 8.5s,后来波动在 3~6s
实测首次运行 8.5 秒,CPU 使用率瞬间冲上 90%。原因是 Python 的 GIL 在 IO 操作时虽然会释放,但 100 个线程的创建、上下文切换、锁争抢带来了巨大的额外开销。把 max_workers 降到 30,耗时降到 2.1 秒,CPU 也稳了——但这就变成了“经验调参”,而且线程数一多,系统就不稳定。
更隐蔽的坑是:requests 库本身不是线程安全的最佳选择,连接池复用也受限,偶尔还会抛出 ConnectionResetError,排查起来欲哭无泪。
方案三:asyncio + aiohttp,顺畅得让人不习惯
# async_demo.py — 使用 asyncio 和 aiohttp 并发请求
import asyncio
import time
import aiohttp
URLS = [f"http://localhost:8000/health" for _ in range(100)]
async def fetch(session, url):
try:
async with session.get(url, timeout=5) as resp:
return await resp.json()
except Exception as e:
return {"error": str(e)}
async def check_async():
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url) for url in URLS]
results = await asyncio.gather(*tasks)
return results
if __name__ == "__main__":
start = time.perf_counter()
asyncio.run(check_async())
elapsed = time.perf_counter() - start
print(f"asyncio 耗时: {elapsed:.2f}s") # 稳定在 0.45~0.60s
100 个任务在同一个事件循环里调度,全部异步发出,实际耗时仅由最慢的一次 IO 决定,稳定在 0.6 秒以内。CPU 使用率全程不超过 15%,内存占用几乎是一条直线。老板在监控大屏上看到效果后,问我是不是偷偷加了服务器——其实只是把代码改写成了 async/await。
踩坑 & 注意事项:这 3 个坑我一个没落下
-
同步代码混进协程,性能直接打回原形。
最早我把requests.get()直接放进async def里,心想“反正加了 async 就起飞了”。结果耗时纹丝不动,还报了RuntimeWarning。asyncio 要求所有 IO 操作都使用支持非阻塞的库,比如aiohttp、httpx的异步模式。如果非得用同步库,必须用asyncio.to_thread()把任务扔到线程池,否则事件循环会被堵塞死。 -
忘记 gather 的异常处理,一个任务挂了全盘崩溃。
最初我直接await asyncio.gather(*tasks),当某个接口超时抛出asyncio.TimeoutError时,所有未完成的任务一起被取消,返回的结果列表直接中断。正确姿势是设置return_exceptions=True,让异常作为结果返回来,不中断其他任务,事后统一处理。 -
ClientSession 要复用,不要每次请求都创建。
一个aiohttp.ClientSession内部维护了连接池,重复创建会大量消耗端口和打开文件数。我习惯在async with上下文里复用同一个 session,所有请求共用一个连接池——这在高并发下是基本素养,否则会被运维追着打。
还有一个不是坑的领悟:asyncio 对 CPU 密集型任务没用,图像处理、数据计算还得乖乖上多进程或线程池。
总结
IO 密集型并发任务,请毫不犹豫选择 asyncio——它用单线程跑出了接近理论极限的吞吐量,把多线程按在地上摩擦。
#Python #异步编程 #asyncio #性能优化 #程序员踩坑