编程 一个线程读不到自己 10 条指令前写的字节:ripgrep #3494 从 musl mallocng 断言挖穿到 Linux 7.0 页表回收竞态

2026-08-02 04:55:09 +0800 CST views 11

一个线程读不到自己 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 作者)问的三个问题非常教科书:

  1. 其他构建也会吗?还是只有 musl? → 只有 musl,glibc 动态链接怎么都复现不了。
  2. 15.1.0 呢? → 也复现,说明不是新引入的回归。
  3. 栈顶在哪?→ 在 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 会在 opendircalloc

先解释调用链底部。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=25size_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 enframeset_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)

38804b38807e,大约 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::newVec::pushString::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 的组合下有三重麻烦:

  1. musl 内部调用可能已经被内联/直连。musl 的部分内部路径调的是 __libc_malloc 之类的内部别名,或者在 -ffunction-sections + --gc-sections 之后被静态解析,你在外面定义 malloc 不一定能全部截住。
  2. Rust 的 musl target 用的是 rustup 自带的 self-contained/libc.a,不是系统的 musl。要覆盖你得动 ~/.rustup/toolchains/<tc>/lib/rustlib/x86_64-unknown-linux-musl/lib/self-contained/libc.a——这正是这次调查者做插桩时干的事,属于「知道自己在干什么」才该碰的操作。
  3. 静态链接下符号冲突会直接报 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 重新编译,加入四类行为保持不变的观测代码:

  1. 事件环(ring buffer):记录每一次 enframe / group 分配 / group 释放 / munmap
  2. SELF-TEAR 自检:在 enframe 末尾把刚写进去的槽位头字节再读一遍,不一致就打日志
  3. GM_FAIL 转储get_meta 断言失败时打印全部上下文再崩
  4. 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=25maplen=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什么都不加(基线)2504
P1预先触碰新 group 的每一页3040
CTRL只预先触碰第 2 页3050
FAKEPF写一块不属于该 group 的暂存缓冲区,循环步长相同3083

FAKEPF 这一组是全场最漂亮的设计。它制造了和 P1/CTRL 完全可比的时间扰动和持锁时长,唯一的区别是:它没有触碰 group 自己的页。

结果:FAKEPF 以满速率崩溃,特征签名一模一样。

结论无可辩驳:决定崩不崩的唯一变量,是「enframe 写入的那一页在此之前是否已经被 fault 进来」,而不是任何时序扰动。

顺便,还做了三组「窗口宽度」探针:

探针在 store→延迟读之间插入运行崩溃
2463
mfence排空 store buffer2460
lfence排空 store buffer2460
额外 load一次无关地址的 volatile load2440

注意 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)大致七步:

  1. 分配一张私有匿名 folio
  2. __folio_mark_uptodate(folio) —— 内存屏障,保证页内容对其他 CPU 可见
  3. pte_offset_map_lock(...) —— 拿 PTE 自旋锁
  4. if (vmf_pte_changed(vmf)) goto release; —— 如果 PTE 已经不是 none 了就放弃重来
  5. set_ptes(...) —— 发布新 PTE,把 VA 映射到新 folio
  6. update_mmu_cache_range(...) —— 原 PTE 是 none,不需要 flush
  7. pte_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...>——也就是 closedirfreemunmap。还有几个线程在 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_rangetlb_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 9575F6.8.0-1-136-generic不复现
AMD Ryzen AI MAX+ 3956.18.35+rex+2-amd64不复现
Intel Xeon 678X6.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_downgradevms_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);
 		}
 	}

一个标识符。addrstart

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)

所以这个地址参数有两个消费者:

  1. __pte_free_tlb(tlb, ptep, address) —— 在 x86 上,释放动作本身忽略 address(它只需要 ptdesc 指针)
  2. 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 辅助做这种深度排查,我的建议:

  1. 让 AI 做实验,不要让 AI 下结论。 插桩、跑 batch、统计、制表,AI 是最好的实验室助手。最终归因留给人。
  2. 强制要求「对照组」。 AI 很容易满足于「加了 X 就好了」。你必须逼它设计 FAKEPF 那样的等效扰动对照。
  3. 强制标注置信度分级。 「已证实 / 强相关 / 猜测」三档分开写,这份报告在这点上做得很好。
  4. 提交给上游前,先自己压缩。 把 27KB 压到 3KB,只留证据链和关键数据表。maintainer 的注意力是最稀缺的资源。
  5. 别让 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 侧(opendirgetaddrinfoiconv 等)仍然走 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 性能优化的一般性教训

从这个案子能提炼出三条通用的:

  1. 短生命周期的大块分配是内核压力源。 mallocng 对 2KB 级别的对象用 2 页 group、3 个槽——意味着每三个 DIR 就要一次 mmap + 一次 munmapmunmap 是要发 IPI 做 TLB shootdown 的,代价随核数线性上升。优化方向:对象池化。 ripgrep 完全可以给 worker 线程做 DIR 复用,或者干脆绕开 opendir 直接用 getdents64 复用一块缓冲区。
  2. 「不分配」永远快过「快速分配」。 HN 上那句话说得好:「大多数程序变快的方式是复用分配,而不是有一个更快的分配器。」 ripgrep 的热循环(逐字节匹配正则)本身几乎不分配,这是它快的原因;目录遍历这部分反而是分配热点。
  3. 静态链接把 libc 变成了你程序的一部分。 你不能再说「这是 libc 的问题」,因为 libc 就在你的二进制里,它的所有 trade-off 都变成了你的 trade-off。选 musl 的时候,你同时选了 mallocng 的性能特征、它的线程栈默认值、它的断言策略。

九、总结与展望

把整条链子再串一遍:

  1. 应用层:ripgrep 用 12 个线程遍历 184 万个文件,每个目录一次 opendir,制造出每秒几万次的 2KB 分配/释放。
  2. 运行时层#[global_allocator] 只覆盖 Rust 侧,libc 侧仍是 mallocng。一个进程两个堆。
  3. C 库层:mallocng 用 2 页 group 承载 3 个 2720 字节的槽,频繁 mmap/munmapenframe 写完 p[-3]set_size 立刻回读——一个横跨约 10 条指令的窗口。
  4. 内核层:per-VMA lock 缺页快路径发布了新 PTE;并发的 munmap 走 PTE 页表回收路径,因为 pte_free_tlb(tlb, ..., addr) 里的 addr 已经等于 end,累积的 TLB 失效范围偏了,加上 freed_tables=1 意味着中间层页表已被释放——刚 fault 进来的页的翻译因此陷入不一致,短暂暴露出零页。
  5. 表象:一个线程读不到自己 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,需要一套「如何压缩、如何标注置信度、如何区分证据与猜测」的规范。这次事件某种意义上是这套规范的第一批案例。

最后送一句从这个案子里学到的、我觉得最值钱的话:

当你确信「这在物理上不可能」的时候,通常是你的抽象层数没数够。


附录:关键坐标

项目
IssueBurntSushi/ripgrep#3494(2026-07-26 提交,撰稿时仍 open)
分析仓库dfoxfranke/ripgrep-3494-analysis
崩溃点musl src/malloc/mallocng/meta.hget_meta() 的 sizeclass 边界断言
触发路径ignore::walkstd::fs::read_dir → musl opendircalloc
复现环境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 上的最新讨论为准。

推荐文章

如何在Vue3中定义一个组件?
2024-11-17 04:15:09 +0800 CST
Rust 并发执行异步操作
2024-11-19 08:16:42 +0800 CST
js一键生成随机颜色:randomColor
2024-11-18 10:13:44 +0800 CST
Web 端 Office 文件预览工具库
2024-11-18 22:19:16 +0800 CST
程序员茄子在线接单