服务释放了对象,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 大小表”。

核心行为只有两点:

  1. free 往往先把 Chunk 交还给分配器,而不是立即解除映射。
  2. 后续 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 不下降仍需结合分配器缓存与碎片分析。

生产排障顺序

  1. 确认增长来自哪个进程、容器和 cgroup,不要只看 Pod 总量。
  2. 用 smaps_rollup 区分匿名驻留、文件映射与 Swap。
  3. 用对象指标、Heap Profile 或 LeakSanitizer 判断活跃对象是否持续增加。
  4. 用 malloc_info、线程数和 Tunable A/B 判断 Arena/Tcache 是否放大 RSS。
  5. 检查请求并发、缓存容量、队列深度和生命周期是否符合设计。
  6. 只有证据指向分配器策略时,才调整 arena_max、Tcache 或 Trim 行为。

优先修复生命周期和无界增长。分配器调参只能改变缓存、碎片和锁竞争,不能修复真正的泄漏。

参考链接