欢迎光临
我们一直在努力

ThreadPoolExecutor 线程越多越快?从 30ms HTTP 调用讲透并发数、连接池与背压

ThreadPoolExecutor 线程越多越快?从 30ms HTTP 调用讲透并发数、连接池与背压

在 Python 并发编程中,我经常看到这样的代码:

from concurrent.futures import ThreadPoolExecutor

with ThreadPoolExecutor(max_workers=500) as executor:
...

甚至有人会进一步问:

500 个线程还不够快,那我改成 1000 个呢?

这是一个非常典型的并发误区。

线程数量不是越大越好,max_workers 也不是一个简单的“性能倍增器”。

在很多真实项目里,你会观察到一个很有意思的现象:

10 threads → 300 QPS
30 threads → 850 QPS
60 threads → 1500 QPS
100 threads → 1600 QPS
300 threads → 1450 QPS
500 threads → 1100 QPS

线程增加到某个临界点以后,吞吐量不再提升,甚至开始下降。

与此同时:

CPU ↑
内存 ↑
P95 延迟 ↑
P99 延迟 ↑
Timeout ↑
429 / 503 ↑

系统明明“更努力”了,却反而更慢。

为什么?

因为真实系统中的并发性能,从来不只取决于:

ThreadPoolExecutor(max_workers=N)

它还受到至少五个重要因素约束:

Context Switching
Memory
HTTP Connection Pool
Downstream QPS
Backpressure

本文就从一个非常实际的项目开始:

某个 HTTP 接口平均响应时间约 30ms,我们应该把 max_workers 设置成多少?

答案不是 100,不是 500,也不是 1000。

真正专业的答案是:

30ms 只能告诉我们一部分信息,合理并发数必须结合目标 QPS、尾延迟、连接池、下游容量和本机资源一起确定。


一、ThreadPoolExecutor 到底解决了什么?

先从基础开始。

如果顺序发送 100 个 HTTP 请求,每个请求耗时:

30ms

那么理论总时间约为:

100 × 30ms = 3000ms

也就是:

3 秒

代码可能是:

for url in urls:
request(url)

问题在于 HTTP 请求的大部分时间,线程可能都在等待:

DNS
TCP
TLS
网络传输
服务器响应
数据库

CPU 并没有持续计算。

这类任务属于典型的:

I/O-bound。

ThreadPoolExecutor 可以让多个线程同时等待不同的 I/O:

from concurrent.futures import ThreadPoolExecutor

def request(url):
...

with ThreadPoolExecutor(max_workers=32) as executor:
results = list(executor.map(request, urls))

于是原本:

request1 ──────────────
request2 ──────────────
request3

变成:

request1 ──────────────
request2 ──────────────
request3 ──────────────
request4 ──────────────

这也是线程池处理 HTTP、数据库、文件 I/O 时非常有价值的原因。

但问题来了:

既然 32 个线程能并发等待,那么:

320 个是不是更好?
3200 个是不是更快?

当然不是。


二、第一堵墙:Context Switching

假设只有一个 CPU 核心。

操作系统同时面对:

Thread A
Thread B
Thread C
Thread D

实际上 CPU 并不能真正同时执行所有线程。

它需要不断切换:

A → B → C → D → A

这种行为就是:

Context Switching,线程上下文切换。

一次切换并不仅仅是“换个线程名字”。

操作系统还要维护和恢复线程执行状态,例如:

寄存器
程序计数器
栈指针
调度状态
CPU cache locality

线程数量少的时候,这种成本相对可控。

但如果:

ThreadPoolExecutor(max_workers=1000)

同时存在大量 runnable threads,操作系统就可能把越来越多时间花在:

调度线程
切换线程
缓存失效
锁竞争

而不是完成真正的业务工作。

于是出现一个非常重要的性能曲线:

吞吐量
^
| ●
| ● ●
| ● ●
| ●
| ●
+————————–> threads
16 32 64 128 500

性能通常不是无限上升,而更像:

先快速提升,随后进入平台期,最后可能下降。

所以看到:

max_workers=500

第一反应不应该是:

“并发很高。”

而应该问:

“为什么需要 500?”


三、第二堵墙:线程本身需要内存

线程不是免费的。

每创建一个线程,除了 Python 对象本身,还需要操作系统线程相关资源,包括:

native thread stack
thread state
TLS
调度数据结构
Python runtime 状态

具体大小取决于:

操作系统
Python 实现
线程栈设置
应用调用深度
第三方库

因此不能机械地说“一个线程固定消耗多少 MB”。

但是可以确定:

500 个线程绝对比 50 个线程有明显更大的内存和系统资源开销。

更危险的其实还不是线程本身。

而是线程正在处理的数据。

假设一个请求对应:

request object
response buffer
JSON
日志上下文
业务对象
异常 traceback

如果一个并发请求平均占:

500KB

那么 500 个同时运行的任务,仅业务数据就可能达到:

500 × 500KB ≈ 250MB

还没有计算线程栈、解释器、连接缓冲区以及其他业务内存。

因此:

more threads

经常意味着:

more in-flight requests
→ more memory
→ more GC / allocator pressure
→ worse latency


四、第三堵墙:你的 HTTP Connection Pool 可能只有几十个连接

这是很多 Python 项目真正的瓶颈。

假设你写:

ThreadPoolExecutor(max_workers=500)

但 HTTP 客户端连接池:

max_connections = 50

那么真正能够同时占用连接的请求最多大致只有:

50

剩下 450 个线程干什么?

等待。

于是系统实际上变成:

500 threads

50 HTTP connections

remote server

等于你雇了:

500 个员工

却只有:

50 台电脑

增加员工并不会让工作速度提升 10 倍。

反而还需要:

调度
等待
内存
线程管理

例如使用 HTTPX,可以明确配置连接池:

import httpx

limits = httpx.Limits(
max_connections=64,
max_keepalive_connections=64,
)

client = httpx.Client(
limits=limits,
timeout=5.0,
)

如果:

max_workers = 500
max_connections = 64

就值得重新思考设计。

一般来说,线程并发和连接池容量应该协同规划,而不是各自随便填一个很大的数字。


五、第四堵墙:下游根本吃不了这么大的 QPS

这是最重要的一点。

假设某接口平均耗时:

30ms

500 个线程理论上最多可能产生怎样的请求压力?

粗略计算:

每线程每秒请求数
≈ 1 / 0.03
≈ 33.3

500 个线程:

500 × 33.3
≈ 16667 QPS

注意,这只是极度理想化的理论值。

真实系统达不到这个数字。

但它能暴露一个关键问题:

下游真的允许你发送每秒一万多个请求吗?

如果下游 API 限制:

1000 QPS

你却开:

500 concurrency

结果很可能不是更快,而是:

429 Too Many Requests
503 Service Unavailable
Timeout
Connection Reset

然后应用开始 retry。

接下来发生更糟糕的事情:

请求太多

失败

重试

请求更多

失败更多

继续重试

这就是典型的:

Retry Storm。

最终可能从“一个接口变慢”,扩散成“整个系统雪崩”。

因此并发控制首先应该问:

下游允许我多快?

而不是:

我的机器最多能创建多少线程?


六、30ms HTTP 请求,到底需要多少并发?

现在进入本文最重要的项目问题。

假设:

HTTP 平均延迟 = 30ms

如何确定并发数?

这里可以使用一个非常经典的关系:

Little’s Law

简化之后:

Concurrency ≈ Throughput × Latency

也就是:

并发数 ≈ QPS × 请求耗时

注意时间单位必须使用:

30ms:

30ms = 0.03s


场景一:目标 100 QPS

Concurrency
≈ 100 × 0.03
≈ 3

理论上平均只需要大约:

3 个并发请求

就能支撑 100 QPS。

显然没有理由一上来就:

max_workers=500


场景二:目标 1000 QPS

Concurrency
≈ 1000 × 0.03
≈ 30

理论平均并发约:

30

所以:

max_workers=32

已经是一个非常值得测试的起点。

但生产环境不能只看 average latency。

假设:

average = 30ms
P95 = 70ms
P99 = 150ms

当请求出现尾延迟时,30 个 workers 可能不足。

因此我通常会把:

Little's Law 理论值

作为起点,而不是最终答案。

例如测试:

32
48
64
96
128

然后观察性能曲线。


七、真正确定 max_workers,我会怎么做?

如果这是一个真实项目,我不会直接拍脑袋写:

max_workers=100

而是先收集五个数字:

1. Target QPS
2. Average latency
3. P95/P99 latency
4. Connection pool size
5. Downstream QPS limit

假设数据如下:

目标吞吐量:1000 QPS

Average:30ms
P95:80ms
P99:200ms

HTTP pool:64 connections

下游推荐限制:1200 QPS

第一步:

1000 × 0.03 = 30

理论平均只需要:

30 concurrency

但考虑延迟波动,我们不会只配置 30。

下一步开始 benchmark:

workers=32
workers=48
workers=64
workers=96
workers=128

记录:

Throughput
P50
P95
P99
Error Rate
CPU
Memory
Connection Pool Wait
429
503
Timeout

可能得到:

WorkersQPSP95Error
16 510 42ms 0%
32 970 55ms 0%
48 1080 68ms 0.1%
64 1120 95ms 0.3%
96 1130 180ms 1.5%
128 1090 320ms 4.2%
500 850 1.4s 12%

这张表里面最重要的不是:

最大 QPS

而是:

系统在哪个点开始进入收益递减区间?

如果:

48 workers → 1080 QPS
64 workers → 1120 QPS

只提升:

3.7%

却让 P95:

68ms → 95ms

那么 48 甚至可能比 64 更值得选择。

生产环境追求的通常不是“某一分钟最大吞吐”,而是:

在可以接受的延迟和错误率下,持续稳定输出吞吐量。


八、Backpressure:比 max_workers 更容易被忽略的问题

这里还有一个坑。

很多开发者认为:

ThreadPoolExecutor(max_workers=32)

意味着系统最多积压 32 个任务。

这是错误的。

例如:

with ThreadPoolExecutor(max_workers=32) as executor:

for item in one_million_items:
executor.submit(process, item)

你虽然只有:

32 workers

但可能快速提交:

1,000,000 tasks

大量任务会等待执行。

于是:

Producer
↓↓↓↓↓↓↓↓↓
ThreadPool Work Queue

32 Workers

生产者完全没有受到足够的背压约束。

后果包括:

Future 对象大量堆积
参数对象无法释放
内存增长
任务排队时间越来越长
服务已经过载但入口仍继续接任务

这也是为什么:

限制 worker 数量,不等于真正实现 backpressure。


九、生产级方案:同时限制 running + pending

可以使用 Semaphore 给 ThreadPoolExecutor 加一层提交限制。

import threading
from concurrent.futures import ThreadPoolExecutor

class BoundedExecutor:

def __init__(
self,
max_workers: int,
max_pending: int,
):
self.executor = ThreadPoolExecutor(
max_workers=max_workers,
)

# 限制:
# running + waiting
self.slots = threading.Semaphore(
max_workers + max_pending
)

def submit(self, fn, /, *args, **kwargs):

# 没有槽位就阻塞 producer
self.slots.acquire()

try:
future = self.executor.submit(
fn,
*args,
**kwargs,
)

except BaseException:
self.slots.release()
raise

# 一个任务真正完成以后
# 才允许新的任务进入
future.add_done_callback(
lambda _: self.slots.release()
)

return future

def shutdown(self, wait=True):
self.executor.shutdown(wait=wait)

使用:

pool = BoundedExecutor(
max_workers=48,
max_pending=200,
)

for task in tasks:
pool.submit(process, task)

pool.shutdown()

现在系统最多存在:

48 running
+
200 pending
=
248 tasks

当达到 248:

submit()

会阻塞 producer。

这个阻塞不是 bug。

它就是:

Backpressure。


十、为什么 Backpressure 如此重要?

假设入口每秒产生:

5000 tasks

而系统只能处理:

1000 tasks/s

如果没有背压:

第一秒:积压 4000
第二秒:积压 8000
第三秒:12000

只要时间足够长:

任何有限内存最终都会耗尽

而且即使没有 OOM,延迟也会越来越离谱。

例如一个任务现在进入队列,却需要:

5 分钟后

才能执行。

用户看到的就不是“系统吞吐量很高”,而是:

请求一直超时

因此背压的核心思想是:

当系统处理不过来时,让压力向生产端传播,而不是无限缓存。


十一、并发数和 QPS 不是一回事

这是面试中特别容易追问的一点。

假设:

Concurrency = 100

并不代表:

QPS = 100

如果请求耗时:

1 秒

那么理论:

≈ 100 QPS

如果请求耗时:

100ms

理论:

≈ 1000 QPS

如果请求耗时:

10ms

理论:

≈ 10000 QPS

所以:

Concurrency

描述:

同时有多少任务处于 in-flight 状态。

而:

QPS

描述:

每秒最终发出了多少请求。

因此有时候你需要两个独立控制器:

Concurrency Limiter
+
Rate Limiter

例如:

最多 64 个并发
同时最多 1000 QPS

这两个限制一点都不冲突。


十二、一个更完整的 HTTP 线程池结构

生产环境中,我更希望看到这样的架构:

Incoming Tasks


┌────────────────┐
│ Bounded Pending│
│ Queue / Slots │
└───────┬────────┘


ThreadPoolExecutor
max_workers=48


QPS Rate Limiter


HTTP Client Pool
max_connections=64


Downstream API
<= 1200 QPS

这里实际上存在四道保护:

第一层:pending task limit
防止内存无限增长

第二层:max_workers
控制线程并发

第三层:connection pool
控制网络连接

第四层:rate limiter
保护 downstream

一个稳定的生产系统,经常不是靠某一个参数,而是靠:

多层限流共同形成安全边界。


十三、什么时候应该继续增加 max_workers?

我会看以下情况。

如果:

CPU 很低
Memory 正常
Connection Pool 没满
Downstream 没有限流
Error Rate 很低
Latency 稳定
QPS 随 workers 增加仍明显提升

那么可以继续加。

例如:

32 workers → 700 QPS
48 workers → 980 QPS
64 workers → 1250 QPS

提升还非常明显。

可以继续测试。


十四、什么时候应该停止增加?

出现以下信号时,我基本就不会继续暴力增加线程:

QPS 基本不再增加

或者:

P95/P99 急剧上升

或者:

CPU context switches 大量增长

或者:

Memory 明显增长

或者:

HTTP connection pool 大量等待

或者:

429 / 503 / timeout 增多

或者:

Downstream CPU / DB 已经满载

这时候增加:

max_workers

通常只是在:

制造更多等待。


十五、特别危险的组合:500 Threads + 10 Connections

假设:

ThreadPoolExecutor = 500

HTTP Connection Pool = 10

Downstream Limit = 300 QPS

HTTP latency = 30ms

此时你开 500 个线程几乎没有意义。

10 个连接在 30ms 延迟下理论吞吐约:

10 / 0.03
≈ 333 QPS

这已经非常接近:

Downstream = 300 QPS

所以合理设计可能只是:

connection pool = 10~16
workers = 12~24
QPS limit = 300

而不是:

workers = 500

500 个线程中的绝大多数可能只是在:

等待 connection

这就是为什么:

看 ThreadPoolExecutor 性能,绝对不能只看 ThreadPoolExecutor。


十六、还有一个因素:下游变慢时,并发会自动膨胀

假设平时:

Latency = 30ms
QPS = 1000

对应平均并发:

1000 × 0.03 = 30

突然数据库出现问题,下游延迟变成:

300ms

此时如果还想保持:

1000 QPS

理论需要:

1000 × 0.3 = 300 concurrency

于是你可能会看到:

请求变慢
→ in-flight 请求增加
→ 连接占满
→ timeout 增多
→ retry 增多
→ downstream 压力进一步增加

这就是为什么一个优秀的客户端还需要:

timeout
retry budget
circuit breaker
backpressure
rate limit

而不是简单:

max_workers=1000


十七、Retry 为什么可能把线程池彻底拖垮?

假设服务正常:

1000 QPS

突然失败率:

50%

并且每个失败请求立即重试 3 次。

理论请求压力可能快速变成:

original traffic
+
retry traffic

甚至远大于原始 QPS。

错误写法:

for _ in range(3):
try:
return request()
except Exception:
continue

没有:

backoff
jitter
retry budget

非常危险。

更合理:

import random
import time

def retry_delay(attempt: int) > None:

base = min(
0.05 * (2 ** attempt),
1.0,
)

jitter = random.uniform(0, base * 0.2)

time.sleep(base + jitter)

即:

Exponential Backoff
+
Jitter

避免所有线程同时重试形成“惊群”。


十八、那为什么不用 asyncio?

如果系统需要:

几十个
一两百个

并发 HTTP 调用,ThreadPoolExecutor 依然非常实用。

它的优势包括:

代码简单
同步库生态成熟
迁移成本低
调试直观

但如果你的设计目标真的变成:

5000
10000
甚至更多

长期活跃网络连接,那么问题就值得重新定义:

我真的需要 10000 个操作系统线程吗?

这种场景可能更适合:

asyncio

配合:

aiohttp
httpx.AsyncClient

因为 coroutine 的调度通常比维护成千上万个 native threads 更轻。

但 asyncio 同样不是“无限并发”。

你依旧需要:

Semaphore
Connection Limits
Rate Limiting
Backpressure
Timeout

例如:

semaphore = asyncio.Semaphore(100)

如果把:

ThreadPoolExecutor(max_workers=10000)

换成:

asyncio.create_task()

然后一次创建 100 万个 Task,也依旧可能把系统拖垮。

所以真正重要的不是:

thread vs async

而是:

有没有明确的并发边界。


十九、一个项目里我会怎样确定最终参数?

回到题目:

HTTP 平均耗时 30ms,你如何确定合理并发数?

我的回答通常是下面这套流程。

第一步:确定业务目标

先问:

目标 QPS 是多少?

例如:

1000 QPS


第二步:计算理论平均并发

Concurrency
≈ QPS × Latency

≈ 1000 × 0.03

≈ 30

于是:

30

是一个理论基线。


第三步:查看尾延迟

不能只看:

avg = 30ms

还必须查看:

P50
P90
P95
P99

例如:

P50 = 25ms
P95 = 70ms
P99 = 180ms

说明系统存在明显 tail latency。

因此需要预留一定余量。


第四步:确定外部硬限制

检查:

HTTP max connections
Downstream max QPS
数据库连接数
代理/NAT 能力
文件描述符限制
第三方 API quota

这些往往比 ThreadPoolExecutor 更早成为瓶颈。


第五步:做阶梯压测

绝不从:

500

开始。

我会测试:

16
32
48
64
96
128

观察:

QPS
P95
P99
Error Rate
CPU
Memory
Context Switch
Connection Pool Wait
429
503
Timeout


第六步:找性能拐点

假设:

48 → 1050 QPS

64 → 1100 QPS

96 → 1110 QPS

128 → 1080 QPS

那么:

64 → 96

已经基本进入收益递减区域。

我大概率不会选择:

128

更不会因为“整数漂亮”选择:

500

最终可能选择:

48~64

并给下游留出安全余量。


二十、面试追问应该怎样回答?

如果面试官问:

为什么 ThreadPoolExecutor(max_workers=500) 可能比 50 更慢?

可以从五个关键词回答。

Context Switching

线程太多导致操作系统调度、切换和缓存失效成本增加。

Memory

每个线程以及每个 in-flight request 都需要内存,过高并发会增加内存压力。

Connection Pool

500 个线程不代表存在 500 个可用 HTTP 连接。

如果 connection pool 只有 50:

450 threads

可能只是在等连接。

Downstream QPS

如果 downstream 只能承受:

1000 QPS

客户端制造:

10000 QPS

不会让业务快 10 倍,只会产生:

429
timeout
retry storm

Backpressure

如果生产任务速度超过消费速度,必须限制:

running + pending

而不是无限 submit,让内存成为最后一道“队列”。


二十一、最终结论:max_workers 是容量规划,而不是性能魔法

第一次接触并发时,我们很容易产生一种直觉:

1 thread < 10 threads < 100 threads < 1000 threads

真正进入生产环境之后,你会慢慢发现:

线程越多

吞吐越高

真正决定系统性能的是一条完整链路:

Producer

Pending Queue

Thread Pool

Connection Pool

Network

Downstream Service

Database / Cache / Third Party

任何一个环节达到容量上限:

继续增加 max_workers

都可能只是增加等待、竞争和失败。

对于:

HTTP average latency = 30ms

我们真正应该做的是:

先确定目标 QPS


利用 Little's Law 估算基础并发


观察 P95 / P99


确认 Connection Pool


确认 Downstream QPS


限制 Pending Tasks


做阶梯压测


找到吞吐、延迟、错误率之间的甜点区

如果只能记住一句话,我希望是:

并发设计的目标从来不是“启动尽可能多的线程”,而是用尽可能少且可控的并发,稳定地达到业务需要的吞吐量。

这也是从“会使用 ThreadPoolExecutor”,走向真正理解 Python 高并发系统设计的重要一步。


延伸思考

最后留下几个非常值得继续思考的问题:

如果 HTTP 从平均 30ms 突然恶化到 500ms,
你的系统会发生什么?

如果 Downstream 返回大量 429,
应该增加线程还是减少请求?

如果 Connection Pool 是 100,
max_workers 设置 500 是否还有意义?

如果 submit 速度是 10000/s,
但 worker 只能消费 1000/s,
剩下的 9000 个任务应该在哪里等待?

如果服务的目标是 50000 个长期 HTTP 连接,
你还会选择 ThreadPoolExecutor 吗?

这些问题的答案,最终都会指向同一个主题:

高性能不是“更多”,而是“有边界、可测量、可控制”。

赞(0)
未经允许不得转载:171主机测评 » ThreadPoolExecutor 线程越多越快?从 30ms HTTP 调用讲透并发数、连接池与背压
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址