malloc 内部:glibc 分配器

本页目录

glibc malloc 源自 ptmalloc2,但长期演进后不能把早期 ptmalloc2 的流程图当成当前全部实现。理解它最有用的方式是区分三层:应用拥有的对象、分配器掌握的 chunk 与缓存、操作系统映射与物理驻留。

对象大小与 chunk 大小不同

malloc 接收请求字节数,内部还要满足对齐、元数据和大小类别要求。因此分配器为一个 33 字节对象保留的空间可能更多,差额属于内部开销或内部碎片。

应用只能依接口契约访问合法对象范围,不能因为某次分配似乎多给了一点空间就越界写元数据。calloc 检查元素数乘大小的溢出,而手写 malloc(n * size) 可能在调用前就已经溢出。realloc 对非零目标大小失败时应保留原指针,不要直接覆盖唯一引用。malloc(3)

扩容必须先算得下,再转移所有权

realloc 可能移动对象;成功后旧指针及指向旧对象内部的指针都不能继续使用。失败且目标大小非零时,旧对象仍由调用者拥有。下面把乘法检查、非零约定和临时指针放在同一个可运行例子里。

#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>

static int resize_ints(int **items, size_t count) {
    if (count == 0 || count > SIZE_MAX / sizeof **items) return 0;
    int *replacement = realloc(*items, count * sizeof **items);
    if (replacement == NULL) return 0;
    *items = replacement;
    return 1;
}
int main(void) {
    int *items = NULL;
    if (!resize_ints(&items, 2)) return 1;
    items[0] = 7;
    items[1] = 9;
    if (!resize_ints(&items, 4)) { free(items); return 1; }
    if (items[0] != 7 || items[1] != 9) { free(items); return 1; }
    /* 扩展部分尚未初始化,写入后才读取。 */
    items[2] = 11;
    items[3] = 13;
    int *before_rejection = items;
    if (resize_ints(&items, 0) || items != before_rejection) abort();
    if (sizeof(int) > 1) {
        size_t too_many = SIZE_MAX / sizeof(int) + 1;
        if (resize_ints(&items, too_many) || items != before_rejection) abort();
    }
    printf("%d %d %d %d\n", items[0], items[1], items[2], items[3]);
    free(items);
    return 0;
}

保存为 resize-demo.c,用 cc -Wall -Wextra resize-demo.c -o resize-demo 编译,输出应为 7 9 11 13。count=0 被接口显式拒绝,避免把各标准和实现中复杂的 realloc(ptr, 0) 行为混入这个“保留旧对象或成功扩容”的契约。realloc(3)

这个小接口只演示拥有指针的更新,没有替调用者管理逻辑长度与容量。成功扩容后,调用者还应提交新容量;若还有元素构造等可能失败的步骤,就需要更完整的回滚或提交设计。零长度和溢出拒绝是可确定复现的;真正内存不足的分配失败应使用可控故障注入验证,不要靠申请一个巨大对象赌系统行为。

tcache 与 arena 是两个层次

分配器缓存已有内存:free 不等于立刻归还操作系统 线程缓存 tcache 快速复用适用大小的 chunk arena 的空闲结构 bins、合并与 top chunk 未命中 向操作系统取得更多空间 brk / mmap;满足条件时归还 线程与 arena 不是一一对应;独立 mmap 分配、具体缓存范围与流程依 glibc 版本而定。

tcache 是每线程缓存,适用请求可从中直接复用,减少进入 arena 的次数。arena 管理较大范围的分配状态和空闲结构,通常有同步保护;多个线程可以共享一个 arena,一个 arena 也不等于一个独立应用对象。

“每个线程一座独享 arena,因此全部无锁”是不准确的。线程缓存命中与 arena 竞争要分别测量;跨线程释放还会影响对象在哪一层被缓存、多久重新可用。

具体 tcache 可缓存大小、bin 数量、arena 上限与调优接口随版本变化。较新源码已存在用于更大 chunk 的 tcache 分类,而部分手册仍描述较早范围;查本机构建对应源码与文档,不要把几十字节、1 KiB 或 CPU 数乘某个常数写成所有 glibc 的不变规则。

空闲结构分别优化什么

结构基本目的需要保留的区别
tcache线程局部复用不等于 arena 自身的 bins
fastbins对适用小块快速入链、延迟合并合并通常推迟到 consolidation 等路径
unsorted bin给新进入的空闲块一次近期复用机会不是所有 free 都先进入它
small bins管理精确大小类别的块类别宽度与边界依目标配置
large bins按较大尺寸组织查找不能概括成纯 FIFO 队列
top chunkarena 尾部可切分空间与 bin 中独立空洞及独立 mmap 块不同

大致路径是先尝试合适缓存和现有空闲空间,需要时切分、合并或向系统申请更多。真正的分支顺序依大小与版本,不能画成“先用 128 KiB 判断,以上必定 mmap,以下严格逐 bin 扫描”的统一流程。glibc malloc 源码

free 把所有权交给了谁?

图中地址标识存储位置,#编号标识对象的一次生命周期。释放后再分配,地址可以相同而编号不同;旧指针不会因为新对象恰好占据同址就自动重新获得合法性。缓存减少重复申请,但保留块数与应用活跃块数因此可能分离。

正在呈现知识画面
free 把所有权交给了谁?

free 结束应用对对象的访问权,但块可能保留在分配器中。再次分配可复用地址,却建立新的对象生命周期。

mmap 阈值不是静态分界线

主 arena 常通过 program break 扩展,其他 arena 可建立 mmap 支持的区域;独立 mmap 大块分配又是另一种用途。小对象也可能住在 mmap 支持的 arena 里。

glibc 可以动态调整 mmap threshold。典型调整发生在释放符合条件的较大 mmap chunk 时,将阈值提高至相应大小,避免重复向系统申请和解除相似块;不是每次 free 大块就把阈值降低。

显式配置相应阈值会影响动态调整。阈值只是一项策略,还需结合现有可复用空间和其他限制理解。相关名称与含义见 glibc allocation tunables。

普通私有匿名 mmap 在 fork 后仍遵循 COW;它不是免复制共享映射。独立块容易单独解除映射,也不代表系统完全没有碎片或页表成本。

free 后 RSS 为何可能不降

应用释放对象后,分配器可能把 chunk 保留在缓存或 arena,供下一次请求复用。即使一些字节空闲,它们所在页仍有活跃对象时,也未必能归还整页。

例如一页里交错分配了长寿命小对象和短寿命对象,后者全释放后,剩余长寿命对象仍把这页留住。简单相加所有 free 字节无法推导可归还页数,这属于布局和寿命造成的外部碎片问题。

malloc_trim 尝试归还可释放的空闲内存,可能通过缩小或释放页内容实现,不保证所有 arena 的所有空闲空间都归还,也不会让活跃对象消失。频繁 trim 可能增加随后缺页和重新分配成本。malloc_trim(3)

同样释放 12 KiB,是否形成整页空闲取决于布局 交错寿命 页 1 页 2 页 3 页 4 按寿命聚集 页 1 页 2 页 3 页 4 蓝色:仍存活;灰色:已空闲。每页示意 4 KiB。 上排没有整页空闲;下排有 3 页候选。能否实际归还还受分配器布局和策略限制。

先算空闲字节,再算可归还页面

在一个忽略元数据的模型里,4 页中每页有 1 KiB 长寿命对象与 3 KiB 短寿命对象。短寿命对象释放后,活跃量为 4 KiB,空闲量为 12 KiB,但每页都还有活跃数据。若相同寿命对象聚集,4 KiB 活跃内容只占一页,另外三页才成为整页回收候选。

page = 4096
interleaved_live = [1024, 1024, 1024, 1024]
grouped_live = [4096, 0, 0, 0]
for live in [interleaved_live, grouped_live]:
    assert sum(live) == 4096
    assert sum(page - n for n in live) == 12288
assert sum(n == 0 for n in interleaved_live) == 0
assert sum(n == 0 for n in grouped_live) == 3

这不是 glibc 会严格按图摆放四个对象的承诺,也不能据此移动任意 malloc 对象:外部保存的指针使普通 C 分配器无法像某些 GC 那样自由压缩活跃对象。对明确同生共死的一批对象,应用层区域分配可以让整批释放更容易;若其中少数对象寿命特别长,整个区域又可能被它们留住。

12 KiB 空闲,为什么一页也收不回?

两种布局的对象数量和释放字节完全相同,差别只在长寿命对象是否占住每一页。先在交错布局释放,再切换聚集布局重做,观察空闲量相同但整页候选从 0 变为 3。普通 malloc 无法随意搬走长寿命对象,布局通常需要从分配策略和对象寿命入手。

正在呈现知识画面
12 KiB 空闲,为什么一页也收不回?

空闲字节之和不决定可回收整页数量;少量长寿命对象分散在不同页上,会让每页都保留。

锁竞争、伪共享和泄漏不同

多个线程争同一个 arena mutex 是锁竞争;两个线程修改同一缓存行上不同对象产生一致性流量,才是典型伪共享。限制 arena 数可能减少保留内存,却增加锁竞争,不能当作同时解决两种问题的万能选项。

泄漏通常指失去必要释放路径或不再需要却持续保留的对象;分配器保留空闲块是另一层现象。判断时对照活跃对象量、分配器统计、RSS/PSS 与负载阶段,见 进程内存布局。

观察与小步调参

#include <malloc.h>
#include <stdio.h>

int dump_allocator(void) {
    return malloc_info(0, stdout);
}

这是 glibc 专用接口,输出 arena 等 XML 信息,不应理解成完整、精确到所有线程缓存对象的业务存活清单。malloc_stats 与 mallinfo2 也各有统计口径和限制。

GLIBC_TUNABLES=glibc.malloc.arena_max=2 ./prog
GLIBC_TUNABLES=glibc.malloc.tcache_count=0 ./prog

这两条用于分别做对照实验,不表示应同时部署到所有程序。记录吞吐、尾延迟、分配调用栈和内存曲线,避免仅以运行结束时 RSS 评价长期服务。

MALLOC_TRACE 环境变量本身并不保证当前 glibc 自动记录全部分配;mtrace 的启用与调试库要求须按版本配置。普通越界和释放后使用优先通过 ASan 或 Memcheck 定位,不能把调低阈值当成修复内存错误。

观测要沿负载阶段取样

阶段应用自己记录分配器与系统侧记录用来判断
预热前初始活跃对象数和字节arena 信息、RSS/PSS基线
峰值时大小分布、峰值并发、寿命保留空间与驻留峰值由什么驱动
释放短命对象后已释放多少,剩余哪些空闲结构与 RSS/PSS对象释放是否变成整页回收
下一轮相同负载新请求量、分配延迟是否复用、是否再增长缓存是否稳定复用

若业务活跃字节降回基线,RSS 保持较高,但下一轮峰值不再增长,保留空间可能在发挥复用作用。若相同循环中业务活跃量持续上涨,应先找持有者和释放路径。若活跃量稳定而系统占用持续上升,再查碎片、线程/arena 数量、映射及统计盲区,不能只凭一条曲线宣布泄漏。

malloc_info 可输出多个 arena 的信息,但它不是业务对象账本。采样本身也有成本;不要在每次 malloc 后格式化整份 XML 再把所得时间当成分配器原始延迟。malloc_info(3)

替换分配器时比较什么

Google TCMalloc 的当前设计可使用 per-CPU 缓存,也有不同模式;它与 gperftools 的 tcmalloc 不能仅凭库名字就混为同一个版本。jemalloc 采用 arena、线程缓存和 extent 等机制,统计与回收行为可配置。TCMalloc 设计、jemalloc 手册

没有跨全部负载稳定成立的“大分配固定阈值、碎片高低排名”。应测试对象大小分布、寿命、跨线程释放、并发和 RSS 曲线。堆 profiler 也不等于 CPU profiler,报告维度需要确认。

LD_PRELOAD 只对适用的动态链接和符号解析路径有效;静态链接、自定义分配接口、受限执行环境及多个运行库可能改变效果。分配和释放必须由兼容的分配器协议配对,不能一边更换 malloc、一边让其他组件用不匹配的 free。

关键词:malloc, glibc, ptmalloc2, tcache, arena, bins, fragmentation, mmap threshold, RSS, malloc_trim, jemalloc, TCMalloc