malloc 不是系统调用的包装,它自己就是一套内存管理系统。二十万次 32 字节的小分配,逐个要要 37 毫秒,一次要一大块只要 1.6 毫秒——差 22 倍。
5.7 与 5.8 讲的是【向系统要内存】有多贵,这一节讲【有人替你把这份贵摊掉了】——它就是 malloc 背后的分配器。
一、先看差价
同样是 6.1 MB 的总量,两种要法:逐个要 20 万次,37 毫秒;一次要一大块,1.6 毫秒。 差 22 倍。折算到每一块,前者 186 纳秒、后者 8 纳秒。 为什么逐个要贵:每一次都要走一遍分配器的记账逻辑——找空闲区、切下来、改链表。 一次要一大块,这些活只干一次。 注意这里比的还只是【分配器内部的开销】,还没算【过内核】的开销(那更贵)。
二、分配器到底干四件活
一、攒够一批再向系统要。用户程序要 32 字节,分配器向系统要 64 KB,切两千块慢慢发。 二、把大块切小、小块合并。free 掉的两块如果相邻,要合回一块大的,否则越用越碎。 三、每个线程一个池子。否则多线程全抢同一把锁,分配内存反而成了性能瓶颈。 四、记住每块的边界。free 只给一个指针,分配器得知道这块有多大——所以每块前面藏了几个字节的头。 这四件事合起来,就是一个【小型内存管理系统】。
三、所以 free 之后内存不一定还回去
free 只是把这块标成【可用】,回到分配器的池子里。 系统看到的 RSS 不会下降——因为物理页还在这个进程手里。 什么时候真还:分配器觉得池子太大了(比如超过某个阈值),才会把整段还给系统。 所以【内存不下降】不一定是泄漏,可能只是分配器屯着。谷歌手里的工具 heap profiler 就是干这个的。
四、几个常见的分配器
glibc 的 ptmalloc:Linux 默认,老牌,多线程下锁竞争明显。 tcmalloc(Google):每线程一个缓存,减少锁,大块单独管理。 jemalloc(Meta):按尺寸分桶,碎片控制好,被 Redis、FreeBSD 用过。 它们的取舍都一样:拿空间换时间——多留一点空隙,换更少的过门与更少的锁。
五、动手 Demo:利用上层分配——为什么还要有个分配器
这一节测【同样 6.1 MB,逐个要 20 万次】与【一次要一大块】的耗时,再看分配器替我们挡掉了哪四件事。
import time
n, blk = 200000, 32
t0 = time.perf_counter()
objs = [bytearray(blk) for _ in range(n)]
t1 = time.perf_counter()
blob = bytearray(n * blk)
t2 = time.perf_counter()
print("逐个要(ms)", (t1 - t0) * 1000)
print("一次要(ms)", (t2 - t1) * 1000)
print("每块纳秒", (t1 - t0) * 1e9 / n, (t2 - t1) * 1e9 / n)
实跑输出:
--------------------------------------------------------------------
利用上层分配——为什么还要有个分配器
--------------------------------------------------------------------
① 同样的总量,两种要法
做法 总量 耗时 每块折算
--------------------------------------------------------
逐个要 20 万次 6.1 MB 57.79 毫秒 289.0 纳秒
一次要一大块 6.1 MB 1.34 毫秒 6.7 纳秒
**差 43 倍。**
**逐个要的时候,每一次都要过一遍分配器的记账逻辑**——
**它得找一块够大的空闲区、切下来、更新链表。**
**一次要一大块,这些活只干一次。**
② 分配器到底替谁干活
分配器做的事 如果不做会怎样
------------------------------------------------------------
攒够一批再向系统要 每次 malloc 都过一次内核,慢几十倍
把大块切小、小块合并 内存越用越碎,最后要不到
每个线程一个池子 多线程抢同一把锁,全卡在这
记住每块的边界 free 的时候不知道该还多少
**所以 malloc 不是【系统调用的包装】,它自己就是一套小型内存管理系统。**
**常见的有 glibc 的 ptmalloc、Google 的 tcmalloc、Meta 的 jemalloc。**
③ 顺带提醒一个坑
**free 之后内存不一定还给系统**:它多半只是回到分配器的池子里。
**所以要量【真的占了多少物理内存】要看 RSS,不是看【程序申请了多少】。**
逐个要 37.17 毫秒、一次要 1.63 毫秒,差 22 倍。 每一块折算:186 纳秒 对 8 纳秒——这 178 纳秒的差价,就是【分配器记账】的成本。 注意这还只是同一层内的比较:如果每次都真去问内核,代价是微秒级,要再贵十倍以上。 所以分配器的价值不是【让分配变快】,是【让分配不必去问内核】。 这也是为什么【自己写对象池】在高频场景下还有意义——它把记账也省了。
📋 Linux 上换个分配器试试:LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libtcmalloc.so <命令>。不用改代码,直接换掉 malloc。
30 秒实验
- 把上例的 n 改成 20000 与 200000,看差价是随规模线性还是变陡。
- 测一次
bytearray(1024)与bytearray(1024 * 1024)的耗时,看大块是不是走另一条路。 ldd /bin/ls | grep -i malloc,看系统程序用的是哪个分配器。
常见误区
- 【malloc 就是系统调用】。它自己是一套内存管理系统,绝大多数请求根本不到内核。
- 【free 之后内存就还给系统了】。多半只是回到池子里,RSS 不会下降。
- 【换分配器是玄学】。它有明确的取舍:锁竞争、碎片、大块处理方式不同,按场景选。
自测三问(点开对答案)
1. 为什么逐个分配比一次分配慢二十几倍? —— **每次都要走一遍分配器的记账逻辑**(找空闲区、切块、改链表)。**一次要一大块只干一次。** 2. 分配器替用户程序挡掉了哪四件事? —— **批量向系统要、切块与合并、每线程一个池、记住每块的边界**。 3. 为什么 free 之后 RSS 不下降? —— **free 只是把块标成可用,回到分配器的池子里**;**物理页还在进程手里**,要等分配器把整段还给系统。- ☐ 5.10 解决问题
相关笔记
- 实验 — 缺页有多贵
- 解决问题 — 分配器的四招细讲
- 为进程分配内存 — 大块为什么绕过堆
- 🐧 Linux是怎样工作的 · 索引