线程池 corePoolSize 我从 320 改到 64,P99 从 2.3s 掉到 210ms,聊聊多线程这事到底该怎么想

🔑 关键词:多线程,线程池参数,上下文切换,虚拟线程,锁竞争

📖 摘要:一次凌晨线上事故的完整复盘:8 核机器线程池设了 320,CPU 只用 34% 但 P99 飙到 2.3 秒。本文给出现场数据、上下文切换的真实成本、锁的几种实现横向对比,以及 JDK 21 虚拟线程的实测数字和踩过的坑,最后是我自己总结的一套判断顺序。

先说说那次半夜被叫起来的事

图片

2023 年 11 月 7 号凌晨两点四十,手机响了。订单查询服务的 P99 从 80ms 飙到 2.3s,上游网关开始大量超时熔断。我爬起来连上 VPN,先看监控面板,CPU 使用率只有 34%,8 核的机器,load average 却到了 21。这个组合很怪——CPU 没吃满,负载高得离谱,说明时间都花在等待和调度上了。用 vmstat 看,cs 那一列是 48 万每秒。机器是 8C16G 的 ecs.g7.2xlarge,这个数字已经远远超过它能舒服承受的范围。

那天晚上我做的第一件事是把核心线程数从 320 改成 64,重启,观察十分钟,P99 掉到 210ms,QPS 只从 1180 掉到 1130,损失 4%。一个参数从 320 到 64,差了五倍,性能反而好了十倍。这个结果让我开始重新想一个问题:我当初到底是怎么算出 320 的?

那个公式我用错了三年

图片

320 是这么来的:N_threads = N_cpu × U_cpu × (1 + W/C),8 核 × 目标利用率 0.8 × (1 + 49/1) = 320。49 是我在 Arthas 里看到的平均响应时间(毫秒),1 是估算的 CPU 计算时间。看着挺严谨,其实从第二步就错了。因为这个接口里混了两种完全不同形态的操作:缓存命中的路径走 Redis,1~2ms 就回来了;没命中的回表查询走 MySQL,40~80ms 是常态。这两种任务的平均响应时间是 49ms,但平均在这里完全没有意义。

W/C 这个公式成立的前提是任务同构,等待时间分布集中。我拿两种分布的混合体去算平均,等于把 1 米深和 100 米深两个水池的深度平均一下,然后说这条河不深,可以走过去。后来我把缓存命中率从 92% 提到 97%,同样 320 个线程,P99 也没降到 300ms 以下。因为剩下 3% 的慢请求照样能把队列堵住,320 个线程全卡在 MySQL 上,CPU 反而是闲的。这就是我踩的第一个坑:调参之前先看任务形态,不要看平均值。

上下文切换到底贵在哪

图片

很多人知道线程多了不好,但说不清不好在哪,面试的时候答一句「切换有开销」就过去了。我在一台 8 核机器上用 sysbench 单独测过,一次轻量级线程上下文切换大约 3 到 5 微秒。听起来不多,但 48 万次每秒的话,光切换本身就吃掉 1.4 到 2.4 个核。更要命的是间接成本:线程被切走之后,CPU 的 L1/L2 缓存基本凉了,回来的时候要从 L3 甚至主存取数据,一次 cache miss 大概 80 到 120 纳秒。

还有个容易被忽略的数字:Linux 的 CFS 调度器在 8 核机器上,运行队列长度超过 8 到 12 的时候,单次调度延迟就开始明显上升。我当时用 perf sched 抓了一下,平均等待调度延迟是 1.7ms。也就是说一个任务就算只需要 0.5ms 的 CPU 时间,它也得先等 1.7ms 才轮到。这种情况下加线程完全是在给自己添堵。后来我给自己定了条粗略的线:8 核机器,IO 密集型任务线程数控制在 32 到 64,计算密集型直接设成核数加一,别多想。这个数不精确,但比算出来的 320 靠谱。

锁这个东西,能不用就别用

图片

JDK 15 之后 synchronized 取消了偏向锁,无竞争情况下进 monitor 的代价大概 20 到 30 纳秒,属于能接受的范围。一旦有竞争,升级到重量级锁需要陷入内核,一次 futex 系统调用大概 1 到 2 微秒,比 CAS 贵两个数量级。ReentrantLock 的 AQS 本质是个 CLH 队列变体,入队出队本身要 CAS 三次,所以在低竞争下反而比 synchronized 慢——我实测过,2 线程竞争时 synchronized 比 ReentrantLock 快大约 15%,这个结论和很多博客说的不太一样。

StampedLock 的乐观读在读多写少场景确实猛,比 ReentrantReadWriteLock 快 3 到 7 倍,但它不可重入,也不支持条件变量。我们组有个同事在一个递归方法里用了它,直接死锁,两个人查了两个多小时才定位到。LongAdder 在 16 线程下比 AtomicLong 吞吐高 8 到 10 倍,原理是分段 cell,代价是 sum() 的时候结果不精确,只能用来做统计不能用来做判断。

但这些都是第二位的问题。我见过太多人花一周优化锁,最后把一次远程调用挪出同步块,性能提升比前面几周加起来还大。ConcurrentHashMap 的 computeIfAbsent 里做远程调用,会把整个 bin 锁住,这个坑我自己踩过,当时 QPS 从 8000 掉到 900。

图片

虚拟线程:JDK 21 之后值得重新想一遍

2023 年 9 月 JDK 21 正式发布,虚拟线程转正。我拿那个订单查询接口做了对比实验:Spring Boot 3.2 加上 spring.threads.virtual.enabled=true,同样 8C16G 的机器,QPS 从 1200 涨到 4700,内存占用反而降了 300MB 左右,因为不再需要 320 个平台线程各自的 1MB 栈空间。

但坑也很实在。第一,synchronized 会把虚拟线程 pin 在载体线程上,JDK 21 里这个问题大面积存在,JDK 24 才基本修掉,如果你的代码里同步块套着 IO,虚拟线程的收益会直接归零。第二,ThreadLocal 用多了会爆内存,平台线程最多几百个无所谓,虚拟线程可能是百万级的。第三,载体线程数默认等于 CPU 核数,可以用 -Djdk.virtualThreadScheduler.parallelism 调,但这个值调大基本没好处。

图片

我现在判断要不要加线程的顺序

经过这几年踩坑,我现在的判断顺序是这样的:先看任务是 CPU 密集还是 IO 密集,再看任务能不能拆开,最后看有没有共享可变状态。三步走完再谈参数。顺序反了,参数调得再精细也是白费。还有一个反直觉的点:大部分多线程 bug 不是并发框架用错了,是共享了不该共享的东西。你把共享去掉,很多问题自动消失,连锁都不用上。

所以如果只让我留一句话,我会说:多线程的优化方向不是让更多线程跑起来,而是让更少的线程等更久。线程数从 320 降到 64 那天,P99 从 2.3 秒掉到 210 毫秒,QPS 只损失 4%,这笔账怎么算都划算。

🏷️ 标签: