一个线程读不到自己 10 条指令前写的字节:ripgrep #3494 从 musl mallocng 断言挖穿到 Linux 7.0 页表回收竞态
先把结论摆在最前面,省得你读到一半跑了:
一个 x86_64-unknown-linux-musl 静态编译的 ripgrep,在超大目录树上高并发搜索时会偶发 SIGSEGV。崩溃点在 musl mallocng 的堆元数据完整性断言里。往下挖,发现一个线程往刚缺页进来的匿名页写了一个字节,大约 10 条指令之后,同一个线程重新读这个字节,读到了 0。
在 x86 上这是不可能的。store buffer forwarding 保证一个线程永远能看见自己此前对同一地址的写。除非——那个虚拟地址在这 10 条指令之间,被换到了另一张物理页上。
最后追到 Linux 内核 mm 子系统:per-VMA lock 缺页快路径和并发 munmap 的 TLB shootdown 之间存在竞态,而 v7.0 引入的「zap 过程中顺手回收 PTE 页表」那套改动把窗口撑大了。更精确地说,一个人类内核开发者最终指出了那行代码:
pte_free_tlb(tlb, pmd_pgtable(pmdval), addr); // addr 此时 == end,越界了
本该是 start。
这个 bug 值得吃透的原因不是「它有多罕见」,而是它同时打穿了四层抽象:应用层(ripgrep 的并行目录遍历)→ 语言运行时(Rust 的 #[global_allocator] 边界)→ C 库(musl mallocng 的带内元数据设计)→ 内核(页表回收与 TLB 失效范围)。每一层单看都没错,叠在一起就炸了。
而且它顺带回答了一个很多人问过的问题:Rust 项目明明把全局分配器换成了 jemalloc,为什么还会死在 musl 的 malloc 里?
下面一层一层拆。
一、案发现场
1.1 报告本身
issue 编号 BurntSushi/ripgrep#3494,2026 年 7 月 26 日提交,标题就是 x86_64-unknown-linux-musl binaries occasionally segfault during very-large searches。
关键信息:
- ripgrep 15.2.0(rev
e89fff89ac),官方 release 里那个 musl 静态二进制 - 报告者最初是在 OpenAI Codex 捆绑的
rg里踩到的,比对之后确认与官方 release 字节级一致,所以跟 Codex 无关 - 机器:AMD Threadripper 9960X(Zen 5),128 GiB ECC
- 内核:openSUSE Tumbleweed 的
7.0.12-1-default - THP
always,KSM off
复现方式简单粗暴:造一棵约 20 GiB、184 万个文件的树,然后在里面死循环搜一个绝对不存在的乱码字符串:
while true; do
rg --threads 12 tnoheueunotshisnthukoethnsueothnsiuothonesuioseuinth
echo "rc=$?"
done
24 核机器上,只要内存足够让整棵树进 page cache,平均 1~3 分钟就能吃到一次 rc=139。
有个特别有意思的细节:崩溃的那次运行只跑了约 1.6 秒,而正常跑完要 7.6 秒。也就是说崩溃总是发生在目录遍历的早期——正是并发建立、大量新目录被打开、分配器疯狂扩张的阶段。这个观察后面会变成重要线索。
1.2 三条排除线索
BurntSushi(ripgrep 作者)问的三个问题非常教科书:
- 其他构建也会吗?还是只有 musl? → 只有 musl,glibc 动态链接怎么都复现不了。
- 15.1.0 呢? → 也复现,说明不是新引入的回归。
- 栈顶在哪?→ 在 musl 分配器内部。
栈是这样的:
#0 get_meta () at src/malloc/mallocng/meta.h:141
#1 __malloc_allzerop () at src/malloc/mallocng/malloc.c:384
#2 calloc () at src/malloc/calloc.c:41
#3 opendir () at src/dirent/opendir.c:15
#4 std::sys::fs::unix::readdir ()
...
#11 ignore::walk::Work::read_dir () at crates/ignore/src/walk.rs:1551
#12 ignore::walk::Worker::run_one () at crates/ignore/src/walk.rs:1749
#13 ignore::walk::Worker::run ()
...
#25 start () at src/thread/pthread_create.c:207
#26 __clone () at src/thread/x86_64/clone.s:22
15.1.0 的栈顶略有不同,是 a_crash() ← get_meta() at meta.h:151。这个差异本身就说明了问题:不同版本 musl 的断言行号不同,但都是 get_meta() 里的完整性断言。
到这里,90% 的人会得出结论:「musl 的 mallocng 有并发 bug」或者「ripgrep 有内存越界写坏了堆」。两个结论都错。
1.3 为什么 rg 会在 opendir 里 calloc
先解释调用链底部。ripgrep 的目录遍历由 ignore crate 的 WalkParallel 完成,模型大致是这样:
// ignore crate 并行遍历的骨架(简化)
pub struct WalkParallel {
paths: Vec<PathBuf>,
threads: usize,
// ...
}
impl WalkParallel {
pub fn run<'s, F>(self, mkf: F)
where F: FnMut() -> Box<dyn FnMut(Result<DirEntry, Error>) -> WalkState + Send + 's>
{
// 1. 一个共享的工作栈(Stack<Message>),每个 worker 从里面 pop 任务
// 2. 遇到目录就 std::fs::read_dir(),把子项重新 push 回栈
// 3. 遇到文件就交给回调(ripgrep 里就是去搜内容)
// 4. 用一个原子计数器 + condvar 做静默检测(quiescence detection)
}
}
关键在于:每个 worker 线程每碰到一个目录,就调用一次 std::fs::read_dir()。184 万个文件的树意味着几十万次 read_dir。
Rust 的 std::fs::read_dir 在 Unix 上最终落到:
// library/std/src/sys/fs/unix.rs
pub fn readdir(path: &Path) -> io::Result<ReadDir> {
let ptr = run_path_with_cstr(path, &|p| {
// 关键:调用 libc 的 opendir
cvt_p(unsafe { libc::opendir(p.as_ptr()) })
})?;
// ...
}
而 musl 的 opendir 长这样:
/* musl src/dirent/opendir.c */
DIR *opendir(const char *name)
{
int fd;
DIR *dir;
if ((fd = open(name, O_RDONLY|O_DIRECTORY|O_CLOEXEC)) < 0)
return 0;
if (!(dir = calloc(1, sizeof *dir))) { /* ← 第 15 行,就是这里 */
__syscall(SYS_close, fd);
return 0;
}
dir->fd = fd;
return dir;
}
DIR 在 musl 里内嵌了 2048 字节的 getdents 缓冲区,所以每个 opendir 都会向 C 分配器要一块 2KB 出头的内存。12 个线程 × 几十万次目录打开 = 每秒几万次 2KB 级别的 calloc/free。
这就是为什么这个 bug 必须要「超大目录树 + 高并发」才能复现:它需要把 mallocng 的 group 申请/释放(也就是 mmap/munmap)频率推到一个极高的水平。
二、把 musl mallocng 讲透:崩溃点断言的到底是什么
要理解这个 bug,你必须先理解 mallocng 的内存布局。这部分是全文的基础,别跳。
2.1 设计哲学:用断言换安全,用带内元数据换体积
musl 1.2.1 之后用 mallocng 替换了老的 oldmalloc。设计目标不是「最快」,而是:
- 体积小(整个分配器几 KB 代码)
- 抗堆溢出攻击(元数据大部分挪出带内区域,剩下的带内部分用交叉校验保护)
- 碎片可控(细粒度 size class)
它的核心结构是三层:
meta_area (4KB 一页,里面塞满 struct meta,带 secret 校验值)
│
└── struct meta ← 带外元数据:位图、sizeclass、last_idx、mem 指针
│
└── struct group ← 带内数据:mmap 出来的一块连续内存
├── slot 0
├── slot 1
└── slot 2 ...
对应的结构体(musl src/malloc/mallocng/meta.h):
struct group {
struct meta *meta; /* 反指回带外 meta */
unsigned char active_idx:5;
char pad[UNIT - sizeof(struct meta *) - 1];
unsigned char storage[]; /* 真正的 slot 区域从这里开始 */
};
struct meta {
struct meta *prev, *next;
struct group *mem;
volatile int avail_mask, freed_mask; /* 32 位位图,故 group 最多 32 槽 */
uintptr_t last_idx:5;
uintptr_t freeable:1;
uintptr_t sizeclass:6;
uintptr_t maplen:8*sizeof(uintptr_t)-12; /* 以 4KB 页为单位的映射长度 */
};
struct meta_area {
uint64_t check; /* 必须等于 ctx.secret */
struct meta_area *next;
int nslots;
struct meta slots[];
};
#define UNIT 16
#define IB 4 /* in-band 头部字节数 */
2.2 槽位头的 4 个字节
每个返回给用户的指针 p 之前有 4 字节带内头(IB = 4):
| 偏移 | 含义 |
|---|---|
p[-1] | 保留 / 用于 size 编码 |
*(uint16_t*)(p-2) | offset16:本 slot 相对 group->storage 的偏移,单位 UNIT(16B) |
p[-3] | 低 5 bit = idx(槽在 group 内的下标),高 3 bit = reserved(尾部空闲字节编码) |
p[-4] | 大偏移标志;非 0 表示真实 offset 存在 *(uint32_t*)(p-8) |
这个设计很精巧:只用 4 个字节,就能从任意用户指针反查出它属于哪个 group、哪个槽、还剩多少空间。代价是这 4 字节暴露在带内,会被堆溢出踩到——所以 mallocng 用一堆断言做交叉校验。
2.3 get_meta():那一串断言
/* musl src/malloc/mallocng/meta.h */
static inline struct meta *get_meta(const unsigned char *p)
{
assert(!((uintptr_t)p & 15));
int offset = *(const uint16_t *)(p - 2);
int index = get_slot_index(p); /* == p[-3] & 31 */
if (p[-4]) {
assert(!offset);
offset = *(uint32_t *)(p - 8);
assert(offset > 0xffff);
}
const struct group *base = (const void *)(p - UNIT*offset - UNIT);
const struct meta *meta = base->meta;
assert(meta->mem == base);
assert(index <= meta->last_idx);
assert(!(meta->avail_mask & (1u<<index))); /* 槽必须是「已被占用」状态 */
assert(!(meta->freed_mask & (1u<<index)));
const struct meta_area *area = (void *)((uintptr_t)meta & -4096);
assert(area->check == ctx.secret); /* meta_area 的防伪校验 */
if (meta->sizeclass < 48) {
assert(offset >= size_classes[meta->sizeclass]*index);
assert(offset < size_classes[meta->sizeclass]*(index+1)); /* ← 就是这条炸的 */
} else {
assert(meta->sizeclass == 63);
}
if (meta->maplen) {
assert(offset <= meta->maplen*4096UL/UNIT - 1);
}
return (struct meta *)meta;
}
崩溃报告里给出的现场是:
GM_FAIL line=153 p=0x7fd927b2b5e0 offset=349 index=0 \
avail=0 freed=0 last_idx=2 sc=25 maplen=2
翻译一下:
sc=25→size_classes[25] == 170(单位 UNIT),即 stride = 170 × 16 = 2720 字节maplen=2→ group 占 2 页 = 8192 字节last_idx=2→ 一共 3 个槽。验算:3 × 2720 = 8160,加上 group 头的 16 字节 = 8176 ≤ 8192 ✓offset=349(UNIT)→ 349 × 16 = 5584 字节,落在第 3 个槽(idx=2,起始 2×2720=5440)里 ✓- 但
index被读成了 0!于是offset < 170*(0+1) = 170这条断言当场炸掉(349 ≥ 170)
也就是说:这块内存的一切都是自洽的——group 活着、meta 指针对、位图显示槽位被正常占用——唯独 p[-3] 的低 5 bit 读出来是 0 而不是 2。
2.4 为什么断言失败表现为 SIGSEGV 而不是 SIGABRT
mallocng 的 assert 不走 libc 的 assert():
/* musl src/malloc/mallocng/glue.h */
#define assert(x) do { if (!(x)) a_crash(); } while(0)
而 a_crash() 在 x86_64 上是:
/* musl arch/x86_64/atomic_arch.h */
static inline void a_crash()
{
__asm__ __volatile__( "hlt" : : : "memory" );
}
hlt 是特权指令,在 ring 3 执行会触发 #GP,内核把它翻译成 SIGSEGV。
这个细节非常重要,因为它解释了一个巨大的排查陷阱:你看到 SIGSEGV,第一反应是「有人解引用了野指针」,于是把精力全砸在找越界写上。实际上这是分配器主动自杀的信号。 如果你在 core dump 里看到 rip 指向一条 hlt,那就是 mallocng 在告诉你「我的元数据被人动了」。
2.5 enframe 与 set_size:那个致命的 read-modify-write
崩溃根源要看写入路径。enframe() 负责把一个空槽包装成用户指针:
static inline void *enframe(struct meta *g, int idx, size_t n, int ctr)
{
size_t stride = get_stride(g);
size_t slack = (stride - IB - n) / UNIT;
unsigned char *p = g->mem->storage + stride*idx;
unsigned char *end = p + stride - IB;
/* 偏移轮转,增大地址复用间隔,方便捕获 double-free */
int off = (p[-3] ? *(uint16_t *)(p-2) + 1 : ctr) & 255;
assert(!p[-4]);
if (off > slack) { /* ... 收敛到 slack 以内 ... */ }
if (off) {
*(uint16_t *)(p-2) = off;
p[-3] = 7<<5;
p += UNIT*off;
p[-4] = 0;
}
*(uint16_t *)(p-2) = (size_t)(p - g->mem->storage)/UNIT; /* 写 offset16 */
p[-3] = idx; /* 写 idx */
set_size(p, end, n); /* ← 关键 */
return p;
}
static inline void set_size(void *p, void *end, size_t n)
{
int reserved = end - (unsigned char *)p - n;
if (reserved) end[-reserved] = 0;
if (reserved >= 5) {
*(size_t *)end = 0;
*(uint32_t *)(end-4) = reserved;
reserved = 5;
}
/* 这是一个 read-modify-write:要先把 p[-3] 读回来 */
((unsigned char *)p)[-3] = (((unsigned char *)p)[-3] & 31) + (reserved << 5);
}
看清楚了吗?enframe 刚把 idx 写进 p[-3],set_size 立刻又要把 p[-3] 读回来(为了保留低 5 位的 idx,只改高 3 位的 reserved)。
反汇编确认这是真实的内存操作,不是寄存器转发:
38804b: mov BYTE PTR [r8-0x3], bl ; S1: p[-3] = idx
38805c: mov WORD PTR [r8-0x2], ax ; *(p-2) = offset16
...
38807e: movzx eax, BYTE PTR [r8-0x3] ; set_size 把 p[-3] 读回来(真 load)
...
38808f: mov BYTE PTR [r8-0x3], al ; 写回 (loaded & 31) | (reserved<<5)
从 38804b 到 38807e,大约 10 条指令。
在 x86 上,38807e 这条 load 必须看到 38804b 那条 store 的结果。 这不是「通常会」,这是 x86-TSO 内存模型的硬性保证:即使 store 还躺在 store buffer 里没提交到 L1,同一核上后续对同一地址的 load 也会从 store buffer 直接转发。
可实测就是读到了 0。
三、为什么 Rust 换了 jemalloc 还是死在 musl 的 malloc 里
这是本案最容易被误解的一环,也是对绝大多数 Rust 工程师最有实操价值的一段。
ripgrep 的 crates/core/main.rs 里明明白白写着(这段注释我原样贴,作者自己解释得很清楚):
// Since Rust no longer uses jemalloc by default, ripgrep will, by default,
// use the system allocator. On Linux, this would normally be glibc's
// allocator, which is pretty good. In particular, ripgrep does not have a
// particularly allocation heavy workload, so there really isn't much
// difference (for ripgrep's purposes) between glibc's allocator and jemalloc.
//
// However, when ripgrep is built with musl, this means ripgrep will use musl's
// allocator, which appears to be substantially worse. (musl's goal is not to
// have the fastest version of everything. Its goal is to be small and amenable
// to static compilation.) Even though ripgrep isn't particularly allocation
// heavy, musl's allocator appears to slow down ripgrep quite a bit. Therefore,
// when building with musl, we use jemalloc.
#[cfg(all(target_env = "musl", target_pointer_width = "64"))]
#[global_allocator]
static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
那为什么栈里还是 mallocng?
3.1 #[global_allocator] 的作用边界
#[global_allocator] 替换的是 Rust 的 GlobalAlloc trait 实现,也就是 alloc::alloc::alloc / __rust_alloc 这条路径。它管的是:
Box::new、Vec::push、String::from- 标准库内部的 Rust 层分配
- 所有 crate 里的 Rust 层分配
它完全管不到:
- 你链接进来的任何 C 库内部调用的
malloc/calloc/free - libc 自己实现的 POSIX 函数内部的分配
opendir() 是 musl 实现的 POSIX 函数,它内部调 calloc(),走的是 musl 自己的 mallocng。Rust 层完全不参与。
一张图说清楚:
┌──────────────────────────────┐
Vec / Box / String → │ __rust_alloc → jemalloc │ ← #[global_allocator] 覆盖到这里
└──────────────────────────────┘
std::fs::read_dir()
│
└→ libc::opendir()
│
└→ ┌──────────────────────────────┐
│ musl calloc → mallocng │ ← 覆盖不到,两套堆并存
└──────────────────────────────┘
结论:一个 musl 静态二进制里同时跑着两个堆分配器。 jemalloc 服务 Rust 侧,mallocng 服务 libc 侧。这不是 bug,是 #[global_allocator] 的语义所决定的。
3.2 那能不能用符号覆盖,把 musl 的 malloc 也换掉?
理论上,动态链接时 malloc 在 glibc 里是弱符号(其实是符号插入 symbol interposition),你 LD_PRELOAD 一个 mimalloc 就能全进程接管。musl 也允许你在链接时提供自己的 malloc 定义来覆盖。
但在 静态链接 + Rust 的组合下有三重麻烦:
- musl 内部调用可能已经被内联/直连。musl 的部分内部路径调的是
__libc_malloc之类的内部别名,或者在-ffunction-sections + --gc-sections之后被静态解析,你在外面定义malloc不一定能全部截住。 - Rust 的 musl target 用的是 rustup 自带的
self-contained/libc.a,不是系统的 musl。要覆盖你得动~/.rustup/toolchains/<tc>/lib/rustlib/x86_64-unknown-linux-musl/lib/self-contained/libc.a——这正是这次调查者做插桩时干的事,属于「知道自己在干什么」才该碰的操作。 - 静态链接下符号冲突会直接报 multiple definition,而不是优雅地覆盖。你需要
--allow-multiple-definition或者--whole-archive之类的链接器咒语,可维护性很差。
所以工程上更常见的做法是:要么别用 musl,要么接受 libc 侧仍然是 mallocng。
3.3 动手验证:Rust 全局分配器管不到 libc 内部
写一个 20 行的程序自己看:
// Cargo.toml:
// [dependencies]
// libc = "0.2"
// [target.'cfg(target_env = "musl")'.dependencies]
// tikv-jemallocator = "0.6"
use std::sync::atomic::{AtomicUsize, Ordering};
use std::alloc::{GlobalAlloc, Layout, System};
static RUST_ALLOCS: AtomicUsize = AtomicUsize::new(0);
struct Counting;
unsafe impl GlobalAlloc for Counting {
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
RUST_ALLOCS.fetch_add(1, Ordering::Relaxed);
unsafe { System.alloc(l) }
}
unsafe fn dealloc(&self, p: *mut u8, l: Layout) {
unsafe { System.dealloc(p, l) }
}
}
#[global_allocator]
static A: Counting = Counting;
fn main() {
let before = RUST_ALLOCS.load(Ordering::Relaxed);
// 走 libc 的 opendir/closedir,各 1000 次
for _ in 0..1000 {
unsafe {
let d = libc::opendir(c"/usr/lib".as_ptr());
assert!(!d.is_null());
libc::closedir(d);
}
}
let after = RUST_ALLOCS.load(Ordering::Relaxed);
println!("libc opendir x1000 期间 Rust 分配器被调用次数: {}", after - before);
// 输出接近 0:这 1000 次 calloc 全部走了 libc 自己的堆
}
跑一遍你就明白了:opendir 那 1000 次 2KB 分配,一次都没经过你的 Rust 分配器。
想更直观地看两个堆并存,用 gdb 打断点:
gdb ./target/x86_64-unknown-linux-musl/release/demo
(gdb) b calloc # musl 的 calloc
(gdb) b __rust_alloc # Rust 侧入口
(gdb) run
# 你会看到两个断点交替命中,分别来自不同的调用链
四、决定性实验:把「不可能」变成「可观测」
这一节是全文最值得学习的部分。不是因为结论,而是因为实验设计。
面对「一个线程读不到自己刚写的值」这种反直觉现象,弱一点的排查者会陷入两个泥潭:要么坚信是自己代码有 UAF,无穷无尽地跑 valgrind/ASan(musl 静态构建下这俩基本用不了);要么甩锅给「CPU 有 bug」然后放弃。
正确的做法是:设计能够互相证伪的对照实验。
4.1 第一步:给 musl 打插桩
把 musl 1.2.5 重新编译,加入四类行为保持不变的观测代码:
- 事件环(ring buffer):记录每一次
enframe/ group 分配 / group 释放 /munmap SELF-TEAR自检:在enframe末尾把刚写进去的槽位头字节再读一遍,不一致就打日志GM_FAIL转储:get_meta断言失败时打印全部上下文再崩CLAIM GHOST复检:在锁保护下重新检查位图
概念上像这样(示意,非原始 patch):
/* 在 enframe 结尾插入的自检 */
{
unsigned char b3 = *(volatile unsigned char *)(p - 3);
uint16_t o16 = *(volatile uint16_t *)(p - 2);
if ((b3 & 31) != (unsigned)idx) {
__gm_log("SELF-TEAR p=%p wrote_idx=%02x read_b3=%s o16=%s\n",
p, idx, bits8(b3), bits16(o16));
}
}
构建流程也值得记一下(这套「替换 rustup 自带 musl」的手法很实用):
# 1. 编译打了插桩的 musl
git clone https://git.musl-libc.org/git/musl && cd musl
git checkout v1.2.5
git apply /path/to/01-instrumentation.patch
CFLAGS="-O2 -g" ./configure && make -j"$(nproc)"
cd ..
# 2. 塞进 rustup 的 self-contained 目录(务必先备份!)
SC=~/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/x86_64-unknown-linux-musl/lib/self-contained
cp "$SC/libc.a" "$SC/libc.a.bak"
cp musl/lib/libc.a "$SC/libc.a"
for f in Scrt1.o crt1.o crti.o crtn.o rcrt1.o; do cp "musl/lib/$f" "$SC/$f"; done
# 3. 用它构建带调试符号的 ripgrep
git clone --branch 15.2.0 https://github.com/BurntSushi/ripgrep.git && cd ripgrep
CARGO_PROFILE_RELEASE_DEBUG=true cargo build --release --target x86_64-unknown-linux-musl
nm target/x86_64-unknown-linux-musl/release/rg | grep __gm_fail # 验证插桩生效
跑起来的结果非常干净:每一次崩溃之前都有一条 SELF-TEAR,每一条 SELF-TEAR 之后都紧跟一次 GM_FAIL。 十次崩溃,特征完全一致:
SELF-TEAR p=0x7fd927b2b5e0 wrote_idx=02 read_b3=10100000 o16=0000000000000000
- 写进去的是
idx=2 - 读回来是
0xa0,低 5 位 = 0 - 连
p[-2]的 offset16 也读成了 0——两个独立的自身写入都消失了
而且每次都是 sc=25、maplen=2、撕裂的槽位落在 group 的第 2 页上——也就是那个 group 里第一次被写到的「新鲜缺页」的页。
4.2 关键探针:立即读 vs 延迟读
这是整个调查的转折点。
如果「store 丢了」,那么按 x86 的规则,store 还在 store buffer 里,后续 load 依然会转发,读到的应该还是 2。要验证这一点,插一个紧跟 store 之后一条指令的 volatile 读:
p[-3] = idx;
unsigned char imm_b3 = *(volatile unsigned char *)(p - 3); /* 立即读 */
set_size(p, end, n); /* 内部约 10 条指令后延迟读 */
8 分钟、246 次运行、3 次崩溃,每次都长一模一样:
SELF-TEAR ... wrote_idx=02 imm_b3=00000010 del_b3=10100000 o16=0000...0000
^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^
立即读 = 2 ✓ 延迟读 = 0 ✗
立即读是对的,延迟读是错的。
这一条数据把可能性砍到只剩两种:
- (a) 另一个 CPU 在两次读之间往这个地址写了 0 —— 即普通的内存破坏(UAF / 越界写 / musl 逻辑 bug)
- (b) 延迟读命中的是另一张物理页 —— 即这段 VA 在函数执行中途被重新映射了,属于内核事件
「store 没提交」这个解释被彻底排除了,因为它读到了 2。
4.3 pagemap:VA 被换成了零页
接下来在检测到不一致的瞬间,直接读 /proc/self/pagemap 查这个 VA 对应的物理页帧号。250 次运行、6 次崩溃、6 次 SELF-TEAR,结果整齐得可怕:
pm_p=1 sd=1 pfn=000000000000
页是 present 的,soft-dirty 置位(说明被写过),但 PFN = 0 —— 那是内核的零页(zero page)。
这就是 (b) 的实锤:一个正在被使用的匿名页的后备物理页,在两次读之间被换成了内核零页。
为什么零页恰好能解释「读到 0」?看 do_anonymous_page():对一个 none PTE 的读缺页,内核不分配新页,而是直接把 PTE 指向 my_zero_pfn(address)——一张全零的共享页。所以延迟读命中零页时,读到的必然是 0,而且是确定性的 0,不是随机垃圾。
这个「确定性」本身又是一条独立证据。
4.4 四组 prefault 对照:教科书级的实验设计
光有 pagemap 还不够(它是崩溃后几个 syscall 才读的,是滞后快照)。所以又设计了四个只差几行代码的二进制:
| 组 | 在 alloc_group(持分配器锁)里加的代码 | 运行次数 | 崩溃次数 |
|---|---|---|---|
| P0 | 什么都不加(基线) | 250 | 4 |
| P1 | 预先触碰新 group 的每一页 | 304 | 0 |
| CTRL | 只预先触碰第 2 页 | 305 | 0 |
| FAKEPF | 写一块不属于该 group 的暂存缓冲区,循环步长相同 | 308 | 3 |
FAKEPF 这一组是全场最漂亮的设计。它制造了和 P1/CTRL 完全可比的时间扰动和持锁时长,唯一的区别是:它没有触碰 group 自己的页。
结果:FAKEPF 以满速率崩溃,特征签名一模一样。
结论无可辩驳:决定崩不崩的唯一变量,是「enframe 写入的那一页在此之前是否已经被 fault 进来」,而不是任何时序扰动。
顺便,还做了三组「窗口宽度」探针:
| 探针 | 在 store→延迟读之间插入 | 运行 | 崩溃 |
|---|---|---|---|
| 无 | 无 | 246 | 3 |
mfence | 排空 store buffer | 246 | 0 |
lfence | 不排空 store buffer | 246 | 0 |
| 额外 load | 一次无关地址的 volatile load | 244 | 0 |
注意 lfence 和「一次无关 load」也能压掉崩溃。这恰恰说明压制效果与内存序无关,纯粹是把时间窗口撑宽了一点点。这个竞态窗口窄到「多执行一条指令就错过」的量级。
4.5 四条独立论据排除 UAF / 越界写
这一步是调查里最严谨的部分,值得逐条学:
① musl 从不往一个已占用槽的 idx 字段写 0。p[-3] 的合法写入者只有三个:enframe(写真实 idx,这里是 2)、set_size(RMW,保留低 5 位)、free(写哨兵值 255)。没有任何一个会写 0。而且位图显示这个槽既不在 avail_mask 也不在 freed_mask 里——按 get_meta 的不变量,其他线程的 malloc/free 根本不该碰它。锁保护下的 CLAIM GHOST 复检一次都没触发,enframe/alloc_group 的越界与重叠断言也一次都没触发。
② 撕裂值是确定的 0,不是垃圾。
10 次以上崩溃,延迟读永远是 0xa0。如果是 UAF 后 VA 被回收给新分配,读到的应该是新的 idx、或者 free 写的 255、或者部分新内容——变化的值。如果是越界写,读到的是溢出方携带的数据。只有零页能给出恒定的全零。补充一点:musl 不会把释放的槽清零,它标 255。
③ pagemap 显示 VA 后备是零页。
并发写只会落在「VA 当下映射到的那张物理页」上,它改变不了 VA 映射到哪张页。能把一个存活中的 VA 重新映射到零页的,只有内核的页表操作。
④ 只有消除「第 2 页的新鲜缺页」才能压制崩溃。
这是最强的一条,且完全不依赖 pagemap 的时序。越界写不会因为你提前 fault 了某一页就不越界了;UAF 不会因此就不 UAF 了;musl 的位图竞态不会因此就不竞态了。但实测就是:CTRL 组(只预 fault 第 2 页)完全压制,FAKEPF 组(同等扰动但不 fault group 的页)满速率崩溃。
这种特异性,只有「缺页路径 vs shootdown 竞态」能预测,没有任何进程内破坏 bug 能预测。
4.6 可复用的排查决策树
把这套方法抽象出来,遇到「诡异内存 bug」可以这么走:
崩溃点在分配器元数据校验里?
├─ 是 → 先看是不是分配器的主动断言(rip 指向 hlt/ud2/int3?)
│ ├─ 是 → 不要去找野指针,去找「谁改了元数据」
│ │ └─ 给分配器打插桩:写后立即回读(SELF-TEAR 检测)
│ │ ├─ 立即读对、延迟读错 → 排除 store buffer,只剩「并发写」或「VA 被重映射」
│ │ │ ├─ 读 /proc/self/pagemap 看 PFN 变没变
│ │ │ ├─ 设计 prefault 对照组(关键!必须配一个等时长的假对照)
│ │ │ └─ 撕裂值是恒定的还是随机的?恒定 0 → 高度怀疑零页
│ │ └─ 立即读就错 → 大概率真是自己代码的问题,回去查 UAF
│ └─ 否 → 常规野指针排查(ASan / valgrind / hardening malloc)
└─ 否 → 常规排查
再补一条铁律:任何「加了 X 就不崩了」的结论,都必须配一个「加了等量但性质不同的 Y」的对照组。 否则你分不清是「X 修了问题」还是「X 只是改变了时序」。FAKEPF 就是那个 Y。
五、进内核:per-VMA lock 快路径 vs munmap 的 TLB shootdown
现在把镜头切到内核。
5.1 Path A:一次匿名页写缺页
自 Linux 6.4 引入 per-VMA lock 之后,缺页处理有了一条不需要拿 mmap_lock 写锁的快路径:
do_user_addr_fault()
└─ lock_vma_under_rcu() /* RCU 下拿 VMA 的读锁,不碰 mmap_lock */
└─ handle_mm_fault(..., FAULT_FLAG_VMA_LOCK)
└─ ...
└─ do_anonymous_page()
do_anonymous_page() 的写分支(mm/memory.c)大致七步:
- 分配一张私有匿名 folio
__folio_mark_uptodate(folio)—— 内存屏障,保证页内容对其他 CPU 可见pte_offset_map_lock(...)—— 拿 PTE 自旋锁if (vmf_pte_changed(vmf)) goto release;—— 如果 PTE 已经不是 none 了就放弃重来set_ptes(...)—— 发布新 PTE,把 VA 映射到新 folioupdate_mmu_cache_range(...)—— 原 PTE 是 none,不需要 flushpte_unmap_unlock(),返回用户态,用户接着写p[-3]/p[-2]
第 6 步是关键:从 none PTE 变成 present PTE,理论上 TLB 里不该有旧条目(negative caching 在 x86 上不做),所以内核认为不需要任何 TLB 失效操作。这个假设本身是对的。
5.2 Path B:并发的 munmap
同一时刻,另一个 worker 线程 closedir() → free() → mallocng 判断某个 group 全空 → munmap():
do_munmap()
└─ do_vmi_align_munmap()
├─ vms_gather_munmap_vmas() /* 隔离 VMA */
├─ vma_iter_clear_gfp() /* 从 maple tree 摘掉 */
└─ vms_complete_munmap_vmas()
└─ vms_clear_ptes()
└─ unmap_region() → unmap_vmas() → zap_pte_range() /* 清 PTE */
└─ tlb_finish_mmu() → tlb_flush_mmu() → tlb_flush()
└─ flush_tlb_mm_range() → flush_tlb_multi()
└─ IPI 广播到所有持有该 mm 的 CPU
注意 core dump 里的旁证:崩溃瞬间,另一个 worker 线程正卡在 alloc::sync::...::drop<...InnerReadDir...> 里——也就是 closedir → free → munmap。还有几个线程在 getdents64,几个在 __malloc_lock 的 futex/CAS 上。
现场齐活了:一边在 fault 新页,一边在 munmap 老页,同一个 mm。
5.3 竞态窗口在哪
per-VMA 读锁(Path A)不排斥 munmap(Path B 拿的是 mmap_lock 写锁,两者是不同的锁)。Path A 的安全性完全押在第 4 步的 vmf_pte_changed() 上——而这个检查只保护 PTE 表项本身不被并发 zap 掉。
问题在于:Path A 过了第 4 步、在第 5 步 set_ptes() 发布了新 PTE 之后,一个已经 zap 完这段 VA 的旧 PTE、但还卡在 zap_pte_range 和 tlb_finish_mmu 之间的 Path B,仍然会继续走到 flush_tlb_mm_range(),对包含刚刚被 Path A 映射的那个 VA 的范围发出 TLB shootdown IPI。
这个 IPI 落到崩溃线程所在的 CPU 上时,Path A 的 set_ptes / update_mmu_cache 已经跑完了。于是刚装好的映射的 TLB 条目被打掉。
线程接下来那条延迟 load 只能重新走一遍页表。而在 shootdown 撕掉一个刚 fault 进来的页的过程中,是有可能短暂暴露出一个零页翻译的——这正是 sandbox 里 pagemap 抓到的 pfn=0。
5.4 为什么 Zen 5 + 7.0.12 才复现
跨机器数据非常说明问题:
| CPU | 内核 | 结果 |
|---|---|---|
| AMD Threadripper 9960X (Zen 5) | 7.0.12-1-default | 复现(原报告机器) |
| AMD Threadripper 9970X (Zen 5) | 6.19.10-1-cachyos | 不复现 |
| AMD EPYC 9575F | 6.8.0-1-136-generic | 不复现 |
| AMD Ryzen AI MAX+ 395 | 6.18.35+rex+2-amd64 | 不复现 |
| Intel Xeon 678X | 6.8.0-1-136-generic | 不复现 |
9970X 那一行是决定性的:同代微架构(Zen 5)、更老的内核,不复现。
所以:崩溃跟着内核版本走,不跟着 CPU 家族走。
顺带说,AMD 的 INVLPGB(广播式 TLB 失效指令,让 shootdown 不必走 IPI 而由硬件广播)在 6.19 上已经有了,9970X 也有,但它照样不复现。所以 INVLPGB 不是根因,它顶多是个放大器。
源码对比给出的差分范围也很干净:v6.19 → v7.0 之间,do_anonymous_page、per-VMA lock 快路径、mmap_write_downgrade 与 vms_clear_ptes 的顺序、CONFIG_PER_VMA_LOCK 默认值……全都没变。变的是 munmap 拆除侧:mm/memory.c 有约 393 行非注释改动,几乎全在 zap_pte_range / unmap_vmas / free_pgtables,核心是一套新的「zap 过程中顺手回收 PTE 页表」机制:
4c640eb4181c mm: move pte table reclaim code to memory.c
fb4ddf208511 mm/memory: handle non-split locks correctly in zap_empty_pte_table()
eda8c5e77622 mm/memory: add tree limit to free_pgtables()
三个都是 2026 年 1 月合入 v7.0 的,v6.19 里没有。和跨机器数据完美吻合。
六、真正的一行 bug
调查报告写到这里就停了,作者自己在 §7.5 里很诚实地标注:「识别出具体是哪个 v7.0 改动扩大了窗口,是强相关,不是证明」。
然后 2026 年 8 月 1 日,事情有了突破。内核圈的人(Andy Lutomirski 在 LKML 上贴了发现,Brad Spengler 在 issue 下给出补丁)指出了那一行:
--- a/mm/memory.c
+++ b/mm/memory.c
@@ -1992,7 +1992,7 @@ static unsigned long zap_pte_range(struct mmu_gather *tlb,
if (can_reclaim_pt) {
if (direct_reclaim || zap_pte_table_if_empty(mm, pmd, start, &pmdval)) {
- pte_free_tlb(tlb, pmd_pgtable(pmdval), addr);
+ pte_free_tlb(tlb, pmd_pgtable(pmdval), start);
mm_dec_nr_ptes(mm);
}
}
一个标识符。addr → start。
6.1 为什么 addr 是错的
zap_pte_range() 的主体是一个 do { ... } while (pte++, addr += PAGE_SIZE, addr != end); 风格的循环,外加重试逻辑。循环退出时,addr 恒等于 end——也就是这段范围的结束地址(开区间的右端点),已经在范围之外了。
而正确的、代表这张 PTE 页表所覆盖范围起点的变量,是 start。
在 commit 4c640eb4181c(「把 PTE 页表回收代码搬到 memory.c」)之前,这段代码用的就是 start。搬家过程中被改成了 addr。经典的重构事故。
6.2 pte_free_tlb 到底拿这个地址干什么
看宏定义(include/asm-generic/tlb.h):
#define pte_free_tlb(tlb, ptep, address) \
do { \
tlb_flush_pmd_range(tlb, address, PAGE_SIZE); \
tlb->freed_tables = 1; \
__pte_free_tlb(tlb, ptep, address); \
} while (0)
再往下:
static inline void tlb_flush_pmd_range(struct mmu_gather *tlb,
unsigned long address, unsigned long size)
{
__tlb_adjust_range(tlb, address, size);
tlb->cleared_pmds = 1;
}
__tlb_adjust_range() 的作用是把 [address, address+size) 并进 mmu_gather 累积的待 flush 范围里。这个范围最终会传给 tlb_flush() → x86 的 flush_tlb_mm_range(mm, tlb->start, tlb->end, stride_shift, tlb->freed_tables)。
所以这个地址参数有两个消费者:
__pte_free_tlb(tlb, ptep, address)—— 在 x86 上,释放动作本身忽略 address(它只需要 ptdesc 指针)tlb_flush_pmd_range(tlb, address, PAGE_SIZE)—— TLB 失效范围用它,而且不忽略
Brad Spengler 的原话总结得非常精准:「x86 上的释放操作会忽略这个地址,但这个地址会被用于 TLB 操作(作用在错误的范围上)」。
6.3 后果链
于是链条是这样的:
zap_pte_range 回收了一张空的 PTE 页表
│
├─ freed_tables = 1 ← 告诉 x86「我释放了页表,你得连 paging-structure cache 一起刷」
└─ 记录的 flush 范围 = [end, end+4096) ← 错的!真正要刷的是 [start, end)
│
v
flush_tlb_mm_range(mm, 记录范围, ..., freed_tables=1)
│
└─ IPI 广播出去,但作用范围偏了一整段
│
v
某个 CPU 上,[start, end) 里刚被 Path A fault 进来的页
既没被正确失效,又被卷进了一次错误范围的失效动作
│
v
page walk 拿到不一致的翻译 → 读到零页 → mallocng 断言炸
要特别理解 freed_tables = 1 的分量:它意味着中间层页表(PMD 指向的 PTE 页)已经被释放了。CPU 的 page walk cache 里如果还缓存着指向这张已释放页表的中间翻译,后续的 page walk 就可能走到一张已经被内核回收、甚至已经被拿去做别的用途的物理页上。这是比普通 TLB 陈旧条目严重一个量级的问题。范围搞错,等于该刷的没刷。
而 can_reclaim_pt 这条路径正好只在「一整张 PTE 页表被清空」时触发——在 ripgrep 这种「疯狂 mmap 2 页的 group、用完立刻 munmap」的负载下,PTE 页表被整张清空的概率极高。这就是为什么只有这个负载能稳定复现。
6.4 案子还没结
坦白说,截至写稿时补丁还没验证成功。原因很典型也很有教育意义:
调查者把补丁打上 SUSE 7.0.12 内核(还贴心地加了个 sysctl 开关方便 A/B),重启之后,原来的复现器在打补丁和不打补丁两种配置下都不崩了。
他的推测是:之前那次开机过程中,系统逐渐演化出了某种特定的页表布局,恰好让 rg 的访问模式踩进这个 bug;重启之后页表全新初始化,就撞不上了。
这是并发时序 bug 最恶心的地方:你的复现器可能依赖于一整个系统的历史状态。 遇到这种情况的常规手段包括:制造大量 mmap/munmap 让地址空间碎片化、跑内存压力工具、故意让页表分配跨越 PMD 边界,等等。但没有银弹。
所以现在的状态是:观测到的错误行为已经被高置信度地证实(一个线程的自身写入因为后备页被替换而消失,这是任何正确的进程内 x86 指令序列都不可能产生的);竞态定位到 per-VMA lock 缺页 vs munmap shootdown 的交互;具体到哪一行代码,pte_free_tlb 那个 addr/start 是目前最有力的候选,等待 A/B 验证。
七、AI 参与调试的边界:这次事件的元层面
这一节值得单独拎出来,因为它可能比技术本身更有现实意义。
Andy Lutomirski 在 LKML 上引用这份分析时,原话是:「我看到了一个有趣的 ripgrep bug 报告,还有一份用功但相当糟糕的 AI 生成分析」。HN 上有人接了一句:「我确实觉得,这写得也太多了,不像人写的」。
那份分析报告确实是 AI 深度参与的产物(连复现器 generate_repro_tree.py 作者都注明是 LLM 写的)。但请注意,这不是一个「AI 搞砸了」的故事,而是一个边界很清晰的故事。
AI 干得很好的部分
- 构造复现器:写一个模拟真实仓库文件大小分布的 20 GiB 树生成脚本,纯体力活,AI 又快又好。
- 设计插桩:事件环、SELF-TEAR 自检、GM_FAIL 转储——这些是模式化的、有明确规格的代码。
- 执行对照实验:五个 patch、几百次运行、统计崩溃率并制表,这是极其枯燥但必须做对的工作。
- 推理链的中段:从「立即读对、延迟读错」推出「排除 store buffer 解释」,从「确定性的 0」推出「不是 UAF 递归」——这些逻辑严密且正确。
- 诚实标注置信度:报告 §7.5 明确写了「这是强相关不是证明」,还列出了「我没有在源码层面证明 X」。这份自知之明比很多人类 bug 报告都强。
AI 干砸的部分
- 最后一跳的归因。分析停在了「v6.19→v7.0 之间只有这套 PTE 回收重构变了,所以嫌疑最大」这个层面——这是相关性论证,不是因果论证。真正的定位需要有人逐行读
zap_pte_range,注意到「循环退出后addr == end」,并且知道pte_free_tlb的第三个参数会喂给__tlb_adjust_range。这需要的是领域内的肌肉记忆,不是推理能力。 - 信噪比。27KB 的 README,对内核 maintainer 来说阅读成本很高。人类专家写同样的结论可能只需要 1/5 篇幅。Lutomirski 那句「pretty bad」大概率是在说这个:不是错,是啰嗦且没落到点子上。
一份实操守则
如果你要用 AI 辅助做这种深度排查,我的建议:
- 让 AI 做实验,不要让 AI 下结论。 插桩、跑 batch、统计、制表,AI 是最好的实验室助手。最终归因留给人。
- 强制要求「对照组」。 AI 很容易满足于「加了 X 就好了」。你必须逼它设计 FAKEPF 那样的等效扰动对照。
- 强制标注置信度分级。 「已证实 / 强相关 / 猜测」三档分开写,这份报告在这点上做得很好。
- 提交给上游前,先自己压缩。 把 27KB 压到 3KB,只留证据链和关键数据表。maintainer 的注意力是最稀缺的资源。
- 别让 AI 猜内核代码语义。
pte_free_tlb第三个参数干什么用,这种事必须去读宏定义、读源码,不能靠「一般来说应该是」。
一句话总结:AI 把这个 bug 从「musl 有毛病」推进到了「内核 mm 有竞态」,人类把它从「内核 mm 有竞态」推进到了「这一行的 addr 应该是 start」。两段路都不可或缺。
八、给普通工程师的实操清单
前面七节是屠龙术。这一节是你明天上班能用的。
8.1 你到底该不该用 musl 静态构建?
musl 静态链接的真实收益:
- 单文件分发,不依赖目标机 glibc 版本(这是最大也往往是唯一的刚需)
- 容器镜像可以做到
FROM scratch - 攻击面小,代码量小
真实代价(很多人只算了收益):
- mallocng 在多线程下的争用。HN 上有工程师报告:一个 I/O 密集型应用换到 musl 后变成 malloc 密集型,8 线程场景下换成 mimalloc 提速 20 倍。另有人报告 mallocng 在 5 线程以上明显成为瓶颈。当然反方也有数据:同一个 Python 文件服务器场景,mimalloc 比 mallocng 快 50%,但内存从 250 MiB 涨到 670 MiB,某些病态场景(libvips 走 imagemagick 解 heif)内存放大 10 倍。没有免费的午餐,只有 trade-off。
- 默认线程栈很小。musl 的默认线程栈远小于 glibc 的 8 MB,递归深的代码容易爆栈。
- DNS 解析行为差异(不支持
nsswitch.conf、老版本不支持 TCP 回退、并发查询语义不同)。 - locale / iconv 支持极简。
- 本文这种:一旦踩到底层坑,可用的调试工具比 glibc 少一大截(ASan、valgrind 在静态 musl 下基本歇菜)。
决策树:
需要单二进制分发、跨发行版运行?
├─ 否 → 用 glibc 动态链接,别折腾
└─ 是 → 你的程序是多线程 + 分配密集吗?
├─ 否 → musl 静态,随便用
└─ 是 → musl 静态 + 显式换分配器(见 8.3)
└─ 换完还是慢?→ 考虑 glibc 静态链接,或者 zig cc / cosmopolitan 之类的方案
8.2 Alpine 镜像的隐藏账单
FROM alpine 省下的那 100 MB 镜像体积,可能要用这些来付:
- 多线程分配性能下降(可能是数量级的)
- Python/Node 生态里 manylinux wheel 不可用,很多包要现场编译
- 一旦出现底层怪问题,社区能帮你的人少一个数量级
判断标准很简单:如果你的服务是 CPU/内存密集的长驻进程,用 Debian slim;如果是短生命周期的 CLI 工具或者纯 I/O 转发,Alpine 挺好。
8.3 换分配器的正确姿势
Rust(推荐 mimalloc 或 jemalloc):
# Cargo.toml
[target.'cfg(target_env = "musl")'.dependencies]
mimalloc = { version = "0.1", default-features = false }
#[cfg(target_env = "musl")]
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
注意:这只换 Rust 侧。libc 侧(opendir、getaddrinfo、iconv 等)仍然走 mallocng。如果你的 libc 侧分配也很热,考虑绕开 libc:
// 用 rustix 直接走 getdents64,不经过 opendir/libc 堆
// rustix = { version = "1", features = ["fs"] }
use rustix::fs::{Dir, Mode, OFlags};
fn walk(path: &std::path::Path) -> std::io::Result<()> {
let fd = rustix::fs::open(path, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, Mode::empty())?;
let mut dir = Dir::read_from(&fd)?;
while let Some(entry) = dir.next() {
let entry = entry?;
// entry.file_name() ...
}
Ok(())
}
这条路在 Linux 上完全可行(syscall 是稳定 ABI),代价是丢失跨平台性——Windows/macOS 上你还是得走 libc。这也正是 HN 讨论里的核心分歧点:POSIX 作为兼容性边界 vs 直接 syscall 换性能,没有标准答案,取决于你的目标平台集合。
C/C++(动态链接):
LD_PRELOAD=/usr/lib/libmimalloc.so ./your_app
# 或链接时:gcc ... -lmimalloc
C/C++(musl 静态链接): 最省心的办法是在链接命令里把 mimalloc 的静态库放在 libc 之前,并确认符号确实被覆盖:
musl-gcc -static -o app app.c /usr/lib/mimalloc.o -lpthread
nm app | grep -w 'T malloc' # 确认 malloc 来自 mimalloc 而不是 musl
8.4 遇到疑似同类崩溃的 5 步自查
# 1. 确认崩溃指令。指向 hlt (0xf4) / ud2 (0x0f 0x0b) → 是主动断言,不是野指针
gdb -batch -ex 'x/i $rip' -c core ./binary
# 2. 换 glibc 构建复现一次。只在 musl 上出 → 高度怀疑分配器交互
cargo build --release --target x86_64-unknown-linux-gnu
# 3. 记录内核版本 + CPU 型号,找一台老内核的机器对比
uname -r; lscpu | grep -E 'Model name|Flags' | head -2
# 4. 看崩溃时其他线程在干什么(有没有 munmap / madvise 在跑)
gdb -batch -ex 'thread apply all bt' -c core ./binary | grep -E 'munmap|madvise|free|closedir'
# 5. 关掉 THP 再试(透明大页会改变缺页与拆分行为)
echo never | sudo tee /sys/kernel/mm/transparent_hugepage/enabled
再补一个临时缓解手段:降低并发。这个 bug 需要「大量并发缺页 + 大量并发 munmap」,rg --threads 2 大概率就不崩了。生产环境救火时,先降并发保命,再慢慢查。
8.5 性能优化的一般性教训
从这个案子能提炼出三条通用的:
- 短生命周期的大块分配是内核压力源。 mallocng 对 2KB 级别的对象用 2 页 group、3 个槽——意味着每三个
DIR就要一次mmap+ 一次munmap。munmap是要发 IPI 做 TLB shootdown 的,代价随核数线性上升。优化方向:对象池化。 ripgrep 完全可以给 worker 线程做DIR复用,或者干脆绕开opendir直接用getdents64复用一块缓冲区。 - 「不分配」永远快过「快速分配」。 HN 上那句话说得好:「大多数程序变快的方式是复用分配,而不是有一个更快的分配器。」 ripgrep 的热循环(逐字节匹配正则)本身几乎不分配,这是它快的原因;目录遍历这部分反而是分配热点。
- 静态链接把 libc 变成了你程序的一部分。 你不能再说「这是 libc 的问题」,因为 libc 就在你的二进制里,它的所有 trade-off 都变成了你的 trade-off。选 musl 的时候,你同时选了 mallocng 的性能特征、它的线程栈默认值、它的断言策略。
九、总结与展望
把整条链子再串一遍:
- 应用层:ripgrep 用 12 个线程遍历 184 万个文件,每个目录一次
opendir,制造出每秒几万次的 2KB 分配/释放。 - 运行时层:
#[global_allocator]只覆盖 Rust 侧,libc 侧仍是 mallocng。一个进程两个堆。 - C 库层:mallocng 用 2 页 group 承载 3 个 2720 字节的槽,频繁
mmap/munmap;enframe写完p[-3]后set_size立刻回读——一个横跨约 10 条指令的窗口。 - 内核层:per-VMA lock 缺页快路径发布了新 PTE;并发的
munmap走 PTE 页表回收路径,因为pte_free_tlb(tlb, ..., addr)里的addr已经等于end,累积的 TLB 失效范围偏了,加上freed_tables=1意味着中间层页表已被释放——刚 fault 进来的页的翻译因此陷入不一致,短暂暴露出零页。 - 表象:一个线程读不到自己 10 条指令前写的字节,mallocng 的完整性断言触发
hlt,用户看到 SIGSEGV。
任何一层单独看都是合理的工程决策。 ripgrep 选 musl 是为了单文件分发;musl 选 mallocng 是为了体积和抗溢出;mallocng 用带内元数据+断言是安全性设计;内核的 per-VMA lock 是为了缓解 mmap_lock 争用;PTE 页表回收重构是为了减少内存占用。五个都对,叠起来炸了。
这就是现代软件栈的真实形态:bug 不再住在某一层里,而是住在层与层的缝隙里。
几点值得关注的走向:
- per-VMA lock 的边界还会继续被压测。 它是近年 mm 子系统最重要的可扩展性改进之一,但「不拿 mmap_lock」意味着一整套新的同步不变量。核数还在涨,这类竞态会越来越多地暴露出来。
- INVLPGB 这类硬件广播失效会改变竞态的形状。 从 IPI 到硬件广播,延迟分布变了,原本窄到撞不上的窗口可能变宽,反之亦然。这类「硬件加速改变软件竞态概率」的问题会持续出现。
- musl 需要一个更好的多线程分配器,或者一个官方推荐的替换路径。 mallocng 的设计目标不包含高并发吞吐,这没错;但现在越来越多的高性能程序因为「单文件分发」而被迫用 musl,这个矛盾需要一个正式的答案。
- AI 辅助调试会常态化,社区需要新的礼仪。 上游 maintainer 的注意力是稀缺资源。带着 27KB AI 生成分析去提 issue,需要一套「如何压缩、如何标注置信度、如何区分证据与猜测」的规范。这次事件某种意义上是这套规范的第一批案例。
最后送一句从这个案子里学到的、我觉得最值钱的话:
当你确信「这在物理上不可能」的时候,通常是你的抽象层数没数够。
附录:关键坐标
| 项目 | 值 |
|---|---|
| Issue | BurntSushi/ripgrep#3494(2026-07-26 提交,撰稿时仍 open) |
| 分析仓库 | dfoxfranke/ripgrep-3494-analysis |
| 崩溃点 | musl src/malloc/mallocng/meta.h 中 get_meta() 的 sizeclass 边界断言 |
| 触发路径 | ignore::walk → std::fs::read_dir → musl opendir → calloc |
| 复现环境 | Threadripper 9960X (Zen 5) + openSUSE 内核 7.0.12 |
| 不复现环境 | 同代 Zen 5 + 内核 6.19.10;EPYC / Xeon + 6.8 |
| 嫌疑改动 | 4c640eb4181c mm: move pte table reclaim code to memory.c(2026-01,v7.0) |
| 候选修复 | zap_pte_range() 中 pte_free_tlb(tlb, pmd_pgtable(pmdval), addr) → start |
| 状态 | 补丁待 A/B 验证(复现器重启后暂时失效) |
技术细节会随上游进展变化,以 issue 和 LKML 上的最新讨论为准。