第 18 章 神秘卡顿——“全机冻结几秒”的微观真凶

前面 11 章,我们像法医一样把一个程序从源码拆到硬件;第 12-17 章,我们把 /proc/sys 下各家族参数逐个钉死。 从这一章起,进入真实诊断案例集:每一个事故都走完整的”现象→推断→可能推错→调整→再观察→改进”迭代环。我故意把”推错”写进来——因为真实排障里,你绝大多数是先猜错的,能救你的不是”一次猜中”,而是”有一套能回头、能验证的方法”。 第 18 章先打一种最让人抓狂的故障:全机每隔一阵子就同时卡几秒,过后又没事。这种”幽灵卡顿”几乎从来不是你代码慢,而是内核在某一刻”暂停了所有进程去干一件要命的事”。


一、卡顿有两种脸:你的慢 vs 内核的”暂停”

墨:老哥,先问个能救命的区分。一台服务”卡了”,你觉得第一时间该问什么?

你:卡……是不是我代码慢?接口 RT 高了?

墨:这是最坑的默认假设。 卡顿其实有两张完全不同的脸,治法天差地别:

脸表现根因在哪你该去哪看
① 单请求慢只有某些接口、某些请求 RT 高,其他正常你的代码/SQL/锁/外部调用应用日志、pprof、trace
② 全机同时卡同一时刻所有请求 RT 一起飙,持续几秒,过后齐刷刷恢复内核在某处”暂停了所有进程”vmstat//proc/vmstat、dmesg、调度/内存子系统

你:内核怎么会”暂停所有进程”?它不是一直在跑吗?

墨:内核确实一直在跑,但有几件事,它是以”整个世界停下来等它”的方式干的。一旦触发,正在跑的所有进程(包括你的服务、包括 ssh)都被冻结几秒。这些事主要有五类,记死:

  1. direct reclaim(直接内存回收):内存跌破 low 水位,分配线程被逼亲自同步回收,卡住自己;严重时所有分配线程一起卡(第 06 章)。
  2. THP 同步 compaction(透明大页压缩整理):申请大页时内存碎片多,内核停下进行碎片整理把零散页拼成大块,拼的过程中可能冻住(第 23 章)。
  3. 软锁 / 硬锁(soft/hard lockup):某段内核代码长时间占着 CPU 不放,抢占都进不去,看门狗最终报 lockup(第 05 章 softlockup_panic)。
  4. stop_machine 类操作:内核要做一件”必须让所有 CPU 同时静止”的事(如某些模块加载、CPU 热插拔),会冻住全机短暂时间。
  5. 写回拥堵 / 文件系统 journal 卡顿:脏页堆积到前台被逼同步刷盘,或 jbd2 事务连带卡住(第 07 章)。

墨:这一章的主角,是前两类——它们最隐蔽,因为 top 看 CPU 不高、free 看内存没满,监控一片”正常”,业务却在尖叫。


二、事故全景:订单服务的”幽灵卡顿”

📦 案例:某电商订单服务,8C16G 容器,Go 写。运维告警"RT 周期性异常",但每次去查都"好了"。

现象(第 0 层:先记录,不解释)

值班同学拉出 APM 曲线:订单服务 P99 延迟每隔 2040 分钟出现一次 25 秒的尖峰,尖峰期间所有接口、所有实例同时飙高,不是某一台、某一个接口。尖峰过后几秒内全部恢复如初,像什么都没发生。top/htop 抓不到(因为太短),CPU 利用率峰值才 30%,free 显示内存还剩 4G+。

墨:注意两个关键信号:(a) 所有请求”同时”卡 → 指向内核级暂停,不是单请求慢;(b) CPU 不高、内存没满 → 排除了”CPU 打满/内存耗尽”这两类最显眼的锅。这把嫌疑直接引向”内核在干某件暂停大家的事”。

推断 1:是不是 GC?(可能推错)

你:Go 服务嘛,我先怀疑 GC。STW 不就会暂停所有 goroutine 几毫秒到几百毫秒?

墨:合理。第一反应查 GOGC 和 GC trace。运维开了 GODEBUG=gctrace=1,翻日志——卡顿的那 2~5 秒里,GC 次数和 STW 时长完全正常,没对上时间点。而且 Go 的 STW 通常是毫秒级,到不了秒级。

结论:推错。GC 不是元凶。 但这一步没白做——你排除了一个大类。

推断 2:是不是网络抖动?(可能推错)

你:那是不是上游网络、或者我们入口 SLB 抖动?

墨:查网络监控:tcp 重传率、入向带宽、SLB 连接数——卡顿窗口内全部平稳。而且如果是网络,应该是”部分请求超时”而不是”全部请求同时 RT 均匀拉高”。

结论:推错。网络排除。

推断 3:是不是慢 SQL?(差点骗过你)

你:会不会是数据库慢查询,把请求堵在 DB 调用上?

墨:这回差点中招。卡顿窗口里,DB 端的慢查询日志确实也跟着涨了——于是第一版结论写的是”DB 慢查询导致卡顿”。但有个细节救了大家:DB 的 RT 上涨曲线,比应用 RT 上涨晚了约 1~2 秒。也就是说,是应用先卡,DB 才跟着卡,而不是反过来。

结论:DB 是”被连带的受害者”,不是源头。(这和第 11 章”卡 2 秒分段决策树”里”谁先动谁就是源头”的判断法一致。)

再观察:把显微镜对准内核(关键转折)

你:网络、GC、DB 都排除了……那还能看什么?

墨:看内核在卡顿那一瞬到底在忙什么。 让运维在机器上常驻跑 vmstat 1,把日志落盘,事后对齐尖峰时间点。对齐后,真相浮现:

procs ---------memory---------- ---swap-- -----io---- -system-- ----cpu----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs  us sy id wa
 2  0   1024  4200    ...   ...      0    0     ...                      
# 卡顿尖峰那几行:
 9  3   4096  3800    ...   ...     85  120    ...   ...                # si/so 突然跳
 7  4   5120  3600    ...   ...    140  200    ...                       # swappiness 在换

关键信号:卡顿瞬间 si(swap in)和 so(swap out)同时跳动,sy(系统态 CPU)偏高,但 free 明明还有 3~4G。也就是说——内存没满,内核却在换页。

推断 4:swap thrashing(内存没满却在换页)

墨:这就是经典 swap 颠簸(thrashing):某进程周期性申请一大块内存,把匿名页挤去 swap;刚换出去,代码又要访问那些页,又换回来。一来一回,si/so 狂跳,被换出的页访问时得从磁盘(swap 分区)读回,所有访问这些页的请求都被冻住等 I/O。因为 swap 分区在磁盘上,这一步是”同步等盘”,于是全机同时卡几秒。

根因参数:vm.swappiness=60(默认值)。在”有一点点 swap 但内存其实够”的容器里,60 的积极性太高,稍微内存波动就把匿名页往 swap 推,然后马上又换回,形成颠簸。

调整 1:把 swappiness 压下来

墨:方向明确——降低换出积极性。运维执行:

sysctl -w vm.swappiness=10          # 从 60 降到 10,除非水位危险绝不主动换

同时排查”周期性大分配”:发现有个监控 agent 每 30 分钟做一次全量 metrics 序列化,临时吃 2G 内存,正好踩中 20~40 分钟的卡顿节奏。把它改成增量上报 + 限制 batch 大小。

再观察 1:频率降了,但没消失

你:调完 swappiness,卡顿从”每 20~40 分钟一次”变成”偶尔一次”,但还是会有一次 8 秒的超长卡顿,没根治。

墨:好,这说明至少有两股力量。第一股(swap 颠簸)压下去了,但还有第二股。继续盯 vmstat 和 /proc/vmstat。这次抓到另一组信号:超长卡顿那几秒,vmstat 里 b(不可中断睡眠进程数)飙到几十,/proc/vmstat 里 compact_stall(因碎片做同步 compaction 而卡住的分配次数)暴涨。

推断 5:THP 同步 compaction 卡住

墨:第二股力量浮出:透明大页(THP)的同步碎片整理。进程申请 2MB 大页(THP 默认对匿名页生效),但内存碎了,凑不出连续 2MB,内核停下来做 compaction——把零散 4KB 页挪来挪去拼成 2MB 大块。这个”挪”是同步的,期间分配线程被冻住;碎片越严重、内存越大,冻得越久,于是那次 8 秒超长卡顿。

相关开关:/sys/kernel/mm/transparent_hugepage/defrag,默认 always——只要缺大页就同步整理。解法:

# 改成 madvise:只有显式 madvise(MADV_HUGEPAGE)的才整理,普通分配不强制
echo madvise > /sys/kernel/mm/transparent_hugepage/defrag

(更稳的做法是 echo never 关掉 THP,或 defrag=defer 交给后台 khugepaged 异步整理。取舍见第 23 章。)

改进:卡顿消失,收口

墨:两股力量都按住后,连续观察一周,幽灵卡顿归零。复盘这次排障,最有价值的不是”最后改了哪两个参数”,而是那条诊断链:

所有请求同时卡 + CPU不高 + 内存没满
   → 排除 GC/网络/DB(它们都会"留下时间差"或"只影响部分")
   → 显微镜对准内核 (vmstat 对齐尖峰)
   → 发现 si/so 跳动 (swap thrashing, swappiness 太高) → 调 swappiness=10 + 杀大分配
   → 残余超长卡 + compact_stall 暴涨 (THP 同步 compaction) → defrag 改 madvise

你:所以”卡顿”在我眼里,从此分两种——我代码的慢,和内核暂停大家的慢。

墨:对。而且第二类,你调应用代码是一点用都没有的,只能去内核那几处”会暂停世界”的机制里找。 这也是全书反复讲的那句话:懂微观,才谈得上优化;不懂,你连锅在谁那是哪都分不清。


三、配套参数与观测:怎么”看见”内核的暂停

1) vmstat 1 关键列(这次真用上了)

列含义卡顿诊断里看什么
si / soswap in / swap out(KB/s)同时跳动 = swap 颠簸,即使 free 还有
sy系统态 CPU 占比卡顿时偏高 = 内核在忙(回收/换页/compaction)
b不可中断睡眠(D 状态)进程数飙高 = 有进程卡在等 I/O 或内核同步操作
cs上下文切换次数异常高 = 可能在抢锁/被调度打断
free / buff / cache内存三态free 没满却 si/so 跳,才是 swap thrashing 的特征

2) swappiness 的权衡(深化第 12 章)

  • =60(默认):积极把匿名页换出,腾 page cache。容器/内存偏紧且有一点 swap 时,极易触发颠簸。
  • =1~10:几乎不主动换,保匿名页低延迟。多数数据库/容器服务的稳妥值。
  • =0:不是”关 swap”,是”除非水位危险绝不换”。在内存真紧时,page cache 被挤、direct reclaim 概率升,反而可能更抖(第 12 章思考题 3 详述)。

3) THP 与 compaction 的开关权衡

开关取值换来 / 代价
transparent_hugepage/enabledalways/madvise/neveralways 普惠大页(TLB 命中高)但碎片整理代价大;never 零风险但丢 TLB 收益
transparent_hugepage/defragalways/defer/madvise/neveralways = 缺页时同步整理(可能冻进程);defer/madvise = 交给后台 khugepaged 异步整理(不冻你)

4) 不能被忽略的兄弟指标

  • /proc/vmstat 里的 compact_stall(因 compaction 卡)、allocstall(因 direct reclaim 卡):这两个计数器的增量,是”卡顿是不是内核暂停类”的铁证。
  • dmesg | grep -i "lockup\|softlockup\|BUG":软锁/硬锁的痕迹。
  • sar -B 1(换页统计)、sar -W 1(swap 进出):事后对齐时间轴。

四、参数总表(这一章动过的”拿什么换什么”)

参数 / 文件默认本次取值拿什么换什么
vm.swappiness6010防 swap 颠簸 ↔ 内存紧张时 page cache 可能被挤
transparent_hugepage/defragalwaysmadvise不冻进程 ↔ 大页覆盖率低一点(交给后台)
(监控 agent 大分配)—增量+限批不周期性挤内存 ↔ 监控精度略降

五、🔧 思考题(都配参考答案)

思考题 1(基础): 一台机器”所有请求同时卡几秒、过后自愈”,你为什么应该先排除 GC、网络、慢 SQL,而不是直接去查应用代码?给出你的判断逻辑。

【参考答案】

“所有请求同时卡、过后齐刷刷恢复”指向内核级暂停,而不是单请求慢。判据:

  • GC:STW 通常毫秒级,Go 的 gctrace 能对上时间点;本次对不上且量级到秒,排除。
  • 网络:若是入口/网络问题,应是”部分请求超时/重传”而非”全部 RT 均匀拉高”,且网络监控平稳,排除。
  • 慢 SQL:DB 端 RT 上涨比应用晚 1~2 秒 → DB 是被连带的受害者,不是源头(谁先动谁才是源头)。 排除这三者后,嫌疑直接收敛到”内核在某一刻暂停了所有进程”(direct reclaim / THP compaction / lockup / 写回),下一步该上 vmstat//proc/vmstat/dmesg 看内核,而不是改应用代码。

思考题 2(深入): 案例里 free 还有 3~4G,si/so 却在跳动。请解释”内存没满却在换页”为何可能,它和 swappiness 的关系是什么?

【参考答案】

“换页”不要求内存”满”,它取决于内核换出的积极性(swappiness)。swappiness=60 默认偏激进:只要有一点 swap 可用、且内存有波动(比如某进程周期性申请一大块,把匿名页挤到回收边缘),内核就会把不常用的匿名页换到 swap 以”腾 page cache”。当代码很快又要访问这些被换出的页,又得 swap in——si/so 同时跳,形成颠簸(thrashing)。因为 swap 在磁盘上,换入是同步等盘,访问这些页的进程被冻住,表现为全机卡几秒。内存”没满却换页”正是 swappiness 太高 + 内存波动共同导致的,不是内存耗尽。解法是压低 swappiness(如 10)并消除周期性大分配。

思考题 3(权衡): 把 transparent_hugepage/defrag 从 always 改成 madvise,可能损失什么?为什么这个损失通常可接受?

【参考答案】

损失:大页的覆盖率会下降。原来所有匿名分配都尽量走 2MB 大页(THP 普惠),改 madvise 后,只有显式 madvise(MADV_HUGEPAGE) 的区域才会整理出大页,普通分配退回 4KB 页,TLB 命中率略降(大页的核心收益就是减少 TLB miss)。 为什么可接受:原来 defrag=always 的代价是缺页时同步做 compaction,碎片多时可能冻住进程几秒(本案 8 秒超长卡顿的真凶)。改 madvise/defer 后,大页整理交给后台 khugepaged 异步做,不再冻你的进程。对绝大多数服务,TLB 命中率的那点损失,远小于”被冻几秒”的风险。真要吃满大页收益又不想冻,可以对关键堆区显式 madvise 并配合 defrag=defer。

思考题 4(进阶总账): 假如你接手一台”偶发卡顿”的机器,请列出你会第一优先常驻采集的 3 类内核信号(命令/文件),以及它们分别能暴露”五类内核暂停”里的哪几类。

【参考答案】

第一优先常驻采集 3 类:

  1. vmstat 1 落盘(或 sar -W/-B)——暴露 direct reclaim(allocstall/b 列飙)、swap 颠簸(si/so 跳)。
  2. /proc/vmstat 的 compact_stall/allocstall 计数器增量——前者钉死 THP compaction 卡顿,后者钉死 direct reclaim 卡顿;对齐时间轴就能和卡顿尖峰对应。
  3. dmesg/kernel log 里的 softlockup/hardlockup/BUG——暴露软锁/硬锁与 stop_machine 类异常。 外围辅助:/proc/<pid>/status 的 State: D(不可中断,对应等 I/O/某些同步内核操作)、iostat 看写回拥堵(第五类)。这三类能覆盖”五类内核暂停”里的四类(direct reclaim、THP compaction、lockup、部分 I/O 等待),剩下写回拥堵靠 iostat/写回指标补。

六、第二个现场:soft lockup 导致的整机冻结(暂停类另一兄弟)

墨:第 18 章开头我说”内核暂停类”有五兄弟,第一现场(订单服务)只牵出了 direct reclaim 和 THP compaction 两位。这第二个现场,牵出第三位——soft lockup(软锁),它和前两位是表亲,但根子不同。

📦 案例:某存储节点,每隔几小时"假死"20~40 秒,期间 SSH 都连不上,top 抓不到,过后自愈。

现象(第 0 层)

运维描述:“机器像被按了暂停键 30 秒,然后自己又好了。“dmesg 里一行反复出现:

watchdog: BUG: soft lockup - CPU#3 stuck for 23s! [kworker/3:1:1234]

墨:关键信号:dmesg 的 soft lockup: CPU#N stuck for Ns——这是内核看门狗(watchdog)发现某颗 CPU 关中断(或长时间不调度其他任务)超过 watchdog_thresh(默认 10 秒)后报的警。它和”全机同时卡”一样,是内核暂停类的成员,但卡的是”某颗 CPU 长时间不放行”,而非”所有进程同步等内存”。

推断 1:CPU 打满?(部分对)

你:top 看是不是 CPU 100%? 墨:看 top——确实有某颗核 sy(系统态)长时间 100%,但整机 CPU 平均才 30%。说明不是”算力不够”,是某核被某个内核任务独占且不让出。这把方向指向”内核路径长关中断 / 长循环 / 实时任务占满”。

推断 2:长关中断的内核路径(命中)

墨:传动链:某个内核函数(常见于特定驱动的 DMA 映射、某个 spin_lock 持锁太久、或某个大循环没 cond_resched)长时间关中断或占着 CPU 不放→ 看门狗线程自己都得不到调度 → 报 soft lockup。期间这颗核上所有用户任务都被冻住,表现就是”那 30 秒部分卡死”。

调整 + 根因

墨:分两层:

  • 止血:临时设 kernel.softlockup_panic=1 + kernel.panic=10(第 16 章事故 C),让”卡死超阈值”直接 panic 并 10 秒自动重启,比”僵死等人工”恢复快(代价是失现场,需 kdump)。
  • 根因:用 perf record -g 抓那颗核的 on-CPU 火焰图,定位是哪个内核函数占着;或 ftrace 函数耗时。本案定位到某老版本网卡驱动在 napi_poll 里关中断做了一长串校验,升级驱动后消失。

墨:soft lockup 和第一现场的 swap thrashing/compaction 是表亲——都是”内核在某刻暂停了进程,你调应用代码无效”。区别:lockup 卡在”关中断/长循环”,reclaim/compaction 卡在”同步内存操作”。排障都靠 dmesg+perf+vmstat,不靠改应用。


七、内核”暂停类”五兄弟总账(把本章升级成武器)

墨:把全书遇到的”内核暂停/冻结”机制收成一张表。你以后任何”全机或某核同时卡、CPU 不高、内存没满、改应用无效”的现场,先按这张表对号入座,5 分钟内缩到某一行:

兄弟触发观测铁证根治方向
① direct reclaim内存跌破 low,分配线程同步回收vmstat 的 b↑、/proc/vmstat 的 allocstall↑watermark_scale_factor↑、min_free_kbytes↑、swappiness↓
② THP compaction缺大页+碎片,同步拼大块compact_stall↑、defrag=alwaysdefrag=madvise/never、显式大页
③ soft lockup长关中断/长循环/rt 占满dmesg “stuck for Ns”、某核 sy 100%找长关中断路径、softlockup_panic
④ stop_machine需全 CPU 静止的操作(模块加载/CPU 热插拔)dmesg/ftrace避免频繁模块加载、热插拔
⑤ 写回/journal 拥堵脏页堆积前台 sync + jbd2iostat w/s↑、jbd2 CPU↑、dirty_ratiodirty_ratio/bg↓、fdatasync 替代

墨:这五兄弟的共同 DNA = “卡顿瞬间全机/某核同时冻,CPU 不一定高,内存不一定满,改应用代码无效”。你掌握这张表,任何”幽灵卡顿”都能在 5 分钟内从”懵”变成”哦,是 ①~⑤ 里的某一个”。这也是全书反复验证的那句:懂微观才配谈优化——你连”卡在哪种内核机制”都分不清,调应用代码一万年也调不好。



八、第三个现场:写回与 journal 拥堵,把”写”冻住(暂停类第四兄弟)

墨:第一现场牵出 direct reclaim + THP compaction,第二现场牵出 soft lockup。这第三个现场,把”暂停类五兄弟”里的 ④ 写回/journal 拥堵 也请出来——它最容易被误判成”磁盘坏了”,但其实卡在”内核逼你同步等脏数据落盘”。

📦 案例:某订单库,每隔几分钟出现 1~3 秒的"所有写请求集体挂起",期间 CPU 不高、磁盘 %util 也不满,但 jbd2 线程 CPU 飙满一颗核,一堆 MySQL 线程卡在 D 状态。

现象(第 0 层)

DBA 描述:“INSERT/UPDATE 偶尔集体卡 2 秒,像被冻住,之后又好了。“atop 看盘没满,但 ps 里一堆线程 STAT=D(不可中断睡眠),jbd2-<dev>-<n> 占满一颗核。

推断 1:磁盘坏道?(推错)

你:是不是盘有坏道、IO error 导致卡? 墨:查 dmesg 没有 media error、没有 IO error;且卡是周期性的(每隔几分钟一波),坏道不会这么规律。排除硬件。

推断 2:前台被逼同步刷脏页(命中一半)

墨:传动链第一层——vm.dirty_ratio 默认 20%,脏页攒到内存 20% 时,新来的写请求被内核拦下,逼它亲自把脏页 sync 落盘(第 06/12 章)。当瞬时大量写(如整点批处理)涌来,脏页瞬间顶到 20%,所有写线程被堵在 sync 上,表现就是”写集体挂起 1~3 秒”。jbd2 是 ext4 的 journal 提交线程,它在替这些 sync 写做事务提交,所以 CPU 飙高。 验证:grep Dirty /proc/meminfo 卡顿时脏页接近 dirty_ratio 对应值;iostat 看 w/s(写完成)在卡顿时暴涨。

推断 3:journal 提交成了”第二堵点”(深化)

墨:传动链第二层——ext4 的 journal(jbd2) 保证崩溃一致性:每次事务提交要等磁盘 ack。默认挂载 commit=5(每 5 秒强制提交),到点时积攒的事务一次性刷盘,期间所有依赖该 journal 的写都被阻塞。data=ordered 模式下,数据页还要先于元数据落盘,进一步拉长阻塞窗口。这解释了”为什么是 1~3 秒而非几十毫秒”:不是单次 IO 慢,是一大波事务被串行化提交,前面的没 ack 后面的全等着(第 07 章写回机制的现场版)。

调整(组合拳)

墨:三剂药:

  1. 压低脏页阈值(延续第一现场套路):sysctl -w vm.dirty_ratio=10、vm.dirty_background_ratio=5,让后台更早、更碎地刷,别等攒到 20% 逼前台 sync。
  2. 给 journal 减负:挂载选项 commit=30(拉长强制提交间隔,削掉”到点一次性刷”的冲击);写极重的库可考虑 XFS(日志设计更顺)。
  3. 应用层削峰:批量写加 rate limiter,把写洪水分摊(呼应第 24 章”缓冲治突发不治过载”)。

改进:写冻结归零,收口

墨:三剂药下去,写挂起从 2 秒降到 <100ms。这一现场把”暂停类第四兄弟”补全了:它和前三个一样——“卡顿时 CPU 不一定高、内存不一定满、改应用代码无效”,根子在内核同步等 IO。你排障时看到 STAT=D + jbd2 高 + 脏页触顶,直接往 dirty_* 和文件系统挂载选项去,别换盘、别查 SQL。这一现场也顺手把第 07 章讲的”写回为什么延迟”从一个机制,变成了一个你能亲手抓到的真凶。


实战补遗一:khugepaged 后台合并大页,把延迟毛刺做成”周期性 200ms”

墨:你开了 THP(defrag 设成 defer/madvise,没用 always),以为不会冻进程。但监控上 P99 每隔几秒抖 200ms,且 top 里有个 khugepaged 内核线程 CPU 偶尔飙高。你第一反应是什么?

你:THP 不是只在分配时合并吗?defrag 没开 always,怎么会冻?

墨:你漏了一个常驻守护线程:khugepaged。它和”分配时合并”是两回事——它是后台主动扫描内存、把连续的 4K 页合并成 2MB 大页的,不管你 defrag 设什么,它都在跑(除非显式关掉它的 defrag 或彻底关 THP)。这是 THP 的”第二张脸”,第 23 章讲 defrag=always 的冻结,这里讲后台合并的冻结。

现象:周期性的 200ms 毛刺,查不到业务原因

某 Java/Go 服务开 THP(enabled=always 或 madvise),运行平稳但 P99 每隔 5s 抖到 200ms,持续 50100ms 然后恢复。业务代码无规律、无 GC 大停顿、CPU 不忙。top 偶尔见 khugepaged 内核线程占一个核 30%~。

推断(可能推错):先查 GC 和调度

第一推断:「Go/Java GC 停顿。」——GC trace 显示停顿 < 5ms,不是。 第二推断:「CPU 调度抢占 / CFS 不公平。」——cpu.stat 无 throttle,也不是。 都推错,因为没看内核线程 khugepaged 和 THP 统计。

真相:khugepaged 合并要锁页、可能搬页

khugepaged 周期扫描,发现一段连续的 4K 页就尝试合并成 2MB 大页。合并过程:

  • 要给目标 2MB 大页找连续物理块(可能触发直接回收);
  • 要锁住相关页表、把旧 4K 页内容拷到大页、更新页表——这段临界区里,访问这些页的进程会短暂 STW(停止世界),表现为几十~几百 ms 的停顿;
  • 合并频率由 /sys/kernel/mm/transparent_hugepage/khugepaged/ 的 pages_to_scan / scan_sleep_millisecs 控制(默认每 10s 扫 4096 页)。

所以即使你 defrag 不是 always,khugepaged 的后台合并照样制造停顿——它和”分配路径上的 defrag”是两条独立的触发路径。你以为躲开了 defrag=always 就安全,其实后台线程一直在你不注意时合并。

调整:按需关掉后台合并

  • 延迟敏感服务:echo never > /sys/kernel/mm/transparent_hugepage/enabled(彻底关 THP)最稳;
  • 或保留 madvise 让应用显式对特定大块内存 madvise(MADV_HUGEPAGE),不交给 khugepaged 全局扫描;
  • 实在要 THP:echo 0 到 khugepaged/defrag 并调大 scan_sleep_millisecs 降低频率(治标);
  • 监控 cat /proc/vmstat | grep thp(thp_collapse_alloc 涨说明在后台合并)、cat /sys/kernel/mm/transparent_hugepage/khugepaged/full_scans;
  • 验证:perf 抓 khugepaged 的 on-CPU 时间,或 bpftrace 盯 collapse_huge_page 耗时。

再观察

echo never 关 THP 后,P99 毛刺消失,khugepaged CPU 归零,吞吐略降(大页 TLB 命中收益没了)但延迟稳定——对延迟敏感服务,稳 > 那点 TLB 收益。

改进(权衡:拿什么换什么)

动作拿什么换什么
关 THP(never)失去大页 TLB 命中收益零 khugepaged 停顿
madvise 显式大页改代码标注热内存只该合并的合并
降 khugepaged 频率合并更慢毛刺变稀

🔧 思考题:defrag=defer 和 khugepaged 的合并,哪个更可能冻你的进程?为什么?

【参考答案】

khugepaged 更可能周期性冻进程,因为它是后台守护线程全局扫描,频率固定、与你分配时机无关,合并临界区里会短暂 STW 访问这些页的进程。defrag=defer 只在”你主动分配大页、且当时没有现成大页”时才异步等合并,影响的是分配路径、且有 madvise 控制范围。所以即便你躲开了 defrag=always(分配即合并冻进程,第 23 章讲过),khugepaged 这条后台路径仍可能在你不分配时制造毛刺——它是 THP 的”第二张脸”,调参时不能只看 defrag,要把 khugepaged 一起看。


实战补遗二:soft lockup 真凶——一个内核模块持自旋锁太久,把单核冻 20 秒

墨:dmesg 刷 BUG: soft lockup - CPU#3 stuck for 23s!,业务那个核上所有任务卡 23 秒。你第一反应查业务代码死循环?

你:卡 23 秒,肯定是哪个进程死循环占满核吧?

墨:soft lockup 不是”进程死循环”,是”内核态持锁/关抢占太久,调度器 23 秒没机会跑”。用户态死循环顶多抢 CPU,不会让 soft lockup(因为抢占还能切);只有内核态长时间不调度才会触发 watchdog。第 18 章五兄弟里”soft lockup”这位,凶手常在驱动/内核模块的自旋锁。

现象:单核冻 23 秒,那个核上全卡

某机器 CPU3 上的服务周期性”假死”23 秒:dmesg soft lockup - CPU#3 stuck for 23s,期间该核上所有任务(包括内核线程)都不动,业务连接超时。其他核正常。

推断(可能推错):先查用户态死循环

第一推断:「业务代码死循环 / 自旋。」——但用户态自旋会被 CFS 抢占,不会 soft lockup,且只卡一个核不符合(死循环会占满但调度器仍 tick)。推错方向。

真相:内核态持自旋锁 + 关抢占,watchdog 饿死

kernel.watchdog(softlockup_panic=0 时只报不崩)每 1/(2*watchdog_thresh) 检查某核是否没发生调度。若某内核路径(如第三方内核模块、或某持 spin_lock 且关了抢占的长临界区)在 CPU3 上跑了 23 秒不释放,该核的 rq 没机会调度 → watchdog 报 soft lockup。常见于:

  • 第三方驱动/内核模块里有长临界区或 while 自旋等锁;
  • vm 的大范围操作(如早期 compact_* 没设限)在某些内核版本持锁过久;
  • rcu 被一个长期关抢占的线程阻塞(rcu_sched stall,类似)。

调整:定位持锁路径 + 治本

  • 用 perf sched record / bpftrace 的 offcputime 看”谁占了 CPU 不调度”;或 echo 1 > /proc/sys/kernel/softlockup_panic 让它 panic 出 vmcore 抓栈(生产慎用);
  • 查最近装的内核模块/驱动(soft lockup 常在装了某驱动后出现);lsmod 时间线对照;
  • watchdog_thresh 调大只延后告警,不解决;
  • 升级内核/换驱动(根因在模块临界区);
  • 监控 dmesg 的 soft lockup / rcu_sched detected stalls。

再观察

定位到一个第三方监控驱动的长自旋锁,卸载后 soft lockup 消失,CPU3 不再冻。

改进(权衡:拿什么换什么)

动作拿什么换什么
卸载问题驱动该驱动功能暂时没了核不冻
softlockup_panic=1可能误重启出 vmcore 抓栈
watchdog_thresh 调大晚告警不解决(仅观察)

🔧 思考题:soft lockup 和 hard lockup 区别?为什么用户态死循环不会触发 soft lockup?

【参考答案】

soft lockup = 某核超过阈值没发生进程调度(内核态持锁/关抢占太久),watchdog 基于调度节律检测;hard lockup = 某核连中断都没响应(连 NMI watchdog 都没 tick,通常是关了本地中断且自旋),更严重,可能真死机。用户态死循环不会 soft lockup,因为 CFS 有周期性 tick、抢占会发生,其他任务仍能被调度;只有内核态关了抢占(preempt_disable/持 spin_lock)又长时间不释放,才让调度器彻底没机会跑 → soft lockup。所以看到 soft lockup,第一怀疑”内核态长临界区/坏驱动”,不是你的 Go 业务代码——这也呼应第 18 章:内核暂停类问题,凶手大多在内核/驱动层,不在应用层;你的 perf/bpftrace(第 25 章)要往”内核持锁路径”方向查,而非用户态火焰图。


实战补遗三:RCU stall——比 soft lockup 更隐蔽的”内核假死”

墨:前面讲了 soft lockup(某 CPU 超过 20 秒没调度其他任务)。但内核还有个更阴的:RCU stall。你知道 RCU 是干嘛的吗?为什么它”卡住”会拖垮整个系统?

你:RCU 是 Read-Copy-Update,grace period 机制,让读者无锁读、写者延时回收?

墨:对。RCU 的核心是”宽限期(grace period)“:写者改完数据后,要等所有 CPU 上都不再有读者处于临界区,才安全回收旧数据。这个”等所有 CPU 报告 quiescent state(静止态)“的活,由 rcu_sched 内核线程盯着。如果某个 CPU 长时间不报告静止态(比如它陷在一个超长的中断禁用区间、或某个实时任务占了 CPU 不松手),rcu_sched 等不到它,就会判”RCU stall”,dmesg 刷:

INFO: rcu_sched self-detected stall on CPU 7
  (t=60002 jiffies g=... c=...)

真实事故:一台机器每隔几天就”假死”——ssh 还能连,但所有命令卡好几秒才动,top 能开但刷新极慢,持续几十秒后自愈。关键:dmesg 里全是 rcu_sched detected stalls,但没有 soft lockup 那种明确”CPU X 上的进程 Y 占了 22 秒”。

推断过程:① 先确认不是 soft lockup——kernel.softlockup_panic 没触发,且 watchdog 线程还在跑,说明单个 CPU 没完全占死;② 看 stall 信息里的 CPU 编号,每次都不同,说明不是某个固定进程作妖,而是某种”短暂但频繁”地禁用抢占/中断的行为让 RCU 收不齐 quiescent state。

根因:这台机器跑了个自研内核模块(一个高性能网络钩子),它在软中断里做了超长循环处理批量包,期间禁用了本地中断和抢占。每次处理几百毫秒,虽没到 soft lockup 的 20 秒,但 RCU 的宽限期默认容忍才 ~60 秒(rcu_cpu_stall_timeout,默认 21 秒实际触发告警),多个 CPU 轮流卡几百毫秒,RCU 收不齐静止态,就报 stall。RCU stall 后,依赖 RCU 回收的内核对象(包括很多 slab、回调)全部停滞,系统表现为”全面变慢”——这正是”假死”的来源。

调整:① 临时的”止血”:调大 rcu_cpu_stall_timeout、或 sysctl kernel.panic_on_rcu_stall=0(别让它升级成 panic);② 根本:改那个内核模块,软中断里别长时间禁用抢占,分批处理(每批处理 N 个包就 cond_resched() 让出一下,让 RCU 能收静止态)。再观察:模块改完后,dmesg 不再刷 RCU stall,假死消失。

你:RCU stall 和 soft lockup 到底谁更严重?怎么区分该查哪条?

墨:两者都表示”某 CPU 长时间不调度”,严重程度看后果:

  • soft lockup:单 CPU 完全没调度其他任务 >20 秒,那个 CPU 上的所有任务(包括 watchdog 之外的)全冻住。表现:该 CPU 上的进程卡死、可能 softlockup_panic 直接崩机。范围局部、后果硬(可能 panic)。
  • RCU stall:不是某 CPU 冻死,而是 RCU 收不齐静止态 >超时。表现:系统全面变慢、卡顿但能自愈,很少直接 panic(除非开了 panic_on_rcu_stall)。范围全局、后果软(但体验极差)。

区分:dmesg 关键词——soft lockup vs rcu_sched detected stall。前者查”谁占了 CPU 20 秒+(死循环/持自旋锁/关抢占太长)“,后者查”谁频繁短暂关抢占/中断导致 RCU 收不齐”(软中断长循环、实时任务啊 CPU、NMI 太久)。都指向”内核态/软中断里待太久”,区别在时长阈值和后果烈度。

🔧 思考题:你写了个 Go 程序,会不会触发 RCU stall 或 soft lockup?还是说这类问题只属于内核模块/C 代码?

【参考答案】

纯用户态 Go 程序几乎不可能直接触发这两者——因为 Go 跑在用户态,会被内核正常调度、时间片到了就让出,内核 watchdog 看的是”内核态/软中断上下文里待太久”,用户态代码再忙也不会让单个 CPU 在内核视角”不调度”。Go 的 GOMAXPROCS 再高,也只是用户态 goroutine 多路复用,内核照样能调度别的任务、RCU 照样能收静止态。

但间接会:① 你的 Go 程序调了 ioctl/系统调用进内核,若内核有 bug 或你触发了某个慢路径(如超大 sendfile/锁争用),可能陷内核久;② 你用 cgo 调了 C 库、C 库里有关中断/自旋锁长循环,那就和内核模块一样危险;③ 你用 perf、bpftrace 挂了内核探针,探针里代码慢也会 stall。所以 Go 程序员主要防的是”别让 cgo/C 扩展在内核态待太久”,业务 Go 代码本身不会引发 RCU/soft lockup。这也提醒:卡顿若只在跑某 cgo 扩展时出现,优先怀疑它而非业务 Go 逻辑。


实战补遗四:watchdog_thresh 与 softlockup_panic——“卡多久算死”的取舍

墨:第 18 章讲了 soft lockup(某 CPU 占满 >20 秒不调度)和 RCU stall。但”20 秒”这个阈值哪来的?能调吗?调了会怎样?

你:kernel.watchdog_thresh 控制这个阈值吧?默认 10 还是 20?

墨:默认 kernel.watchdog_thresh=10(秒),而 soft lockup 的判定是 2 × watchdog_thresh = 20 秒(这个内核写死的倍数)。所以”占满 20 秒才报 soft lockup”来自 10 × 2。你能调 watchdog_thresh 改灵敏度:设 5 → 10 秒就报(更灵敏),设 30 → 60 秒才报(更钝)。

但更关键的是 kernel.softlockup_panic(默认 0):soft lockup 发生时,0 = 只打印告警、不重启;1 = 直接 panic 重启机器。这是个”要命”的开关。

真实事故:一个生产节点,某个内核模块 bug 导致偶发 soft lockup(占满某 CPU 约 25 秒,然后自动恢复——因为 bug 是间歇的)。softlockup_panic=0,所以内核只 dmesg 刷 BUG: soft lockup,不重启。问题:这个 CPU 被占 25 秒期间,上面所有任务(包括关键业务)全冻住,业务超时、对账失败。运维看到了告警,但机器”还活着”,没人第一时间处理,bug 每天偶发几次,每次冻 25 秒,业务天天抖。

推断:soft lockup 不 panic,机器”假活”——业务在抖,但机器没死,没人紧急处理。这比”直接崩”更阴,因为它介于活和死之间,最容易被忽略。

调整(两种思路,权衡不同):① 开 softlockup_panic=1:soft lockup 直接 panic → 触发节点重启 / K8s 驱逐,业务切到健康节点,用”快速死”换”快速恢复”(适合无状态、可漂移的服务);② 或更稳:先不开 panic,但把 soft lockup 接进告警(监控 dmesg/kernel 日志的 soft lockup 关键字),一出现立刻 page 人修模块,避免每天抖。再观察:

  • 方案①:soft lockup 直接重启,业务 30 秒内漂移到别处,抖一次就结束(代价:误报也会重启,如某次合法长计算占 25 秒被误杀);
  • 方案②:告警 + 人工修模块,不误杀,但依赖人响应快。

选哪个看业务:无状态可漂移 → ①(快速死快恢复);有状态/不能随便重启 → ②(告警 + 修根因)。

你:softlockup_panic=1 会不会”误杀”?什么情况会冤枉重启?

墨:会。误杀场景:watchdog_thresh 设太小(如 5→10 秒判定),而业务合法地有”单 CPU 计算密集、不主动调度”的段(如某些加密、压缩、GC 的 STW),超过 10 秒就被判 soft lockup → panic 重启。这是假阳性:机器没坏,只是忙。

所以调 softlockup_panic=1 前必须:① watchdog_thresh 留够余量(别设太小,默认 10 通常合理);② 确认业务没有合法的长占 CPU 不调度段——Go 的 GC STW 通常 < 几 ms、用户 goroutine 会被 runtime 调度,一般不会占满 20 秒,所以 Go 服务开 panic 相对安全;但 C 扩展/cgo 里的死循环、或 CPU 绑核后单核跑满的长任务,要小心;③ 区分 soft lockup(单 CPU 占满)和 hard lockup(中断都关了、NMI watchdog 管)——hardlockup_panic 更危险(连 NMI 都触发说明真死锁),一般只在调试内核时开。

🔧 思考题:kernel.hung_task_timeout_secs(默认 120)和 softlockup 是什么关系?一个”任务 hung”和一个”CPU soft lockup”是同一回事吗?

【参考答案】

不是一回事,管的不同层:

  • soft lockup:某 CPU 超过 2×watchdog_thresh 秒没发生调度(即 watchdog 线程没被调度到)→ 说明有代码在该 CPU 上占着不放(死循环/长禁用抢占)。内核态视角,“这个 CPU 不调度别人了”。
  • hung task(hung_task_timeout_secs,默认 120):某个进程/任务处于 D 状态(不可中断睡眠)超过 120 秒→ 说明它在等 IO/锁,且等得异常久(正常 D 状态是毫秒级)。这是”任务卡在等某资源”,不是”CPU 占满”。

所以:

  • soft lockup = CPU 被占满不调度(主动占);
  • hung task = 任务卡在 D 状态等不到资源(被动等,如 NFS 卡死、设备无响应、锁死锁)。

两者都靠 watchdog,但监控对象不同:soft lockup 看”调度器是否还能跑”,hung task 看”D 状态任务是否超时”。生产都该监控:

  • soft lockup 日志 BUG: soft lockup → 查占用 CPU 的代码/模块;
  • hung task 日志 INFO: task xxx blocked for more than 120 seconds → 查它卡在哪个锁/IO(如 cat /proc/<pid>/stack 看内核栈,常指向某个文件系统/NFS/设备驱动)。

口诀:soft lockup 是”CPU 忙到不理人”,hung task 是”任务等资源等到天荒地老”,一个主动占、一个被动等,排查方向完全不同(前者查计算/禁用抢占的代码,后者查锁/IO/驱动)。



实战补遗五:RCU stall 与 soft lockup 误报——内核”卡住”不一定是应用慢

墨:老哥,你 dmesg 里见过 BUG: soft lockup - CPU#3 stuck for 23s 吗?第一反应是不是”CPU 跑满了”?

你:对,以为是进程把核占满 23 秒。

墨:这个报错字面是”软锁死”,但未必是用户态进程占着 CPU。我拆过一次——根因在内核态持锁太久,把”看门狗”饿死了,应用反而背锅。

现象:一台机器偶发 soft lockup 告警,对应核上的 Go 服务 P99 同时飙到秒级,但 top 看那个核 CPU 利用率只有 60%,并不满。运维以为是 Go 服务忙,加机器反而没用。

推断:CPU 被应用占满导致无法调度其他任务,触发看门狗。

可能推错:CPU 才 60%,说明不是”应用占满”。soft lockup 的本质是:某个 CPU 超过 20 秒(默认 kernel.watchdog_thresh=20)没发生一次调度(没跑过调度器)。原因不一定是用户态忙,更常见是内核态持自旋锁/禁止抢占太久——比如某个内核路径(文件系统、网络协议栈、RCU 回调)长时间关抢占或持锁,这段时间里这个核”无法被调度器接管”,看门狗(watchdog 线程也是普通任务)得不到运行,就报 soft lockup。

调整:看 dmesg 里 soft lockup 的堆栈回溯(stack trace),它通常会打印卡住时在内核的哪个函数。常见真凶:

  • RCU stall:RCU(读拷贝更新)的宽限期(grace period)迟迟不能结束,通常因为有任务在 RCU 临界区里待太久或长时间禁止抢占。报错是 INFO: rcu_sched self-detected stall on CPU,跟 soft lockup 常伴生。根因可能是某个内核线程/驱动长时间关抢占。
  • 关抢占的长时间操作:如 khugepaged 合并大页(见第 18 章首节)、或某个 stop_machine、或 NMI 处理过长。
  • 硬件/虚拟化:云上”偷时钟”(steal time)高、宿主机调度抖动,也会让客户机核”看似卡住”。

再观察:那次根因是宿主机热迁移/调度抖动(云厂商的”闹脾气”),客户机内一个核被宿主暂停了 20+ 秒,客户机内 watchdog 以为自己软锁死。这种”假 soft lockup”调应用没用,得找云厂商或调 kernel.watchdog_thresh 放宽阈值(临时止血,不治本)。真内核态持锁过长则要靠 perf 抓内核栈、或开 CONFIG_DETECT_HUNG_TASK 定位。

改进:

  • soft lockup 先看 dmesg 堆栈,区分”用户态忙”(top 该核算满)还是”内核态持锁/禁止抢占”(CPU 不满但卡)。
  • 云上先排除 steal time(top 里 st 列高 = 宿主偷你时间),那是宿主问题不是你的问题。
  • 真内核持锁过长:用 perf record -e sched:sched_switch 抓调度轨迹,看哪个任务长时间不交权。
  • 临时放宽 kernel.watchdog_thresh(如 30/60)减少误报,但治本要消除长时间关抢占的路径。

🔧 思考题:为什么”CPU 利用率才 60% 却报 soft lockup”恰恰说明问题不在用户态进程?soft lockup 的判定标准到底看什么,而不是看 CPU 占用率?

【参考答案】

soft lockup 的判定机制:内核每个 CPU 上有个 watchdog 线程( watchdog/ watchdogN ),它定期(每 watchdog_thresh 秒)被调度运行,运行时更新一个时间戳。如果某 CPU 超过 watchdog_thresh 秒没发生过一次进程切换(没跑过调度器),说明这个核”卡死在某一处没法被抢占调度”,watchdog 线程一直得不到运行,于是报 soft lockup。

关键点:它看的是”是否发生了调度(上下文切换)“,不是”CPU 占用率多少”。所以:

  • 如果用户态进程跑满一个核(死循环、疯狂计算),该核利用率 100%,且一直不主动让出 CPU(没 syscall、没调度点),watchdog 也跑不了 → 报 soft lockup,且此时 top 该核 100%。
  • 但如果 CPU 才 60%,说明核上有空闲、也在跑任务,却还是 20 秒没调度——那只能是”内核态某条路径关了抢占/持自旋锁,把调度器本身挡在外面了”。用户态任务即使想跑也插不进去,核看起来”有空”是因为那段内核代码没在烧 CPU,只是”占着核不让调度”。

所以”CPU 不满却软锁死”= 内核态长关抢占/长持锁(或虚拟化偷时钟),不是你的 Go 服务忙。这也是为什么”加机器/调 GOMAXPROCS”没用——根因在宿主或内核路径。区分手段:top 看 st(steal)列 + dmesg 堆栈 + perf sched 抓切换轨迹。RCU stall 同源:RCU 宽限期靠所有 CPU 都经过一次 quiescent state(静止态,通常就是一次调度点或上下文切换),某核长时间不调度 → RCU 宽限期卡住 → 报 rcu stall。

延伸:这跟第 5 章 CPU 调度器一脉相承——调度器是”一切任务能跑”的前提,它被挡住(关抢占/持锁/虚拟化暂停),上层所有延迟现象都解释不通,必须往”是不是调度本身被卡”这层想。

下一章预告

墨:第 18 章我们收拾了”全机同时卡几秒”这种最瘆人的幽灵卡顿,根子在内核的”暂停类”机制——swap 颠簸和 THP 同步 compaction。你学会了用 vmstat 对齐尖峰、看 si/so 和 compact_stall 两个铁证。

下一章(第 19 章)我们换个更血腥的现场:OOM 连环杀——一台机器上进程像多米诺一样一个接一个被 OOM Killer 干掉,dmesg 里一片 Out of memory: Killed process。这次我们要搞清楚:OOM 到底”杀谁不杀谁”由什么决定?为什么有时候被杀的不是最胖的?overcommit、swappiness、oom_score_adj 这三个参数怎么联手导演了这场连环惨案,又怎么用”免死金牌”和正确的 overcommit 策略止血。


本章完。OOM 实战见第 19 章。