《Python编程实战》15.2 应用服务器进程模型与调优

真跑 uvicorn --workers 4 并用 httpx 并发实测:串行为何全落一个 worker、并发如何均匀分到四个进程、1/4/8 worker 的 CPU 密集吞吐差多少,再讲 gunicorn + UvicornWorker 的进程模型与 worker 数公式。

本节目标:用本机实测讲清 ASGI 服务器的进程模型——master 与 worker 怎么分工、请求怎么分到不同进程、worker 数该开多少,并给出 uvicorn 的调优参数与优雅关闭实践。
适用版本:Python 3.12+(实测 3.14.6);uvicorn 0.54.0、fastapi 0.143.0、httpx 0.28.1

15.2 应用服务器进程模型与调优

上一节把应用封进了镜像,但镜像里的启动命令是 uvicorn main:app——默认只起一个进程。单进程能扛住开发流量,扛不住生产。这一节回答一个具体问题:该起几个进程、为什么,以及它们之间到底怎么分工。所有数字都是本机真跑出来的。

15.2.1 进程模型:master 只负责管,worker 才干活

uvicorn --workers N 启动后,进程树是这样的(本机实测,--workers 4):

  PID  PPID COMMAND
53086 53084 uvicorn worker_app:app --host 127.0.0.1 --port 18211 --workers 4   # master
53089 53086 python -c from multiprocessing.spawn import spawn_main ...          # worker 之一
  • master 进程(53086):不处理任何请求,只负责拉起 worker、监听它们的存活、转发信号。worker 崩了它重新拉起,收到 SIGTERM 它转发给所有 worker。
  • worker 进程(53088–53091):各自是一个独立的 Python 解释器,各自持有自己的事件循环,真正接受并处理连接。

关键点:每个 worker 是独立进程,因此各自有独立的 GIL。这是多进程相对多线程的根本优势——多线程受 GIL 限制无法并行跑 Python 字节码,多进程可以真正吃满多核。

15.2.2 实测:请求怎么分到不同 worker

写一个返回自身 PID 的端点,用 uvicorn --workers 4 起服务,先串行打 40 次:

@app.get("/pid")
def pid() -> dict[str, object]:
    return {"pid": os.getpid(), "ppid": os.getppid()}
串行 40 次 /pid 命中 worker 数: 1
分布: {53089: 40}

40 次全落在同一个 worker(53089)。这不是 bug,而是 HTTP keep-alive 的必然结果:同一个 httpx Client 复用一条 TCP 长连接,这条连接从头到尾由内核交给同一个 worker 处理。连接级粘性,不是请求级。

再改成并发打 160 次(每次新连接):

并发 160 次 /cpu worker 分布: {53088: 39, 53089: 40, 53090: 39, 53091: 42}

四个 worker 几乎平均分摊。原因是 uvicorn 多进程模式下,每个 worker 用 SO_REUSEPORT 绑定同一端口,内核负责把新连接负载均衡到各 worker 的 accept 队列。所以:

  • 新连接会被内核轮询分发到不同 worker;
  • 复用连接(keep-alive)则一直粘在最初那个 worker。

这解释了一个常见困惑:「我起了 4 个 worker,为什么监控里只有一个进程在忙?」——如果你的客户端全是长连接(比如浏览器、连接池),很可能连接数远少于 worker 数,大部分 worker 在挨饿。

15.2.3 实测:worker 数对 CPU 密集吞吐的影响

光看 PID 分布不够,得看吞吐。用一个 CPU 密集端点(空转 80 万次乘法)压测,对比不同 worker 数:

@app.get("/cpu")
def cpu(n: int = 300_000) -> dict[str, object]:
    total = 0
    for i in range(n):
        total += i * i
    return {"pid": os.getpid(), "sum": total}

用 concurrent.futures.ThreadPoolExecutor(max_workers=32) 并发打请求,本机(10 核)实测:

workers请求数总耗时吞吐
11204.27s28 req/s
41201.20s100 req/s
81601.17s137 req/s

1 → 4 worker,吞吐提升 3.6 倍(接近 4 倍的理想值,因为各 worker 跑在不同核上,GIL 互不干扰)。但 4 → 8 worker 只从 100 涨到 137 req/s(1.37 倍),收益明显递减——这台机器 10 核,8 个 worker 已接近把 CPU 占满,再加进程只是互相抢核、增加上下文切换。

结论:CPU 密集型应用,worker 数取「核数」附近即可,不是越多越好。

15.2.4 worker 数公式:异步与同步不是一回事

网上一句广为流传的口诀是 workers = 2 * cpu_count() + 1。这条公式来自 gunicorn 的同步 worker(sync)模型,不能无脑套到 uvicorn 的异步 worker 上,原因:

模型单 worker 行为公式逻辑
gunicorn sync(默认)一次只处理一个请求,请求阻塞在 I/O 时 CPU 闲着需要更多 worker 填补 I/O 空隙,故 2n+1
uvicorn / UvicornWorker(异步)一个事件循环并发处理成百上千个 I/O 请求单个 worker 已能吃满一个核,worker 数 ≈ 核数

异步 worker 的价值在于用单进程处理海量并发 I/O,它不需要靠「堆进程数」来掩盖 I/O 等待。所以:

  • I/O 密集型(绝大多数 Web API):workers ≈ CPU 核数,甚至略少;
  • CPU 密集型(本节的 /cpu):workers ≈ CPU 核数,实测 4 核机器开 4 个是甜点;
  • 2n+1:留给 gunicorn 同步 worker 的老规矩,异步场景照搬会开出一堆互相抢核的进程。

本机 os.cpu_count() 返回 10,所以合理起点是 --workers 10 或略低,而不是 2*10+1=21。

另外要区分「worker 数」和「并发上限」:worker 数决定能并行跑多少 Python 字节码(吃几个核),--limit-concurrency 决定单个 worker 允许多少连接在途。前者按核数定,后者按「应用能承受多少并发」定,两者独立,别混为一谈。

15.2.5 gunicorn + UvicornWorker(本机未实测)

生产里常见另一种组合:用 gunicorn 当进程管理器,用 uvicorn 当 worker 类。它的进程模型是「gunicorn master 管着一群 UvicornWorker」——master 负责平滑重启、worker 超时回收,每个 worker 内部跑 uvicorn 的异步事件循环。

gunicorn 本机未安装(which gunicorn 为空,它是 Linux 专用,macOS 上通常不装),所以下面是配置形态,未实测:

gunicorn main:app \
    --workers 4 \
    --worker-class uvicorn.workers.UvicornWorker \
    --bind 0.0.0.0:8000 \
    --timeout 120 \
    --graceful-timeout 30 \
    --keep-alive 5 \
    --max-requests 10000 \
    --max-requests-jitter 1000 \
    --access-logfile - \
    --error-logfile -

各参数的作用:

参数含义调优建议
--worker-class指定异步 worker 类必须是 uvicorn.workers.UvicornWorker
--timeoutworker 静默超时(秒)长任务调大,默认 30 太短
--max-requests单 worker 处理多少请求后重启防内存泄漏,配 jitter 错峰重启
--max-requests-jitter重启抖动量避免所有 worker 同时重启
--keep-alive长连接保持秒数与上游代理对齐

UvicornWorker 与「直接 uvicorn –workers」的差别主要在进程管理能力:gunicorn 的 master 支持 SIGHUP 平滑重启、更成熟的 worker 超时回收、更丰富的钩子。纯 uvicorn 多进程更简单,gunicorn 生态更成熟——两者都是主流选择,取舍看团队运维习惯。

15.2.6 uvicorn 常用调优参数

即便只用 uvicorn,也有几个参数值得配:

uvicorn main:app \
    --host 0.0.0.0 --port 8000 \
    --workers 4 \
    --limit-concurrency 1000 \
    --timeout-keep-alive 5 \
    --backlog 2048 \
    --timeout-graceful-shutdown 30
参数作用
--limit-concurrency超过阈值直接返回 503,防雪崩(保护后端,不是保护自己)
--timeout-keep-alive空闲长连接多久后关闭,防连接堆积
--backlog等待 accept 的连接队列长度,高并发下调大防丢连接
--timeout-graceful-shutdown收到关闭信号后等多久,超时强制杀

--limit-concurrency 是最容易被忽略的一条:它把「无限排队」变成「快速失败」。当下游数据库已经扛不住时,让请求立刻拿到 503 比让它们全堆积、把连接池和内存耗光要健康得多。

15.2.7 快速失败:--limit-concurrency 实测

--limit-concurrency 把「无限排队」变成「快速失败」。用一个异步 sleep 的慢端点实测——单 worker、--limit-concurrency 2,并发打 12 个 800ms 的请求:

@app.get("/slow")
async def slow(ms: int = 500) -> dict[str, str]:
    await asyncio.sleep(ms / 1000)
    return {"status": "done"}

真实结果:

limit=2,  conc=12: {200: 1, 503: 11}
limit=4,  conc=16: {200: 3, 503: 13}

超过并发上限的请求没有排队等待,而是立刻收到 503。这正是我们想要的:当下游已经饱和,让请求快速失败、把压力挡在门外,比让它们全堆积、耗尽连接池和内存要健康得多。注意 --limit-concurrency 保护的是整个服务不被拖垮,不是「限制用户」——配合客户端的重试与退避,它能把过载从「雪崩」降级为「部分失败」。

15.2.8 复现与压测注意事项

上面所有数字都能复现,但压测本身有几个坑要讲清:

  • 客户端可能先成为瓶颈:本次用 ThreadPoolExecutor(max_workers=32) 造并发,若客户端线程数低于服务端并发能力,测出的是客户端的极限,不是服务端的。
  • 连接复用影响结论:同一个 httpx.Client 复用长连接会让请求粘在单个 worker(见 15.2.2),要测多 worker 分布必须让每个并发任务各建连接。
  • CPU 密集型才有意义:/cpu 端点才体现多进程的 GIL 优势;I/O 密集端点在单 worker 内就能靠事件循环并发,加 worker 收益没那么大。
  • 多跑几次取最小值:首次请求含连接建立、缓存预热,用多次运行的最小值更接近稳态。

这四点也适用于第 16 章的压测——先确认压测工具本身不是瓶颈,结论才可信。

15.2.9 优雅关闭:别让请求被腰斩

容器被编排系统回收时会发 SIGTERM,若进程立刻退出,正在处理的请求全部失败。正确姿势是先停止接受新连接,处理完在途请求,再退出:

uvicorn main:app --workers 4 --timeout-graceful-shutdown 30

uvicorn 收到 SIGTERM 后:master 转发给 worker → 各 worker 停止 accept 新连接 → 等待在途请求完成(最多 timeout-graceful-shutdown 秒)→ 退出。这与第 5 章的 FastAPI lifespan 关闭钩子配合:lifespan 里关数据库连接池、刷缓冲,服务器层保证请求处理完。容器编排的 terminationGracePeriodSeconds 要大于这里的 timeout-graceful-shutdown,否则还没等优雅关闭完成,容器就被强杀了。

15.2.10 访问日志与可观测性

服务器层的访问日志是第一手观测数据。uvicorn 默认把访问日志打到 stderr,容器里配 PYTHONUNBUFFERED=1 后即可被平台采集:

uvicorn main:app --workers 4 --access-log --no-use-colors

要判断「worker 够不够」,看的是每个 worker 的活跃度分布:如果日志里几乎所有请求都来自同一个 PID,说明你的客户端在用长连接、其余 worker 在空转(回到 15.2.2 的结论);如果各 PID 均匀出现,说明负载均衡生效了。更工程化的做法是把访问日志、worker PID、耗时打成结构化日志(第 3 章),按 pid 维度聚合,才能看出「哪个 worker 在拖后腿」。

对多进程还有一个坑:进程内的指标(内存、GC、连接池)是每个 worker 各一份,Prometheus 抓取时要注意汇总口径——不能只抓到一个 worker 就以为看到了全局。这也是为什么多进程部署下,指标要么走 pushgateway,要么让抓取端轮询所有实例。

最后提醒一句:调优的顺序是先测量、再动手。本节给出的 worker 数、并发上限都是起点,不是终点——你的应用到底是 CPU 密集还是 I/O 密集、真实流量是长连接还是短连接,只有压测数据能回答。把本节当作「建立基线」的方法,把第 16 章的剖析工具当作「找瓶颈」的手段,两者配合才调得准。

延伸阅读

小结

  • ASGI 服务器是 master + N worker 进程模型:master 只管拉起与转发信号,worker 各自独立解释器、独立 GIL、独立事件循环。
  • 请求分发靠 SO_REUSEPORT + 内核负载均衡:新连接轮询分给各 worker,keep-alive 长连接则一直粘在同一个 worker(实测串行 40 次全落一个进程)。
  • CPU 密集吞吐实测:1 worker 28 req/s → 4 worker 100 req/s(3.6 倍)→ 8 worker 137 req/s,收益递减。
  • worker 数取 ≈ CPU 核数;2n+1 是 gunicorn 同步 worker 的旧公式,别照搬到异步 worker。
  • gunicorn + UvicornWorker 本机未实测(gunicorn 未安装、Linux 专用),它比纯 uvicorn 多的是进程管理能力。
  • 调优重点是 --limit-concurrency(快速失败防雪崩)、--timeout-graceful-shutdown(优雅关闭),且编排层的宽限期要大于后者。

进程模型调好了,最后一个问题是:这套进程到底是跑在你自己的机器上,还是托管给云平台? 下一节把容器、PaaS、FaaS 三种形态摆在一起,讲清冷启动、密钥注入与健康检查在云上的落点。

阅读导航:上一节:多阶段 Dockerfile 与镜像瘦身 · 下一节:云平台部署与 Serverless 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时