FastAPI 教程

Chapter 07 · 异步与后台任务:不等人的艺术

📚 学完本章,你将能够:

  • 用生活化的比喻理解 async/await 和事件循环
  • 判断一个操作是否应该写成异步的
  • 用 BackgroundTasks 处理发邮件等轻量后台工作
  • 用 asyncio.gather 并发执行多个 I/O 操作
  • 识别 CPU 密集型任务,并知道为什么它不适合放在 async 里

💡 场景引入

用户在你的网站注册账号。注册逻辑包括:写数据库 + 发欢迎邮件。如果发邮件需要 3 秒钟,用户就得盯着浏览器转圈 3 秒才能看到"注册成功"。

这显然不合理。发邮件不应该让用户等。本章教你如何让"发邮件"这件事在后台悄悄进行,同时用异步技术让你的服务器能同时服务更多用户。

7.1 同步 vs 异步:一个咖啡店的比喻

想象你是一个咖啡师(服务器),面前有一排顾客(请求):

同步模式:一个顾客做完才做下一个
def make_coffee(order):
    grind_beans()      # 2秒
    brew()             # 3秒
    serve()            # 1秒

# 顾客A(6秒)→ 顾客B(6秒)→ 顾客C(6秒)
# 总耗时:18秒

你在等咖啡豆磨好的时候,干站着不服务其他人。这就是同步阻塞。

异步模式:磨豆的时候去接待下一个人
async def make_coffee(order):
    await grind_beans()  # "我去磨豆了,磨好叫我"
    await brew()         # "我去煮了,煮好叫我"
    serve()

# 三个顾客的请求交错执行
# 总耗时:约 6 秒(不是 18 秒!)

await 的意思是"这件事需要等待,你去忙别的吧"。事件循环会在磨豆完成时自动切回来。

核心区别:同步代码阻塞线程,异步代码释放线程去干别的。但只有I/O 操作(网络请求、数据库查询、文件读写)才适合异步。如果是纯计算(比如算圆周率后100位),await 不会释放线程,因为 CPU 根本停不下来。

7.2 FastAPI 中的 BackgroundTasks

对于"发邮件"这种不需要立刻完成、也不要求结果的操作,FastAPI 提供了最简单的后台任务机制:

后台发邮件
from fastapi import BackgroundTasks

async def send_welcome_email(email: str, username: str):
    # 模拟发邮件耗时 2 秒
    await asyncio.sleep(2)
    print(f"邮件已发送至 {email}")

@app.post("/register")
async def register(
    data: UserCreate,
    background_tasks: BackgroundTasks,
    db: Annotated[AsyncSession, Depends(get_db)]
):
    user = await create_user(db, data)

    # 把发邮件任务丢到后台,不阻塞响应
    background_tasks.add_task(send_welcome_email, user.email, user.username)

    # 用户立刻收到响应,不需要等邮件发完
    return {"message": "注册成功", "user_id": user.id}
BackgroundTasks 的限制

它运行在 Uvicorn 的同一个进程里。如果任务很重(比如处理大文件),或者任务失败了,没有重试机制。对于重要的后台工作,需要用 Celery(第 11 章介绍)。

7.3 并发查询:asyncio.gather

假设一个页面需要同时展示:用户信息 + 订单列表 + 优惠券。如果串行查询,总耗时是三部分之和。用 gather 可以并发发请求:

并发查询
import asyncio

@app.get("/dashboard")
async def dashboard(user_id: int, db: Annotated[AsyncSession, Depends(get_db)]):
    # 三个查询同时发出去,总耗时 ≈ 最慢的那个
    user, orders, coupons = await asyncio.gather(
        get_user(db, user_id),
        get_orders(db, user_id),
        get_coupons(db, user_id),
    )
    return {"user": user, "orders": orders, "coupons": coupons}
串行 vs 并发
串行(慢)
用户查询  100ms
订单查询  150ms  →  总耗时 350ms
优惠券查询 100ms
并发(快)
用户查询   ──┐
订单查询   ──┼──→  总耗时 150ms
优惠券查询 ──┘

7.4 CPU 密集型任务的陷阱

asyncio 不能加速计算。如果你在协程里做大量数学运算,整个事件循环会被卡住,其他请求都得等着。

❌ 错误:在 async 里做 CPU 计算
@app.post("/analyze")
async def analyze(data: list[float]):
    # 这个循环会阻塞事件循环!
    result = sum(x ** 2 for x in data)  # 假设数据很大
    return {"result": result}

解决方案:用 ProcessPoolExecutor 把计算丢到单独的进程里,释放事件循环:

✅ 正确:进程池卸载
from concurrent.futures import ProcessPoolExecutor
import asyncio

pool = ProcessPoolExecutor(max_workers=4)

def heavy_compute(data: list[float]) -> float:
    return sum(x ** 2 for x in data)

@app.post("/analyze")
async def analyze(data: list[float]):
    # 把 CPU 任务丢到进程池,async 事件循环继续处理其他请求
    result = await asyncio.get_event_loop().run_in_executor(pool, heavy_compute, data)
    return {"result": result}

🎯 知识检测

问题 1
以下哪种操作最适合用 async/await?
计算 100 万位圆周率
等待数据库返回查询结果
压缩一个大视频文件
渲染 3D 图像
解析:async/await 的优势在于 I/O 等待时释放线程。数据库查询、网络请求、文件读写都是 I/O 操作。而纯计算(数学运算、视频压缩、图像渲染)是 CPU 密集型,async 不会加速它们,反而需要用进程池卸载。
问题 2
BackgroundTasks 适合做什么?
执行必须成功且需要重试的关键支付逻辑
发送邮件、写日志等"做了更好,失败也不致命"的操作
执行耗时 30 分钟的大数据处理
替代数据库事务
解析:BackgroundTasks 运行在 Uvicorn 进程内,没有重试机制,进程崩溃时任务会丢失。它只适合轻量、非关键的后台工作。关键任务用 Celery,大计算用专门的 worker。
问题 3
asyncio.gather 的作用是什么?
让代码按顺序执行
同时启动多个异步任务,等它们全部完成
自动把同步代码变成异步
关闭事件循环
解析:gather 让你同时发多个异步请求(如查用户、查订单、查优惠券),总耗时等于最慢的那个任务,而不是所有任务之和。这是提升 API 响应速度的利器。

本章小结

✓

async 只加速 I/O 等待

数据库查询、网络请求、文件读写适合 async。纯计算不适合。

✓

BackgroundTasks 做轻量后台工作

发邮件、记日志,不阻塞用户响应。但不保证一定成功。

✓

gather 并发多个 I/O 操作

多个查询同时发,总时间由最慢的决定,大幅提升响应速度。

✓

CPU 任务用进程池卸载

别把重计算放在 async 函数里,会卡住整个服务器。

🛠️ 动手实验

实现用户注册接口:写数据库(同步/异步都可以)后,用 BackgroundTasks 异步发送欢迎邮件(模拟 2 秒延迟)。
再实现一个仪表板接口,用 asyncio.gather 并发查询"用户信息""最近订单""未读消息"三个来源,验证总耗时是否接近最慢的那个。