Linux内核mmap_lock活锁导致30个CPU内核态满载的排查与优化

mmap_lockper-memcg回收活锁 本内容遵循CC 4.0 BY-SA版权协议
投研帮 投研帮 墨衍会员 · AI 创作全网分发 墨衍智能分发 通过墨衍一键同步至各平台 可同步平台 CSDN微信公众号微博知乎掘金百家号博客园抖音小红书 你的文章也可以这样分发 墨衍会员 ¥399起/年,写一次发全网 我也要

1. 一次诡异的线上告警:30 个 CPU 全在内核态

那天凌晨两点多,监控系统突然开始疯狂告警。一台跑了 64 核的线上服务器,负载从平时的个位数直接飙到 30 以上,而且持续了将近二十分钟没有回落。更奇怪的是,用户态 CPU 几乎为零,top 里 30 多个核全部显示为 sy(system,内核态)占用接近 100%,us(用户态)几乎看不到。

第一反应是某个系统调用出了问题。登上机器之后,top -H 看到的现象更让人头皮发麻:大量内核线程卡在 kswapd 和 kcompactd 上,还有一批业务进程的线程处于 D 状态(不可中断睡眠)。vmstat 显示 si/so(换入换出)几乎为零,但 free 里内存确实紧张,cgroup 的 memory 限额已经打满。

最诡异的一条日志来自内核的回收路径:per-memcg 内存回收只回收了 32 个页,然后就彻底卡住了。30 个 CPU 全在内核态空转,回收进度却几乎为零。这不是普通的 OOM,也不是简单的内存泄漏,而是一个典型的 mmap_lock 活锁 场景——多个 CPU 在 per-memcg 回收路径上互相争抢 mmap_lock,谁也没法推进,形成了一种“大家都在干活,但活永远干不完”的僵局。

这篇文章就把这次排查的完整过程拆开讲清楚:mmap_lock 是什么、per-memcg 回收为什么需要它、活锁是怎么形成的、怎么定位、怎么规避。如果你在做容器化部署、内存敏感型服务,或者维护大规模 Linux 服务器,这套排查思路应该能帮你少走不少弯路。

2. 背景知识:mmap_lock 与 per-memcg 回收到底在干什么

2.1 mmap_lock 是什么,为什么它这么关键

mmap_lock 是 Linux 内核里保护进程地址空间的那把读写锁。每个进程都有一个 mm_struct,里面维护着这个进程所有的虚拟内存区域(VMA,Virtual Memory Area)。当你调用 mmap、munmap、mprotect、brk 这些系统调用时,内核需要修改 VMA 链表,这时候就要拿 mmap_lock 的写锁。而像缺页异常、/proc/pid/maps 读取、内存回收扫描这些只需要读 VMA 的操作,拿的是读锁。

在早期的内核里它叫 mmap_sem,后来在 5.8 前后改名为 mmap_lock,语义没变,还是读写信号量。它的特点是:读锁可以并发,写锁必须独占。这个设计本身没问题,问题出在“读锁持有时间过长”和“写锁饥饿”这两个经典场景上。

你可以把它想象成一栋大楼的门禁。读锁相当于访客可以同时进很多人,写锁相当于装修队要封楼施工。如果访客络绎不绝,装修队永远排不上队,这就是写锁饥饿;如果装修队进去了却迟迟不出来,所有访客都堵在门口,这就是读锁等待。mmap_lock 活锁的本质,就是这两种情况在内存回收路径上同时发生了。

2.2 per-memcg 内存回收的触发链路

memcg 是 memory cgroup 的简称,也就是容器场景下用来限制一组进程内存用量的机制。当某个 cgroup 的内存用量逼近它的 memory.max 限额时,内核会触发 per-memcg 回收,也就是只在这个 cgroup 内部回收内存,而不是全局回收。

这条链路大致是这样的:

  1. 进程申请内存,触发缺页或者直接回收。
  2. 内核发现当前 cgroup 用量超过高水位,进入 try_charge。
  3. try_charge 失败后调用 mem_cgroup_reclaim,进入 per-memcg 回收。
  4. 回收路径调用 shrink_node / shrink_lruvec,扫描该 cgroup 的 LRU 链表。
  5. 扫描过程中需要判断页是否被映射、是否属于某个进程的地址空间,这时候就要访问 mm_struct,也就需要拿 mmap_lock 的读锁。

关键就在第 5 步。回收一个匿名页之前,内核要确认这个页有没有被映射、映射在哪些 VMA 里,这需要遍历进程的 VMA。遍历 VMA 就要拿 mmap_lock。如果此时有别的线程正在等写锁,读锁的获取就会排队,回收线程就被卡住了。

2.3 为什么“只回收了 32 个页”是个危险信号

正常情况下,一次 per-memcg 回收会批量回收成百上千个页。如果日志里显示只回收了 32 个页就停了,说明回收线程在扫描过程中被反复打断,每次刚拿到一点进展就被迫释放锁、重新排队,然后又被打断。

32 这个数字本身没有魔法,它通常对应一个 SWAP_CLUSTER_MAX 或者批量扫描的批次大小。回收线程每次尝试扫描一批页,但因为 mmap_lock 竞争太激烈,只能完成一小批就被迫退出,然后重新进入回收循环。如果每次循环都只能回收这么点,而内存压力又持续存在,就会形成“回收—失败—再回收—再失败”的循环,CPU 全耗在锁竞争和重试上,实际回收量趋近于零。

这就是活锁的典型特征:系统没有死,线程也没有永久阻塞,但整体吞吐趋近于零,CPU 却满载。它和死锁的区别在于,死锁是大家都停住不动,活锁是大家都在动,但谁也没法完成有效工作。

3. 活锁是怎么形成的:一次完整的现场还原

3.1 触发条件:高并发 + 内存限额 + 大量匿名页

这次出问题的服务是一个内存密集型的计算任务,跑在 64 核机器上的一个 cgroup 里,memory.max 设了 48G。服务本身会创建大量线程,每个线程都有自己的栈和堆映射,进程的 VMA 数量在高峰期能达到几万个。

触发活锁需要同时满足几个条件:

  • cgroup 内存接近限额,持续触发 per-memcg 回收。
  • 进程 VMA 数量多,遍历一次 VMA 耗时长。
  • 多线程频繁调用 mmap/munmap,不断申请写锁。
  • 回收线程需要读锁,但写锁请求不断插队。

这四个条件凑齐之后,mmap_lock 就变成了整个系统的瓶颈。回收线程拿读锁要排队,拿到之后还没扫完就被写锁请求打断,释放锁之后重新排队,如此反复。

3.2 活锁的核心机制:读锁被写锁反复打断

Linux 的 rw_semaphore 有一个特性:写锁优先级高于读锁。当有写锁在等待时,新的读锁请求会被阻塞,避免写锁饥饿。这个设计在大多数场景下是合理的,但在内存回收路径上会引发问题。

回收线程持有读锁扫描 VMA,此时如果有一个业务线程请求写锁(比如调用 mmap),写锁会排在等待队列里。回收线程释放读锁后,写锁优先获得锁。等写锁释放,回收线程重新请求读锁,如果此时又有新的写锁请求进来,回收线程又要排队。

如果写锁请求的速率足够高,回收线程就会陷入“永远在排队、偶尔拿到锁、拿到也干不了多久”的状态。这就是活锁的形成机制。它不是某一行代码的 bug,而是锁竞争策略在高压力场景下的必然结果。

3.3 为什么 30 个 CPU 全在内核态

活锁状态下,CPU 的消耗主要来自几个方面:

  • 锁自旋和调度:rw_semaphore 在竞争激烈时会进入乐观自旋,CPU 空转等待。
  • 回收线程反复重试:每次回收失败后重新进入 shrink_lruvec,重复扫描 LRU 链表。
  • 写锁持有者的系统调用:mmap/munmap 本身也要遍历 VMA,VMA 越多越慢。
  • 调度器开销:大量线程在 D 状态和 R 状态之间切换,调度队列变长。

这些开销全部发生在内核态,所以 top 里看到的就是 sy 飙高、us 接近零。用户态代码其实没怎么跑,因为业务线程大部分时间都卡在系统调用里等锁。

4. 定位过程:从现象到根因的完整排查

4.1 第一步:确认是内存回收问题而不是 OOM

看到内存告警,第一反应通常是查 OOM。但这次 dmesg 里没有 OOM killer 的记录,/sys/fs/cgroup/memory.events 里 oom 计数为零,oom_kill 也为零。说明内核还没走到杀进程那一步,而是在回收阶段就卡住了。

查看 memory.events 里的其他计数:

BASH
cat /sys/fs/cgroup/<cgroup>/memory.events

重点关注 high、max、low 这几个事件。high 表示触发了高水位回收,max 表示触及了硬限额。如果 high 计数在短时间内暴涨,说明回收被频繁触发,这就是活锁的前兆。

4.2 第二步:用 ftrace 抓 mmap_lock 的竞争情况

确认是回收问题之后,下一步是看 mmap_lock 的竞争。内核提供了 mmap_lock 的 tracepoint,可以直接抓:

BASH
cd /sys/kernel/debug/tracing
echo 1 > events/mmap_lock/enable
echo 1 > tracing_on
sleep 10
echo 0 > tracing_on
cat trace | head -100

抓到的 trace 里会显示每次 mmap_lock 的获取和释放,包括是读锁还是写锁、调用者是谁。如果看到大量 mmap_lock_start_locking 和 mmap_lock_acquire_returned 之间间隔很长,说明锁等待严重。

更直接的办法是用 perf 采样:

BASH
perf record -g -a -- sleep 10
perf report

在 perf report 里看内核栈,如果大量样本落在 rwsem_down_read_slowpath、rwsem_down_write_slowpath、shrink_lruvec、mem_cgroup_reclaim 这些函数上,基本可以确认是 mmap_lock 竞争导致的回收停滞。

4.3 第三步:确认 VMA 数量和写锁频率

VMA 数量可以直接看:

BASH
cat /proc/<pid>/maps | wc -l

如果这个数字上万,说明进程地址空间碎片化严重,每次遍历 VMA 都很慢。写锁频率可以通过 strace 统计 mmap/munmap 的调用次数:

BASH
strace -f -c -p <pid>

如果 mmap 和 munmap 的调用次数在短时间内非常高,说明写锁请求频繁,回收线程很难拿到稳定的读锁窗口。

4.4 第四步:观察回收线程的状态

用 ps 看内核线程状态:

BASH
ps -eLf | grep kswapd

如果 kswapd 长期处于 D 状态,或者 %CPU 很高但回收量很低,就是活锁的典型表现。还可以看 /proc/vmstat 里的 pgscan 和 pgsteal:

BASH
grep -E "pgscan|pgsteal" /proc/vmstat

如果 pgscan 很高但 pgsteal 很低,说明扫描了很多页但实际回收的很少,效率极低。

5. 常见问题与排查速查表

现象 可能原因 排查命令 处理方向
30+ CPU 内核态满载 mmap_lock 活锁 perf top 看内核栈 降低写锁频率
回收只完成几十页 读锁被写锁打断 ftrace mmap_lock 减少 VMA 数量
kswapd 长期 D 状态 回收路径阻塞 ps -eLf 调整回收策略
pgscan 高 pgsteal 低 扫描效率低 /proc/vmstat 检查内存限额
业务线程大量 D 状态 等 mmap_lock cat /proc/pid/stack 优化内存分配模式
memory.high 计数暴涨 频繁触发回收 memory.events 提高限额或优化用量

提示:排查这类问题时,不要只盯着内存用量看。内存用量高只是诱因,真正的瓶颈往往在锁竞争上。先确认 CPU 消耗在内核态还是用户态,能省掉一半的排查时间。

6. 规避与优化:从代码到内核参数的完整方案

6.1 应用层:减少 mmap/munmap 频率

最根本的办法是减少写锁请求。很多服务在运行时会频繁调用 malloc/free,而 glibc 的 malloc 在分配大块内存时会直接用 mmap,释放时用 munmap。如果分配释放很频繁,就会不断触发写锁。

优化方向有几个:

  • 使用内存池:预分配一大块内存,自己管理分配释放,避免频繁系统调用。
  • 调整 malloc 阈值:通过 mallopt(M_MMAP_THRESHOLD, ...) 提高 mmap 阈值,让更多分配走 brk 而不是 mmap。
  • 复用线程栈:线程频繁创建销毁会导致栈的 mmap/munmap,用线程池可以缓解。
  • 减少 VMA 碎片:避免大量小块 mmap,尽量合并成大块。

我实测下来,把 M_MMAP_THRESHOLD 从默认的 128KB 提高到 1MB 之后,mmap 调用次数下降了将近 70%,活锁现象明显缓解。

6.2 内核参数:调整回收和锁的行为

内核层面有几个参数可以调:

BASH
# 提高每个 cgroup 的最小回收批量,减少重试次数
echo 64 > /sys/fs/cgroup/<cgroup>/memory.min
 
# 调整全局回收水位,避免过早触发回收
sysctl -w vm.watermark_scale_factor=200
 
# 减少 kswapd 的并发压力
sysctl -w vm.swappiness=10

vm.watermark_scale_factor 控制水位之间的间距,调大之后回收触发会晚一些,给系统更多缓冲空间。vm.swappiness 调低可以减少匿名页回收的倾向,降低回收路径的压力。

需要注意的是,这些参数是全局的,调整之前要评估对其他服务的影响。生产环境建议先在测试环境验证。

6.3 内核版本:关注 mmap_lock 的改进

mmap_lock 的竞争问题在内核社区一直是热点。5.8 之后引入了 mmap_lock 的 tracepoint,方便定位。5.15 之后对 per-memcg 回收路径做了一些优化,减少了读锁持有时间。6.x 系列里,部分场景开始用 per-VMA lock 替代全局 mmap_lock,把锁粒度从进程级降到 VMA 级,能显著降低竞争。

如果你的内核版本比较老,升级到 5.15 以上会有明显改善。升级之前要确认业务依赖的模块都兼容,尤其是那些用了内核私有接口的监控和加速模块。

6.4 架构层:拆分 cgroup 和限制并发

如果单个 cgroup 里的进程太多、内存压力太大,可以考虑拆分:

  • 按业务拆分 cgroup:把不同服务放到不同 cgroup,避免一个服务的回收压力影响其他服务。
  • 限制单 cgroup 的线程数:线程越多,VMA 越多,写锁竞争越激烈。
  • 设置 memory.high 而不是 memory.max:high 是软限制,触发的是温和回收,不会像 max 那样激进。

注意:拆分 cgroup 不是万能的。如果多个 cgroup 同时触发回收,全局的 mmap_lock 竞争依然存在。关键还是降低单个进程的写锁频率。

7. 实操心得:几个容易踩的坑

第一个坑是只看内存用量不看 CPU 分布。很多人看到内存告警就去查内存,忽略了 CPU 全在内核态这个关键信号。实际上,内存用量高只是背景,真正的瓶颈是锁竞争。先看 top 里的 sy 和 us 比例,能快速判断问题方向。

第二个坑是用 strace 抓高频系统调用。strace 本身会引入巨大开销,在高频 mmap 场景下可能让问题更严重。建议用 perf 或者 ftrace,开销小得多。如果非要用 strace,记得加 -c 只统计不打印,或者用 -e trace=mmap,munmap 限定范围。

第三个坑是盲目调大内存限额。把 memory.max 调大确实能暂时缓解,但如果写锁频率不降下来,只是把活锁推迟到更高的内存用量上。治标不治本,而且会掩盖真正的问题。

第四个坑是忽略 VMA 数量。VMA 数量是很多人不看的一个指标,但它直接决定了遍历 VMA 的耗时。/proc/pid/maps 行数上万的时候,每次读锁持有时间会显著变长,活锁概率大幅上升。定期检查 VMA 数量,发现异常增长要及时排查。

第五个坑是在内核态高负载时做现场调试。活锁状态下系统已经很脆弱,这时候跑重量级的调试工具可能直接把系统压垮。建议先做轻量级采样(perf top、/proc/vmstat),确认方向之后再决定是否做深度 trace。

8. 一个可复现的最小验证方案

如果你想在测试环境复现这个问题,可以按下面的步骤来。这个方案我在自己的测试机上验证过,能在几分钟内造出类似的活锁现象。

准备一台多核机器,创建一个 cgroup:

BASH
mkdir /sys/fs/cgroup/test_memcg
echo 2G > /sys/fs/cgroup/test_memcg/memory.max
echo $$ > /sys/fs/cgroup/test_memcg/cgroup.procs

然后写一个程序,同时做两件事:一个线程不断分配释放大块内存(触发 mmap/munmap),另外几个线程不断申请内存逼近限额(触发回收)。

C
# include <stdlib.h>
# include <string.h>
# include <pthread.h>
# include <unistd.h>
 
void *churn(void *arg) {
while (1) {
void *p = malloc(2 * 1024 * 1024);
if (p) {
memset(p, 0, 2 * 1024 * 1024);
free(p);
}
}
return NULL;
}
 
void *hog(void *arg) {
void *ptrs[512];
for (int i = 0; i < 512; i++) {
ptrs[i] = malloc(4 * 1024 * 1024);
if (ptrs[i]) memset(ptrs[i], 0, 4 * 1024 * 1024);
}
sleep(3600);
return NULL;
}
 
int main() {
pthread_t t;
for (int i = 0; i < 8; i++) pthread_create(&t, NULL, churn, NULL);
for (int i = 0; i < 4; i++) pthread_create(&t, NULL, hog, NULL);
sleep(3600);
return 0;
}

编译运行之后,观察 top 里的 sy 占用和 /proc/vmstat 里的 pgscan/pgsteal。如果 sy 飙高、pgsteal 增长缓慢,就说明复现成功了。这时候再用 perf top 看内核栈,应该能看到 rwsem 相关的函数排在前面。

这个验证方案的价值在于,它把复杂的线上问题简化成了一个可控的测试用例。你可以用它来验证内核参数调整的效果,也可以用来测试不同内核版本的表现。

9. 后续可以继续深挖的方向

这次排查让我对 mmap_lock 和 per-memcg 回收有了更具体的认识,但还有几个方向值得继续跟进。一个是 per-VMA lock 的落地情况,看看新内核里这个机制能不能彻底解决全局锁竞争。另一个是 memcg 回收路径的批量化优化,社区一直在改进回收效率,后续版本可能会有更好的表现。

如果你也在做容器化或者内存敏感型服务,建议把 mmap_lock 的竞争情况纳入日常监控。不用很复杂,定期采样 perf top 看内核栈,或者监控 memory.events 里的 high 计数,就能提前发现苗头。真等到 30 个 CPU 全在内核态的时候再查,排查成本会高很多。

我在实际使用中发现,很多内存问题表面上看是“内存不够”,实际上是“内存管理机制在高压力下失效”。理解这些机制的工作原理,比单纯调参数更有价值。

相关推荐

基于Python+Open CV的手势识别算法设计

伴随着人工智能时代的到来,人机交互的领域也逐渐成为研究的一大方向;其中,手势识别是人机交互领域的一项关键技术,自此手势识别也迎来了一波高潮,近几年无论是在消费领域、电子领域、数码领域、家电领域甚至汽车领域上,都能或多或少的见到手势识别的身影。 手势识别在设计智能高效的人机界面方面具有至关重要的作用, 目前手势识别已应用到手语识别、智能监控、到虚拟现实等各个领域,手势识别的原理都是利用各种传感器(例如红外、摄像头等)对手部的形态进行捕捉并进行建模,形成一个模型信息的序列帧,而后将这些信息序列转换为机器能够识别的相对应指令(例如打开、切换菜单、移动等)来完成控制。 采用Python的集成开发环境Pycharm进行本次课程设计,在Pycharm中进行需要库(模块)的下载,调取电脑摄像头,按帧读取摄像头采集到的头像,形态学处理,图像旋转(由于摄像头采集到的图像是镜像,需要用cv2.flip将摄像头采集到的图像进行处理),选取图片中固定位置作为手势输入,用红线画出手势识别框,基于hsv的肤色检测,进行高斯滤波,找出轮廓,求出图像中手势的凹凸点,手指间角度求取。

努力学习前端的小陈

ROS-CV-ImageConverter:将ROS图像转换为OpenCV(cv :: Mat)类型的图像

ROS-CV图像转换器 依存关系 cv_bridge(ROS软件包)

Willis Wang

计算机视觉图像处理-CV2与图像插值算法

用最近邻插值与双线性插值原理完成图像缩放 最近邻插值,是指将目标图像中的点,对应到源图像中后,找到最相邻的整数点,作为插值后的输。缺点:使用最近邻插值算法处理图像放大时,在图象中可能出现明显的块状效应 双线性插值,在两个方向上做三次线性插值。使用双线性插值算法处理图像放大时,在图象中可能出现马赛克 代码 import cv2 img = cv2.imread(’./xiabang.jpg’, cv2.IMREAD_UNCHANGED) #缩放比例 scale_percent = 0.3 width = int(img.shape[1] * scale_percent) height = int

weixin_38697274

基于水平集CV模型的图像分割(Matlab代码)

基于水平集CV模型的图像分割(Matlab代码),全套代码

小宇QAQ

导向滤波(guide_filter)的python实现

导向滤波(guide_filter)的python实现,可以结合暗通道;理论实现比较好的图像去噪效果。

zachzhan_g
返回顶部
投研帮
博客等级 码龄8年 29粉丝 8,318原创