x = [1, 2, 3] 的瞬间,Python 在堆上分配了一个 list 对象。但「堆」是谁管的?谁决定 list 落在哪个地址?为什么频繁 malloc/free 会让程序越跑越慢?为什么 PyTorch 推荐你把 jemalloc 预加载(LD_PRELOAD)?今天我们从内核最底层的 buddy 分配器开始,一路向上爬到 slab → glibc 的 ptmalloc → jemalloc/tcmalloc,最后讲清楚 OOM killer 是怎么挑选"祭品进程"的——这关系到你的大模型服务什么时候会被无声杀死。
"分配内存"听起来简单,但底下其实是整整 5 层不同粒度的分配器叠起来的。先看大图,后面每一节都会展开一层:
关键事实:每一层的目标都不同。Layer 1 管 GB 级别的物理内存、追求碎片小;Layer 2 服务内核里数以万计的小对象(一个 inode 才 0.5KB),不能浪费整页;Layer 4 要服务用户程序千奇百怪的请求大小,目标是「单次分配延迟极低 + 多线程不抢锁」。理解了这种分工,你就会明白为什么 PyTorch 不直接用 cudaMalloc,而要在上面再写一个 caching allocator——同样的"分层"思想。
所有内存分配最终都要落到"物理页"上。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 的幂次的连续块"。
# 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 节点的典型衰老症状。
buddy 最小粒度是 4KB。但内核里到处是几十字节到几百字节的小对象——task_struct、dentry、inode、socket buffer……如果每个都占一页,内核自己就把内存吃光了。
所以内核在 buddy 之上再加一层 slab 分配器(现代 Linux 默认是它的简化版 slub)。思想是"按类型预切好":
task_struct_cache(每个对象 7KB)、inode_cache(每个对象 0.5KB)。# 看你内核的 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。
现在视角从内核切回用户态。你的进程要分配内存,最终只能通过 syscall 向内核要。用户态拿堆内存只有两条路:
每个进程的"堆"(昨天讲的 heap 段)有个边界叫 program break。brk(addr) 系统调用就是「请把堆顶移到 addr」。把它推高就分到内存,推低就释放。
简单但局限:堆是一整块连续区,中间释放的洞没法还给内核——只有堆顶能推。所以 brk 适合小而短命的分配。
mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) 是一个完全不同的玩法:在地址空间的 mmap 区(昨天图里栈和堆之间的大空隙)里另开一块独立映射。可以单独 munmap() 还给内核,互不影响。
缺点是每次系统调用都有 syscall + 缺页 + TLB 等代价,相对昂贵。所以 mmap 适合大而独立的分配(一般阈值 128KB 以上)。
| 请求大小 | 底层走 | 能不能还给内核 |
|---|---|---|
< 128KB(默认阈值 M_MMAP_THRESHOLD) | brk 扩堆,从堆里切 | 很难——只有堆顶连续空闲才能 brk 下去 |
| ≥ 128KB | mmap 单独映射 | 容易——free 时直接 munmap |
这个分界点直接决定了你程序的内存能不能"还回去"。Python 进程跑久了内存只增不减,常见原因之一就是大量"中等大小"的分配落在堆里,free 之后还给堆但还不给内核。
当你写 malloc(64),glibc 里的 ptmalloc2(基于 Doug Lea 的 dlmalloc)接住这个调用。它是 Linux 上 C 程序默认的分配器,你的 Python 解释器、PyTorch C 扩展、所有不显式换 malloc 的程序,都在用它。
[size 头 (8B)] [用户数据],相邻 chunk 通过 size 字段串成链。对追求性能/稳定性的服务(Redis、Cassandra、Firefox、Facebook 几乎所有 C++ 服务、PyTorch 推荐配置),人们会换掉默认 malloc。两大主流:
| 分配器 | 出身 | 关键设计 | 典型场景 |
|---|---|---|---|
| jemalloc | FreeBSD → Facebook | 多 arena 分散争用、按 size class 精细分桶、积极归还内存(purge)、统计极强 | Redis 默认、Rust 默认、PyTorch 推理推荐 |
| tcmalloc | 每线程超大 thread cache、中心 heap 收割、低延迟优先 | Google 全家桶、高并发 web 服务 |
# 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 延迟更稳。这是少有的"加一行环境变量就有收益"的优化。
所有分配器都要对抗一个共同敌人——碎片化。它有两种长相,机制完全不同:
你申请 17 字节,分配器给你一个 24 字节的 chunk(因为有最小对齐和 size class)。多出来的 7 字节在 chunk 内部浪费。slab、buddy 都有内部碎片,但通常占比小、可控。
更阴险。空闲内存总和很大,但都被分割成不连续的小块,导致一个"中等大小"的申请无法满足。典型场景:
典型长跑服务的死法:内存总用量看起来才 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
当物理内存彻底用光、swap 也满、内核已经回收完所有 page cache 还不够时,内核没有别的选择,只能挑一个进程杀掉。这个机制叫 OOM killer。理解它对运维 LLM 服务至关重要——你的 7B 模型半夜被无声 kill,多半就是它干的。
内核为每个进程算一个 oom_score(0~1000),分数越高越容易被选中。基础分主要看:
/proc/<PID>/oom_score_adj,范围 -1000~+1000,加到基础分上。-1000 表示永不被杀,+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
在 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)。
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,还要加 VmPeak、jemalloc 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,它们分别是什么内核对象?提示——名字像 dentry、inode_cache、kmalloc-1024。
35 分钟 · 思考:OOM 的优先级设计
你在做一个 LLM Agent 安全网关:
主机突发其他服务挤占内存导致即将 OOM。请设计三个进程的 oom_score_adj 值,并说明:(a) 如果只能保活两个,应该保哪两个?(b) 攻击者最希望谁先死?理由?