3. 内存泄漏的典型模式:未释放的 kmalloc、slab 对象泄漏、引用计数泄漏、页面泄漏
内核内存泄漏并非单一故障,而是由多种编程疏忽导致的资源未归还。理解泄漏的典型模式,是编写有效 SystemTap 探测脚本的前提。本章将剖析四种最常见的内核内存泄漏模式,并给出每种模式在 SystemTap 下的可观测特征。
3.1 未释放的 kmalloc 堆内存
这是最直观的泄漏模式:驱动或内核模块通过 kmalloc()、kzalloc() 或 vmalloc() 分配了内存,但在错误路径或正常释放点遗漏了对应的 kfree() 或 vfree()。
RSS 或系统 Slab 使用量,但释放从未发生。
SystemTap 可以通过跟踪 kmalloc 和 kfree 的调用点,统计每个调用栈的净分配量。以下是一个简单的计数脚本片段:
global alloc_count, free_count
probe kernel.trace("kmalloc") {
alloc_count[backtrace()] <<< 1
}
probe kernel.trace("kfree") {
free_count[backtrace()] <<< 1
}
probe timer.s(60) {
foreach (bt in alloc_count) {
net = @count(alloc_count[bt]) - @count(free_count[bt])
if (net > 100)
printf("泄漏栈: %s, 净分配: %d\n", sprint_ustack(bt), net)
}
}
backtrace() 而非 execname() 定位泄漏点。同一个模块的不同函数可能都调用 kmalloc,只有调用栈才能精确区分哪个路径遗漏了 kfree。
3.2 Slab 对象泄漏
Slab 分配器管理固定大小的对象缓存(如 kmem_cache_alloc)。泄漏通常发生在对象被分配后,使用者忘记调用 kmem_cache_free 将其归还给缓存。这种泄漏比普通 kmalloc 更难发现,因为 Slab 缓存可能长期保持“部分满”状态,不会立即触发 OOM。
| 指标 | 正常状态 | 泄漏状态 |
|---|---|---|
/proc/slabinfo 中对象总数 |
稳定或缓慢增长 | 持续线性增长 |
| 活跃对象数 | 与使用量匹配 | 远高于预期 |
| 缓存大小 | 稳定 | 持续增长 |
SystemTap 可以挂钩 kmem_cache_alloc 和 kmem_cache_free 的 tracepoint,按缓存名称统计净分配量:
global slab_alloc, slab_free
probe kernel.trace("kmem_cache_alloc") {
slab_alloc[$cache_name, backtrace()] <<< 1
}
probe kernel.trace("kmem_cache_free") {
slab_free[$cache_name, backtrace()] <<< 1
}
probe timer.s(120) {
foreach ([name, bt] in slab_alloc) {
net = @count(slab_alloc[name, bt]) - @count(slab_free[name, bt])
if (net > 50)
printf("Slab %s 泄漏 %d 对象, 栈: %s\n", kernel_string(name), net, sprint_backtrace(bt))
}
}
dentry、inode_cache)在 VFS 压力下会自然增长。不要仅凭对象总数判断泄漏,必须结合调用栈确认是否有未释放的分配路径。
3.3 引用计数泄漏
内核对象(如 struct file、struct inode、struct sk_buff)通过引用计数管理生命周期。每次 get() 或 kref_get() 增加计数,put() 或 kref_put() 减少计数。当计数无法归零时,对象及其关联内存永远不会被释放。
引用计数泄漏的典型症状:
- 文件描述符泄漏:
/proc/<pid>/fd数量持续增长,但应用并未打开新文件。 - 网络套接字泄漏:
ss -s显示大量TIME_WAIT或CLOSE_WAIT状态的 socket。 - 设备引用泄漏: 卸载模块时提示
Device or resource busy,即使没有进程在使用。
SystemTap 可以通过跟踪 atomic_inc 和 atomic_dec 来监控特定对象的引用计数变化:
global ref_tracker
probe kernel.function("get_file") {
ref_tracker[tid(), $file] <<< 1
}
probe kernel.function("fput") {
ref_tracker[tid(), $file] <<< -1
}
probe timer.s(30) {
foreach ([tid, file] in ref_tracker) {
net = @sum(ref_tracker[tid, file])
if (net > 5)
printf("PID %d 文件 %p 引用计数净增 %d\n", tid, file, net)
}
}
kref 管理的对象,可以挂钩 kref_get 和 kref_put 的返回值。当 kref_put 返回 1 时表示对象被释放,如果从未返回 1 则说明泄漏。
3.4 页面泄漏
页面泄漏发生在物理页面分配器层面。驱动通过 alloc_pages() 或 __get_free_pages() 获取连续物理页,但未调用 __free_pages() 归还。这种泄漏会直接减少 MemFree 和 MemAvailable,严重时导致系统无法分配连续大块内存。
页面泄漏的检测指标:
| 指标来源 | 正常值 | 泄漏警告值 |
|---|---|---|
/proc/meminfo 的 MemFree |
稳定或缓慢波动 | 持续下降,重启后恢复 |
/proc/buddyinfo 的连续块数 |
各 order 块数稳定 | 高阶块(order ≥ 3)持续减少 |
vmstat 的 pgalloc / pgfree |
差值接近 0 | 差值持续为正且增长 |
SystemTap 可以通过跟踪 __alloc_pages_nodemask 和 free_pages 来监控页面分配与释放的平衡:
global page_alloc, page_free
probe kernel.function("__alloc_pages_nodemask").return {
if ($return != 0)
page_alloc[backtrace()] <<< 1
}
probe kernel.function("free_pages") {
page_free[backtrace()] <<< 1
}
probe timer.s(60) {
foreach (bt in page_alloc) {
net = @count(page_alloc[bt]) - @count(page_free[bt])
if (net > 10)
printf("页面泄漏 %d 页, 栈: %s\n", net, sprint_backtrace(bt))
}
}
__alloc_pages_nodemask 是热路径函数,在生产环境高频挂钩可能导致显著延迟。建议使用 kernel.trace("mm_page_alloc") 和 kernel.trace("mm_page_free") tracepoint 替代,开销更低。
3.5 四种泄漏模式的对比与选择
在实际工程中,一次内存泄漏可能同时涉及多种模式。下表帮助你在 SystemTap 脚本设计时快速定位应优先跟踪的指标:
| 泄漏模式 | 分配函数 | 释放函数 | SystemTap 首选探测点 | 典型泄漏速率 |
|---|---|---|---|---|
| kmalloc 泄漏 | kmalloc |
kfree |
kernel.trace("kmalloc") |
快(每次调用泄漏) |
| Slab 对象泄漏 | kmem_cache_alloc |
kmem_cache_free |
kernel.trace("kmem_cache_alloc") |
中(取决于对象使用频率) |
| 引用计数泄漏 | kref_get / get_file |
kref_put / fput |
kernel.function("kref_get") |
慢(多次 get 才泄漏一个对象) |
| 页面泄漏 | alloc_pages |
free_pages |
kernel.trace("mm_page_alloc") |
极快(每页 4KB,大量泄漏) |
slabinfo 中某个缓存持续增长时,优先怀疑 Slab 对象泄漏;当 MemFree 直线下降但 Slab 无明显变化时,优先检查页面泄漏;当文件描述符或 socket 数量异常时,优先检查引用计数泄漏。