多线程模型选型与线程池调优实战:从并发原理到死锁排查
发布时间:2026/10/26 4:38:44 锦皓数字建站

我见过太多团队在并发这块栽跟头问题往往不出在代码写得多烂而出在最初那一步——多线程模型选错了。有人拿单核时代的思路往多核机器上套有人把线程池参数拍脑袋定成 200有人以为加个synchronized就万事大吉结果压测一上QPS 从 3000 掉到 800CPU 还只跑到 40%。多线程模型这四个字听起来像教科书章节实际上它决定了你的程序怎么把任务分给 CPU、怎么在阻塞时让出执行权、怎么在锁竞争下退化一句话它决定了并发能力的上限。这篇东西不是教材复述是我把进程/线程/协程这几条路线、锁与内存可见性的底层逻辑、线程池参数的推导过程、以及排查死锁和 CPU 打满的实战手法按能直接抄作业的方式整理出来。适合写过并发代码但被坑过的中级开发者也适合刚接触并发、想少走几年弯路的新手——里面的参数计算和压测方法你看完就能在自己机器上跑一遍验证。1. 先把多线程模型这四个字拆开看1.1 并发和并行不是一回事混淆了模型就选歪了并发concurrency说的是同时处理多件事的结构能力并行parallelism说的是同一时刻真的有多件事在跑。这两个词被混用得太厉害直接导致选型失误一个 IO 密集的服务用并发就够了你给它配 64 个线程并不能让它更快反而增加切换开销一个纯计算任务光靠事件循环永远吃不满 8 个核必须真并行。我通常用一个类比来跟新人解释并发像一个厨师同时照看 4 口锅他在切菜、翻炒、看火之间来回切换同一时刻他本人只做一件事但 4 道菜在同时推进并行像 4 个厨师各守一口锅物理上真的同时动手。多线程模型解决的正是厨师和锅怎么分配这个问题——是每个厨师配一口锅一对一还是一个厨师假装照看很多锅多对一还是几个厨师轮着管一堆锅多对多。理解这一层之后很多看似玄学的性能问题就有了答案。比如一个 HTTP 服务每个请求 95% 的时间在等数据库返回那你的瓶颈根本不在 CPU线程数可以远大于核数反过来一个图像滤镜批处理每个任务都在烧 CPU线程数超过核数就是纯亏上下文切换会把收益吃干净。判断依据只有一个任务里等待时间和计算时间的比例。1.2 为什么模型选择比线程数量更关键线程数量是可以调的参数模型是改起来伤筋动骨的地基。我踩过的最典型的一次坑是早期写一个日志采集 agent为了高效每个日志源开一个线程去阻塞读文件源数量一多线程数直接飙到几百内存里每个线程默认栈 1MB可调但很多人不调光是栈就吃掉几百 MB而且线程调度抖动导致采集延迟忽高忽低。后来改成少量线程 事件驱动epoll 那套思路线程数从 300 降到 4延迟曲线立刻平了。这件事让我形成一个判断习惯先问任务形态再问模型最后才调参数。任务形态分三类——CPU 密集型、IO 密集型、混合型模型分四类——一对一内核线程、多对一用户态线程、多对多混合调度、事件驱动/协程。大部分线上事故不是线程数设小了而是模型和任务形态不匹配比如在 CPU 密集型任务上用多对一模型多个用户线程抢一个内核线程多核完全用不上。还有一个容易被忽略的点模型决定了故障的表现形式。一对一模型出问题通常是线程数爆炸、内存吃紧、切换开销大事件驱动模型出问题则是一个回调里做了阻塞操作整个事件循环卡死症状是所有请求一起变慢而不是某类请求变慢。你排查的时候如果不知道底层是哪种模型很容易往错误方向查半天。2. 主流多线程模型的路线图与选型逻辑2.1 一对一模型内核线程直通车简单但贵一对一1:1就是每个用户线程绑定一个内核调度实体操作系统直接参与调度。现代主流操作系统上的原生线程基本都是这个路子。它的优点非常直白编程模型简单你new一个线程出来就是一个真线程阻塞了也只阻塞自己多核天然能用满调试工具链栈 dump、性能剖析全都支持。代价在于贵。每个线程要有独立的内核栈和用户栈创建销毁要走系统调用切换要陷入内核、保存恢复寄存器上下文、还可能污染 TLB 和 CPU 缓存。我实测过一个粗糙的数字供参考在普通 x86 机器上一次线程上下文切换大约在 1 到 10 微秒量级具体取决于缓存热度当线程数从 8 涨到 800同样逻辑的吞吐往往先升后降拐点通常出现在活跃线程数明显超过核数之后。所以一对一模型的正确用法是线程数控制在合理范围 用池复用。池化解决的是创建销毁成本控制数量解决的是切换成本。真正需要警惕的是那种来一个请求开一个线程的写法短时间高并发下线程数会瞬间冲到几千轻则延迟抖动重则直接 OOM 或者触发系统的线程数上限。这一点在压测时特别容易暴露——平时流量平稳看不出来一到峰值就雪崩。2.2 多对一模型用户态线程的轻与险多对一N:1是多个用户态线程映射到单个内核线程上调度完全在用户态完成不经过内核。好处是极轻创建销毁纳秒级切换不用陷入内核几十万个线程也不在话下。早期的绿色线程、部分语言运行时的协程实现本质都是这个思路。但它有两个致命弱点必须记牢。第一多核用不上。因为只有一个内核线程同一时刻只有一个用户线程真正在跑你的 8 核机器有 7 个核在围观。第二一个阻塞全阻塞。如果某个用户线程执行了真正的阻塞式系统调用比如传统的阻塞 read内核会把整个进程的那个内核线程挂起其他所有用户线程跟着一起停摆。这个特性导致它几乎无法与传统阻塞式 IO 库共存。规避办法是配套使用非阻塞 IO 事件循环让所有可能阻塞的操作都变成注册回调、稍后回来这样内核线程永远不会被挂住。这也是为什么很多语言的协程运行时必须自带一套网络库不能用系统默认的阻塞调用——不是不想用是用了就废了。2.3 多对多模型调度器在中间做买卖多对多M:N是把 N 个用户态线程调度到 M 个内核线程上M 通常等于或略大于核数。它想同时拿到用户态线程的轻量和多核并行代价是调度器复杂度陡增——用户态调度器要和内核调度器打配合还得处理某个内核线程被阻塞时把它手上的用户线程迁移走这种难题。现代高性能运行时里这类模型做得比较成熟的代表是 Go 的 GMP 调度。粗略拆一下G 是 goroutine用户态任务M 是 machine真正干活的内核线程P 是 processor逻辑处理器数量默认等于 CPU 核数可用环境变量调整。每个 P 挂一个本地运行队列新创建的 G 优先塞本地队列本地队列空了会去全局队列或者其他 P 的队列里偷任务work stealing这就是负载均衡的来源。当一个 M 因为系统调用阻塞时运行时会把它手上的 P 交给别的 M保证并行度不掉——这套机制叫 hand off。这套设计的好处很实在单机轻松起十万级 goroutine每个初始栈只有 2KB 左右且可动态增长写起来还是同步阻塞的代码风格不用回调地狱。代价在于你不再完全掌控调度比如 goroutine 里执行了一个不受管制的 C 调用或者用了大量同步原语制造竞争性能特征会和你的直觉有偏差。另外早期版本存在长循环无法被抢占的问题后来引入了基于信号的异步抢占才缓解。2.4 事件驱动与协程把线程降到语言层事件驱动Reactor / Proactor严格说不是线程模型而是 IO 模型但它经常被拿来和线程模型对比因为两者解决的是同一个问题如何用少量执行单元支撑大量并发连接。典型形态是一个或少数几个事件循环线程配合非阻塞 IO 和就绪事件回调所有连接的状态都存在堆上的对象里而非调用栈上。它的优势是资源占用极低一个线程能扛上万连接没有线程切换开销。劣势是编程心智负担重——业务逻辑被拆成一堆回调错误处理、状态流转、超时控制都得手工管理一不小心就在回调里做了个耗时操作把循环堵死。我见过最典型的事故是有人在事件回调里做了一次同步的磁盘写整个服务响应时间从 5ms 涨到 2s日志上看不出任何异常因为是所有请求一起慢。协程coroutine可以理解为把事件驱动的回调写法还原成同步写法的语法糖底层仍然挂在事件循环或调度器上。它有栈协程和无栈协程之分有栈协程每个协程有自己的栈可以嵌套调用、可以在任意深度挂起无栈协程靠编译器把函数改写成状态机只能在特定位置挂起但开销更小。选哪种取决于你的业务代码嵌套深度和运行时支持一般业务开发不需要纠结直接用语言推荐的异步写法就行。2.5 模型对照与选型决策表下面这张表是我自己选型时会过一遍的清单参数是经验值不同机器和负载会有偏差但量级关系基本稳定。模型执行单元开销多核利用阻塞容忍度典型适用场景主要风险一对一内核线程高栈 MB 级好好阻塞只影响自身CPU 密集计算、需要真并行的任务线程数爆炸、切换开销多对一用户态线程极低纳秒级差单核差一个阻塞全阻塞大量短任务、纯内存计算阻塞调用拖垮全局多对多混合调度低KB 级栈好较好需运行时配合高并发网络服务、混合型负载调度不可控、GC 压力事件驱动 / 协程极低对象级取决于线程数依赖非阻塞改造海量长连接、IO 密集网关回调内阻塞、状态管理复杂判断路径我一般这么走先看瓶颈是 CPU 还是 IO。CPU 打满且核数没吃满说明并行度不够上一对一或 M:NIO 等待占比超过 80%说明线程大部分时间在等优先协程或事件驱动至少也要把线程数按等待比例放大。再看团队维护成本事件驱动的回调写法对新人不友好如果团队规模小、业务变化快用语言原生协程比手写事件循环划算得多。这条维护成本很少有人写进技术选型文档但它造成的长期损耗往往比性能差异更大。3. 绕不开的底层细节内存模型、锁与切换成本3.1 为什么看起来对的代码会丢更新多线程最反直觉的地方在于你以为代码是按书写顺序执行的实际上编译器和 CPU 都会重排而且每个核有自己的缓存写操作不一定会立刻让别的核看见。这就是可见性问题。一段经典的错误代码是这样的一个线程写flag true另一个线程while (!flag) {}空转等待结果等一辈子。原因不是逻辑错是第二个线程读的flag一直是自己缓存里的旧值编译器甚至可能把这次读优化成只读一次。要解决就得靠内存模型提供的同步语义。以 Java 为例volatile保证可见性和禁止特定重排synchronized除了互斥还建立 happens-before 关系——前一个线程在释放锁之前的写对后一个获取同一把锁的线程是可见的。很多人只把synchronized当锁用忽略了它的内存语义于是在用锁保护 A 变量却想靠它顺带保证 B 变量可见这种场景里写出玄学 bug。我的实操建议是共享可变状态越少越好能不用锁就不用锁。不可变对象、线程封闭每个线程自己的数据、消息传递把数据复制一份发过去而不是共享指针这三种手段能消灭掉绝大部分并发 bug。真正需要共享的时候把保护范围写清楚注释里标明这个字段由哪把锁保护别让后来人靠猜。3.2 锁的粒度、竞争与几种替代方案锁的本质是把并行强行串行化所以它的性能特征很容易预测无竞争时开销很小一次原子操作量级有竞争时线程会自旋或挂起竞争激烈时大量时间浪费在排队和唤醒上。我判断锁是否需要优化的标准很简单如果压测时 CPU 利用率上不去、但吞吐也上不去同时火焰图里锁相关函数占比很高那就是锁竞争成瓶颈了。优化路径按性价比排序第一缩小临界区把不共享的计算挪到锁外这个改动最安全收益也最直接第二降低锁粒度把一把大锁拆成按 key 分片的多把小锁分段锁思路代价是某些需要全局一致的操作变复杂第三换无锁结构用 CAS 类原子操作替代互斥适合计数器、状态标志这类简单结构但要注意 ABA 问题和自旋浪费第四改用消息传递把共享状态收归单一执行单元其他线程发消息给它彻底消灭共享。有个坑得提醒不要为了无锁而无锁。CAS 在高竞争下自旋开销可能比加锁更大而且无锁代码的正确性验证成本极高一旦写错就是极小概率的数据错乱线上排查几乎无解。我的经验是除非这个点已经被压测证明是瓶颈比如每秒千万次的计数器否则老老实实用锁把精力花在减少共享上更划算。3.3 上下文切换到底贵在哪怎么量化上下文切换的成本常被低估。一次切换要做的事包括保存当前线程的寄存器组、更新调度数据结构、恢复目标线程的寄存器、切换栈指针还可能导致 TLB 失效和 CPU 缓存命中率下降。前几项是固定的微秒级开销后面几项才是真正的大头——新线程访问的数据不在缓存里全得从内存甚至更远的地方捞这个代价可能是固定开销的几十倍。量化方法很直接。在 Linux 上可以用pidstat -w看每秒的自愿/非自愿上下文切换次数# 每秒采样一次观察目标进程的切换情况 pidstat -w -p pid 1输出里cswch/s是自愿切换线程主动等待比如等锁、等 IOnvcswch/s是非自愿切换时间片用完被强制换下。如果非自愿切换很高说明活跃线程数远超核数每个线程都跑不满时间片就被换下纯属内耗如果自愿切换极高但 CPU 不高说明大量时间在等锁或等 IO该去查竞争点或者 IO 延迟。还有一个更宏观的指标线程数除以核数的比值。我的经验阈值是CPU 密集型任务这个比值超过 1.5 就该警惕了超过 3 基本可以确定有浪费IO 密集型可以放宽到 10 甚至更高因为大部分线程在睡眠不占 CPU。这个判断不需要精确测量top看一眼负载和 CPU 利用率就能估个八九不离十。3.4 线程池参数的计算逻辑别再拍脑袋了线程池的核心参数就四个核心线程数、最大线程数、队列容量、拒绝策略。绝大多数人卡在第一个上。常用估算公式是最佳线程数 ≈ CPU 核数 × (1 平均等待时间 / 平均计算时间)举个具体例子。假设 8 核机器一个任务平均耗时 100ms其中 CPU 计算 20ms、等数据库 80ms。套公式8 × (1 80/20) 40。意思是 40 个线程差不多能让 8 个核一直有活干因为每个线程只有 20% 的时间在烧 CPU。如果实测发现 CPU 只跑到 60%可以往上加如果非自愿切换明显上升而吞吐不涨说明加多了。队列容量和最大线程数的配合更微妙。很多框架的默认行为是核心线程满了先排队队列满了才扩容到最大线程数这个顺序在高并发下很糟糕——任务全堆在队列里慢慢消化延迟飙升而最大线程数形同虚设。我的做法是把队列设小比如几百让压力快速传导到扩容逻辑同时配一个明确的拒绝策略。拒绝策略不能随便选默认的抛异常得看业务能丢的用丢弃最旧不能丢的用调用方执行回压到上游让它自己慢下来绝对不能无声无息地丢。参数常见错误值推荐起点调整依据核心线程数200拍脑袋按公式算出后取整实测 CPU 利用率与非自愿切换队列容量无界Integer.MAX_VALUE500~2000可接受的排队延迟上限最大线程数与核心线程数相同核心数 × 1.5~2突发流量下的扩容需求拒绝策略默认抛异常或静默丢弃调用方执行 / 丢弃最旧业务是否能容忍丢失最后一列很关键参数不是算出来的是测出来再算出来的。公式给的是起点压测给的是答案。没有压测数据支撑的参数调整本质上还是在拍脑袋只不过拍得看起来有道理一点。4. 从零落地一个可复现的线程池与协程对比实验4.1 实验设计把口径先定死否则数据没意义要验证模型差异实验设计比代码更重要。我给自己定的口径是同一台 8 核机器同一份任务逻辑分三组对照——固定大小线程池、动态扩缩线程池、协程。任务分两种CPU 密集型对一个大数组做多轮哈希计算和 IO 密集型模拟 50ms 的远程调用用 sleep 代替。每组跑 60 秒记录吞吐、P99 延迟、CPU 利用率、上下文切换次数。压测工具用什么不重要关键是指标采集要同步进行。我一般开三个窗口一个跑压测、一个top看 CPU、一个pidstat -w看切换。事后把三份数据按时间对齐能很直观看出吞吐到顶和切换飙升的拐点是否重合。有一点要提前说明下面所有代码和数据都是为了说明方法具体数字在你的机器上一定不一样别照抄结论照抄方法。4.2 一对一模型的实现把线程池参数写明白Java 的ThreadPoolExecutor是最典型的一对一模型落地。下面这段是我常用的模板重点是四个参数的含义和拒绝策略的选择int cores Runtime.getRuntime().availableProcessors(); // 8 // 任务计算 20ms、等待 80ms代入公式 8 * (1 80/20) 40 int coreSize cores * 5; int maxSize coreSize * 2; BlockingQueueRunnable queue new ArrayBlockingQueue(1000); ThreadPoolExecutor pool new ThreadPoolExecutor( coreSize, // 核心线程数 maxSize, // 最大线程数 60L, TimeUnit.SECONDS, // 空闲回收时间 queue, // 有界队列防止无限堆积 r - { Thread t new Thread(r, biz-pool- r.hashCode()); t.setDaemon(false); return t; }, new ThreadPoolExecutor.CallerRunsPolicy() // 满了回压到调用方 );几个细节值得单独说。第一ArrayBlockingQueue必须给容量用LinkedBlockingQueue不带参等于设了个Integer.MAX_VALUE的队列最大线程数永远用不上任务全在队列里躺平。第二线程工厂里给线程起有意义的名字出问题的时候jstack一眼就能认出是谁这个习惯能省你半小时排查时间。第三CallerRunsPolicy在 Web 服务里意味着线程池满了就让 Tomcat 的工作线程自己干活天然形成背压比直接抛异常温和也比静默丢弃安全。第四如果不希望核心线程被回收注意允许核心线程超时的开关默认是关的这个不用动。Java 21 之后还有虚拟线程这条路本质是运行时的 M:N 调度写法和普通线程几乎一样在 IO 密集场景下可以把线程数拉到很高而不用担心内存。它适合的场景和协程一致遇到synchronized块里的阻塞操作还会有钉住现象需要留意。4.3 Python 的特殊情况GIL 决定了多线程的边界Python 这边必须先讲清楚一个前提CPython 有全局解释器锁GIL同一时刻只有一个线程在执行字节码。这意味着CPU 密集型任务用多线程基本没有加速效果甚至会因为锁竞争变慢。from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor import time def cpu_task(n: int) - int: s 0 for i in range(n): s i * i return s def io_task(_: int) - int: time.sleep(0.05) # 模拟 50ms 远程调用 return 1 # IO 密集多线程有效等待期间 GIL 会释放 with ThreadPoolExecutor(max_workers64) as pool: list(pool.map(io_task, range(2000))) # CPU 密集多进程才有并行收益进程数别超过核数太多 with ProcessPoolExecutor(max_workers8) as pool: list(pool.map(cpu_task, [10**6] * 64))判断标准很清楚任务里有大量sleep、网络、磁盘操作GIL 在这些阻塞点会释放多线程能拿到收益任务是纯计算乖乖用多进程或者换用支持并行的扩展库。我见过有人在 Python 里开 100 个线程算哈希然后纳闷为什么还不如单线程问题就出在这。4.4 多对多模型的实现Go 的写法与注意点Go 的 goroutine 属于运行时调度的 M:N 模型写法上几乎是零成本——go关键字一加就是一个并发任务。但写得简单不等于跑得对有两个点必须控制。func main() { tasks : make([]int, 10000) sem : make(chan struct{}, 64) // 限制同时执行的任务数 var wg sync.WaitGroup for _, t : range tasks { wg.Add(1) go func(id int) { defer wg.Done() sem - struct{}{} // 获取令牌满了就阻塞 defer func() { -sem }() // 释放令牌 doWork(id) // 真正的业务逻辑 }(t) } wg.Wait() }第一点是并发度要显式限制。goroutine 虽轻但每个也有栈和调度开销无限制地起十万个去抢下游数据库连接最后崩的是数据库。上面用带缓冲 channel 做信号量是最简单的限流手段。第二点是错误要收集goroutine 里panic会直接终止进程必须用recover兜住否则一个任务出错整个服务重启。第三点如果任务有返回值用errgroup那类封装比手工维护 channel 清爽得多。另外调优时记得关注GOMAXPROCS默认等于 CPU 核数容器环境下如果核数识别不准会导致并行度不够必要时显式设置。这个坑在容器化部署里踩中概率不低。4.5 实测数据与结论提炼下面是同一台 8 核机器上的量级记录任务是 IO 密集型每次 50ms 等待压测 60 秒模型并发度吞吐req/sP99 延迟CPU 利用率非自愿切换/s线程池固定 4040约 76065ms约 30%低线程池固定 400400约 740120ms约 32%明显升高线程池固定 88约 15060ms约 15%极低协程并发 50005000约 90070ms约 35%低几个结论直接摆出来。并发度 40 到 400吞吐几乎没涨延迟反而翻倍这就是典型的加线程不加吞吐多出来的线程全在抢 CPU 和排队。并发度 8 的时候吞吐只有 150说明线程数不足以覆盖等待时间白白浪费了等待窗口。协程那组用远低于线程的内存开销拿到了略高的吞吐优势在并发规模而不是单请求速度。换到 CPU 密集型任务结论会完全反过来线程数超过核数之后吞吐持平甚至下降协程也不能突破物理核数限制8 个协程和 8 个线程的吞吐基本一样。这时候唯一的解法是加机器或者优化算法不是调并发模型。5. 常见问题与排查技巧实录5.1 死锁现象、定位手法与预防死锁的典型现象是服务卡住但 CPU 不高、内存正常、日志戛然而止。四个必要条件互斥、持有并等待、不可剥夺、循环等待教科书都写了实操中真正高频触发的是加锁顺序不一致// 线程 A synchronized (lockA) { synchronized (lockB) { /* ... */ } } // 线程 B顺序反了A 持 A 等 BB 持 B 等 A synchronized (lockB) { synchronized (lockA) { /* ... */ } }定位靠线程栈。jstack会把死锁直接标出来jstack -l pid stack.txt grep -A 30 Found one Java-level deadlock stack.txt输出里会写明哪个线程持有了哪把锁、在等哪把锁一目了然。如果用的是其他运行时思路一样找能打印所有执行单元调用栈的工具即可。预防手段我按有效性排序第一全局约定加锁顺序所有地方都按对象 ID 排序后加锁这条能消灭绝大多数死锁第二用带超时的锁尝试tryLock那类拿不到就放弃并回退把死锁变成可处理的失败第三减少嵌套锁一个方法里尽量只持一把锁。还有一类活锁和饥饿容易被忽略——线程都在跑但谁都推进不了症状是 CPU 很高、吞吐为零这时候要看是不是重试逻辑太激进把资源全耗在冲突检测上了。5.2 CPU 打满但吞吐不涨先分清是干活还是空转这个症状有两种完全不同的成因必须分清。第一种是真在干活top -H -p pid看哪个线程占 CPU如果占 CPU 的线程数恰好等于核数说明并行度已经打满该考虑加机器或者降单任务成本。第二种是空转线程在自旋等锁、在做无效重试、或者在 GC。区分方法看火焰图或者采样栈。如果是锁竞争栈顶大概率停在原子操作或者锁的等待实现上如果是 GC会看到大量时间在内存回收相关函数上同时内存曲线呈锯齿状。还有一个隐蔽原因是伪共享——两个线程频繁写同一个缓存行里的不同变量硬件层面互相把对方的缓存行置为无效导致性能比单线程还差。这种问题极其难查一般只有在极端性能敏感的代码里才需要考虑处理方式是把热点变量按缓存行填充对齐。我踩过的一个真实情况是线程池里跑的任务里有synchronized保护的日志写入峰值时所有线程都在这把锁上排队CPU 全花在自旋和上下文切换上top显示 100% 但业务吞吐很低。把日志改成异步队列 单独线程落盘之后问题立刻消失。5.3 线程泄漏与队列堆积慢慢死掉的系统比死锁更阴险的是线程泄漏。现象是内存缓慢上涨、响应时间一周比一周差重启就好过阵子又坏。原因通常是手动创建线程却没在异常路径上关闭或者线程池里的任务自己又创建了线程池套娃或者用了ThreadLocal存大对象但线程长期存活导致无法回收。排查方向先数线程。jstack输出里 grep 一下线程名前缀的数量看是否随时间单调上涨再看这些线程的栈是不是都停在同一个位置那基本就是泄漏源头。线程池的命名规范在这里价值巨大——没有名字的pool-1-thread-1你根本分不清是谁创建的。队列堆积的排查更简单看队列长度指标。我一般会在线程池上挂一个定时任务每隔几秒打印当前活跃线程数、队列长度、已完成任务数。这三个数一起看能判断出到底是任务进得太快还是消费得太慢。如果活跃线程数等于最大线程数、队列持续增长那就是消费能力不足加线程或者优化单任务耗时如果活跃线程数很低但队列很长那多半是队列消费逻辑有问题比如消费者线程挂了没人发现。5.4 排查速查表把上面这些整理成一张表出问题时从上往下过一遍能覆盖大部分情况。现象最可能原因第一动作后续处理服务卡死、CPU 不高死锁 / 线程全部阻塞打线程栈搜死锁标记统一加锁顺序或加超时CPU 100%、吞吐低锁竞争自旋 / GC / 伪共享采样火焰图定位热点缩小临界区或改异步延迟周期性抖动队列积压后批量处理 / GC看队列长度与 GC 曲线缩小队列、平滑提交速率内存持续上涨线程泄漏 / 线程本地变量堆积数线程数量变化趋势补齐关闭逻辑、清理本地变量加线程吞吐不涨已过并发拐点看非自愿切换次数回调线程数或改模型协程版本比线程还慢阻塞调用堵住调度器检查是否有同步 IO全面替换为非阻塞实现表格之外再补一条经验所有并发问题都应该在压测环境复现而不是等线上一层层剥。压测时把并发从低到高拉一遍记录每个档位的吞吐、延迟、CPU、切换次数画出曲线。拐点在哪、从哪个点开始劣化一眼就清楚。这套数据比任何经验值都可靠而且下次改代码的时候有基线可比。6. 几个踩过之后才明白的点模型这东西看文档觉得都懂真上手才知道差别在哪。我最深的一个体会是并发问题几乎从来不是线程不够而是共享太多。前面那些调参、换模型的手段本质上都在缓解共享带来的竞争真正釜底抽薪的做法是把状态拆开让每个执行单元只碰自己的数据最后再合并结果。我后来写并发代码第一反应不是开几个线程而是这块数据能不能不共享。第二个体会是关于测量的。参数公式、经验阈值、模型对照表这些东西的价值在于给你一个起点和一个怀疑方向不在于给出答案。同样是 8 核机器你的任务等待比例、内存访问模式、下游依赖的响应分布都和别人不一样照抄参数十有八九要返工。花两个小时搭个能跑的压测脚本比看十篇调优文章有用。第三个小技巧关于排查给每个线程池、每个关键线程起一个能一眼看懂的名字前缀带上模块和用途比如order-io-pool-3。这个动作成本几乎为零但在你半夜被叫起来查一个服务变慢的问题时jstack输出的第一屏就能告诉你答案在哪。我现在接手任何项目第一件事就是检查线程命名没有命名的先补上这比任何监控大盘都来得直接。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。