ThreadPoolExecutor 线程越多越快?从 30ms HTTP 调用讲透并发数、连接池与背压
在 Python 并发编程中,我经常看到这样的代码:
python
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=500) as executor:
...
甚至有人会进一步问:
500 个线程还不够快,那我改成 1000 个呢?
这是一个非常典型的并发误区。
线程数量不是越大越好,max_workers 也不是一个简单的"性能倍增器"。
在很多真实项目里,你会观察到一个很有意思的现象:
text
10 threads → 300 QPS
30 threads → 850 QPS
60 threads → 1500 QPS
100 threads → 1600 QPS
300 threads → 1450 QPS
500 threads → 1100 QPS
线程增加到某个临界点以后,吞吐量不再提升,甚至开始下降。
与此同时:
text
CPU ↑
内存 ↑
P95 延迟 ↑
P99 延迟 ↑
Timeout ↑
429 / 503 ↑
系统明明"更努力"了,却反而更慢。
为什么?
因为真实系统中的并发性能,从来不只取决于:
text
ThreadPoolExecutor(max_workers=N)
它还受到至少五个重要因素约束:
text
Context Switching
Memory
HTTP Connection Pool
Downstream QPS
Backpressure
本文就从一个非常实际的项目开始:
某个 HTTP 接口平均响应时间约 30ms,我们应该把
max_workers设置成多少?
答案不是 100,不是 500,也不是 1000。
真正专业的答案是:
30ms 只能告诉我们一部分信息,合理并发数必须结合目标 QPS、尾延迟、连接池、下游容量和本机资源一起确定。
一、ThreadPoolExecutor 到底解决了什么?
先从基础开始。
如果顺序发送 100 个 HTTP 请求,每个请求耗时:
text
30ms
那么理论总时间约为:
text
100 × 30ms = 3000ms
也就是:
text
3 秒
代码可能是:
python
for url in urls:
request(url)
问题在于 HTTP 请求的大部分时间,线程可能都在等待:
text
DNS
TCP
TLS
网络传输
服务器响应
数据库
CPU 并没有持续计算。
这类任务属于典型的:
I/O-bound。
ThreadPoolExecutor 可以让多个线程同时等待不同的 I/O:
python
from concurrent.futures import ThreadPoolExecutor
def request(url):
...
with ThreadPoolExecutor(max_workers=32) as executor:
results = list(executor.map(request, urls))
于是原本:
text
request1 ──────────────
request2 ──────────────
request3
变成:
text
request1 ──────────────
request2 ──────────────
request3 ──────────────
request4 ──────────────
...
这也是线程池处理 HTTP、数据库、文件 I/O 时非常有价值的原因。
但问题来了:
既然 32 个线程能并发等待,那么:
text
320 个是不是更好?
3200 个是不是更快?
当然不是。
二、第一堵墙:Context Switching
假设只有一个 CPU 核心。
操作系统同时面对:
text
Thread A
Thread B
Thread C
Thread D
...
实际上 CPU 并不能真正同时执行所有线程。
它需要不断切换:
text
A → B → C → D → A
这种行为就是:
Context Switching,线程上下文切换。
一次切换并不仅仅是"换个线程名字"。
操作系统还要维护和恢复线程执行状态,例如:
text
寄存器
程序计数器
栈指针
调度状态
CPU cache locality
线程数量少的时候,这种成本相对可控。
但如果:
python
ThreadPoolExecutor(max_workers=1000)
同时存在大量 runnable threads,操作系统就可能把越来越多时间花在:
text
调度线程
切换线程
缓存失效
锁竞争
而不是完成真正的业务工作。
于是出现一个非常重要的性能曲线:
text
吞吐量
^
| ●
| ● ●
| ● ●
| ●
| ●
+--------------------------> threads
16 32 64 128 500
性能通常不是无限上升,而更像:
先快速提升,随后进入平台期,最后可能下降。
所以看到:
python
max_workers=500
第一反应不应该是:
"并发很高。"
而应该问:
"为什么需要 500?"
三、第二堵墙:线程本身需要内存
线程不是免费的。
每创建一个线程,除了 Python 对象本身,还需要操作系统线程相关资源,包括:
text
native thread stack
thread state
TLS
调度数据结构
Python runtime 状态
具体大小取决于:
text
操作系统
Python 实现
线程栈设置
应用调用深度
第三方库
因此不能机械地说"一个线程固定消耗多少 MB"。
但是可以确定:
500 个线程绝对比 50 个线程有明显更大的内存和系统资源开销。
更危险的其实还不是线程本身。
而是线程正在处理的数据。
假设一个请求对应:
text
request object
response buffer
JSON
日志上下文
业务对象
异常 traceback
如果一个并发请求平均占:
text
500KB
那么 500 个同时运行的任务,仅业务数据就可能达到:
text
500 × 500KB ≈ 250MB
还没有计算线程栈、解释器、连接缓冲区以及其他业务内存。
因此:
text
more threads
经常意味着:
text
more in-flight requests
→ more memory
→ more GC / allocator pressure
→ worse latency
四、第三堵墙:你的 HTTP Connection Pool 可能只有几十个连接
这是很多 Python 项目真正的瓶颈。
假设你写:
python
ThreadPoolExecutor(max_workers=500)
但 HTTP 客户端连接池:
text
max_connections = 50
那么真正能够同时占用连接的请求最多大致只有:
text
50
剩下 450 个线程干什么?
等待。
于是系统实际上变成:
text
500 threads
↓
50 HTTP connections
↓
remote server
等于你雇了:
text
500 个员工
却只有:
text
50 台电脑
增加员工并不会让工作速度提升 10 倍。
反而还需要:
text
调度
等待
内存
线程管理
例如使用 HTTPX,可以明确配置连接池:
python
import httpx
limits = httpx.Limits(
max_connections=64,
max_keepalive_connections=64,
)
client = httpx.Client(
limits=limits,
timeout=5.0,
)
如果:
text
max_workers = 500
max_connections = 64
就值得重新思考设计。
一般来说,线程并发和连接池容量应该协同规划,而不是各自随便填一个很大的数字。
五、第四堵墙:下游根本吃不了这么大的 QPS
这是最重要的一点。
假设某接口平均耗时:
text
30ms
500 个线程理论上最多可能产生怎样的请求压力?
粗略计算:
text
每线程每秒请求数
≈ 1 / 0.03
≈ 33.3
500 个线程:
text
500 × 33.3
≈ 16667 QPS
注意,这只是极度理想化的理论值。
真实系统达不到这个数字。
但它能暴露一个关键问题:
下游真的允许你发送每秒一万多个请求吗?
如果下游 API 限制:
text
1000 QPS
你却开:
text
500 concurrency
结果很可能不是更快,而是:
text
429 Too Many Requests
503 Service Unavailable
Timeout
Connection Reset
然后应用开始 retry。
接下来发生更糟糕的事情:
text
请求太多
↓
失败
↓
重试
↓
请求更多
↓
失败更多
↓
继续重试
这就是典型的:
Retry Storm。
最终可能从"一个接口变慢",扩散成"整个系统雪崩"。
因此并发控制首先应该问:
下游允许我多快?
而不是:
我的机器最多能创建多少线程?
六、30ms HTTP 请求,到底需要多少并发?
现在进入本文最重要的项目问题。
假设:
text
HTTP 平均延迟 = 30ms
如何确定并发数?
这里可以使用一个非常经典的关系:
Little's Law
简化之后:
text
Concurrency ≈ Throughput × Latency
也就是:
text
并发数 ≈ QPS × 请求耗时
注意时间单位必须使用:
text
秒
30ms:
text
30ms = 0.03s
场景一:目标 100 QPS
text
Concurrency
≈ 100 × 0.03
≈ 3
理论上平均只需要大约:
text
3 个并发请求
就能支撑 100 QPS。
显然没有理由一上来就:
python
max_workers=500
场景二:目标 1000 QPS
text
Concurrency
≈ 1000 × 0.03
≈ 30
理论平均并发约:
text
30
所以:
python
max_workers=32
已经是一个非常值得测试的起点。
但生产环境不能只看 average latency。
假设:
text
average = 30ms
P95 = 70ms
P99 = 150ms
当请求出现尾延迟时,30 个 workers 可能不足。
因此我通常会把:
text
Little's Law 理论值
作为起点,而不是最终答案。
例如测试:
text
32
48
64
96
128
然后观察性能曲线。
七、真正确定 max_workers,我会怎么做?
如果这是一个真实项目,我不会直接拍脑袋写:
python
max_workers=100
而是先收集五个数字:
text
1. Target QPS
2. Average latency
3. P95/P99 latency
4. Connection pool size
5. Downstream QPS limit
假设数据如下:
text
目标吞吐量:1000 QPS
Average:30ms
P95:80ms
P99:200ms
HTTP pool:64 connections
下游推荐限制:1200 QPS
第一步:
text
1000 × 0.03 = 30
理论平均只需要:
text
30 concurrency
但考虑延迟波动,我们不会只配置 30。
下一步开始 benchmark:
text
workers=32
workers=48
workers=64
workers=96
workers=128
记录:
text
Throughput
P50
P95
P99
Error Rate
CPU
Memory
Connection Pool Wait
429
503
Timeout
可能得到:
| Workers | QPS | P95 | Error |
|---|---|---|---|
| 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% |
这张表里面最重要的不是:
text
最大 QPS
而是:
系统在哪个点开始进入收益递减区间?
如果:
text
48 workers → 1080 QPS
64 workers → 1120 QPS
只提升:
text
3.7%
却让 P95:
text
68ms → 95ms
那么 48 甚至可能比 64 更值得选择。
生产环境追求的通常不是"某一分钟最大吞吐",而是:
在可以接受的延迟和错误率下,持续稳定输出吞吐量。
八、Backpressure:比 max_workers 更容易被忽略的问题
这里还有一个坑。
很多开发者认为:
python
ThreadPoolExecutor(max_workers=32)
意味着系统最多积压 32 个任务。
这是错误的。
例如:
python
with ThreadPoolExecutor(max_workers=32) as executor:
for item in one_million_items:
executor.submit(process, item)
你虽然只有:
text
32 workers
但可能快速提交:
text
1,000,000 tasks
大量任务会等待执行。
于是:
text
Producer
↓↓↓↓↓↓↓↓↓
ThreadPool Work Queue
↓
32 Workers
生产者完全没有受到足够的背压约束。
后果包括:
text
Future 对象大量堆积
参数对象无法释放
内存增长
任务排队时间越来越长
服务已经过载但入口仍继续接任务
这也是为什么:
限制 worker 数量,不等于真正实现 backpressure。
九、生产级方案:同时限制 running + pending
可以使用 Semaphore 给 ThreadPoolExecutor 加一层提交限制。
python
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)
使用:
python
pool = BoundedExecutor(
max_workers=48,
max_pending=200,
)
for task in tasks:
pool.submit(process, task)
pool.shutdown()
现在系统最多存在:
text
48 running
+
200 pending
=
248 tasks
当达到 248:
python
submit()
会阻塞 producer。
这个阻塞不是 bug。
它就是:
Backpressure。
十、为什么 Backpressure 如此重要?
假设入口每秒产生:
text
5000 tasks
而系统只能处理:
text
1000 tasks/s
如果没有背压:
text
第一秒:积压 4000
第二秒:积压 8000
第三秒:12000
...
只要时间足够长:
text
任何有限内存最终都会耗尽
而且即使没有 OOM,延迟也会越来越离谱。
例如一个任务现在进入队列,却需要:
text
5 分钟后
才能执行。
用户看到的就不是"系统吞吐量很高",而是:
text
请求一直超时
因此背压的核心思想是:
当系统处理不过来时,让压力向生产端传播,而不是无限缓存。
十一、并发数和 QPS 不是一回事
这是面试中特别容易追问的一点。
假设:
text
Concurrency = 100
并不代表:
text
QPS = 100
如果请求耗时:
text
1 秒
那么理论:
text
≈ 100 QPS
如果请求耗时:
text
100ms
理论:
text
≈ 1000 QPS
如果请求耗时:
text
10ms
理论:
text
≈ 10000 QPS
所以:
text
Concurrency
描述:
同时有多少任务处于 in-flight 状态。
而:
text
QPS
描述:
每秒最终发出了多少请求。
因此有时候你需要两个独立控制器:
text
Concurrency Limiter
+
Rate Limiter
例如:
text
最多 64 个并发
同时最多 1000 QPS
这两个限制一点都不冲突。
十二、一个更完整的 HTTP 线程池结构
生产环境中,我更希望看到这样的架构:
text
Incoming Tasks
│
▼
┌────────────────┐
│ Bounded Pending│
│ Queue / Slots │
└───────┬────────┘
│
▼
ThreadPoolExecutor
max_workers=48
│
▼
QPS Rate Limiter
│
▼
HTTP Client Pool
max_connections=64
│
▼
Downstream API
<= 1200 QPS
这里实际上存在四道保护:
text
第一层:pending task limit
防止内存无限增长
第二层:max_workers
控制线程并发
第三层:connection pool
控制网络连接
第四层:rate limiter
保护 downstream
一个稳定的生产系统,经常不是靠某一个参数,而是靠:
多层限流共同形成安全边界。
十三、什么时候应该继续增加 max_workers?
我会看以下情况。
如果:
text
CPU 很低
Memory 正常
Connection Pool 没满
Downstream 没有限流
Error Rate 很低
Latency 稳定
QPS 随 workers 增加仍明显提升
那么可以继续加。
例如:
text
32 workers → 700 QPS
48 workers → 980 QPS
64 workers → 1250 QPS
提升还非常明显。
可以继续测试。
十四、什么时候应该停止增加?
出现以下信号时,我基本就不会继续暴力增加线程:
text
QPS 基本不再增加
或者:
text
P95/P99 急剧上升
或者:
text
CPU context switches 大量增长
或者:
text
Memory 明显增长
或者:
text
HTTP connection pool 大量等待
或者:
text
429 / 503 / timeout 增多
或者:
text
Downstream CPU / DB 已经满载
这时候增加:
python
max_workers
通常只是在:
制造更多等待。
十五、特别危险的组合:500 Threads + 10 Connections
假设:
text
ThreadPoolExecutor = 500
HTTP Connection Pool = 10
Downstream Limit = 300 QPS
HTTP latency = 30ms
此时你开 500 个线程几乎没有意义。
10 个连接在 30ms 延迟下理论吞吐约:
text
10 / 0.03
≈ 333 QPS
这已经非常接近:
text
Downstream = 300 QPS
所以合理设计可能只是:
text
connection pool = 10~16
workers = 12~24
QPS limit = 300
而不是:
text
workers = 500
500 个线程中的绝大多数可能只是在:
text
等待 connection
这就是为什么:
看 ThreadPoolExecutor 性能,绝对不能只看 ThreadPoolExecutor。
十六、还有一个因素:下游变慢时,并发会自动膨胀
假设平时:
text
Latency = 30ms
QPS = 1000
对应平均并发:
text
1000 × 0.03 = 30
突然数据库出现问题,下游延迟变成:
text
300ms
此时如果还想保持:
text
1000 QPS
理论需要:
text
1000 × 0.3 = 300 concurrency
于是你可能会看到:
text
请求变慢
→ in-flight 请求增加
→ 连接占满
→ timeout 增多
→ retry 增多
→ downstream 压力进一步增加
这就是为什么一个优秀的客户端还需要:
text
timeout
retry budget
circuit breaker
backpressure
rate limit
而不是简单:
python
max_workers=1000
十七、Retry 为什么可能把线程池彻底拖垮?
假设服务正常:
text
1000 QPS
突然失败率:
text
50%
并且每个失败请求立即重试 3 次。
理论请求压力可能快速变成:
text
original traffic
+
retry traffic
甚至远大于原始 QPS。
错误写法:
python
for _ in range(3):
try:
return request()
except Exception:
continue
没有:
text
backoff
jitter
retry budget
非常危险。
更合理:
python
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)
即:
text
Exponential Backoff
+
Jitter
避免所有线程同时重试形成"惊群"。
十八、那为什么不用 asyncio?
如果系统需要:
text
几十个
一两百个
并发 HTTP 调用,ThreadPoolExecutor 依然非常实用。
它的优势包括:
text
代码简单
同步库生态成熟
迁移成本低
调试直观
但如果你的设计目标真的变成:
text
5000
10000
甚至更多
长期活跃网络连接,那么问题就值得重新定义:
我真的需要 10000 个操作系统线程吗?
这种场景可能更适合:
python
asyncio
配合:
text
aiohttp
httpx.AsyncClient
因为 coroutine 的调度通常比维护成千上万个 native threads 更轻。
但 asyncio 同样不是"无限并发"。
你依旧需要:
text
Semaphore
Connection Limits
Rate Limiting
Backpressure
Timeout
例如:
python
semaphore = asyncio.Semaphore(100)
如果把:
text
ThreadPoolExecutor(max_workers=10000)
换成:
text
asyncio.create_task()
然后一次创建 100 万个 Task,也依旧可能把系统拖垮。
所以真正重要的不是:
text
thread vs async
而是:
有没有明确的并发边界。
十九、一个项目里我会怎样确定最终参数?
回到题目:
HTTP 平均耗时 30ms,你如何确定合理并发数?
我的回答通常是下面这套流程。
第一步:确定业务目标
先问:
text
目标 QPS 是多少?
例如:
text
1000 QPS
第二步:计算理论平均并发
text
Concurrency
≈ QPS × Latency
≈ 1000 × 0.03
≈ 30
于是:
text
30
是一个理论基线。
第三步:查看尾延迟
不能只看:
text
avg = 30ms
还必须查看:
text
P50
P90
P95
P99
例如:
text
P50 = 25ms
P95 = 70ms
P99 = 180ms
说明系统存在明显 tail latency。
因此需要预留一定余量。
第四步:确定外部硬限制
检查:
text
HTTP max connections
Downstream max QPS
数据库连接数
代理/NAT 能力
文件描述符限制
第三方 API quota
这些往往比 ThreadPoolExecutor 更早成为瓶颈。
第五步:做阶梯压测
绝不从:
text
500
开始。
我会测试:
text
16
32
48
64
96
128
观察:
text
QPS
P95
P99
Error Rate
CPU
Memory
Context Switch
Connection Pool Wait
429
503
Timeout
第六步:找性能拐点
假设:
text
48 → 1050 QPS
64 → 1100 QPS
96 → 1110 QPS
128 → 1080 QPS
那么:
text
64 → 96
已经基本进入收益递减区域。
我大概率不会选择:
text
128
更不会因为"整数漂亮"选择:
text
500
最终可能选择:
text
48~64
并给下游留出安全余量。
二十、面试追问应该怎样回答?
如果面试官问:
为什么
ThreadPoolExecutor(max_workers=500)可能比 50 更慢?
可以从五个关键词回答。
Context Switching
线程太多导致操作系统调度、切换和缓存失效成本增加。
Memory
每个线程以及每个 in-flight request 都需要内存,过高并发会增加内存压力。
Connection Pool
500 个线程不代表存在 500 个可用 HTTP 连接。
如果 connection pool 只有 50:
text
450 threads
可能只是在等连接。
Downstream QPS
如果 downstream 只能承受:
text
1000 QPS
客户端制造:
text
10000 QPS
不会让业务快 10 倍,只会产生:
text
429
timeout
retry storm
Backpressure
如果生产任务速度超过消费速度,必须限制:
text
running + pending
而不是无限 submit,让内存成为最后一道"队列"。
二十一、最终结论:max_workers 是容量规划,而不是性能魔法
第一次接触并发时,我们很容易产生一种直觉:
text
1 thread < 10 threads < 100 threads < 1000 threads
真正进入生产环境之后,你会慢慢发现:
text
线程越多
≠
吞吐越高
真正决定系统性能的是一条完整链路:
text
Producer
↓
Pending Queue
↓
Thread Pool
↓
Connection Pool
↓
Network
↓
Downstream Service
↓
Database / Cache / Third Party
任何一个环节达到容量上限:
text
继续增加 max_workers
都可能只是增加等待、竞争和失败。
对于:
text
HTTP average latency = 30ms
我们真正应该做的是:
text
先确定目标 QPS
↓
利用 Little's Law 估算基础并发
↓
观察 P95 / P99
↓
确认 Connection Pool
↓
确认 Downstream QPS
↓
限制 Pending Tasks
↓
做阶梯压测
↓
找到吞吐、延迟、错误率之间的甜点区
如果只能记住一句话,我希望是:
并发设计的目标从来不是"启动尽可能多的线程",而是用尽可能少且可控的并发,稳定地达到业务需要的吞吐量。
这也是从"会使用 ThreadPoolExecutor",走向真正理解 Python 高并发系统设计的重要一步。
延伸思考
最后留下几个非常值得继续思考的问题:
text
如果 HTTP 从平均 30ms 突然恶化到 500ms,
你的系统会发生什么?
如果 Downstream 返回大量 429,
应该增加线程还是减少请求?
如果 Connection Pool 是 100,
max_workers 设置 500 是否还有意义?
如果 submit 速度是 10000/s,
但 worker 只能消费 1000/s,
剩下的 9000 个任务应该在哪里等待?
如果服务的目标是 50000 个长期 HTTP 连接,
你还会选择 ThreadPoolExecutor 吗?
这些问题的答案,最终都会指向同一个主题:
高性能不是"更多",而是"有边界、可测量、可控制"。