《Java 并发编程·性能诊断与事故》5.2 CPU 密集与 IO 密集的池大小

同样是「加线程」,CPU 密集任务在 10 线程后吞吐就不再上涨,IO 密集任务到 48 线程仍在涨。本节讲清 N+1 与 N×(1+W/C) 的来历,用 ThreadMXBean 的线程 CPU 时间实测 W/C,在本机十核上跑出两条完整的池大小曲线,并说明虚拟线程时代公式的落点如何转移到连接池。

承接的问题

构造两个对照场景,都来自 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)平均墙钟实际用到的核
1654261.43.82 ms1.00
21286513.93.89 ms2.00
425461016.93.93 ms4.00
847391892.64.22 ms7.56
1055912232.94.47 ms9.30
1256242232.95.34 ms9.33
1656452230.07.09 ms9.32
2456462208.410.64 ms9.23
3258732266.313.66 ms9.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 CW/C实际用到的核
2101.619.63 ms5.13 ms3.830.52
4214.618.60 ms4.17 ms4.460.89
8434.918.35 ms3.92 ms4.681.71
16887.218.00 ms3.98 ms4.533.53
241318.918.14 ms4.06 ms4.475.35
321757.518.15 ms4.11 ms4.417.23
482261.621.15 ms4.20 ms5.039.51
642211.928.79 ms4.22 ms6.839.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 虚拟线程,无信号量69292241.548–64 平台线程:2212–2262
200 虚拟线程,Semaphore(8)1591467.48 平台线程:435–442
200 虚拟线程,Semaphore(32)64862095.732 平台线程:1715–1777

三行数据说明三件事:

  1. 无信号量时,虚拟线程的吞吐和平台线程的饱和吞吐几乎一样(2241 对 2212–2262)。CPU 是硬上限,虚拟线程不能凭空变出核来。
  2. 加了 Semaphore(8) 之后,吞吐降到 467/s,与 8 个平台线程的 435–442/s 只差 6%。限制从线程数变成了许可证数,公式里的 N 换成了连接池大小。
  3. 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 队列长度、拒绝率与降级

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收