"线程数等于 CPU 核数"------这可能是程序员最耳熟能详的性能优化建议之一。
但当你真正着手设计一个系统时,你会发现事情远没有这么简单:Web 服务器动辄上千线程,游戏引擎可能只用寥寥几个,而一些高性能中间件甚至会创建 CPU 核数两倍的线程。到底谁是对的?
这篇文章试图回答一个看似简单的问题:你的程序应该启动多少线程?
一、那条广为流传的经验法则
几乎每本并发编程的书都会告诉你:
对于 CPU 密集型任务,线程数应等于 CPU 核数(或核数 + 1)。
这条规则背后的逻辑很直观:每个 CPU 核心同一时刻只能执行一个线程。如果线程数超过核心数,多余的线程只能等待,还会带来额外的上下文切换开销。如果线程数少于核心数,又会让部分核心空转。
这条规则没有错,但它只回答了一个非常狭窄的问题:当你的唯一目标是最大化 CPU 利用率时,应该用多少线程?
现实中的软件系统要复杂得多。
二、线程的真实作用:不只是并行
当我们谈论"为什么需要线程"时,教科书往往只强调一点:并行计算。但在实际工程中,线程至少承担着三种截然不同的职责:
1. 通过异步避免阻塞
想象一个 GUI 程序:用户点击按钮后,程序需要从网络加载数据。如果在主线程中同步等待网络响应,整个界面就会冻结。
scss
// 糟糕的做法:阻塞主线程
void onClick() {
Data data = network.fetchSync(); // 界面卡住 3 秒
updateUI(data);
}
// 更好的做法:用单独线程处理阻塞操作
void onClick() {
new Thread(() -> {
Data data = network.fetchSync();
runOnUIThread(() -> updateUI(data));
}).start();
}
这里的线程不是为了并行计算,而是为了不阻塞主线程。即使在单核 CPU 上,这种设计也是有意义的。
2. 故障隔离:舱壁模式
在微服务架构中,一个服务可能依赖多个下游系统。如果所有请求共享同一个线程池,当某个下游系统变慢时,线程会被逐渐耗尽,最终导致整个服务不可用------这就是级联故障。
css
┌─────────────────────────────────────────────────┐
│ 共享线程池 │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ T1 │ │ T2 │ │ T3 │ │ T4 │ │ T5 │ │
│ │阻塞 │ │阻塞 │ │阻塞 │ │阻塞 │ │阻塞 │ │
│ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ │
│ │ │ │ │ │ │
│ └───────┴───────┼───────┴───────┘ │
│ ▼ │
│ 下游服务 A(响应变慢) │
│ │
│ 结果:所有线程被 A 占满,B 和 C 的请求无法处理 │
└─────────────────────────────────────────────────┘
舱壁模式(Bulkhead Pattern)借鉴了船舶设计的思想:将船体分隔成多个水密舱,一个舱室进水不会导致整艘船沉没。
css
┌──────────────────────────────────────────────────┐
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 线程池 A │ │ 线程池 B │ │ 线程池 C │ │
│ │ (3线程) │ │ (3线程) │ │ (3线程) │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ 下游服务A 下游服务B 下游服务C │
│ (变慢) (正常) (正常) │
│ │
│ 结果:A 的线程池耗尽,但 B 和 C 不受影响 │
└──────────────────────────────────────────────────┘
这种设计会让总线程数远超 CPU 核数,但换来的是系统的韧性。
3. 简化抽象:让代码更易理解
有时候,多线程的目的纯粹是为了代码组织。
考虑一个游戏服务器需要同时处理:
- 网络消息收发
- 游戏逻辑 tick
- 定时任务调度
- 日志异步写入
- 监控指标上报
你当然可以用一个复杂的事件循环把它们全部塞进单线程:
csharp
while True:
if has_network_event():
handle_network()
if time_for_game_tick():
game_tick()
if has_scheduled_task():
run_task()
if log_buffer_not_empty():
flush_logs()
# ... 代码很快变成一团乱麻
但更清晰的做法是为每个职责分配独立的线程:
scss
Thread("network", network_loop)
Thread("game-tick", game_loop)
Thread("scheduler", scheduler_loop)
Thread("logger", log_writer_loop)
这些线程大部分时间可能都在 sleep 或等待 I/O,根本不争抢 CPU。但它们让代码结构变得清晰:每个线程有明确的职责和生命周期。
三、线程的真实开销
既然线程有这么多用途,是不是可以随意创建?在回答这个问题之前,我们需要理解线程在现代操作系统中的真实开销。
1. 创建开销
创建一个线程需要:
- 分配内核数据结构(Linux 上是
task_struct,约 2-3 KB) - 分配用户态栈空间
- 在调度器中注册
- 各种安全检查和初始化
在 Linux 上,创建一个线程大约需要 10-30 微秒。这个开销对于长生命周期的线程可以忽略,但如果频繁创建销毁(如"每个请求一个线程"的模型),累积起来就相当可观。
arduino
// 简单测试:创建 10000 个线程
for (int i = 0; i < 10000; i++) {
pthread_create(&threads[i], NULL, empty_func, NULL);
}
// 在普通机器上可能需要 100-300ms
2. 内存占用
每个线程需要独立的栈空间。默认配置下:
- Linux:8 MB(虚拟内存),实际物理内存按需分配
- Windows:1 MB(commit)
- macOS:512 KB(主线程 8 MB)
1000 个线程,按 Linux 默认配置,光栈空间就需要 8 GB 的虚拟地址空间。虽然物理内存是惰性分配的,但这个数字仍然令人警醒。
你可以通过调整栈大小来优化:
scss
pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setstacksize(&attr, 256 * 1024); // 256 KB
pthread_create(&thread, &attr, func, NULL);
或者在 Java 中:
java
Thread thread = new Thread(null, runnable, "name", 256 * 1024);
// 或者 JVM 参数 -Xss256k
3. 上下文切换
这是最常被提及的开销。当 CPU 从执行线程 A 切换到线程 B 时,需要:
- 保存现场:寄存器状态、程序计数器、栈指针等
- 切换页表(如果是不同进程的线程)
- 恢复现场:加载线程 B 的状态
- 缓存失效:这往往是最大的隐藏开销
纯粹的上下文切换本身只需要 1-5 微秒 。但切换后,新线程访问的数据很可能不在 CPU 缓存中,需要从内存重新加载。这种缓存污染导致的性能损失可能比切换本身高出一个数量级。
┌─────────────────────────────────────────────────────┐
│ 上下文切换的真实开销 │
├─────────────────────────────────────────────────────┤
│ 直接开销(保存/恢复状态) ~1-5 μs │
│ 间接开销(缓存失效) ~10-100 μs │
│ 总体影响 视工作负载而定 │
└─────────────────────────────────────────────────────┘
4. 调度开销
操作系统需要维护运行队列、就绪队列,执行调度算法来决定下一个运行的线程。线程数越多,调度器的负担越重。
在极端情况下(数万线程),光是遍历调度队列都会成为性能瓶颈。
量化视角
让我们把这些数字放在一起:
| 开销类型 | 量级 | 影响 |
|---|---|---|
| 创建线程 | 10-30 μs | 频繁创建时累积 |
| 栈内存 | 256KB-8MB/线程 | 限制最大线程数 |
| 上下文切换 | 1-5 μs | 高频切换时累积 |
| 缓存失效 | 10-100 μs | 最大的隐藏开销 |
对于大多数应用来说,几十到几百个线程是完全可以接受的。现代 Linux 内核可以轻松处理上万个线程,只要它们不都在同时争抢 CPU。
四、决策框架:按用途确定线程数
理解了线程的多重作用和真实开销后,我们可以建立一个决策框架:
1. CPU 密集型计算:等于核数
如果线程的主要工作是计算(数学运算、数据处理、加密解密等),坚守经典法则:
ini
int threadCount = Runtime.getRuntime().availableProcessors();
// 或者 核数 + 1,留一个处理偶发的 I/O
这里的逻辑很简单:更多的线程只会增加切换开销,不会提升吞吐量。
实际案例:
- 图像处理库
- 科学计算框架
- 视频编解码器
2. I/O 密集型:优先考虑非阻塞
如果线程大部分时间在等待 I/O(网络、磁盘、数据库),你有两个选择:
选项 A:非阻塞 I/O + 事件循环(推荐)
csharp
# Python asyncio
async def fetch_all(urls):
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url) for url in urls]
return await asyncio.gather(*tasks)
# 用少量线程处理大量并发连接
Node.js、Nginx、Redis 都采用这种模型,用极少的线程处理海量并发。
选项 B:线程池 + 阻塞 I/O
如果你必须使用阻塞 I/O(比如 JDBC 不支持异步),可以用经典公式:
css
线程数 = CPU 核数 × (1 + I/O 等待时间 / CPU 计算时间)
如果平均每个请求花 100ms 等待 I/O、1ms 做计算,在 8 核机器上:
scss
线程数 = 8 × (1 + 100/1) = 808
这只是理论上限。实际中还要考虑:
- 下游系统能否承受这么多并发
- 内存是否足够
- 连接池大小限制
3. 故障隔离:按风险域划分
为每个可能独立失败的依赖分配独立的线程池:
less
// Hystrix/Resilience4j 风格
ThreadPoolBulkhead paymentPool = ThreadPoolBulkhead.of("payment",
ThreadPoolBulkheadConfig.custom()
.maxThreadPoolSize(10)
.coreThreadPoolSize(5)
.queueCapacity(20)
.build());
ThreadPoolBulkhead inventoryPool = ThreadPoolBulkhead.of("inventory",
ThreadPoolBulkheadConfig.custom()
.maxThreadPoolSize(8)
.coreThreadPoolSize(4)
.queueCapacity(15)
.build());
ThreadPoolBulkhead shippingPool = ThreadPoolBulkhead.of("shipping",
ThreadPoolBulkheadConfig.custom()
.maxThreadPoolSize(6)
.coreThreadPoolSize(3)
.queueCapacity(10)
.build());
每个池的大小取决于:
- 下游服务的正常响应时间:响应越慢,需要越多线程来维持吞吐量
- 可接受的最大并发数:下游服务能承受多少并发请求
- 降级策略:线程池满时是快速失败、排队等待,还是返回降级结果
计算舱壁大小的方法
假设某个下游服务:
- 正常响应时间:50ms
- 期望吞吐量:每秒 100 个请求
- 可接受的排队延迟:100ms
ini
最小线程数 = 吞吐量 × 响应时间
= 100 × 0.05
= 5 个线程
队列容量 = 吞吐量 × 可接受排队延迟
= 100 × 0.1
= 10 个请求
但还要考虑异常情况。如果下游服务变慢(响应时间从 50ms 变成 500ms):
ini
此时需要的线程数 = 100 × 0.5 = 50 个线程
这就是舱壁要保护的场景。我们不应该给它 50 个线程,而是:
less
ThreadPoolBulkhead.of("payment",
ThreadPoolBulkheadConfig.custom()
.maxThreadPoolSize(10) // 硬上限:最多 10 个线程
.coreThreadPoolSize(5) // 正常情况够用
.queueCapacity(20) // 允许短暂排队
.build());
// 当下游变慢时:
// - 10 个线程被占满
// - 20 个请求在队列等待
// - 第 31 个请求立即被拒绝(快速失败)
// - 系统其他部分不受影响
舱壁 vs 断路器
舱壁模式常与断路器(Circuit Breaker)配合使用:
┌─────────────────────────────────────────────────────────┐
│ 请求流程 │
│ │
│ 请求 ──→ 断路器 ──→ 舱壁(线程池) ──→ 下游服务 │
│ │ │ │
│ │ │ │
│ 检查是否熔断 检查是否有空闲线程 │
│ │ │ │
│ ▼ ▼ │
│ 熔断则快速失败 无空闲则拒绝或排队 │
│ │
└─────────────────────────────────────────────────────────┘
scss
// Resilience4j 组合使用示例
CircuitBreaker circuitBreaker = CircuitBreaker.of("payment",
CircuitBreakerConfig.custom()
.failureRateThreshold(50) // 失败率超过 50% 则熔断
.waitDurationInOpenState(Duration.ofSeconds(30))
.build());
ThreadPoolBulkhead bulkhead = ThreadPoolBulkhead.of("payment", ...);
Supplier<Response> decoratedSupplier = Decorators
.ofSupplier(() -> paymentService.call())
.withCircuitBreaker(circuitBreaker) // 先检查断路器
.withThreadPoolBulkhead(bulkhead) // 再进入线程池
.withFallback(ex -> fallbackResponse()) // 降级响应
.decorate();
舱壁的代价
舱壁模式会显著增加系统的总线程数:
ini
传统模式:
1 个共享线程池 × 50 线程 = 50 线程
舱壁模式:
支付服务池 10 线程
库存服务池 8 线程
物流服务池 6 线程
用户服务池 8 线程
通知服务池 4 线程
─────────────────────
总计 36 线程(但每个池都有冗余)
实际配置时考虑峰值:
每个池 ×1.5 = 54 线程
这看起来线程更多了,但关键区别在于:
| 对比项 | 共享线程池 | 舱壁模式 |
|---|---|---|
| 总线程数 | 较少 | 较多 |
| 单点故障影响 | 整个系统 | 仅一个服务 |
| 资源利用率 | 更高 | 有冗余浪费 |
| 容量规划 | 简单 | 需要分别规划 |
| 故障恢复 | 慢(需等待所有线程释放) | 快(其他池不受影响) |
在微服务架构中,隔离性通常比资源利用率更重要。舱壁带来的额外线程开销,换来的是系统在部分故障时仍能提供服务的能力。
4. 简化抽象:按职责最小化
当线程用于代码组织时,遵循够用就好原则:
java
// 典型的服务端应用线程分配
Thread acceptor = new Thread(this::acceptLoop); // 1个:接受连接
Thread[] workers = new Thread[cpuCores]; // N个:处理业务
Thread timer = new Thread(this::timerLoop); // 1个:定时任务
Thread logger = new Thread(this::logWriter); // 1个:异步日志
Thread monitor = new Thread(this::metricsReport); // 1个:监控上报
// 总计:cpuCores + 4 个线程
这些辅助线程大部分时间在休眠,不会与 worker 线程竞争 CPU。关键是确保它们:
- 不会执行耗时的计算
- 不会频繁唤醒
- 有明确的单一职责
五、警惕"线程风暴"
即使每个决策单独看都合理,累积起来也可能造成问题。
叠加效应
假设你的 Java 服务:
- Tomcat 线程池:200 个
- 数据库连接池:每个连接有后台线程,50 个
- Redis 客户端池:20 个
- Kafka 消费者:10 个分区 × 3 个消费者组 = 30 个
- 定时任务调度器:核心线程 10 个
- JVM GC 线程:8 个
- 其他框架的后台线程:若干
加起来可能有 300-500 个线程,而你的机器可能只有 8 个 CPU 核心。
抖动风险
在某些时刻,大量线程可能同时被唤醒:
yaml
┌─────────────────────────────────────────────────────┐
│ t0: 某个事件触发 │
│ │ │
│ ┌────────────────┼───────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │100个│ │50个 │ │30个 │ │
│ │HTTP │ │定时 │ │Kafka│ │
│ │请求 │ │任务 │ │消息 │ │
│ └──┬──┘ └──┬──┘ └──┬──┘ │
│ │ │ │ │
│ └───────────────┼───────────────┘ │
│ ▼ │
│ 8 个 CPU 核心开始疯狂切换 │
│ │ │
│ ▼ │
│ 延迟飙升,GC 停顿,服务抖动 │
└─────────────────────────────────────────────────────┘
这种"线程风暴"会导致:
- 所有请求的延迟同时上升
- P99 延迟剧烈波动
- 可能触发超时和级联故障
缓解策略
策略一:错峰调度
让定时任务随机分散,而不是整点触发:
ini
// 不好:所有实例同时执行
@Scheduled(cron = "0 0 * * * *") // 每小时整点
// 更好:启动时计算随机偏移
int jitter = random.nextInt(60);
@Scheduled(cron = "0 " + jitter + " * * * *")
策略二:为关键线程提升优先级
确保 CPU 密集型的核心工作线程能优先获得调度:
arduino
// 关键业务线程
thread.setPriority(Thread.MAX_PRIORITY); // Java: 1-10,默认 5
// 或者在 Linux 上使用 nice 值
// nice -n -5 java -jar app.jar
ini
// C/C++:使用实时调度策略
struct sched_param param;
param.sched_priority = 50; // 实时优先级
pthread_setschedparam(thread, SCHED_FIFO, ¶m);
策略三:CPU 绑定(CPU Affinity)
将关键线程绑定到特定 CPU,避免缓存失效:
arduino
// 使用 JNA 或 JNI 调用系统 API
// Linux: sched_setaffinity()
// 或使用 Disruptor 等框架内置的支持
scss
// C: 将线程绑定到 CPU 0 和 1
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(0, &cpuset);
CPU_SET(1, &cpuset);
pthread_setaffinity_np(thread, sizeof(cpuset), &cpuset);
策略四:限制并发
使用信号量或令牌桶限制同时运行的线程数:
scss
Semaphore semaphore = new Semaphore(cpuCores * 2);
void process(Request request) {
semaphore.acquire();
try {
doWork(request);
} finally {
semaphore.release();
}
}
六、协程时代:换汤不换药?
Go 语言的 goroutine、Kotlin 的协程、Java 的虚拟线程(Project Loom)......协程似乎成了并发的银弹。
"创建一百万个协程"的 demo 随处可见,这是否意味着我们不用再关心"数量"问题了?
协程的本质
协程(或用户态线程、绿色线程)本质是把调度权从操作系统收回到用户态:
┌───────────────────────────────────────────────────┐
│ 传统线程 │
│ │
│ 线程1 线程2 线程3 线程4 ... 线程1000 │
│ │ │ │ │ │ │
│ └──────┴──────┴──────┴───────────┘ │
│ │ │
│ 操作系统调度器 │
│ │ │
│ ┌──────┬──────┬──────┬──────────┐ │
│ ▼ ▼ ▼ ▼ ▼ │
│ CPU0 CPU1 CPU2 CPU3 ... CPU7 │
└───────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────┐
│ 协程模型 │
│ │
│ 协程1 协程2 协程3 ... 协程1000000 │
│ │ │ │ │ │
│ └──────┴──────┴────────────┘ │
│ │ │
│ 语言运行时调度器 │
│ │ │
│ ┌──────────────┼──────────────┐ │
│ ▼ ▼ ▼ │
│ 线程1 线程2 ... 线程N │
│ │ │ │ │
│ └──────────────┼──────────────┘ │
│ │ │
│ 操作系统调度器 │
│ │ │
│ ┌───────┴───────┐ │
│ ▼ ▼ │
│ CPU0 ... CPU7 │
└───────────────────────────────────────────────────┘
协程的优势在于:
- 创建开销极小:Go 的 goroutine 初始栈只有 2KB
- 切换开销极小:用户态切换,不需要进入内核
- 调度更智能:运行时了解协程在做什么(如等待 channel)
但物理规则依然适用
协程改变的是切换效率 ,不是 CPU 核心数。
scss
// 100 万个协程同时做 CPU 密集计算?
for i := 0; i < 1000000; i++ {
go func() {
for {
// 纯计算,没有 I/O
heavyComputation()
}
}()
}
// 结果:并不会比 GOMAXPROCS 个协程更快
核心洞察:如果协程在执行时不主动让出(通过 I/O、channel、sleep 等),它就和操作系统线程没有本质区别。
协程的正确心智模型
| 操作类型 | 协程行为 | 考量 |
|---|---|---|
| I/O 等待 | 挂起,让出执行权 | 可以有百万并发 |
| Channel 等待 | 挂起,让出执行权 | 可以有百万并发 |
| CPU 计算 | 持续占用线程 | 同时计算数 ≈ GOMAXPROCS |
| 调用 C 代码 | 可能阻塞线程 | 可能需要更多线程 |
Go 运行时会自动调整实际的操作系统线程数,但 GOMAXPROCS(默认等于 CPU 核数)限制了同时执行的线程数。
go
// 正确用法:百万协程处理 I/O
for i := 0; i < 1000000; i++ {
go handleConnection(conn[i]) // 每个协程大部分时间在等待网络
}
// 需要注意:CPU 密集型任务
pool := make(chan struct{}, runtime.NumCPU()) // 信号量
for task := range tasks {
pool <- struct{}{} // 获取令牌
go func(t Task) {
defer func() { <-pool }() // 释放令牌
cpuIntensiveWork(t) // 同时只有 NumCPU 个在计算
}(task)
}
Java 虚拟线程的启示
Java 21 引入的虚拟线程(Virtual Threads)同样遵循这个逻辑:
scss
// 可以创建大量虚拟线程处理阻塞 I/O
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100000; i++) {
executor.submit(() -> {
// 阻塞 I/O 会自动让出载体线程
String result = httpClient.send(request);
process(result);
});
}
}
// 但如果是 CPU 密集型...
executor.submit(() -> {
// 这个虚拟线程会持续占用载体线程
while (true) {
computePi(); // 其他虚拟线程饿死
}
});
七、实践清单
让我们把讨论转化为可操作的检查清单:
启动前问自己
css
□ 这个线程/协程的主要工作是什么?
- [ ] CPU 计算
- [ ] I/O 等待
- [ ] 故障隔离
- [ ] 代码组织
□ 它会阻塞吗?阻塞多久?
□ 它需要和其他线程竞争资源吗?
□ 它的生命周期是什么?
- [ ] 与应用相同(后台线程)
- [ ] 与请求相同(per-request)
- [ ] 执行完任务就结束(fire-and-forget)
配置建议速查
| 场景 | 线程数建议 | 关键考量 |
|---|---|---|
| 纯 CPU 计算 | = 核数 | 更多无益 |
| CPU 计算 + 偶发 I/O | = 核数 + 1~2 | 应对偶发阻塞 |
| I/O 密集(非阻塞) | 核数或更少 | 事件循环处理并发 |
| I/O 密集(阻塞) | 取决于 I/O 时间比例 | 用公式估算,压测验证 |
| 舱壁隔离 | 每依赖一个独立池 | 池大小取决于下游容量 |
| 辅助功能 | 每职责 1 个 | 确保不争抢 CPU |
监控指标
上线后,持续关注:
bash
线程状态分布
├── RUNNABLE(运行中) → 应该 ≈ CPU 核数
├── BLOCKED(锁等待) → 过高说明有锁竞争
├── WAITING(条件等待) → I/O 线程正常状态
└── TIMED_WAITING(超时等待)→ sleep 或 poll
上下文切换率
└── vmstat, pidstat -w → 每秒切换数
线程创建率
└── 高频创建说明需要用池
CPU 使用率
└── 高于预期 → 检查是否在自旋
└── 低于预期 → 检查是否在等锁
八、总结
回到最初的问题:你的程序应该启动多少线程?
答案是:取决于这些线程要做什么。
- 如果是为了并行计算,线程数应该接近 CPU 核心数
- 如果是为了处理阻塞 I/O,优先考虑非阻塞方案;如果必须阻塞,根据 I/O 时间比例估算
- 如果是为了故障隔离,为每个风险域分配独立的资源边界
- 如果是为了简化代码,确保这些线程不会争抢关键资源
"线程数 = CPU 核心数"是一个好的起点,但不是终点。理解你的工作负载,理解线程的真实开销,理解你想通过多线程解决的问题------这比任何公式都重要。
最后,无论你做出什么选择,记得压测验证。真实世界的表现总是比理论分析更复杂,而性能问题往往藏在那些"理论上应该没问题"的地方。
附录:常见框架的默认配置参考
了解你正在使用的框架的默认行为,有助于做出更好的决策。
Web 服务器
| 框架/服务器 | 默认线程配置 | 说明 |
|---|---|---|
| Tomcat | 最大 200,最小 10 | 每个请求一个线程 |
| Jetty | 最大 200 | QueuedThreadPool |
| Undertow | 核数 × 8(I/O)+ 核数(Worker) | 非阻塞架构 |
| Netty | 核数 × 2(EventLoop) | 事件驱动,少量线程 |
| Go net/http | 无限制(goroutine) | 每连接一个 goroutine |
| Node.js | 1(主线程)+ 4(libuv 线程池) | 单线程事件循环 |
| Nginx | worker 数通常 = 核数 | 每个 worker 单线程事件循环 |
数据库连接池
| 连接池 | 默认配置 | 推荐起点 |
|---|---|---|
| HikariCP | 最大 10 | 核数 × 2 + 磁盘数 |
| Druid | 最大 8 | 根据并发量调整 |
| c3p0 | 最大 15 | 通常偏保守 |
| pgBouncer | 取决于模式 | transaction 模式更高效 |
HikariCP 作者给出的经验公式:
scss
连接数 = ((核心数 × 2) + 有效磁盘数)
对于大多数场景,10-20 个连接足以支撑相当高的吞吐量。更多的连接往往意味着更多的锁竞争和上下文切换,反而降低性能。
消息队列客户端
| 客户端 | 默认配置 | 注意事项 |
|---|---|---|
| Kafka Consumer | 每分区一个线程 | 分区数决定并行度上限 |
| RabbitMQ Consumer | 可配置 prefetch | 控制未确认消息数 |
| Redis (Lettuce) | 共享连接 + 核数个事件线程 | 非阻塞,高效 |
| Redis (Jedis) | 连接池,每操作占用一个 | 阻塞模型 |
线程池最佳实践
less
// 推荐:根据任务类型创建不同的线程池
// 而不是所有任务共享一个
// CPU 密集型任务
ExecutorService cpuPool = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors(),
new ThreadFactoryBuilder().setNameFormat("cpu-worker-%d").build()
);
// I/O 密集型任务
ExecutorService ioPool = new ThreadPoolExecutor(
corePoolSize, // 核心线程数
maxPoolSize, // 最大线程数
60, TimeUnit.SECONDS, // 空闲线程存活时间
new LinkedBlockingQueue<>(queueCapacity), // 有界队列!
new ThreadFactoryBuilder().setNameFormat("io-worker-%d").build(),
new CallerRunsPolicy() // 拒绝策略:让调用者自己执行
);
// 定时任务
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(
2, // 通常不需要很多
new ThreadFactoryBuilder().setNameFormat("scheduler-%d").build()
);
关键提醒 :永远使用有界队列 和合理的拒绝策略。无界队列在高负载下会导致内存溢出。