2.4 区分CPU任务与后台任务
区分CPU任务与后台任务
I/O 等待和 CPU 计算都可能让请求变慢,但处理办法不同。I/O 任务是在等待网络、磁盘或数据库;正确的异步调用可以让事件循环推进其他协程。CPU 任务则需要处理器持续执行指令,await 没有可等待的外部资源可以交还控制权。把计算函数包进 async def,或者简单地套一层线程池,都不会自动产生多核并行。
await 不是并行计算
下面这段代码只有在 slow_io() 自己在 await 处让出控制权时,两个任务才可能交错运行:
results = await asyncio.gather(slow_io("a"), slow_io("b"))
如果 slow_io 改成了长时间纯 Python 循环,那么事件循环仍会被当前协程占用。await 描述的是协作式调度点,不是创建线程或进程的指令。同步 SDK 放进线程池也主要适合 I/O:线程在等待外部服务时可以不占住事件循环,但普通启用 GIL 的 CPython 中,多个线程执行纯 Python 计算不能期待稳定的多核并行。
本节关于 GIL 的判断限定在常规启用 GIL 的 CPython 和纯 Python 计算。某些数值、图像或压缩扩展会主动释放 GIL,新的特殊构建也可能改变边界;要根据具体 Python 构建和库文档核实,不能把“Python 线程永远不能并行”当成所有环境的规则。
选择执行方式
| 工作 | 合适的起点 | 需要确认的边界 |
|---|---|---|
| 异步网络、异步数据库 | async def + await |
客户端确实提供异步实现 |
| 同步外部 I/O | 普通同步路由或显式线程池 | 线程限制、连接池、SDK 线程安全和超时 |
| 短小的内存计算 | 当前请求内直接执行 | 是否足以拖慢事件循环 |
| 较重的纯 Python 计算 | 进程池或任务系统 | 进程启动、参数序列化和结果回收成本 |
| 发通知、写小型审计记录等请求后工作 | BackgroundTasks |
进程重启会丢失,不能当持久队列 |
不要为了展示“后台”而把所有工作移出请求。如果客户端必须收到数据库写入成功的结果,那个写入就属于请求的可靠路径;如果任务可以稍后完成并允许失败重试,才考虑后台执行。
可选的进程池最小例子
进程池把函数放到其他进程执行,适合有明确边界的 CPU 计算。下面的 count_primes 只使用标准库,进程池在一次命令运行中创建一次、处理一批输入后关闭;它没有放到每个 HTTP 请求中创建。函数必须定义在模块顶层,且入口必须受到 if __name__ == "__main__" 保护,尤其是在使用 spawn 启动方式的平台上。
<!-- file: ch02_cpu/process_pool_demo.py -->
from __future__ import annotations
from concurrent.futures import ProcessPoolExecutor
from math import isqrt
def count_primes(limit: int) -> int:
count = 0
for candidate in range(2, limit + 1):
for divisor in range(2, isqrt(candidate) + 1):
if candidate % divisor == 0:
break
else:
count += 1
return count
def self_check() -> None:
inputs = (30, 40)
with ProcessPoolExecutor(max_workers=2) as pool:
results = list(pool.map(count_primes, inputs))
assert results == [10, 12]
print("process_pool_demo self-check passed")
if __name__ == "__main__":
self_check()
运行:
python ch02_cpu/process_pool_demo.py
真实 API 如果要等待计算结果,应在应用生命周期中创建和关闭一个有明确上限的进程池,或把工作交给外部任务系统;不要在每次请求里执行 ProcessPoolExecutor(...)。进程间传递参数和结果需要序列化,数据库会话、打开的文件句柄和带线程锁的客户端不能直接当作普通参数传入。若计算很重,任务系统通常还要配合超时、重试、幂等键和结果存储,这些属于另一层设计。
BackgroundTasks 能做什么
FastAPI 的 BackgroundTasks 适合在响应发送后执行少量工作,例如追加一条不重要的审计记录、发送一个不需要可靠投递的通知。任务仍由当前应用进程负责,和请求使用相同的部署生命周期;它不是消息队列,也没有跨重启的持久化保证。进程在任务运行前崩溃、容器被替换或 worker 被杀死时,任务可能丢失。同步后台函数会占用与同步路由相同的线程 token;异步后台函数如果内部调用阻塞代码,仍会卡住事件循环。
下面的示例用内存列表模拟日志文件,只为验证演示事件确实被登记和执行;它没有创建文章。生产代码应使用可接受丢失语义的真实写入方式,或在任务不能丢失时改用持久队列和独立 worker。TestClient 在本例中会等待后台任务完成,因此自检能检查列表;这不代表线上任务具有持久保证。
<!-- file: ch02_cpu/background_demo.py -->
from __future__ import annotations
from fastapi import BackgroundTasks, FastAPI
from fastapi.testclient import TestClient
EVENTS: list[str] = []
app = FastAPI(title="后台任务边界示例")
def record_demo_event(event: str) -> None:
EVENTS.append(event)
@app.post("/demo-events", status_code=202)
async def enqueue_demo_event(
background_tasks: BackgroundTasks,
) -> dict[str, str | bool]:
event = "demo-event"
background_tasks.add_task(record_demo_event, event)
return {"accepted": True, "event": event}
@app.get("/demo-events")
async def audit() -> dict[str, list[str]]:
return {"events": EVENTS}
def self_check() -> None:
EVENTS.clear()
with TestClient(app) as client:
response = client.post("/demo-events")
audit_response = client.get("/demo-events")
assert response.status_code == 202
assert response.json() == {"accepted": True, "event": "demo-event"}
assert audit_response.json() == {"events": ["demo-event"]}
print("background_demo self-check passed")
if __name__ == "__main__":
self_check()
同一个响应注册多个后台任务时,任务按注册顺序执行;前一个任务抛出异常时,后续任务不会继续执行。响应体和状态码已经发送后,后台任务失败也不能把这次响应改成另一个状态码,所以关键失败必须在请求的可靠路径或持久队列中处理。同步任务还会消耗 AnyIO 的线程 token,异步任务也不能隐藏其中的 time.sleep() 等阻塞调用。
本节统一自检
本节只保留一个入口来检查两类示例:它先在当前进程调用 BackgroundTasks 示例的自检,再以子进程运行受 main 保护的进程池文件。这样既验证 HTTP 路由的返回和任务结果,也验证进程池不会因为缺少入口保护而递归启动。
<!-- file: ch02_cpu/check.py -->
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from background_demo import self_check as check_background_tasks
def main() -> None:
check_background_tasks()
process_demo = Path(__file__).with_name("process_pool_demo.py")
completed = subprocess.run(
[sys.executable, str(process_demo)],
check=True,
capture_output=True,
text=True,
)
assert "process_pool_demo self-check passed" in completed.stdout
print("chapter 2.4 self-check passed")
if __name__ == "__main__":
main()
在包含这三个文件的目录运行:
python check.py
如果任务需要可靠交付、跨进程扩展、失败重试或可查询进度,应在确认业务要求后接入持久化任务队列;如果只是一次快速的非关键后处理,BackgroundTasks 可以保持实现简单。选择的关键是任务丢失是否可接受,而不是“后台”这个名称本身。
来源与相邻小节
- 主要参考:fastapi-best-practices 中文 README 的 CPU 密集型任务提醒。本节补充了常规 CPython 的边界、受保护的进程池自检和
BackgroundTasks的持久性限制。 - 官方说明:Python
concurrent.futures、FastAPI 后台任务。 - 相邻小节:2.1 选择同步路由还是异步路由、2.2 复现并定位事件循环阻塞、2.3 安全接入只能同步调用的 SDK。
阅读相邻小节时,请在教程目录中选择对应标题。