30 个 CPU 全在内核态,只回收了 32 个页——per-memcg 内存回收的 mmap_lock 活锁

搬运并适配自国外社区真实案例(ClickHouse Cloud 在 GKE 上的一次 8 个月内核级排障)。原文出处见文末。

TL;DR

症状 :某几个 Pod 的 CPU 被打满到配额上限并被 throttle,内存贴着 cgroup 上限却从不 OOM ,进程完全不前进;最诡异的是 ps aux、perf、top 这些诊断工具自己也卡死 ,只有重启 Pod 能恢复。根因 :内核在处理缺页(page fault)时持有进程的 mmap_lock 去执行 memcg 内存回收 (shrink_lruvec),而高并发线程数(1000+)叠加公平调度让持锁线程被 __cond_resched() 反复让出 CPU,导致这个读锁被持有几分钟到几小时 ;期间所有需要 mmap_lock 的操作(包括 ps aux 读 /proc/<pid>/cmdline)全部阻塞。解决 :开启 MGLRU(echo y > /sys/kernel/mm/lru_gen/enabled)或升级到已修复该锁顺序的内核。

一句话记住这个案例:CPU 全烧在内核态回收上,扫了 4.27GB 只回收 32 个页------不足以触发 OOM,但足以让整台机器看起来像死机。

现象

环境:GKE + Container-Optimized OS(COS),ClickHouse Cloud 生产集群,Pod 配额多核 + 内存限额(本例 4 核 / 4GB 复现环境)。

可观测到的症状组合(原文描述 + 复现环境实测):

text 复制代码
# 1. CPU 打满并被 throttle,且完全在内核态
$ pidstat 1
Average:  UID  PID      %usr %system %guest %wait  %CPU   CPU  Command
Average:  101  66       0.00  3000.00   0.00   0.00  3000.00  39  clickhouse-serv

# 2. 任务时钟全在内核,缺页率却低到离谱(10 秒只 90 次)
$ perf stat -p $PID -- sleep 10
   304675.80 msec task-clock        # 29.130 CPUs utilized
        97934      context-switches # 0.321 K/sec
           90      page-faults      # 0.000 K/sec

# 3. 内存贴着 cgroup 上限,但没有 OOM(cgroup v1 路径)
==> memory.failcnt <==             31
==> memory.limit_in_bytes <==      4294967296
==> memory.usage_in_bytes <==      4294967296
==> memory.max_usage_in_bytes <==  4294967296

==> memory.stat <==
cache 4001792000        rss 128729088        mapped_file 4001792000
inactive_anon 128729088 active_anon 0
inactive_file 2411884544 active_file 1589776384

四个反直觉点,值得先记住,后面每一条都会在排查里起作用:

  1. ps aux 自己会挂------不只是变慢,是整条命令卡住不返回;
  2. perf 大多数时候跑不起来------没有输出、没有报错,终端就停在那里;
  3. 内存没超限、没 OOM,但进程一步都走不动;
  4. 只在 GCP 上出现,同一套服务在 AWS、Azure 上从未复现。

同时,第一次上 gdb 抓全线程栈时看到的画面完全把人带偏:线程数超过 1000,大量线程在等临界区(但不是死锁,因为总有人在持锁干活),而且大量栈帧指向 libunwind------看起来像"异常处理开销爆炸"。方向就此错了一整轮。

排查过程

第一回合:按"线程争用 + 栈回溯开销"找,失败

线索来自 gdb 的全线程 backtrace:线程数异常多、栈回溯占比高。团队据此优化了 ClickHouse 内部符号缓存(减少全局锁争用),无改善。

为什么这一轮必然失败 :gdb(以及所有基于信号的采样器)只能看到用户态栈。当 100% 的 CPU 实际消耗在内核函数里时,用户态栈提供的信息与真实瓶颈几乎无关。这一条是本案最重要的方法论教训。

第二回合:排查历史嫌疑犯 mincore,被排除

两个月前有过一次性能退化,根因是 libunwind 用 mincore 校验地址有效性,而 mincore 需要 mmap_lock,高线程数下争用严重。当时通过去掉 mincore 修掉了,并全局关闭了 profiler。

本轮用 bpftrace 直接统计 mincore 的调用次数与耗时:

bash 复制代码
# 统计 mincore 调用延迟分布
tracepoint:syscalls:sys_enter_mincore /pid==$PID/ { @start[tid] = nsecs; }
tracepoint:syscalls:sys_exit_mincore  /pid==$PID/ && @start[tid]/ {
    @latency_us = hist((nsecs - @start[tid]) / 1000);
    delete(@start[tid]);
}

结果:这次一次 mincore 调用都没有 。嫌疑犯排除------但注意,它和真正的根因共享同一个关键资源(mmap_lock),这个细节在后面回看时才连起来。

第三回合:唯一一次成功的 perf,把方向彻底掰正

perf top 十次里九次不工作。终于成功的那一次,输出是这样的:

text 复制代码
PerfTop: 8062 irqs/sec  kernel:100.0%  exact: 0.0%  lost: 0/0  [4000Hz cpu-clock]
-------------------------------------------------------------------------------
  64.28%  [kernel]  [k] shrink_lruvec
  28.81%  [kernel]  [k] lru_note_cost
   2.91%  [kernel]  [k] shrink_node
   1.84%  [kernel]  [k] _raw_spin_unlock_irqrestore
   0.38%  [kernel]  [k] count_shadow_nodes
   0.33%  [kernel]  [k] shrink_page_list

kernel:100.0% 是决定性信息:CPU 不是被业务代码吃掉的,而是被内核内存回收(shrink_lruvec)吃掉的 。这也解释了为什么 gdb 的栈那么没用------它压根看不到这些内核帧。

第四回合:做 runbook,等下一次故障

问题偶发、随机 Pod、无法按需复现。团队的做法值得抄:按 Brendan Gregg 的 60 秒分析法准备一份 runbook(dmesg、bpftrace 内核栈、pidstat、mpstat、perf、sar、iostat),故障一出现就按固定顺序抓数据。同时给 on-call 一个 10 秒判据:

bash 复制代码
# 出现问题时,先看 %system 是否异常
pidstat 1 3

第五回合:用 vmscan tracepoint 量化"回收有多慢"

bash 复制代码
# 回收开始/结束配对,输出耗时直方图与回收页数
tracepoint:vmscan:mm_vmscan_memcg_reclaim_begin /pid == $PID/ {
    @memcg_begin[tid] = nsecs;
    printf("begin t=%d order=%d gfp=%ld\n", (nsecs-@epoch)/1000000, args.order, args.gfp_flags);
}
tracepoint:vmscan:mm_vmscan_memcg_reclaim_end /pid == $PID/ && @memcg_begin[tid] > 0/ {
    $elapsed = nsecs - @memcg_begin[tid];
    @mm_vmscan_memcg_reclaim_ns = hist($elapsed);
    printf("end t=%d nr_reclaimed=%ld elapsed_ns=%ld\n",
           (nsecs-@epoch)/1000000, args.nr_reclaimed, $elapsed);
    @memcg_begin[tid] = 0;
}

真实输出读起来非常刺眼:单次回收耗时 1 至 4 秒,而回收到的页只有 4 到 60 个。直方图长这样(单位纳秒):

text 复制代码
@mm_vmscan_memcg_reclaim_ns:
  [512M, 1G)   354 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
  [1G, 2G)     403 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
  [2G, 4G)     300 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
  [4G, 8G)     139 |@@@@@@@@@@@@@@@@@@@@@|
  [8G, 16G)     29 |@@@|

"扫描几个 GB 的内存,只回收几十页"------这个比例本身就是活锁的指纹:回收工作一直在做,却几乎不产生成果,同时把 CPU 全部吃光。

第六回合:云厂商的第一次回复是"没异常"

向云厂商支持的第一次沟通并不顺利:提交 sosreport 后得到"没有异常,就是内存不足被 OOM kill"。这与观测不符(Pod 从没重启过 OOM)。推动事情往前走的那一步是:给出一个 100% 可复现的用例。

第七回合:写一个 100% 复现的 C++ 用例,问题才真正被承认

复现程序(作者公开了原始代码,见文末出处)跑在 4 核 / 4GB 的容器里,分两阶段:

  1. 前 30 秒 :起 1000 个线程,各写 1000 个小文件(合计约 4GB),然后 mmap 并按顺序持续遍历这些文件页;
  2. 30 秒之后 :再起 1000 个线程,分配匿名内存并随机访问,触发大量缺页。

第二阶段一起来,现场三个症状同时出现,与生产完全一致:

text 复制代码
1. ps aux 全系统卡死
2. perf 完全失效
3. 进程不再前进,但 CPU 全被占满

strace ps aux 给出了突破口:它卡在读取 /proc/<pid>/cmdline。查 procfs 内核代码可知,读取目标进程的这些信息需要获取该进程的 mmap_lock ------而这个锁已经被持有太久(实测 10 秒到 2 小时不等)。perf 同样需要读取进程信息,所以一起挂。

第八回合:量化持锁时长与"锁里在干什么"

bash 复制代码
# 1) 持锁时长分布(只打印超过 50ms 的)
tracepoint:mmap_lock:mmap_lock_acquire_returned /pid == $1/ { @start[tid] = nsecs; }
tracepoint:mmap_lock:mmap_lock_released /pid == $1/ && @start[tid] > 0/ {
    $us = (nsecs - @start[tid]) / 1000;
    if ($us > 50000) { printf("hold %d us tid=%d comm=%s\n", $us, tid, comm); }
    @hold_us = hist($us);
    delete(@start[tid]);
}

# 2) 只在"持锁期间"采样内核栈,找出锁里在跑什么
tracepoint:mmap_lock:mmap_lock_acquire_returned /pid == $1/ { @is_holding[tid] = 1; }
tracepoint:mmap_lock:mmap_lock_released         /pid == $1/ && @is_holding[tid] == 1/ { @is_holding[tid] = 0; }
profile:hz:99 /pid == $1/ && @is_holding[tid] == 1/ { @under_lock[kstack, comm] = count(); }

第 1 个脚本抓到一条 230818460 us(230 秒 )的持锁记录;第 2 个脚本证明锁里跑的是 shrink_lruvec(内存回收),调用路径来自缺页:

text 复制代码
@[
    do_user_addr_fault+727
    exc_page_fault+120
    asm_exc_page_fault+34
]: 1

第九回合:单次临界区的完整账本(本案最硬的一份证据)

自定义 bpftrace 脚本按"每次 acquire→release 周期"汇总所有指标,拿到的账本:

text 复制代码
=== [READ] mmap_lock hold stats in TID 1891547 COMM r_memory ===
number of context switches: 2569
number of preemptions: 0                 ← 从未被强制抢占
__cond_resched() calls: 2255178          ← 主动让出 CPU 225 万次
shrink_lruvec() calls: 13
shrink_list() calls: 34172
nr_scanned: 1093267                      ← 扫描了 109 万页(约 4.27 GB)
nr_reclaimed: 32                         ← 只回收了 32 页
nr_activate1: 564142                     ← 56 万页被"晋升"回活跃链
nr_ref_keep: 529093                      ← 52 万页被判定"还被引用,先留着"
runtime: 3699226 us                      ← 真正占用的 CPU 时间:3.7 秒
duration: 563997395 us                   ← 墙钟耗时:564 秒

翻译成人话:这个线程拿着锁,花了 564 秒墙钟时间去扫描 4.27GB 内存,真正烧掉的 CPU 只有 3.7 秒------其余时间都在和约 150 个线程抢那 4 个 CPU 核的调度。

而且它是主动 让出的:__cond_resched() 被调用了 225 万次,抢占次数为 0。这正是高线程数把这个 bug 放大的地方------线程越多,持锁者被让出 CPU 后重新被调度的间隔越长,锁的持有时间就被拉得越长。

根因:page fault 路径持有 mmap_lock 执行 memcg 回收

先把 Linux 页面回收的最少必要背景讲清楚:

概念 要点
LRU 双链 匿名页与文件页各维护 active / inactive 两条链
accessed bit 硬件在 PTE 上置位,内核只能在自己的扫描过程中读取并清零
两次扫描才能回收 页先被放进 inactive 链,下次扫描若仍未访问才可回收
cgroup 没有异步回收 kswapd 是为系统级 压力服务的,cgroup 超限只能走同步回收------在申请内存的进程上下文里做,申请者阻塞等待
内存贴着上限 于是每一次内存分配都触发一次同步回收

根因链条完整表述如下:

text 复制代码
进程缺页
  └─► asm_exc_page_fault → do_user_addr_fault → handle_mm_fault → filemap_fault
        └─► page_cache_ra_unbounded(预读)
              └─► add_to_page_cache_lru → __mem_cgroup_charge
                    └─► charge_memcg → try_charge_memcg
                          └─► try_to_free_mem_cgroup_pages   ← 需要内存,触发同步回收
                                └─► do_try_to_free_pages → shrink_node → shrink_lruvec
                                      └─► vmscan:扫描 inactive/active 链

        ❗ 以上全程持有进程的 mmap_read_lock(本应只保护地址映射表)

三个机制叠加,形成活锁(livelock):

  1. 锁用在了错误的范围上 :mmap_lock 是为保护地址空间映射而存在的,把"回收几 GB 内存"这种长耗时工作放进它的临界区,本身就是设计缺陷。
  2. 回收效率趋近于零 :cgroup 内所有页几乎都处于"刚被访问过"的状态(active/inactive 都被标记),扫描必须扫完整条链才能判定,于是 4.27GB 扫出 32 个可回收页。
  3. 不触发 OOM 但烧光 CPU :回收"成功"了(回收到了 32 页),所以内核没有理由启动 OOM killer;CPU 却 100% 消耗在内核态扫描上,进程无法前进------这就是"没死但也没活着"的状态。

补充解释最初那个"为什么只有 GCP"的疑问:同一内核版本在不同发行版上行为不同。实测矩阵:

环境 内核 是否复现
AWS EKS Amazon Linux 2 5.10 ❌ 无
AWS EKS Amazon Linux 2023 6.1 ❌ 无
GCP COS 101 5.10 ✅ 有
GCP COS 105 5.15 ✅ 有
GCP COS 109 6.1 ✅ 有
GCP COS 113 6.1 ❌ 无(已含修复)
Azure Linux 5.15 ⚠️ 用例可触发,生产未观测到

结论:不要用"内核版本号"判断是否中招,要用"发行版 + 补丁 + 配置"的组合判断。

解决方案

按推荐程度排序:

方案 1:开启 MGLRU(Multi-Gen LRU)------本案唯一"当天生效"的手段

bash 复制代码
# 前提:内核编译时带 CONFIG_LRU_GEN;生效不需要重启
echo y > /sys/kernel/mm/lru_gen/enabled
cat /sys/kernel/mm/lru_gen/enabled
# 0x0007

原文实测:对整个 GCP 机队开启后,该问题一周内不再出现,且未观测到性能退化 。参考 内核官方文档。

方案 2:升级到已修复该锁顺序的发行版

新内核的行为差异是根本性的:回收前先释放 mmap_lock 。对应到环境矩阵,就是 COS 113(6.1)不再复现。注意在托管 K8s 里你往往无法单独升级内核,只能跟随节点池的系统版本升级------这也是本案拖了 8 个月的现实原因之一。

方案 3:降低单进程并发线程数(缓解,不是根治)

复现用例说明:1000 线程 + 4 核是放大器。线程数越多,__cond_resched() 让权重调度造成的锁持有时间越长。减少同时访问文件映射内存的线程数,能让锁持有时间从"分钟级"降到"秒级"------不解决问题,但能让服务活着。

方案 4:外部 liveness 检测 + 自动重启(兜底)

原文最终就是靠这个收尾的。团队明确拒绝把"无响应就重启"当成修复(那是把问题扫到地毯下),但承认在无法升级内核的托管环境里,它是可接受的服务可用性保障:

bash 复制代码
# cgroup v2 环境用 PSI 观测"回收是否卡住"(压力指标比 CPU 更能反映真相)
cat /proc/pressure/memory
# some avg10=45.22 avg60=38.10 avg300=12.55 total=12345678

判断逻辑:some avg10 长期高位 + 应用存活探针失败 → 触发重启。不要把 PSI 单一指标当判据,要与应用层探测组合。

方案 5:别用 gdb 的栈单独下结论(工具纪律)

gdb(信号采样)永远看不到内核帧。当 CPU 100% 在内核态时,仅凭它的栈会指向完全错误的方向。内核态问题必须用内核态工具:perf、bpftrace、/proc/pressure、/proc/vmstat。

一个必须知道的后续:修好之后又冒出第二个 bug

开启 MGLRU 后同类告警再次出现,但这次现场不同了:

text 复制代码
$ perf top
  55.47%  [kernel]  [k] _raw_spin_unlock_irq
  12.05%  [kernel]  [k] _raw_spin_unlock_irqrestore

关键差别:ps aux、perf 都正常工作了(因为不再有长时间持锁),但它仍然全在内核态烧 CPU。栈变成:

text 复制代码
_raw_spin_unlock_irq → evict_folios → shrink_lruvec → shrink_node
  → do_try_to_free_pages → try_to_free_mem_cgroup_pages
  → try_charge_memcg → charge_memcg → __mem_cgroup_charge
  → __filemap_add_folio → filemap_fault → handle_mm_fault → exc_page_fault

根因是新机制自己的锁:MGLRU 的 evict_folios 需要获取 每个 cgroup / NUMA 节点一把的 lru_lock (保护 struct lruvec,定义在内核 include/linux/mmzone.h)。当 100+ 个线程各自独立做回收时,这把自旋锁 被高频争用------top 里 3 秒内有至少 138 个活跃线程,火焰图中 84% 的栈含 evict_folios。

这个后续给出了两条工程结论:

  1. "打开 MGLRU"不是万能药:它解决了上一个 bug(长时间持锁),但代价是把压力搬到了自旋锁上;好在它把"服务看起来死机"降级成"CPU 高但工具可用"。
  2. 同类问题在 MGLRU 下仍然存在 ,且官方 issue(COS 105 慢回收)长期无修复活动。剩下的选择只有两个:升级内核,或做活锁检测 + 自动重启。

如何自检你是否中招

一行判据(最先跑这条):

bash 复制代码
# 内核态 CPU 是否异常(正常服务不应长期接近 100% system)
pidstat 1 3 | awk 'NR<=3 || $5+0 > 50 {print}'

一段持续观测脚本(放到节点上跑,故障时自动留证):

bash 复制代码
#!/usr/bin/env bash
# memcg_livelock_watch.sh ------ 内存回收活锁取证
PID="${1:?usage: $0 <pid>}"
OUT="/tmp/livelock-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$OUT"

echo "== 1. 内核态 CPU 与缺页率(低缺页 + 高 system = 可疑)"
perf stat -p "$PID" -- sleep 10 2>&1 | tee "$OUT/perf-stat.txt"

echo "== 2. 回收频率与页数(高频回收 + 个位数回收页 = 活锁指纹)"
timeout 20 bpftrace -e '
tracepoint:vmscan:mm_vmscan_memcg_reclaim_end /pid == '"$PID"'/ {
    @reclaimed = hist(args.nr_reclaimed);
    @count = count();
} interval:s:5 { print(@count, @reclaimed); clear(@count, @reclaimed); }' 2>&1 | tee "$OUT/reclaim.txt"

echo "== 3. 内存压力(PSI,比 CPU 更能说明回收是否卡住)"
cat /proc/pressure/memory | tee "$OUT/psi.txt"

echo "== 4. cgroup 内存账本(贴着上限 = 每次分配都触发回收)"
CG=$(cat /proc/$PID/cgroup | grep -oE '[0-9]+:[^:]*:[^ ]*' | head -1 | cut -d: -f3)
for f in memory.max memory.current memory.stat memory.pressure; do
    echo "--- $f"; cat "/sys/fs/cgroup$CG/$f" 2>/dev/null
done | tee "$OUT/memcg.txt"

echo "== 5. MGLRU 是否开启(0x0000 = 关闭)"
cat /sys/kernel/mm/lru_gen/enabled 2>/dev/null || echo "未编译 CONFIG_LRU_GEN"
echo "取证目录:$OUT"

读法 :第 1 项出现"高 %system + 低 page-faults"、第 2 项出现"大量次回收但每次数十个页"、第 3 项 PSI 高位、第 4 项 memory.current ≈ memory.max、第 5 项显示未开启------五条同时命中,基本可以判定为同一类 memcg 回收活锁。此时先用 ps aux 是否能正常返回做快速确认(能返回说明不在长时间持锁路径上)。

启示

  1. 工具会给你错误的方向,而且不会报错。 gdb / 信号采样看不到内核栈;当 CPU 100% 在内核态时,用户态栈越"像问题",越说明你在错路上。先确认"CPU 花在用户态还是内核态",再决定用哪套工具。
  2. "没有 OOM"不等于"内存没问题"。 回收 32 个页足以让内核认为"回收成功了",从而既不启动 OOM killer,又把 CPU 全烧掉。内存问题的表现形式可以是"CPU 100% 但进程不前进"。
  3. 内存回收是"别人的进程"替你付账。 同步回收发生在申请内存的那个进程的上下文里,所以肇事者与受害者往往不是同一个 Pod;排查时不要只盯着"哪个进程吃内存",要盯"谁在缺页路径上被迫回收"。
  4. 同一内核版本 ≠ 同一行为。 本例 5.10/5.15/6.1 在 AWS 与 GCP 上表现相反。内核判断必须落实到"发行版 fork + 补丁 + 配置",不能靠版本号。
  5. 一个可复现用例是推动外部依赖方认账的唯一硬通货。 云厂商第一次回复是"没有异常";转折点是那份 100% 复现的 C++ 用例。排障的产出物不只是修复,还包括能把责任边界说清楚的证据。
  6. 修好一个活动锁,可能只是把压力搬到另一把锁上。 MGLRU 修复了 mmap_lock 长持锁,却暴露出 lru_lock 自旋争用。工程上必须接受"内核 bug 修不完",并为不可升级的环境准备兜底(liveness + 重启)。

原始出处


本文首发于 CSDN 专栏《运维漏洞指南》。

相关推荐
大雄很用心1 小时前
package.json 中的 dependencies 是否都会被下载?|Angular 回归笔记 02
angular.js
starrysky8101 小时前
同一个 submit(),两条报错——ThreadPoolExecutor 在解释器退出时的竞态(附 4 组可复现实验)
angular.js
starrysky81014 天前
一个 NameError 掩盖了真根因:IPython 补全在 Django shell 里二次崩溃——从 jedi 版本错位查到 CPython LOAD_GLOBAL
angular.js
starrysky81014 天前
SQL 注入 + msgpack 反序列化 = RCE:LangGraph Checkpointer 三洞连爆——Agent 的记忆层就是攻击面
angular.js
starrysky81014 天前
NVD 9.8、官方 7.5:Redis TLS use-after-free 的评分之争——tlsProcessPendingData 悬指针踩空全解析
angular.js
巴勒个啦20 天前
2026 年 CSS 选型真相:我用 Tailwind v4 + 原生新特性重构了一个组件库
前端·angular.js
starrysky81022 天前
服务全绿、队列却 15 天没人消费?redis-py 8.0 默认切 RESP3 的阻塞命令坑
angular.js
starrysky81022 天前
写个管道就被 BrokenPipeError 打爆?先看 CPython 把 SIGPIPE 藏哪了
angular.js
starrysky8101 个月前
画像空不是故障:Honcho peer card 观察者视角源码拆解——1956 条结论为何换不来一张卡
angular.js