Python asyncio 异步编程实战:用协程与事件循环构建高性能网络服务

导语

你有没有写过这样的程序:单进程里依次请求 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())

三个要点:

  1. 复用同一个 ClientSession。它内部维护连接池,每次请求都新建 session 既慢又容易耗尽资源。
  2. 超时必须显式设置ClientTimeout(total=10) 是整体上限,避免某个 URL 卡死导致整个 gather 一直挂着。
  3. 异常就地消化 。把 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())

三条黄金法则务必刻进肌肉记忆:

  1. await 不可省略 。写了 asyncio.sleep(1) 却没 await,协程不会等待,逻辑直接跳过。
  2. 同步函数里不能直接 awaitawait 只能出现在 async def 内部,普通函数里写会语法报错。
  3. 异步上下文里不混阻塞调用 。一旦在协程里写了 requests/time.sleep,整个事件循环都会被打断,并发优势归零。

总结

asyncio 不是银弹,但它是 Python 写 IO 密集型网络服务的标配。回想本文主线:事件循环是调度核心,async/await 让出控制权,create_task/gather 负责并发编排,aiohttp 解决并发请求,FastAPI 的 async def 把并发优势带到线上服务 。踩坑大多集中在"协程里混了阻塞调用"和"忘了 await"这两类,debug 模式和 run_in_executor 能兜住大部分性能问题。

如果你也在用 asyncio 搭服务,欢迎在评论区聊聊踩过的坑。觉得有用可以收藏,下次写异步接口时照着第五、六节直接套。


参考来源

© 2026 | 转载请注明出处

相关推荐
明航咨询-陈老师1 小时前
ITSS 运行维护服务能力成熟度:2026 新版标准落地后的量化证据链与运维项目投标资质门槛
网络·itss
运维行者_1 小时前
ISP 企业级带宽计费怎么做?网络流量计费的 6 大核心能力
运维·服务器·网络·数据库·支持向量机·接口隔离原则
2601_962298091 小时前
零基础学大数据分析全栈
hadoop·python·spark·实战·大数据分析
云边云科技_云网融合1 小时前
混合云多云组网架构:跨云互联、公私融合、云边协同与专线替代全解析
网络·科技
奇树谦1 小时前
PCB生产制造全流程详解:从开料、钻孔、沉铜到检测、包装
网络·数据库·制造
quantdash_cc2 小时前
Python 股票 K 线数据质量校验:字段、缺失值、重复行和价格异常
开发语言·python·数据分析·量化交易·股票数据·quantdash
Zenova EdgeOS2 小时前
工业网关重试机制:从固定间隔到指数退避的工程实战
开发语言·网络·php
长空任鸟飞_阿康2 小时前
三、《从零手撸 Agent》 · system prompt 与核心参数:调好你的旋钮
人工智能·python·ai·prompt
一晌小贪欢2 小时前
python-第29天:Python面向对象之多态与抽象类
开发语言·python·数据可视化·面向对象·python办公·python多态