文章/gperftools-memory-release-policy
19 分钟阅读
gperftools 什么时候把内存还给操作系统
从 TCMalloc_SystemRelease 反向追踪分配与归还路径,解释增量回收、碎片合并和堆上限背后的成本取舍。
TL; DR
gperftools 默认先缓存空闲内存供复用,再按归还给 PageHeap 的页数触发增量回收;分配时也可能为了合并空闲 span 或满足堆上限而主动回收。Linux 下,TCMalloc_SystemRelease() 默认用 MADV_DONTNEED 归还物理页、保留虚拟地址,减少内存占用,代价是后续复用可能重新缺页。
TCMalloc_SystemRelease
本文讨论 AMD64(x86-64)上的 Linux,gperftools 版本是 gperftools-2.18.1-27-g69601ce,对应提交 69601ce9eff9。
TCMalloc_SystemRelease(void* start, size_t length) 负责把一段空闲内存交还操作系统。start 是起始地址,length 是字节数。它先把起点向上、终点向下调整到操作系统页边界,只释放范围内完整的页;收缩后没有完整页,就直接返回 false。
TCMALLOC_DISABLE_MEMORY_RELEASE 是总开关:设为 true 时,这个函数直接返回 false,增量回收、激进回收和显式释放都无法通过它归还内存。
允许释放且范围内有完整页时,调用:
madvise(reinterpret_cast<char*>(new_start), new_end - new_start, MADV_FREE);
Linux 下默认会先 #undef MADV_FREE,再 #define MADV_FREE MADV_DONTNEED,所以这里实际用的是 MADV_DONTNEED,见宏定义。
对这里的私有匿名内存,MADV_DONTNEED 归还的是物理页,虚拟地址还留着。以后再访问这段地址,会触发缺页,读到的是零,原来的数据已经丢了。 MADV_FREE 则可以等内核需要内存时再回收,具体区别见 madvise(2)。
这里是故意不用 MADV_FREE。源码注释指向 #780:Linux 4.5 才加入这个接口,如果编译时的头文件支持,运行时的旧内核却不支持,调用就会失败。当时只做编译时检测,没有运行时回退到 MADV_DONTNEED,于是先在 Linux 下禁用它。
维护者当时还担心,分配器重新使用这些页时,缺少明确通知内核的接口,可能影响内核的页面回收,认为需要进一步测试。到 2023 年关闭这个 issue 时,他仍主张继续使用 MADV_DONTNEED。这份源码保留了 TCMALLOC_USE_MADV_FREE 编译开关,显式定义后才会跳过上述禁用逻辑。
向系统要内存的成本
malloc() 有缓存可用时,可以直接在用户态返回一个对象。缓存不够,也可能从 PageHeap 已有的空闲 span 中补充。只有这些地方都满足不了请求,才需要 GrowHeap() 经由 TCMalloc_SystemAlloc() 向系统申请更多内存。这份源码的系统分配器包含 sbrk 和匿名映射路径。
sbrk 扩展进程堆的末尾,mmap 则申请一段匿名映射。默认系统分配器在初始化时排好尝试顺序。在本文的 AMD64 环境下:
- Release 构建(定义了
NDEBUG)先试sbrk,失败后再试mmap。 - Debug 构建(未定义
NDEBUG)先试mmap,失败后再试sbrk。源码注释解释,64 位环境下mmap更容易返回超出 32 位范围的地址,便于暴露指针截断问题,也能减少 heap checker 把普通数值误认成指针的情况。
比如 Release 构建向系统申请 64 MiB,sbrk 成功就直接使用它返回的内存;堆末尾无法继续扩展、sbrk 失败,才轮到 mmap。
我的理解是,sbrk 和 PageHeap 逐步扩堆的方式比较合拍。PageHeap 向系统要一大块内存,再自行切分、复用;下一次不够了,就继续扩。sbrk 沿堆末尾增长,通常能延长已有的 heap VMA。如果新区域与 PageHeap 原有的空闲 span 相邻,也就有机会合并成更大的 span。
堆末尾的地址空间被挡住时,mmap 还可以去其他位置找空间。先连续扩堆,再用其他地址补充,可能就是这样安排的考虑。
成本上,sbrk 扩堆仍要进入内核。例如 Linux 6.6 的 brk 实现同样获取 mmap 写锁,并在跨页扩展时维护 VMA。对频繁分配的小对象,更直接的收益来自分配器内部复用:已有空间够用,就省掉这次向系统申请的过程。
失败还会被记下来:某条路径失败后,后续申请会跳过它,继续使用另一条路径。等两条路径都失败,本次申请返回失败,并清除失败标记,让之后的申请可以重新尝试。
TCMALLOC_SKIP_SBRK 和 TCMALLOC_SKIP_MMAP 可以分别禁用这两条路径,默认都不禁用。开关初始化前的少量早期分配仍可能使用对应路径。以上是默认系统分配器的行为,应用也可以替换系统分配器。
系统调用要进入内核,检查地址范围并维护进程的虚拟内存映射。匿名映射通常按需分配,拿到地址时,对应的物理页和页表项可能还没准备好。首次读写触发缺页后,CPU 进入内核,内核检查访问是否合法,按需分配缺少的页表页,建立或更新页表项,最后才能回到用户代码继续执行。
读取尚未写过的匿名页时,内核可能先映射共享零页;写入时则需要独立的物理页,并保证初始内容为零。正常情况下,这些是无需磁盘读取的 minor faults,但缺页处理、物理页准备和页表维护都要花时间。如果物理内存紧张,还可能遇到直接回收等额外开销。匿名映射的按需分配行为见 mmap(2)。
比如申请一块 64 MiB 的缓冲区,随后把它写满。暂不考虑透明大页,按 4 KiB 的普通页计算,这块缓冲区包含 16384 页。即使 malloc() 很快就返回了,后续写入仍可能要为这些页准备物理内存和映射。
如果缓冲区用完后留在分配器里,物理页仍然驻留、页表项仍然有效,下一轮拿到同一段内存就可以直接访问,省掉再次缺页和建立映射的开销。如果先用 MADV_DONTNEED 归还,虚拟地址虽然还在,对应的物理页映射已经撤掉,下一轮写入又要走缺页处理。缓存同时保留了可复用的内存和已经建立好的映射,这也是延迟归还的收益;代价是这段时间仍占着物理内存。
空闲的内存先留着复用
free() 先把内存交回分配器。至于是否在这次调用中归还操作系统,要看 PageHeap 的回收条件。小对象可能先留在对象缓存里;等整个 span 归还给 PageHeap,才进入这里讨论的回收流程。一个 span 由连续的 TCMalloc pages 组成。
PageHeap 收到空闲 span,默认先留着,下次分配还能直接用。这时 span 的状态是 ON_NORMAL_FREELIST。等 backing pages 成功归还操作系统,状态才变成 ON_RETURNED_FREELIST。地址仍归 PageHeap 管,后面也能再分配出去,只是访问时可能要重新缺页。
PageHeap 按归还的页数推进计数器,达到回收条件后释放一部分 span,其余的继续留着复用。
增量回收的页数计数器
DeleteLocked() 先把归还的 span 标为 ON_NORMAL_FREELIST,调用 MergeIntoFreeList() 尝试合并,再调用 IncrementalScavenge(n)。其中 n 是本次归还的 span 页数。
IncrementalScavenge(n) 决定这次要不要回收,以及下次还要等多少页。逻辑和等待阈值简化如下:
counter -= n # counter:回收前的页数倒计数;n:本次归还的页数
if counter >= 0:
return
if release_rate <= 1e-6: # TCMALLOC_RELEASE_RATE,默认 1.0;接近零时关闭增量回收
counter = 1 << 18 # 默认等待页数
return
released_pages = ReleaseAtLeastNPages(1) # 尝试释放至少一页,返回实际释放的页数
if released_pages == 0:
counter = 1 << 18
else:
counter = min(released_pages * 1000 / release_rate, 1 << 20) # rate 越大,等待越短;最多等 1 << 20 页
按默认 rate,如果这次实际回收了 R 页,下一轮大致要等 PageHeap 再接收 1000 × R 页。代码在计数器减到负数时才继续尝试;等待页数会截断为整数,并受最大值限制。
假设 release_rate = 1、counter = 0,只看增量回收这条路径。现在一个 4 页的 span 归还给 PageHeap,计数器变成 0 - 4 = -4,于是触发回收。假设这次从空闲链表中选中了一个 8 页的 span,并成功归还操作系统,released_pages 就是 8。触发回收的 span 和实际回收的 span 不必是同一个。
接着,counter 被设为 8 × 1000 / 1 = 8000。后续的归还会这样推进计数器:
- 又累计归还 3000 页,
counter变成8000 - 3000 = 5000,不触发回收。 - 再累计归还 5000 页,
counter变成5000 - 5000 = 0,等于零还不触发。 - 再归还一个 4 页的 span,
counter变成0 - 4 = -4,这时才再次尝试释放空闲 span。
最后这次如果成功释放了 2 页,计数器就重新设为 2 × 1000 / 1 = 2000;如果一页也没释放,则设为默认等待值 1 << 18。把 rate 改为 2,同样释放 8 页后,计数器只设为 4000,下一轮回收就会更早触发。
这里的等待量以页数计算。程序如果不再经过这条路径,时间过去再久,计数器也不会自行变化。
按页数计量的实现成本很低:在已有的 PageHeap 操作中扣减计数器就行,不必另设后台线程,也不用维护每个 span 的空闲时间。比起按调用次数计数,它还能区分归还 1 页和归还 100 页。
一次回收越多,后面等待的归还页数也越多,这样可以补偿整 span 释放的粒度差异。实际回收比例还受整 span 回收、计数器截断、退避上限和其他回收入口的影响。
回收哪个 span
ReleaseAtLeastNPages(1) 从 PageHeap 的 normal 空闲结构里选 span。returned span 已经归还过,扫描时跳过。
PageHeap 按页数存放这些 span:1 页一组、2 页一组,一直到 kMaxPages;更大的 span 放进 large_normal_。ReleaseAtLeastNPages() 用 release_index_ 记住扫描位置,按组轮转,空组就跳过。初始位置是 large 组,之后从 1 页组往后走;成功释放后,下次从下一组继续,不会每次都从最小的 span 找起。
到了有候选的组,再按下面的规则取:
- 不超过
kMaxPages的组是双向链表,取链表尾部。span 放回链表时插在头部,所以先选的是这条链表中较早放进去的那个。 - large 组是有序集合,取
large_normal_.begin()。排序规则是页数从小到大,页数相同再按起始地址排序。因此选的是 large 组里最小的 span,同样大小时选地址更低的。
比如,假设本次从 6 页组开始扫描,6 页和 7 页组都是空的,8 页组里有两个互不相邻的 span:A 先放进去,B 后放进去。
- 扫描跳过 6 页、7 页组,来到 8 页组。
- B 在链表头,A 在链表尾,因此选 A。
- 虽然只请求释放至少 1 页,A 仍会整块释放。成功后返回 8,满足本次目标,B 留着。
- 下次从 9 页组继续。即使 8 页组还有 B,也不会立刻回来选它,要等扫描位置绕回来。
选中的 span 经由 ReleaseSpan()、DecommitSpan() 进入 TCMalloc_SystemRelease()。每次释放整个 span,页数还没凑够就继续扫描下一组;没有普通空闲页或某次释放失败,也会停止。release_rate 只影响发起回收的时机,候选选择仍按上面的顺序。
申请内存为什么会触发回收
合并被状态隔开的空闲 span
默认模式只合并相同空闲状态的相邻 span。于是会有这样的地址布局:
[normal: 4 pages][returned: 4 pages][normal: 4 pages]
假设除此之外没有其他可用空间,现在需要一个 12 页的 span。总空闲量够,地址也连续,但状态不同,查找不到一个现成的 12 页 span。
NewLocked() 在首次查找失败、准备扩堆前,会检查是否值得先回收普通空闲 span,让它们转为 returned,再尝试合并和查找。触发需要同时满足:
free_bytes和unmapped_bytes都非零。- 两者之和至少达到
system_bytes / 4。 system_bytes / 128 MiB与(system_bytes + 请求字节数) / 128 MiB的整数商不同。
满足后,代码请求 ReleaseAtLeastNPages(0x7fffffff),以一个很大的页数目标尝试释放普通空闲 span,然后重新搜索。释放失败时会提前停止。
unmapped_bytes 统计 returned span 的字节数,这些 span 的虚拟地址仍由 PageHeap 保留。
释放 normal 页后,它们就能与相邻的 returned span 合并,前面那三段 4 页的空闲区域也就有机会合成 12 页。即使仍凑不出足够大的 span,也交还了一部分物理内存。
频繁这样做,后续复用就可能增加 minor page faults。源码用 128 MiB 区间判断限制触发频率,判断依据是当前堆大小和请求大小。
满足堆内存上限
另一条入口是 EnsureLimit()。它在扩堆前,以及准备重新使用 returned span 等路径中检查 TCMALLOC_HEAP_LIMIT_MB。默认值为零,表示不启用这项限制。
它用 TCMalloc_SystemTaken 的页数减去 returned 页数,作为当前计账量。若加上本次需要的页数会超限,而且本次检查允许回收,就调用:
ReleaseAtLeastNPages(taken_pages + requested_pages - limit_pages)
回收后再检查一次限制。这里用的是分配器账目;RSS、系统可用内存和容器的 cgroup 限额各有自己的统计与控制机制。元数据分配和底层分配取整仍可能让账目超过这个限制。
复用普通空闲 span 不需要新增这部分计账量,而重新使用 returned span 会增加。因此“从现有空闲链表拿内存”也可能先释放其他空闲 span,为这次使用腾出额度。
扩堆也借用了归还路径
GrowHeap() 从系统申请一段内存后,会先创建 span,再调用 DeleteLocked(span),让新区域与已有空闲区域合并,之后才重新查找满足请求的 span。
NewLocked
GrowHeap
TCMalloc_SystemAlloc
DeleteLocked
MergeIntoFreeList
IncrementalScavenge
SearchFreeAndLargeLists
新申请的 span 就这样进入了归还路径,同样扣减增量回收计数器。计数器初始值是零,满足回收条件时,即使应用还没调用过 free(),这里也可能走到 SystemRelease。这不需要前面的碎片整理或堆上限条件。
NewAligned() 同样会把为地址对齐而多申请的头部或尾部交给 DeleteLocked()。反过来,普通 Carve() 切出剩余 span 时只是把剩余部分放回空闲结构,并不经过增量回收。
计数器统计的是进入 DeleteLocked() 的页数,应用释放和这些内部归还都会计入。
激进回收模式与显式释放
假设一个程序每批任务都要用一块 64 MiB 的缓冲区,处理完就 free(),下一批又申请同样大小的缓冲区。默认策略下,如果这块内存没有被回收,下一批拿到同一段仍然驻留的内存,就可以接着用。
开启 aggressive_decommit_ 后,每批任务会多出这些操作:
- 本批任务结束,span 回到 PageHeap。
MergeIntoFreeList()不等计数器,直接尝试DecommitSpan();成功后,物理页归还操作系统。为合并相邻 span,它还可能释放旁边的 normal span。 - 下一批来了,即使分配器又给出同一个虚拟地址,原来的物理页也已经还掉了。写入这块缓冲区时,内核又要处理缺页、准备物理页。
- 每批都这样用,释放和重新缺页的开销就会一遍遍发生。换来的是两批任务之间,分配器不再保留这块内存。
这个模式默认关闭,作用于回到 PageHeap 的 span。小对象仍可能留在对象缓存里。扩堆时,新申请的 span 也会经过 MergeIntoFreeList(),可能刚拿到就 decommit,接着又被分配出去。在本文的默认 Linux 路径下,TCMalloc_SystemCommit() 本身是空操作,重新准备物理页的成本落在后续访问上。
显式释放适合另一种情况:应用知道一大批任务已经结束,接下来一段时间不会再需要这么多内存,可以调用 MallocExtension::ReleaseToSystem(),不必等页数计数器慢慢推进。它会先抵扣前次多释放的字节数,再向 PageHeap 请求剩余部分。可释放的范围是 PageHeap 已有的空闲内存,在用对象和仍留在对象缓存里的内存保持原样。
将 release_rate 设为零,只关闭增量回收。碎片合并、堆上限、激进回收模式和显式释放仍有各自的入口。
为什么选这样的策略
gperftools 把回收放在已有操作里,用页数计数器控制频率,省去了计时和主动唤醒的机制。业务静默后,增量回收也会停下来。几种策略各有取舍:
- span 空闲后立即归还,可以更早交还 backing pages,但周期性负载可能反复付出释放和缺页的开销。
- 始终保留空闲页,复用时省掉了主动释放带来的额外成本,但高峰留下的内存会继续占用机器和容器的内存额度。
- 按空闲时长或定时回收,业务静默时也能继续回收,不过需要记录时间或主动唤醒,等待多久仍要选择。
- 根据系统内存压力回收,可以响应外部资源需求,但要依赖压力信号,并弄清它在操作系统和容器中的含义。
- 按 PageHeap 归还页数节流,计量便宜,随着已有操作就能推进,但不直接感知时间、真实内存压力或后续的复用需求。
容器里的空闲页也占额度
容器通常通过 cgroup 限制内存。以 cgroup v2 为例,memory.current 统计整个 cgroup 及其子组的内存使用量,memory.max 设置硬上限。进程 RSS 可以观察驻留内存,容器额度则还包含其他进程、文件缓存和内核内存等计账。
free() 后留在分配器空闲结构里的匿名页,只要仍然驻留,就继续占用容器额度。内核按进程的匿名内存计账,分配器内部的空闲状态对它不可见。禁用 swap 时,这些页也无法通过换出来腾出空间。
比如一个容器的 memory.max 是 8 GiB,里面运行主进程和一个处理辅助任务的子进程:
- 一轮任务结束后,
memory.current仍是 7 GiB,其中有 5 GiB 是主进程分配器保留的空闲匿名页。 - 子进程又需要 2 GiB。主进程的空闲 span 留在自己的地址空间里,子进程无法直接复用,却和它共享这 8 GiB 的额度。新增内存计账会碰到上限,内核需要尝试回收;如果回收不出足够空间,就可能触发 cgroup OOM,即使宿主机还有空闲内存。
- 如果主进程提前用
MADV_DONTNEED归还了这些可释放的空闲页,就能降低自身的匿名内存计账,为子进程腾出额度。
缓存能加快本进程的复用,也会占去同一 cgroup 中其他用途可用的空间。延迟归还给近期复用留了机会,再逐步交还一部分额度。这条增量回收路径仍按页数计数器推进,不直接读取 cgroup 的剩余额度。
回收策略小结
gperftools 先把空闲内存留在内部复用,省掉向系统申请和重新缺页的开销,再逐步归还一部分,释放物理内存和容器额度。具体分成几种情况:
- span 回到 PageHeap 时,默认先加入 normal 空闲结构,再按归还页数扣减计数器。计数器减到负数、release rate 允许回收时,才发起增量回收;这次释放得越多,下次等待的页数通常也越多。
- 回收时,按
release_index_轮转扫描 normal 空闲组。小 span 从链表尾取,large 组选最小的 span,整块释放,达到目标页数就停止。 - 分配时,PageHeap 可能为合并不同状态的相邻空闲 span,或满足配置的堆上限而主动回收。扩堆和对齐分配还会借用归还路径,因此也可能触发增量回收。
- 激进回收模式会在 span 回到空闲结构时直接尝试归还;应用也可以显式请求释放。这些入口不等待增量回收计数器。
最终都由 DecommitSpan() 调用 TCMalloc_SystemRelease()。在本文的默认 Linux 配置下,它将地址范围收缩到完整的操作系统页,再用 madvise(MADV_DONTNEED) 归还 backing pages。成功后,PageHeap 将 span 转为 returned,保留虚拟地址供后续分配;再次写入时,物理页和页表映射由缺页处理重新准备。释放失败的 span 仍留在 normal 状态。
讨论
邮箱用于身份识别和回复通知,并由 Waline 存储。