你写了个多线程服务,本地压测挺顺,一上线就 CPU 打满、延迟抖动,或者偶尔整个卡住不动。这类问题十有八九不是算法错,而是线程模型没选对、锁用得乱、或者共享状态没规划好。这篇不讲语言语法,只给一套从线程模型选型到同步原语取舍、再到卡死与性能问题排查的判断顺序,让你能对着自己的代码逐条过一遍。
你写了个多线程服务,本地压测挺顺,一上线就 CPU 打满、延迟抖动,或者偶尔整个卡住不动。这类问题十有八九不是算法错,而是线程模型没选对、锁用得乱、或者共享状态没规划好。
这篇不讲语法,只给一套从线程模型选型到同步原语取舍、再到卡死与性能问题排查的判断顺序。你可以对着自己的代码逐条过一遍,把「感觉没问题」的地方换成能验证的检查点。
先把线程模型定下来,再谈优化
模型选错,后面所有优化都是给错误的架子刷漆。常见有三种:一请求一线程、固定线程池、异步事件循环加少量工作线程。判断依据不是哪个更高级,而是你的任务里阻塞占比有多少、请求量是否可预测、以及能不能接受排队。
| 模型 | 适合的负载 | 明显不适用的情况 |
|---|---|---|
| 一请求一线程 | 请求量低、任务耗时长且形态各异 | 并发请求数上千,线程创建销毁本身就成了开销 |
| 固定线程池 | 请求量中等偏高、任务耗时接近、可接受排队 | 任务里有大量长时间阻塞,线程会被占死,队列越堆越长 |
| 异步事件循环加少量工作线程 | 连接数极多、单请求计算量小、IO 密集 | 有大量同步阻塞调用或重计算,事件循环会被一个任务拖住 |
线程数不是越多越好。一个粗略思路是:以核心数为基准,再按任务里等待时间占总时间的比例往上调,等待占比越高可以调得越多。但这套算法在混合任务场景下会失准,因为不是所有任务都按同一个比例等待,调完必须用压测数据回验,别只靠公式。
线程归属和生命周期要写清楚:谁创建、谁回收、退出时怎么保证在途任务不丢。至少要明确三件事——线程由哪个模块统一管理、退出信号发出后已入队的任务走完还是丢弃、以及有没有一个地方能等到全部线程结束再释放资源。
同步原语的取舍清单
互斥量、读写锁、条件变量、原子操作、无锁队列,各自解决的是不同问题。选错的典型表现是:本来该用消息传递的地方硬上锁,或者该用条件变量等事件的地方拿原子变量死循环自旋。
- 互斥量
- 保护一段临界区,最简单也最容易用错粒度;临界区里做 IO 或长循环会拖垮并发
- 读写锁
- 读多写少时有用;写操作频繁或临界区很短时,额外开销可能比互斥量还大
- 条件变量
- 等一个状态变化;必须配一个能表达该状态的谓词,且唤醒后要重新检查谓词
- 原子操作
- 单个变量的无锁读写;多个变量要一起保持一致性时,单靠原子操作往往不够
- 无锁队列
- 高频生产者消费者场景;实现复杂,内存回收和 ABA 问题处理不好反而更难查
锁粒度怎么切,看你手里的两个信号:一是临界区里有没有阻塞调用或重计算,有就说明该拆;二是加锁后并发度是不是明显掉下来,掉了说明锁太粗。反过来,如果拆完发现到处都在修竞态、代码越来越难读,说明拆过头了,该往回收一点。
- 能用线程本地数据解决的,就别共享
- 能用消息传递代替共享内存的,优先消息传递
- 非共享不可时,把读写范围压到最小,别顺手把整个对象锁住
- 共享状态的修改尽量集中到少数几个入口,方便日后审查
- 每次加锁前问一句:这段代码真的需要同时被多个线程看到吗
卡死和竞态怎么查
卡死优先按顺序排查,别一上来就怀疑业务逻辑。多数所谓死锁,其实是锁顺序不一致,或者条件变量漏了唤醒、等错了条件。
- 查锁顺序列出所有会嵌套持有的锁,看不同线程获取它们的顺序是否一致;不一致就是死锁高发点
- 查条件变量确认等待方用的是 while 循环检查谓词而不是 if,确认通知发生在持锁状态下、且覆盖了所有会改变谓词的分支
- 缩并发复现把线程数降到最低、把并发路径缩到两条,看问题还在不在;不在就逐步加回,定位到具体那条路径
- 用工具确认位置调试器看各线程栈,重点看哪些线程卡在等锁;注意发布构建被优化过的二进制上,栈信息可能不完整
竞态比死锁更隐蔽,因为它不一定每次都触发。一个实用习惯是:给关键共享状态加上断言或运行时检查,在测试构建里开着,发布构建再关掉。这类手段不能证明没有竞态,但能让偶发问题在压测阶段多暴露几次。
查不出位置的卡死,先怀疑锁顺序;时有时无的错乱,先怀疑共享状态入口太多。
高并发下性能上不去怎么定位
先分清线程到底在忙还是在等。CPU 打满和线程在等锁、等 IO、等内存分配,是四种完全不同的优化方向。看 CPU 使用率、线程状态分布、以及锁竞争统计,比盯着总延迟数字有用得多。
| 现象 | 常见原因 | 先动哪里 |
|---|---|---|
| CPU 打满、吞吐上不去 | 锁竞争激烈或计算本身太重 | 先减少临界区,再看热点函数 |
| CPU 不高但延迟高 | 线程在等锁、等 IO 或等内存分配 | 先定位等待类型,再决定换原语还是换模型 |
| 并发一高就抖 | 上下文切换过密、频繁创建销毁线程 | 把线程生命周期改成复用,拉开单次任务粒度 |
| 加核数不涨吞吐 | 存在串行段或伪共享 | 找共享写点,检查相邻数据是否被不同线程高频写 |
几个隐性开销容易被忽略:频繁加解锁、伪共享、频繁创建销毁线程、上下文切换过密。它们单独看都不大,叠在一起就会让并发度上不去。压测要有基线:先量出单线程或低并发下的延迟,再逐级加压,看拐点出现在哪一档。
想横向比较不同实现或配置的实测表现,可以到站内的速度测试与工具页面记录当时的数字,别凭记忆对比;价格和模型资料涉及的数值,到 /price-watch 和 /sites 核对当时的版本与口径。
下一步把你这套服务的线程数、锁清单和并发压测基线各记一份,再对着站内工具页做一轮可复现的对比。→常见问题
线程池的线程数到底设多少合适?
没有一个固定公式,它取决于任务是 CPU 密集还是等 IO、单次任务耗时多长、机器有几个核。稳妥做法是先用一个偏保守的值跑压测,按延迟拐点逐步往上调,别一次开很大。具体到你的场景,还要看同一台机器上还有没有别的进程在抢资源。
原子操作能完全替代锁吗?
不能。原子操作适合单个变量的计数、标志位这类简单场景,一旦要同时保证多个变量的一致性,或者逻辑里有「先判断再修改」的步骤,还是得靠锁。无锁结构写起来容易出隐蔽错误,团队里没人熟悉的话,用锁反而更稳。
程序跑着跑着卡住不动,第一步看什么?
先确认是所有线程都停住,还是只有部分请求没响应。所有线程停住,优先怀疑死锁,去看当前各线程卡在哪个调用上;只有部分卡住,更像是某个任务队列堵了或者锁被长时间占用。定位清楚再改,别盲目加超时掩盖问题。