承接的问题
构造两个对照场景,都来自 libraryd-conc。
场景 A:书目索引重建。任务体是纯计算——遍历、排序、拼装,不碰磁盘也不碰网络。线程池从 8 加到 32,P99 从 4 ms 涨到 14 ms,吞吐从 1892/s 涨到 2266/s——涨了 20%,而线程数翻了 4 倍。运维的结论是「加线程没用」,但真正的原因更简单:这台机器只有 10 个核,从第 11 个线程开始,多出来的线程只是在抢 CPU。
场景 B:借阅查询。任务体是三次下游调用(mail/sms/push),每次等待 10 ms,中间夹一点 CPU 计算。8 个线程时吞吐只有 436/s,而 CPU 只用了 1.7 个核——机器闲着 8 个核,线程却在等网络。
同一个「线程数」参数,在两个场景里含义完全不同:A 里线程数是核数的上限,B 里线程数是等待与计算的比值放大器。这一节把两个公式的来历讲清楚,然后用本机数据校核它们准不准。
两个公式的来历
CPU 密集:N+1
CPU 密集任务在运行期间几乎一直处于 runnable 状态,一个核同时只能跑一个。所以线程数超过核数之后,多出来的线程不会增加吞吐,只会增加上下文切换和尾延迟。N+1 里的 N 是核数,+1 是给那些偶尔发生但必然发生的「线程不在跑」留的余量:
- GC 线程需要 CPU,业务线程会被短暂挤下;
- 缺页、JIT 编译、日志刷盘会让某个线程短暂阻塞;
- 监控、心跳线程也要占一点。
+1 是一个经验值,不是推导结果。对 10 核机器,它意味着 11 个线程;对 64 核机器,1 个备胎显然不够,工程上常取 N + N/8 或固定留 10% 余量。公式的真正作用是给你一个不该越过的数量级,而不是精确值。
IO 密集:N×(1+W/C)
设单核数为 N_core,单个任务的墙钟时间是 W(不含排队),其中真正占用 CPU 的时间是 C。稳态下,一个线程平均只有 C/W 的时间是可运行的,其余时间在等。于是要让所有核吃满,需要的可运行线程数等于核数:
N × (C / W) = N_core
N = N_core × (W / C)
这是下界,不是推荐值:它假设所有等待都严格重叠、没有任何抖动。实际配池时习惯写成
N_threads = N_core × (1 + W/C)
多出来的 N_core × 1 就是各组「同时变回 runnable」时的缓冲。两种写法在 W/C 很大时差别不大(1+W/C ≈ W/C),在 W/C 接近 1 时差别很大——那时候本来就该用 N+1。
这里有一个必须说清的口径:W 是任务的服务时间,不含它在队列里的等待。如果你把含排队的端到端延迟代进来,算出来的线程数会随着排队自我放大:队列越长,算出的线程越多,线程越多,队列越长。这个正反馈是很多「越扩容越慢」事故的起点。
怎么实测 W/C
W 用墙钟就能测,难的是 C——不能用「感觉它算得快」,得用 JVM 自己记的线程 CPU 时间。ThreadMXBean.getCurrentThreadCpuTime() 返回当前线程累计消耗的 CPU 纳秒数,两次采样之差就是一个任务真正烧掉的 CPU 时间。
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadMXBean;
import java.util.concurrent.locks.LockSupport;
public class WaitComputeRatio {
static final ThreadMXBean MX = ManagementFactory.getThreadMXBean();
static final int ITER = 2_000_000;
static volatile long sink;
static void burn(int n) { // 任务里的计算部分
long x = 0x9E3779B97F4A7C15L;
for (int i = 0; i < n; i++) {
x ^= x << 13;
x ^= x >>> 7;
x ^= x << 17;
}
sink = x;
}
static void ioTask() {
LockSupport.parkNanos(10_000_000L); // 名义 10 ms 等待
burn(ITER);
}
public static void main(String[] args) {
System.out.println("cpuTimeSupported=" + MX.isCurrentThreadCpuTimeSupported());
for (int i = 0; i < 400; i++) { // 预热:让 C2 编译 burn 的循环
ioTask();
}
long wallSum = 0, cpuSum = 0;
int repeats = 300;
for (int i = 0; i < repeats; i++) {
long w0 = System.nanoTime();
long c0 = MX.getCurrentThreadCpuTime();
ioTask();
cpuSum += MX.getCurrentThreadCpuTime() - c0;
wallSum += System.nanoTime() - w0;
}
double w = wallSum / (double) repeats / 1e6;
double c = cpuSum / (double) repeats / 1e6;
System.out.printf("W=%.2fms C=%.2fms W/C=%.2f%n", w, c, w / c);
}
}
预热那 400 次不是走过场。第一次实测时我只预热了 3 次,结果是 W=21.73ms C=7.17ms W/C=3.03;预热 400 次之后同一段代码给出 W=18.99ms C=5.63ms W/C=3.37。没被 C2 编译的循环,CPU 时间会虚高 27%,而 W/C 是除数,虚高的 C 会把算出来的线程数压低。测 W/C 之前先确认目标方法已经被充分编译。
实测校核:两条曲线
两个实验都是闭环的:固定 N 个工作线程,各自「做完一个任务再来一个」,跑固定时长,统计完成数和每任务墙钟。机器是 Apple M1 Pro 10 核 / 32 GB,Temurin 25.0.5+8。
CPU 密集任务(2M 次异或,纯计算),每档 2.5 秒:
| 线程数 | 完成数 | 吞吐(/s) | 平均墙钟 | 实际用到的核 |
|---|---|---|---|---|
| 1 | 654 | 261.4 | 3.82 ms | 1.00 |
| 2 | 1286 | 513.9 | 3.89 ms | 2.00 |
| 4 | 2546 | 1016.9 | 3.93 ms | 4.00 |
| 8 | 4739 | 1892.6 | 4.22 ms | 7.56 |
| 10 | 5591 | 2232.9 | 4.47 ms | 9.30 |
| 12 | 5624 | 2232.9 | 5.34 ms | 9.33 |
| 16 | 5645 | 2230.0 | 7.09 ms | 9.32 |
| 24 | 5646 | 2208.4 | 10.64 ms | 9.23 |
| 32 | 5873 | 2266.3 | 13.66 ms | 9.49 |
这张表和 N+1 对得很准:吞吐在 10 线程达到 2233/s,12 线程一模一样(2233/s),之后在 2208–2266/s 之间波动——波动来自整机负载(同期 load average 75–85),不是线程数带来的收益。单线程吞吐 261/s × 10 = 2614/s 是理想线性值,实测 2233/s 是它的 85%,差额是超线程、内存带宽和调度开销。
更有说服力的是墙钟那一列:10 线程时 4.47 ms,12 线程 5.34 ms,32 线程 13.66 ms。线程数翻三倍,吞吐没动,单任务耗时涨三倍——多出来的线程全部变成了排队。
IO 密集任务(parkNanos(10ms) + 2M 次异或),每档 3 秒,同时在任务内部逐次采样线程 CPU 时间:
| 线程数 | 吞吐(/s) | 平均墙钟 W | 平均 CPU C | W/C | 实际用到的核 |
|---|---|---|---|---|---|
| 2 | 101.6 | 19.63 ms | 5.13 ms | 3.83 | 0.52 |
| 4 | 214.6 | 18.60 ms | 4.17 ms | 4.46 | 0.89 |
| 8 | 434.9 | 18.35 ms | 3.92 ms | 4.68 | 1.71 |
| 16 | 887.2 | 18.00 ms | 3.98 ms | 4.53 | 3.53 |
| 24 | 1318.9 | 18.14 ms | 4.06 ms | 4.47 | 5.35 |
| 32 | 1757.5 | 18.15 ms | 4.11 ms | 4.41 | 7.23 |
| 48 | 2261.6 | 21.15 ms | 4.20 ms | 5.03 | 9.51 |
| 64 | 2211.9 | 28.79 ms | 4.22 ms | 6.83 | 9.34 |
W/C 在 4.4–4.7 之间(线程数 4–32 的区间),取 4.5 代入公式:
N_threads = 10 × (1 + 4.5) = 55
实测的拐点在 48 到 64 之间:48 线程吞吐 2261.6/s、用到 9.51 核;64 线程吞吐反而降到 2211.9/s,墙钟从 21.15 ms 涨到 28.79 ms。理论 55,实测 48–64,方向与量级都对上了。64 线程之后 W/C 不再是 4.5 而涨到 6.83,因为此时线程之间开始互相挤占,W 里的排队成分变大了——这正是前面说的那个正反馈。
同一组实验重复跑的吞吐范围:8 线程 435–442/s,32 线程 1715–1777/s,48 线程 2157–2261/s。单次运行、随调度波动,只作量级参考。
两个口径对不上的地方,如实写出来
隔离单线程测出的 C=5.63 ms,池内逐任务测出的 C=3.9–4.2 ms,差了约 30%。同一段 burn,同样的预热次数,CPU 时间不该差这么多。可能的来源有三个:单线程任务之间紧挨着 parkNanos,唤醒后的前几微秒缓存是冷的;单线程跑在同一个核上,池内线程会迁移到不同核,频率与缓存状态不同;getCurrentThreadCpuTime 的采样精度在单线程高频调用下也可能有偏差。本节没有做进一步归因,两个数都保留在文中。
工程上的结论是:W/C 必须在与目标相同的工况下测量。要知道 8 线程的池该开多大,就在 8 线程附近测 W/C;拿隔离单线程的数去推 64 线程的池子,误差会落在公式最敏感的位置上。
虚拟线程时代,公式怎么变
虚拟线程不改变 W/C 的物理含义,它改变的是这个比值决定什么。
平台线程时代,N×(1+W/C) 直接算出线程数,线程数又是硬上限。虚拟线程时代,等待几乎不花钱:一个虚拟线程阻塞在 socket 读上时,载体线程会被立刻拿去跑别的虚拟线程。于是「IO 密集任务可以用无限线程」在 CPU 意义上成立,公式的落点转移到真正稀缺的那个资源上:连接池、文件描述符、下游并发配额。
把同一个 IO 任务换成 200 个虚拟线程,跑 3 秒,加不加信号量做对照:
| 配置 | 完成数 | 吞吐(/s) | 对照(平台线程) |
|---|---|---|---|
| 200 虚拟线程,无信号量 | 6929 | 2241.5 | 48–64 平台线程:2212–2262 |
200 虚拟线程,Semaphore(8) | 1591 | 467.4 | 8 平台线程:435–442 |
200 虚拟线程,Semaphore(32) | 6486 | 2095.7 | 32 平台线程:1715–1777 |
三行数据说明三件事:
- 无信号量时,虚拟线程的吞吐和平台线程的饱和吞吐几乎一样(2241 对 2212–2262)。CPU 是硬上限,虚拟线程不能凭空变出核来。
- 加了
Semaphore(8)之后,吞吐降到 467/s,与 8 个平台线程的 435–442/s 只差 6%。限制从线程数变成了许可证数,公式里的 N 换成了连接池大小。 Semaphore(32)也没有跑满(2096 对无限制的 2241),因为 32 个许可证意味着同时只有 32 个任务在算,CPU 用不满。
所以虚拟线程时代要算的不是线程数,而是 min(下游连接池大小, 下游并发配额, 核数 × (1+W/C) 中的 CPU 项)。如果下游只有 8 个数据库连接,开 100 万个虚拟线程也没用——它们会在 DataSource.getConnection() 上排队,只是排队的地方从线程池队列换成了连接池队列。6.3 会专门讲这个。
线上怎么校核池子配得对不对
公式给的是启动值,不是终值。线上判断「这个池子是不是配错了」,看两个数就够了:队列有没有积压、工作线程实际吃掉了几个核。第二个数用 ThreadMXBean 按线程累加就能得到,不需要任何外部 profiler:
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadMXBean;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ThreadPoolExecutor;
/** 采样两次,用「线程 CPU 时间增量 / 墙钟增量」判断池子被什么卡住。 */
public final class PoolHealth {
private static final ThreadMXBean MX = ManagementFactory.getThreadMXBean();
private final Set<Thread> workers = ConcurrentHashMap.newKeySet();
private long lastNanos = System.nanoTime();
private long lastCpuNanos;
public void register(Thread t) {
workers.add(t);
}
public String sample(ThreadPoolExecutor pool, int cores) {
long now = System.nanoTime();
long cpu = 0;
for (Thread t : workers) {
long v = MX.getThreadCpuTime(t.threadId());
if (v > 0) {
cpu += v;
} else {
workers.remove(t); // 已终止的线程返回 -1
}
}
double wall = (now - lastNanos) / 1e9;
double coresUsed = (cpu - lastCpuNanos) / 1e9 / wall;
lastNanos = now;
lastCpuNanos = cpu;
int queued = pool.getQueue().size();
if (queued > 0 && coresUsed > cores * 0.9) {
return "CPU 饱和:加线程无效,先减计算或加机器";
}
if (queued > 0 && coresUsed < cores * 0.5) {
return "等待型积压:可加线程,但先确认下游连接池上限";
}
return String.format("健康:queued=%d coresUsed=%.2f workers=%d",
queued, coresUsed, workers.size());
}
}
关键在 register:ThreadPoolExecutor 不暴露工作线程集合,想按线程测 CPU 时间,只能在自己的 ThreadFactory 里把 Thread 交给 PoolHealth 登记:
ThreadFactory factory = r -> {
Thread t = new Thread(r, "libraryd-pool-" + SEQ.incrementAndGet());
health.register(t);
return t;
};
这个登记动作顺手解决了 10.3 要讲的线程泄漏检测——能枚举线程,才能发现「池子缩容后线程没退出」。
两条判据的用法:
- 队列在涨、核也吃满了 → 这是 CPU 密集或下游已经饱和,加线程只会增加切换。处置是减少计算、加机器,或者把慢调用拆出去。
- 队列在涨、核只用了不到一半 → 线程确实在等,可以加线程;但加之前先看下游连接池还剩几个空闲连接,否则线程只是从池队列搬到了连接池队列。
在 libraryd-conc 里落地
把两个公式和一个「下游上限」封成一个类,强制调用方写清楚自己在算什么:
package com.plume.conc.ops;
/** 池大小估算:先按任务类型给公式值,再被下游上限和核数上限夹住。 */
public record PoolSizing(int cores, double waitComputeRatio, int dependencyLimit) {
public PoolSizing {
if (cores <= 0 || dependencyLimit <= 0) {
throw new IllegalArgumentException("cores 与 dependencyLimit 必须为正");
}
}
/** CPU 密集型:N + 1,只作为上限使用。 */
public int cpuBound() {
return cores + 1;
}
/** IO 密集型公式值:N * (1 + W/C),向上取整。 */
public int ioBound() {
return (int) Math.ceil(cores * (1 + waitComputeRatio));
}
/**
* 最终建议值:公式值与下游上限取小。
* 平台线程下这是线程数;虚拟线程下它退化成并发上限,线程数可以放开。
*/
public int recommended(boolean ioHeavy) {
int formula = ioHeavy ? ioBound() : cpuBound();
return Math.min(formula, dependencyLimit);
}
/** W/C 超过 10 时,问题不在池子大小,而在那个慢调用本身。 */
public boolean shouldSplitDependency() {
return waitComputeRatio > 10;
}
/**
* 与实测吞吐对账:给定实测 W 与 C,公式给出的池子应该落在实测拐点附近。
* 偏差超过 ±30% 时,先怀疑 W/C 测错了工况,而不是系统有问题。
*/
public double kneeDeviation(double measuredKnee, boolean ioHeavy) {
int predicted = ioHeavy ? ioBound() : cpuBound();
return (measuredKnee - predicted) / (double) predicted;
}
}
shouldSplitDependency 是这个类里最重要的一个方法。有人把 waitComputeRatio 填成 50(比如一个超时 5 秒的上游),公式会给出 510 个线程,而这些线程全部堆在同一个下游上只会把下游打穿。recommended 用 dependencyLimit 把结果夹住,但夹住只是止损;真正的处置是把那个慢调用拆到独立的池、单独的超时与熔断,而不是给它配 500 个线程。
常见错误与边界
| 错误 | 后果 | 正确做法 |
|---|---|---|
用 N+1 配 IO 池 | 8 核机器开 9 个线程,CPU 只用 1.7 核 | IO 密集用 N×(1+W/C) |
用 N×(1+W/C) 配 CPU 池 | 算出 55 个线程去跑纯计算,吞吐不涨、延迟翻倍 | CPU 密集用 N+1 |
| W 用含排队的端到端延迟 | 池越大算出的池越大,正反馈 | W 用任务服务时间,不含队列等待 |
| W/C 用隔离单线程的测量值 | 与目标工况差 30% | 在同一线程数区间测 |
| 相信公式超过相信下游 | 线程全堆在一个连接池上,吞吐反而降 | 先查依赖上限,再算公式 |
| 忽略 GC 与监控线程 | 11 个线程里实际只有 9.5 个在干活 | 留 10% 余量,用 coresUsed 核对 |
什么时候不要用这两个公式:任务混合的池子(一个池里既有 3 ms 的缓存命中,也有 500 ms 的慢查询,W/C 是分布的混合,均值没有代表性)、有明显长尾的任务(P99 是均值的 50 倍时,均值公式会严重低估)、以及阻塞时间由外部决定的场景(下游超时 30 秒,W/C 算出来 6000,这时候公式没有意义,该做的是熔断和隔离)。混合任务应该拆池,而不是找一个「平均 W/C」。
小结
N+1 和 N×(1+W/C) 是同一个问题的两个特例:前者假设 W/C≈0,后者把等待算进分母。本机十核上,CPU 密集任务的吞吐在 10 线程达到 2233/s,12 线程完全不变而墙钟涨 19%,32 线程墙钟涨到 3 倍——N+1=11 精确命中。IO 密集任务在池内测得 W/C≈4.5,公式给出 55,实测拐点落在 48–64 之间;64 线程之后 W/C 自身涨到 6.83,因为排队开始污染 W。
虚拟线程没有推翻这两个公式,只是把「线程数」这个变量换成了「并发上限」:200 个虚拟线程的吞吐(2241/s)和饱和平台线程池(2212–2262/s)相同,而加了 Semaphore(8) 之后掉到 467/s,与 8 个平台线程的 435–442/s 基本一致——限制没有消失,只是从线程池搬到了连接池。
下一节处理池子的另一半:当并发真的不够时,队列该多长、拒绝该选哪种策略、拒绝之后怎么降级。
阅读导航:上一节 5.1 Little’s law 在池化中的应用 | 下一节 5.3 队列长度、拒绝率与降级
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。