服务释放了对象,RSS 却不下降,不一定是内存泄漏。glibc 的分配器会缓存空闲块,Arena 可能产生碎片,匿名映射也可能继续保留。反过来,RSS 暂时稳定也不能证明没有泄漏:进程可能只是在复用已经申请的页面。
本文不讲过时的堆利用配方,只回答生产排障更常见的问题:malloc 从哪里拿内存、Arena/Tcache 为什么影响 RSS、free 后发生了什么,以及如何建立可验证的证据链。
先把四类“内存增长”分开
| 现象 | 可能原因 | 优先证据 |
|---|---|---|
| 活跃对象持续增加 | 业务泄漏、无界缓存、队列堆积 | 对象计数、堆分析、LeakSanitizer |
| 对象已释放,RSS 不降 | 分配器缓存、Arena 保留、碎片 | malloc_info、smaps_rollup、malloc_trim 对照 |
| RSS 增加但 malloc 指标变化小 | 线程栈、JIT、直接 mmap、共享内存 |
/proc/<pid>/maps、smaps_rollup |
| Pod 内存高于进程 RSS | Page Cache、内核内存、同 Pod 其他容器 | cgroup memory.stat、Pod/容器指标 |
先确认增长属于哪一类,再决定修代码、调分配器,还是调整容器资源。
malloc 如何向内核申请内存
glibc malloc 主要从两类区域获得地址空间:
- 主 Arena 可以扩展传统 Heap,也就是
[heap]对应的 program break 区域。 - 较大分配、线程 Arena 或其他场景可以使用匿名
mmap。
不要把“128 KiB 以上一定走 mmap”写进 Runbook。mmap_threshold 会受 glibc 版本、历史分配行为和 Tunable 影响;设置固定阈值还会关闭部分动态调整。
观察进程映射:
grep -E '\[heap\]|rw-p.*00:00' /proc/<pid>/maps
grep -E '^(Rss|Pss|Private|Anonymous|Swap):' /proc/<pid>/smaps_rollup
maps 告诉你区域布局,smaps_rollup 给出整个进程的聚合驻留信息。它们不能说明哪些 C/C++ 对象仍然存活,需要与分配器或应用指标结合。
Arena、Chunk、Bin 和 Tcache 分别做什么
Arena:降低分配锁竞争
每个 Arena 有独立状态和锁。主 Arena 通常关联传统 Heap,其他 Arena 通常使用 mmap 获得内存。线程不是永久绑定“一线程一个 Arena”;glibc 会按需创建并复用 Arena,数量受平台和 Tunable 影响。
更多 Arena 可以降低并发分配的锁竞争,也可能扩大碎片和驻留内存。glibc.malloc.arena_max 是压缩 Arena 数量的实验旋钮,不是所有服务都该设成 2 或 4。
Chunk:分配器管理的块
glibc 在用户数据附近保存 Chunk 元数据,例如大小、相邻块状态,以及空闲链表需要的指针。已分配与空闲状态会复用部分字段,因此越界写、Use-After-Free 和 Double-Free 可能破坏分配器状态。
现代 glibc 有一致性检查和 Safe-Linking 等缓解措施,但它们只能让部分破坏更早失败或更难利用,不能把内存不安全代码变安全。
Bin:组织可复用的空闲 Chunk
空闲 Chunk 会根据大小和状态进入 Tcache、Fastbin、Unsorted Bin、Small Bin 或 Large Bin。精确尺寸边界、检查和流转顺序会随架构与 glibc 版本变化;生产排障不应依赖一张固定的“Chunk 大小表”。
核心行为只有两点:
free往往先把 Chunk 交还给分配器,而不是立即解除映射。- 后续
malloc可以复用这些 Chunk,减少系统调用,但 RSS 可能保持不变。
Tcache:每线程的小块缓存
Tcache 让常见尺寸的分配和释放绕过 Arena 锁。长生命周期线程各自保留缓存时,线程数会影响可回收内存和 RSS。
glibc.malloc.tcache_count=0 可以关闭 Tcache 做 A/B 对照,但可能明显改变吞吐与锁竞争。它适合诊断实验,不适合作为“RSS 高”的默认修复。
可复现实验:free 后 RSS 为什么还在
保存为 heap-lab.c:
#define _GNU_SOURCE
#include <errno.h>
#include <malloc.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
static size_t parse_size(const char *text, size_t fallback) {
if (text == NULL) {
return fallback;
}
errno = 0;
char *end = NULL;
unsigned long long value = strtoull(text, &end, 10);
if (errno != 0 || end == text || *end != '\0' || value == 0 || value > SIZE_MAX) {
fprintf(stderr, "invalid positive integer: %s\n", text);
exit(2);
}
return (size_t)value;
}
static void pause_at(const char *stage) {
printf("pid=%ld stage=%s; press Enter\n", (long)getpid(), stage);
fflush(stdout);
(void)getchar();
}
int main(int argc, char **argv) {
size_t count = parse_size(argc > 1 ? argv[1] : NULL, 1024);
size_t block_size = parse_size(argc > 2 ? argv[2] : NULL, 65536);
if (count > SIZE_MAX / sizeof(void *)) {
fputs("allocation table is too large\n", stderr);
return 2;
}
void **blocks = calloc(count, sizeof(*blocks));
if (blocks == NULL) {
perror("calloc");
return 1;
}
for (size_t index = 0; index < count; index++) {
blocks[index] = malloc(block_size);
if (blocks[index] == NULL) {
perror("malloc");
return 1;
}
memset(blocks[index], 0xa5, block_size);
}
pause_at("allocated");
for (size_t index = 0; index < count; index += 2) {
free(blocks[index]);
blocks[index] = NULL;
}
pause_at("half-freed");
for (size_t index = 1; index < count; index += 2) {
free(blocks[index]);
}
pause_at("all-freed");
printf("malloc_trim released memory: %s\n", malloc_trim(0) ? "yes" : "no");
(void)malloc_info(0, stdout);
pause_at("after-trim");
free(blocks);
return 0;
}
编译并运行。默认参数触碰约 64 MiB 用户数据:
gcc -O2 -g -Wall -Wextra heap-lab.c -o heap-lab
./heap-lab 1024 65536
另开终端,在每个阶段采集:
pid=<heap-lab-pid>
grep -E '^(Rss|Pss|Private|Anonymous|Swap):' /proc/$pid/smaps_rollup
pmap -x $pid | tail -n 1
常见结果:
allocated后 RSS 上升,因为memset真正触碰了页面。half-freed后部分 Chunk 可复用,但碎片阻止整页回收。all-freed后活跃对象已释放,RSS 仍可能高于初始值。malloc_trim(0)可能释放可回收页面,但返回0不是错误,只表示这次没有释放。
不要在每个请求结束时调用 malloc_trim。它会改变延迟和系统调用行为,只适合经过压测的批处理点或诊断实验。
用 Tunable 做对照,不要直接进生产
对同一负载分别运行:
./heap-lab 1024 65536
GLIBC_TUNABLES=glibc.malloc.arena_max=2 \
./heap-lab 1024 65536
GLIBC_TUNABLES=glibc.malloc.tcache_count=0 \
./heap-lab 1024 65536
比较三组的:
- 峰值和稳定期 RSS
malloc_info中的 Arena 与映射统计- 请求吞吐、P50/P99 分配延迟
- CPU 时间和锁竞争
如果 RSS 降了但尾延迟或 CPU 变差,说明原配置是在用内存换并发性能。是否接受这项交换,要看容器上限、节点密度和服务 SLO。
分配器指标不等于进程总内存
常用接口:
malloc_info(0, stream):输出 XML,包含 Arena 和mmap汇总。mallinfo2():返回分配器计数,避免旧mallinfo()的整数溢出问题。malloc_stats():把分配器统计写到标准错误。malloc_trim(0):尝试归还可释放页面。
这些接口只描述 glibc malloc 管理的部分内存,不包含线程栈、JIT、其他分配器、驱动映射和所有共享内存。必须同时观察 RSS、smaps_rollup 与 cgroup memory.current/memory.stat。
内存错误用 Sanitizer,不要靠分配器猜
AddressSanitizer 能直接定位堆越界、Use-After-Free 和部分 Double-Free:
gcc -O1 -g \
-fsanitize=address,undefined \
-fno-omit-frame-pointer \
app.c -o app-asan
ASAN_OPTIONS=detect_leaks=1 ./app-asan
无法重新编译时,可以使用 Memcheck:
valgrind --leak-check=full \
--show-leak-kinds=all \
--track-origins=yes \
./app
Sanitizer 和 Memcheck 用于测试与问题复现,不应直接替代生产监控。它们报告的是非法访问和泄漏证据;RSS 不下降仍需结合分配器缓存与碎片分析。
生产排障顺序
- 确认增长来自哪个进程、容器和 cgroup,不要只看 Pod 总量。
- 用
smaps_rollup区分匿名驻留、文件映射与 Swap。 - 用对象指标、Heap Profile 或 LeakSanitizer 判断活跃对象是否持续增加。
- 用
malloc_info、线程数和 Tunable A/B 判断 Arena/Tcache 是否放大 RSS。 - 检查请求并发、缓存容量、队列深度和生命周期是否符合设计。
- 只有证据指向分配器策略时,才调整
arena_max、Tcache 或 Trim 行为。
优先修复生命周期和无界增长。分配器调参只能改变缓存、碎片和锁竞争,不能修复真正的泄漏。