Day 09 · 内存分配器:从 buddy 到 jemalloc

Week 02 · 周二 · 2026-05-19 · 主线:操作系统核心 · 预计 1.5h 阅读 + 30min 实操
今日导读:你写 x = [1, 2, 3] 的瞬间,Python 在堆上分配了一个 list 对象。但「堆」是谁管的?谁决定 list 落在哪个地址?为什么频繁 malloc/free 会让程序越跑越慢?为什么 PyTorch 推荐你把 jemalloc 预加载(LD_PRELOAD)?今天我们从内核最底层的 buddy 分配器开始,一路向上爬到 slab → glibc 的 ptmalloc → jemalloc/tcmalloc,最后讲清楚 OOM killer 是怎么挑选"祭品进程"的——这关系到你的大模型服务什么时候会被无声杀死。
今日目录
  1. 内存分配的整体地图:5 层结构
  2. 内核底座:buddy 系统分配器
  3. 小对象之王:slab / slub 分配器
  4. 用户态的两道门:brk 与 mmap
  5. glibc ptmalloc:你不知道你在用
  6. jemalloc 与 tcmalloc:现代服务的标配
  7. 内存碎片:内部与外部
  8. OOM killer:谁会被处决
  9. 与 AI / Agent 安全的连结
  10. 今日小练习(3 道)

1. 内存分配的整体地图:5 层结构

"分配内存"听起来简单,但底下其实是整整 5 层不同粒度的分配器叠起来的。先看大图,后面每一节都会展开一层:

┌──────────────────────────────────────────────┐ │ Layer 5: 应用层对象池(Python list / PyTorch tensor caching allocator)│ ← Python / framework 自己管理 ├──────────────────────────────────────────────┤ │ Layer 4: 用户态 malloc(ptmalloc / jemalloc / tcmalloc) │ ← 你的 C/C++ 代码用的 ├──────────────────────────────────────────────┤ │ Layer 3: 系统调用 brk / mmap │ ← 用户态 ↔ 内核的边界 ├──────────────────────────────────────────────┤ │ Layer 2: 内核对象分配 slab / slub(小内核对象专用) │ ← task_struct, inode, ... ├──────────────────────────────────────────────┤ │ Layer 1: buddy 系统分配器(以 4KB 物理页为单位) │ ← 内核最底层的物理内存账本 └──────────────────────────────────────────────┘

关键事实:每一层的目标都不同。Layer 1 管 GB 级别的物理内存、追求碎片小;Layer 2 服务内核里数以万计的小对象(一个 inode 才 0.5KB),不能浪费整页;Layer 4 要服务用户程序千奇百怪的请求大小,目标是「单次分配延迟极低 + 多线程不抢锁」。理解了这种分工,你就会明白为什么 PyTorch 不直接用 cudaMalloc,而要在上面再写一个 caching allocator——同样的"分层"思想。

2. 内核底座:buddy 系统分配器

所有内存分配最终都要落到"物理页"上。x86_64 Linux 上一页通常是 4KB。内核维护着所有空闲物理页的账本,这个账本叫 buddy 系统

核心思想

buddy 把空闲内存按"大小阶(order)"分桶:

每一阶都有一个空闲链表。需要 16KB?去 order 2 链表摘一个。如果空了?去 order 3 摘一个 32KB 的,劈成两半,一半还回 order 2 链表给你,一半也回 order 2 给以后用。这两个"被劈出来的兄弟"就叫 buddy(伙伴)——它们的物理地址只差一个 bit。

释放时反过来:如果你释放一块,检查它的 buddy 是不是也空闲。如果是,合并成更大的一块、放到更高阶链表。这就是"分裂合并对称、保持大块可用"的精髓。

类比

想象图书馆有 1 个 4MB 书架(order 10)。你借 17KB?管理员把书架劈成 2 个 2MB,再把其中一个劈成 2 个 1MB,一路劈到 32KB(order 3),把这个 32KB 块给你(你浪费了 15KB 的内部碎片)。还书时管理员看你旁边的"伙伴块"还在不在,在就合并、还原成更大的块。整个图书馆的状态永远是若干"2 的幂次的连续块"。

查看你机器的 buddy 状态

# Linux 上看每个 order 还有多少空闲页
cat /proc/buddyinfo
# 输出示例(每行是一个 NUMA 节点 + zone):
# Node 0, zone Normal  120  85  43  22  10  4  1  0  0  0  0
#                    ↑order 0..10 各阶的空闲块数

如果你看到 低阶很多、高阶全是 0,说明物理内存严重碎片化——以后再申请 2MB 大页(HugePage)就会失败。这是长跑 K8s 节点的典型衰老症状。

3. 小对象之王:slab / slub 分配器

buddy 最小粒度是 4KB。但内核里到处是几十字节到几百字节的小对象——task_structdentryinode、socket buffer……如果每个都占一页,内核自己就把内存吃光了。

所以内核在 buddy 之上再加一层 slab 分配器(现代 Linux 默认是它的简化版 slub)。思想是"按类型预切好"

  1. 内核启动时为每种小对象建一个 cache,比如 task_struct_cache(每个对象 7KB)、inode_cache(每个对象 0.5KB)。
  2. 每个 cache 从 buddy 申请几页(一个 slab),把这几页切成 N 个等大对象槽位。
  3. 申请:从对应 cache 取一个空闲槽,几乎零开销,且对象常常已经构造过(fields 已置好)——重复利用比 malloc 还快。
# 看你内核的 slab 用量
sudo cat /proc/slabinfo | head -10
# 或者更友好:
sudo slabtop
# 你会看到:
# OBJS  ACTIVE  USE  OBJ SIZE  SLABS  OBJ/SLAB  CACHE SIZE  NAME
# 12450 12100   97%  0.19K     593    21        2372K       dentry

如果你以后排查"机器内存被吃光了,但 ps 看用户进程都不大",第一反应就是看 slabinfo——内核自己的小对象(特别是 dentry 和 inode 缓存)可以吃掉几 GB。

4. 用户态的两道门:brk 与 mmap

现在视角从内核切回用户态。你的进程要分配内存,最终只能通过 syscall 向内核要。用户态拿堆内存只有两条路

(a) brk / sbrk —— 推堆顶

每个进程的"堆"(昨天讲的 heap 段)有个边界叫 program breakbrk(addr) 系统调用就是「请把堆顶移到 addr」。把它推高就分到内存,推低就释放。

高地址 ↑ ┌──────────┐ │ 栈 │ ├──────────┤ │ ░░░░░ │ ├──────────┤ ← program break(brk 推这里) │ 堆 │ malloc 小块在这里 │ ↑↑↑ │ ├──────────┤ │ bss/data│ │ text │ └──────────┘ 低地址

简单但局限:堆是一整块连续区,中间释放的洞没法还给内核——只有堆顶能推。所以 brk 适合小而短命的分配。

(b) mmap —— 映射独立区域

mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) 是一个完全不同的玩法:在地址空间的 mmap 区(昨天图里栈和堆之间的大空隙)里另开一块独立映射。可以单独 munmap() 还给内核,互不影响。

缺点是每次系统调用都有 syscall + 缺页 + TLB 等代价,相对昂贵。所以 mmap 适合大而独立的分配(一般阈值 128KB 以上)。

glibc 的策略

请求大小底层走能不能还给内核
< 128KB(默认阈值 M_MMAP_THRESHOLDbrk 扩堆,从堆里切很难——只有堆顶连续空闲才能 brk 下去
≥ 128KBmmap 单独映射容易——free 时直接 munmap

这个分界点直接决定了你程序的内存能不能"还回去"。Python 进程跑久了内存只增不减,常见原因之一就是大量"中等大小"的分配落在堆里,free 之后还给堆但还不给内核。

5. glibc ptmalloc:你不知道你在用

当你写 malloc(64),glibc 里的 ptmalloc2(基于 Doug Lea 的 dlmalloc)接住这个调用。它是 Linux 上 C 程序默认的分配器,你的 Python 解释器、PyTorch C 扩展、所有不显式换 malloc 的程序,都在用它。

核心数据结构

分配流程(精简)

  1. 请求 ≤ tcache 上限(默认 1024B)?看本线程 tcache,有匹配 size 的 → 直接拿,O(1) 无锁
  2. tcache 没有?看 fastbin(最近 free 的小块)。
  3. 没有?查 smallbin / largebin 找最合适的空闲块(best-fit)。
  4. 都没有?从 top chunk(堆顶大块)切一块给你;不够就 brk / mmap 向内核要。
为什么 glibc 默认是"够用但不出众":ptmalloc 的设计偏保守、单线程历史包袱重。多线程下 arena 之间的锁争用、内存释放后归还内核的不积极、tcache 容易被攻击(堆漏洞利用的经典对象)——这些都是它被替换的理由。但它不会出大问题,所以仍是默认。

6. jemalloc 与 tcmalloc:现代服务的标配

对追求性能/稳定性的服务(Redis、Cassandra、Firefox、Facebook 几乎所有 C++ 服务、PyTorch 推荐配置),人们会换掉默认 malloc。两大主流:

分配器出身关键设计典型场景
jemallocFreeBSD → Facebook多 arena 分散争用、按 size class 精细分桶、积极归还内存(purge)、统计极强Redis 默认、Rust 默认、PyTorch 推理推荐
tcmallocGoogle每线程超大 thread cache、中心 heap 收割、低延迟优先Google 全家桶、高并发 web 服务

在你 Python/PyTorch 服务里换 malloc(不改代码)

# Ubuntu 装 jemalloc
sudo apt-get install libjemalloc2

# 让你的 Python 服务用 jemalloc(LD_PRELOAD 是用户态的"替身术")
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 \
    python serve.py

# 大模型推理常用配置:限制碎片
MALLOC_CONF="background_thread:true,dirty_decay_ms:1000,muzzy_decay_ms:0" \
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 \
    python serve.py

典型收益:Python 长跑服务 RSS(实际占用物理内存)从"只增不减"变成"周期归还";多线程吞吐+10~30%;P99 延迟更稳。这是少有的"加一行环境变量就有收益"的优化

PyTorch 推理为什么特别关心 malloc:除 CUDA caching allocator 管 GPU 显存外,host 端(CPU 内存)的张量、tokenizer 缓存、HuggingFace transformers 的预处理仍然走 host malloc。一个长跑的推理服务(几天不重启)在 ptmalloc 下经常出现"RSS 慢慢爬到几十 GB",换成 jemalloc 后稳定下来。Meta 内部博客和 PyTorch 官方都推荐过这个组合。

7. 内存碎片:内部与外部

所有分配器都要对抗一个共同敌人——碎片化。它有两种长相,机制完全不同:

内部碎片(internal fragmentation)

你申请 17 字节,分配器给你一个 24 字节的 chunk(因为有最小对齐和 size class)。多出来的 7 字节在 chunk 内部浪费。slab、buddy 都有内部碎片,但通常占比小、可控。

外部碎片(external fragmentation)

更阴险。空闲内存总和很大,但都被分割成不连续的小块,导致一个"中等大小"的申请无法满足。典型场景:

堆中现状(█=已分配 ░=空闲): █████░░░░█████░░░░░░░█████░░░░█████ 空闲总量:14 块 最大连续空闲:5 块 现在申请 7 块 → 失败(虽然有 14 块空闲)

典型长跑服务的死法:内存总用量看起来才 30%,但分配 1MB 的请求返回 NULL。jemalloc 的两个关键参数 dirty_decay_ms / muzzy_decay_ms 就是控制多久把脏页归还内核,避免长期碎片。

如何观察

# 一个粗略指标:进程的 VSZ - RSS - 共享映射 ≈ 已申请但未住人的虚拟空间
ps -o pid,vsz,rss,command -p <PID>

# 详细:看映射粒度
cat /proc/<PID>/smaps | grep -E "Size:|Rss:|Pss:" | head -30

# jemalloc 自带的运行时统计(需要程序开启)
MALLOC_CONF=stats_print:true ./your_program

8. OOM killer:谁会被处决

当物理内存彻底用光、swap 也满、内核已经回收完所有 page cache 还不够时,内核没有别的选择,只能挑一个进程杀掉。这个机制叫 OOM killer。理解它对运维 LLM 服务至关重要——你的 7B 模型半夜被无声 kill,多半就是它干的。

选谁?

内核为每个进程算一个 oom_score(0~1000),分数越高越容易被选中。基础分主要看:

# 看一个进程当前的 oom 分数和调整值
cat /proc/<PID>/oom_score          # 当前实际分数
cat /proc/<PID>/oom_score_adj      # 你给它的调整

# 把你的 LLM 服务设成"永不被杀"(root 权限)
echo -1000 | sudo tee /proc/<PID>/oom_score_adj

# 看历史 OOM 事件
dmesg | grep -i "killed process"
sudo journalctl -k | grep -i oom

OOM 在容器里的特殊性

在 Docker / K8s 里你常常看不到 host 的 OOM——因为每个容器有自己的 cgroup 内存上限,达到上限会触发 cgroup-level OOM,只杀这个容器里的进程。kubectl describe pod 看到 OOMKilled 就是这个:

# Pod 描述里这一段非常常见
Last State:     Terminated
  Reason:       OOMKilled
  Exit Code:    137   ← 137 = 128 + 9 (SIGKILL)

所以"K8s LLM 服务凌晨挂掉"的标准排查:先 kubectl describe pod 看是不是 OOMKilled、再看 memory.limit 设了多少、再看实际峰值(用 Prometheus 的 container_memory_working_set_bytes)。

🔗 与 AI / Agent 安全的连结

1. 内存耗尽是 LLM 推理服务最便宜的 DoS。攻击者只要发一批超长 prompt(或者高并发短 prompt 但 batch 不够),就能让你的 KV cache 暴涨、host 端 tokenizer 缓存暴涨,触发 OOM。今天讲的 oom_score_adj 和 cgroup 限制是纵深防御的底层一环——把入口层流量控制不住时,至少别让推理进程被 host 上其他服务连累,也别让一个 Pod 拖垮整个 node。

2. 堆溢出与现代 malloc 的攻防。Agent 跑用户给的 native 代码时,堆漏洞(UAF、double-free、heap overflow)就是攻击者的入场券。glibc 的 tcache 因为结构简单、校验弱,是近年 CTF 和真实 CVE 的高频目标("tcache poisoning")。换 jemalloc/tcmalloc 不仅是性能优化——它们的元数据校验更严,对一部分堆攻击是天然抵抗。这是你做 sandbox 时可以默认加的 hardening。

3. 内存碎片是"看不见的容量下降"。一个长跑 7 天的 LLM 服务,从外面看 RSS 稳定,但实际可用堆空间因为碎片缩水了一半——下一波流量峰值过来就崩。这是对手主动诱导的攻击面:精心设计的 prompt 模式让分配器陷入最坏情况。监控指标除了 RSS,还要加 VmPeakjemalloc stats 里的 retained/fragmentation 比例。

4. 模型权重加载和 mmap 的安全含义。HuggingFace 的 safetensors 是用 mmap 加载权重的(昨天讲的 mmap 区)。这意味着权重文件路径和权限就是攻击面——攻击者如果能替换 safetensors 文件,就等于改了你正在运行的模型权重。检测:把权重做哈希、加 IMA 完整性度量、用只读挂载。

5. OOM 杀错了进程的风险。多租户 GPU 节点上,你的安全审计代理(监控用户 LLM 调用的 sidecar)如果 oom_score 高于被监控服务,就会先于被监控对象被杀——攻击者甚至可能故意触发这种情况来"先解除监控"。永远把审计/安全相关进程的 oom_score_adj 设成强负值,并加监控告警:"如果审计进程消失,立即降级被监控服务"。

📝 今日小练习

115 分钟 · 动手:换 malloc 看效果

找一个会持续分配释放的 Python 脚本,跑一段时间用 ps 看 RSS 变化;然后用 LD_PRELOAD 加载 jemalloc 再跑一次,对比:

# 任意一个会循环创建大量字符串/列表的脚本
cat > churn.py <<'EOF'
import time
for i in range(60):
    bigs = [b"x" * 4096 for _ in range(200_000)]
    del bigs
    time.sleep(1)
EOF

# 基线
python3 churn.py &
PID=$!
for i in 1 2 3 4 5; do ps -o rss= -p $PID; sleep 10; done
wait

# jemalloc 版(路径按你系统调整)
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 python3 churn.py &
PID=$!
for i in 1 2 3 4 5; do ps -o rss= -p $PID; sleep 10; done
wait

观察:两次的 RSS 曲线长得不一样。哪一次会"归还更彻底"?

210 分钟 · 观察:buddy 和 slab 的真实形态

在 Linux 机器(或 Docker / VM 里)跑:

# 看 buddy 各阶空闲块
cat /proc/buddyinfo

# 看 slab 用量 top 10
sudo slabtop -o -s c | head -15

# 看你某个进程的实际地址空间和映射数量
cat /proc/self/maps | wc -l
cat /proc/self/status | grep -E "Vm|Threads"

问题:找出你机器上占内存最多的 3 个 slab cache,它们分别是什么内核对象?提示——名字像 dentryinode_cachekmalloc-1024

35 分钟 · 思考:OOM 的优先级设计

你在做一个 LLM Agent 安全网关:

主机突发其他服务挤占内存导致即将 OOM。请设计三个进程的 oom_score_adj 值,并说明:(a) 如果只能保活两个,应该保哪两个?(b) 攻击者最希望谁先死?理由?