codecamp

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.futuresFastAPI 后台任务
  • 相邻小节:2.1 选择同步路由还是异步路由、2.2 复现并定位事件循环阻塞、2.3 安全接入只能同步调用的 SDK。

阅读相邻小节时,请在教程目录中选择对应标题。

2.3 安全接入只能同步调用的SDK
3.1 用Pydantic表达输入规则
温馨提示
下载编程狮App,免费阅读超1000+编程语言教程
取消
确定
目录

关闭

MIP.setData({ 'pageTheme' : getCookie('pageTheme') || {'day':true, 'night':false}, 'pageFontSize' : getCookie('pageFontSize') || 20 }); MIP.watch('pageTheme', function(newValue){ setCookie('pageTheme', JSON.stringify(newValue)) }); MIP.watch('pageFontSize', function(newValue){ setCookie('pageFontSize', newValue) }); function setCookie(name, value){ var days = 1; var exp = new Date(); exp.setTime(exp.getTime() + days*24*60*60*1000); document.cookie = name + '=' + value + ';expires=' + exp.toUTCString(); } function getCookie(name){ var reg = new RegExp('(^| )' + name + '=([^;]*)(;|$)'); return document.cookie.match(reg) ? JSON.parse(document.cookie.match(reg)[2]) : null; }