🔢 并发数量深度解析
并发到底取决于什么?如何确定最优并发数?

并发(Concurrency)不是越大越好,也不是越小越好。并发数量的最优值取决于一系列因素的综合权衡。

一、并发 vs 并行 — 先搞清概念

概念定义举例
并发
Concurrency
多个任务在同一个时间段内交替执行(逻辑上同时)一个人同时煮水、切菜、炒菜(来回切换)
并行
Parallelism
多个任务在同一个时刻真正同时执行(物理上同时)三个人各做一道菜,同时进行

关键区别:并发是"看起来同时",并行是"真正同时"。单核 CPU 可以有并发但不可能有并行。

现实关系:并行是并发的子集——你设计的并发任务,最终通过多核 CPU 实现部分并行。

二、并发数量的决定因素

因素①:任务类型(最重要)

任务类型特点CPU 占用最优并发例子
CPU 密集型大量计算,很少等待90~100%≈ CPU 核数视频编码、模型推理
IO 密集型大量等待,很少计算10~30%几十~几千Web 服务、API 调用
混合型有计算有等待30~70%介于两者之间数据处理+写入

怎么判断?跑一个任务,看 CPU 占用率:

  • CPU 飙到 90%+ → CPU 密集型 — 并发别超过核数
  • CPU 只有 10~30% → IO 密集型 — 并发可以很大

因素②:硬件资源

CPU 密集型:最大合理并发 ≈ CPU 核心数 × (1 + 超线程系数)
              例子:4核8线程 → 合理并发 4~8,再多只会导致上下文切换开销

IO 密集型:最大并发取决于其他资源瓶颈
              - 内存:每个线程/协程需要空间
              - 文件句柄:Linux 默认 ulimit -n = 1024
              - 连接数:数据库连接池、Redis 连接数
              - 网络带宽:带宽打满也会限制有效并发

典型资源限制表:

资源默认上限可调大超出后果
OS 线程数取决于内存❌ 每个线程 ~8MB 栈OOM 崩溃
文件句柄1024ulimit -n 65535Too many open files
MySQL 连接151可配置连接被拒绝
PostgreSQL 连接100可配置连接超时
Redis 连接10000可配置连接被拒绝
Nginx 反向代理worker_connections 768可配置502 Bad Gateway

因素③:编程语言与并发模型

语言并发模型CPU 密集有效并发IO 密集有效并发原因
Python多线程 / asyncio / 多进程1(多线程无效,GIL)
≈核数(多进程)
几百~几千(asyncio)
几十(多线程,GIL 切换)
GIL 限制 CPU 并行
Gogoroutine(协程)≈核数(自动调度)10万+(轻量)goroutine ~4KB栈
Node.js事件循环 + Worker Threads≈核数(Worker Threads)
1(主线程)
几千(事件循环高效)单线程不能做 CPU 密集
Java线程池 + 虚拟线程 (Loom)≈核数(传统线程)几千(虚拟线程)
几百(传统线程池)
虚拟线程类似 goroutine

关于 Python GIL 的真相:

  • GIL 导致 Python 多线程不能利用多核做 CPU 并行
  • 但 IO 密集型多线程仍然有效——因为线程在等待 IO 时会释放 GIL
  • 正确做法:CPU 密集用 multiprocessing,IO 密集用 asyncioconcurrent.futures.ThreadPoolExecutor

因素④:锁竞争与共享资源

并发不是越多越快——锁竞争会导致严重的性能下降:

想象一个卫生间(共享资源):
- 1 个人用:不用排队,0 等待
- 10 个人用:稍微排队,还能接受
- 100 个人用:门口排长队,大部分时间在等待
- 1000 个人用:所有人都在排队,实际"使用"时间接近 0

同样的道理适用于:
- 数据库行锁(同一行数据被并发更新)
- 文件写锁(多个进程写同一个文件)
- 分布式锁(Redis Redlock / ZooKeeper)
- 信号量(Semaphore)
- 连接池(连接不够用)

经验法则:如果并发增加后吞吐量不再线性增长,甚至下降 → 锁竞争太严重了。

因素⑤:外部依赖限制

外部依赖常见限制怎么破
数据库连接池上限、磁盘 IOPS、锁竞争连接池调优、读写分离、分库分表
第三方 APIRate Limit(如 OpenAI 每分钟 1000 次)本地缓存、请求队列、限流
消息队列消费速度跟不上生产速度增加消费者、优化消费逻辑
网络带宽带宽打满导致延迟飙升CDN、压缩、升级带宽
文件系统磁盘 IOPS 上限、inode 用完SSD、分布式存储

三、如何找到最优并发数?

方法:压测 + 观测

# 用 wrk 做简单的 HTTP 压测(Linux)
wrk -t4 -c100 -d30s http://localhost:5000/api

# -t4: 4 个线程
# -c100: 100 个并发连接
# -d30s: 压测 30 秒

# 返回结果示例:
#   Thread Stats   Avg      Stdev     Max   +/- Stdev
#     Latency    45.23ms   12.34ms  567ms   83.17%
#     Req/Sec     2,215      301    3,892   75.33%
#   665,432 requests in 30s, 890MB read
#   Requests/sec: 22,181

# 逐步增加并发 -c200, -c500, -c1000
# 观察:吞吐量(Requests/sec)是否还在增长
# 如果不再增长甚至下降 → 这就是你系统的最优并发点
并发数延迟 P50延迟 P99吞吐量 QPSCPU
105ms12ms2,00015%
5025ms80ms10,00040%
200100ms350ms22,00080%
500400ms1.2s21,00095%
1000900ms3s18,00098%

从上面数据可以看到:最优并发点是 200。再往上加并发,吞吐量不再增长,延迟反而快速恶化。

四、实战中的经验公式

IO 密集型任务的最优并发 ≈ 预期延迟 × 目标吞吐量

例子:API 响应时间平均 50ms,目标每秒处理 1000 请求
  最优并发 = 0.05s × 1000 = 50(这是理论最小值,实际要放大 2~5x)

CPU 密集型任务的最优并发 ≈ CPU 核数(或 +1)

例子:8 核服务器跑计算任务
  最优并发 = 8(不加超线程)或 16(加超线程)

实际取值建议:
- Web 服务(Python Flask + Gunicorn):workers = 2~4 × CPU 核数
- Web 服务(Go):默认 GOMAXPROCS = CPU 核数,goroutine 池 100~10000
- 数据库连接池:Spring Boot 默认 10, Django 默认 10, 调大要谨慎
- 消息队列消费者:通常 1~10 个消费者进程

五、一句话总结

并发数量取决于你的"最短板":
CPU 密集 → 看核数
IO 密集 → 看 IO 延迟 + 外部资源限制
有锁竞争 → 锁释放速度

最优并发不是算出来的,是压测测出来的。
逐步加压,观察吞吐量拐点——拐点处的并发数就是你的最优值。