导语
你有没有写过这样的程序:单进程里依次请求 100 个接口,前面一个没回来,后面 99 个只能干等着;或者 FastAPI 路由明明标了 async def,压测一上来 QPS 却怎么也上不去。问题往往不在机器,而在于你把"等待"当成了"占用"。
声明:本文基于个人使用体验,非商业推广。
摘要:asyncio 用事件循环和协程,把 IO 等待转化为可以切换的间隙,让单进程也能撑起海量并发。本文从底层模型讲起,结合 aiohttp 并发请求与 FastAPI 异步接口两个实战,带你落地一套可复现的高性能网络服务写法。
一、为什么需要 asyncio:从同步阻塞到协程并发
先说一个最朴素的痛点。下面这段同步代码请求 3 个接口,总耗时是三个请求之和:
python
import requests, time
def sync_fetch(urls):
start = time.perf_counter()
for u in urls:
requests.get(u) # 阻塞:当前线程在这里卡住,啥也干不了
print(f"耗时 {time.perf_counter() - start:.2f}s")
requests.get 是阻塞调用,线程发完请求后就停在 recv 上等服务器回包,CPU 空转。如果三个请求各耗时 1 秒,整体就是 3 秒。这种同步阻塞模型在 IO 密集型场景(网络、磁盘、数据库)下,CPU 利用率极低。
选型上先分清两类负载:
- IO 密集型:程序大部分时间在等网络/磁盘,真正算的时间很短。爬虫、网关、聚合接口都属于这类,是 asyncio 的主场。
- CPU 密集型 :大量计算(图像处理、加密、科学计算)。这类用多进程(
multiprocessing)才能真正并行,asyncio 帮不上忙,反而会因单线程被长计算占满而让出不了事件循环。
asyncio 的核心思路很直白:单进程里跑一个"调度器",谁在等 IO 谁就先让出来,让别的协程先跑。于是一个进程在等待 A 接口回包时,可以去处理 B 接口的请求,表面上"同时"服务了海量连接。这就是协程并发。

二、核心模型:事件循环、协程与 Task
理解 asyncio 只需要抓住三个名词。
事件循环(Event Loop) 可以理解成一位"乐队指挥"。它手里有一张任务清单,不断问:"现在谁在等 IO?谁可以跑了?"哪个协程遇到 await 主动让出,指挥就转去调度下一个准备好的协程。整个程序始终跑在同一个线程里,靠切换而非多线程来实现并发。
协程(Coroutine) 是用 async def 定义的函数。关键认知:async def 定义出来的对象,仅创建不执行。下面这行只是造了一个协程对象,它一句话都不会跑:
python
async def hello():
print("hi")
coro = hello() # 只是对象,没有打印任何东西
要让它跑,必须被事件循环"调度"。最外层的入口由 asyncio.run() 负责------它帮你创建事件循环、运行协程、最后清理:
python
import asyncio
async def say_after(delay: float, msg: str) -> str:
await asyncio.sleep(delay) # await 时把控制权交还事件循环
return msg
async def main() -> None:
# create_task 立即把协程交给事件循环调度,不等它跑完
t1 = asyncio.create_task(say_after(1.0, "hello"))
t2 = asyncio.create_task(say_after(1.0, "world"))
# gather 并发等待多个可等待对象,按传入顺序返回结果列表
results = await asyncio.gather(t1, t2)
print(results) # ['hello', 'world']
if __name__ == "__main__":
asyncio.run(main()) # 创建/运行/销毁事件循环,只调用一次
asyncio.run() 与 asyncio.create_task() 的分工要分清:run 是程序的唯一入口 ,负责生命周期;create_task 是在已有循环内把一个协程登记成"待调度的任务",让它真正并发起来。
最后记住,能被 await 的东西只有三类,统称可等待对象(Awaitable):
- 协程(coroutine) :
async def函数返回的对象。 - Task :协程被
create_task包装后的调度单元,是"正在跑的协程"。 - Future:更底层的"未来结果"占位符,Task 本身继承自 Future,日常写业务基本碰不到,但理解它有助于读懂源码。

三、并发控制:asyncio.gather 与任务编排
并发的"扇出"主要靠 asyncio.gather。它接收一个或多个可等待对象,并发运行并聚合结果:
python
import asyncio
async def main():
r = await asyncio.gather(
say_after(1, "a"),
say_after(2, "b"),
say_after(1, "c"),
)
print(r) # ['a', 'b', 'c']
asyncio.run(main())
三个协程并发,总耗时约等于最慢那一个(约 2 秒),而不是 1+2+1=4 秒。这就是并发批量调用的收益。
生产环境一定要加 return_exceptions=True。默认情况下,gather 里只要有一个协程抛异常,整个 gather 会立刻把异常抛出来,其它已成功的结果也拿不到。加上这个参数后,异常会被当作普通结果返回(保留在对应位置),整体不会被单个失败打断:
python
import asyncio
async def boom():
raise ValueError("挂了")
async def main():
r = await asyncio.gather(boom(), asyncio.sleep(0, result="ok"),
return_exceptions=True)
print(r) # [ValueError('挂了'), 'ok'] ------ 各自结果都在
asyncio.run(main())
拿到结果后记得逐个判断是不是 Exception 类型,再做分支处理,而不是无脑当正常数据用。
四、实战一:用 aiohttp 构建并发网络请求
标准库 asyncio 本身不提供 HTTP 客户端,实战里常用 aiohttp。它的 ClientSession 是一个异步上下文管理器 ,推荐用 async with 保证连接正确关闭。
核心模式是:封装一个 fetch 协程,再用 gather 并发抓多个 URL:
python
import asyncio
import aiohttp
async def fetch_url(session: aiohttp.ClientSession, url: str) -> str:
# ClientTimeout 限定整体耗时,防止单个慢请求拖垮整个并发池
timeout = aiohttp.ClientTimeout(total=10)
try:
async with session.get(url, timeout=timeout) as resp:
resp.raise_for_status() # 4xx/5xx 会抛 ClientResponseError
return await resp.text()
except aiohttp.ClientError as e:
return f"ERROR:{url}:{e}" # 把异常转成字符串,便于后续统计
async def main() -> None:
urls = [
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/2",
"https://httpbin.org/status/404",
]
async with aiohttp.ClientSession() as session:
# 用生成器表达式批量造协程,return_exceptions 让个别失败不影响整体
pages = await asyncio.gather(
*(fetch_url(session, u) for u in urls),
return_exceptions=True,
)
for url, html in zip(urls, pages):
print(url, "->", len(html))
if __name__ == "__main__":
asyncio.run(main())
三个要点:
- 复用同一个
ClientSession。它内部维护连接池,每次请求都新建 session 既慢又容易耗尽资源。 - 超时必须显式设置 。
ClientTimeout(total=10)是整体上限,避免某个 URL 卡死导致整个 gather 一直挂着。 - 异常就地消化 。把
ClientError转成带标记的字符串返回,配合return_exceptions=True,批量抓取时不会因为一个 404 就让全部结果丢失。

五、实战二:FastAPI 异步接口构建高性能服务
到了服务层,async def 路由是 asyncio 发挥作用的地方。当路由里出现 await(比如等数据库、等下游 HTTP),事件循环会在等待期间去服务其它请求,于是单进程也能扛高并发 IO。
下面这个聚合接口,并发拉取两个外部用户服务再合并返回:
python
import asyncio
import aiohttp
from contextlib import asynccontextmanager
from fastapi import FastAPI
async def fetch_user(uid: int) -> dict:
async with aiohttp.ClientSession() as session:
async with session.get(f"https://api.example.com/users/{uid}") as resp:
return await resp.json()
@asynccontextmanager
async def lifespan(app: FastAPI):
# 启动阶段创建后台任务,交给事件循环调度
app.state.bg = asyncio.create_task(periodic_sync())
yield
app.state.bg.cancel() # 关闭阶段取消任务,避免协程泄漏
async def periodic_sync() -> None:
while True:
await asyncio.sleep(60)
# 实际的增量同步逻辑......
app = FastAPI(lifespan=lifespan)
@app.get("/aggregate")
async def aggregate():
# await 处让出事件循环:两个外部调用并行,而非串行
u1, u2 = await asyncio.gather(fetch_user(1), fetch_user(2))
return {"u1": u1, "u2": u2}
这里有几个工程上容易出错的点:
- 多外部 API 聚合用
gather并行 。若写成两次顺序await,耗时就是两者相加;用gather则约等于最慢一个。 - 异步后台任务用
asyncio.create_task。上例用lifespan在启动期登记后台任务(注意on_event("startup")已废弃,新项目建议用lifespan)。关闭时一定要cancel(),否则协程会泄漏。 - 避坑红线:协程内严禁调用阻塞操作 。绝不要在
async def里写requests.get(...)或time.sleep(...),它们会卡死整个事件循环,让所有并发请求一起 stalled。CPU 密集活儿请用run_in_executor卸载到线程池(见下一节)。

六、调试与性能最佳实践
写异步代码最怕"看起来在跑,其实全卡住"。几条实用手段:
开启 asyncio 调试模式,让循环检测超过 100ms 的慢回调并告警,帮你发现偷偷阻塞的事件循环:
python
import asyncio
async def main():
...
asyncio.run(main(), debug=True) # 或用环境变量 PYTHONASYNCIODEBUG=1
run_in_executor 卸载阻塞调用。如果必须调用同步库(如某些不支持异步的 SDK),把它丢到默认线程池,别堵在主线程:
python
import asyncio, time
def blocking_work(x):
time.sleep(2) # 假设这是改不了源的同步阻塞库
return x * 2
async def main():
loop = asyncio.get_running_loop()
# run_in_executor 把阻塞函数交给线程池,await 处主循环仍可调度其它协程
result = await loop.run_in_executor(None, blocking_work, 21)
print(result) # 42
asyncio.run(main())
三条黄金法则务必刻进肌肉记忆:
await不可省略 。写了asyncio.sleep(1)却没await,协程不会等待,逻辑直接跳过。- 同步函数里不能直接
await。await只能出现在async def内部,普通函数里写会语法报错。 - 异步上下文里不混阻塞调用 。一旦在协程里写了
requests/time.sleep,整个事件循环都会被打断,并发优势归零。
总结
asyncio 不是银弹,但它是 Python 写 IO 密集型网络服务的标配。回想本文主线:事件循环是调度核心,async/await 让出控制权,create_task/gather 负责并发编排,aiohttp 解决并发请求,FastAPI 的 async def 把并发优势带到线上服务 。踩坑大多集中在"协程里混了阻塞调用"和"忘了 await"这两类,debug 模式和 run_in_executor 能兜住大部分性能问题。
如果你也在用 asyncio 搭服务,欢迎在评论区聊聊踩过的坑。觉得有用可以收藏,下次写异步接口时照着第五、六节直接套。
参考来源
- Python 官方:asyncio 概念概览 https://docs.python.org/zh-cn/3/howto/a-conceptual-overview-of-asyncio.html
- Python 官方:asyncio 协程与任务 https://docs.python.org/3/library/asyncio-task.html
- CSDN 站内相关:asyncio 并发实战解析 https://blog.csdn.net/SearchB/article/details/161058580
© 2026 | 转载请注明出处