分配器的四个招式:批量取页、按尺寸分桶、每线程一个池、块头记大小。一百万次请求,直接问系统要 100 万次,按 64KB 批只要 489 次——差 2044 倍。
5.9 看了【有分配器】与【没有】的差价,这一节看分配器具体用哪四招把差价压下去,以及每一招的代价。
一、先把过门次数算出来
一百万个 32 字节的请求,总需求 32 MB。 直接问系统:100 万次系统调用。 按 64 KB 一块批:每次拿 2048 块,只要 489 次。 这就是分配器存在的第一理由:把过门次数从【请求数】降到【请求数 ÷ 批量】。 注意这个比例是可以算出来的,不是玄学——批量越大,次数越少,但一次浪费的空间也越多。
二、招式一:批量取页(arena)
做法:向系统要一大段(比如 64 KB 或 1 MB),自己切成小块慢慢发。 好处:过门次数直接除以批量。 代价:内部碎片——最后用不完的那一块就闲置着。 改进:多个 arena 轮流用,避免多线程抢同一把锁——这就是 glibc 的 arena 机制。
三、招式二:按尺寸分桶(slab/size class)
做法:把请求大小归到固定的几档(8、16、32、64…),每档一个池子,池子里的块大小一致。 好处:分配与释放都变成【从链表头摘一块/挂回去】,O(1);相同尺寸的块可以复用,不会越切越碎。 代价:要 33 字节就给你 48 字节,浪费 15 字节——这是内部碎片换速度。 Linux 内核里管这个的叫 slab(后来是 slub),用户态的 tcmalloc 与 jemalloc 也是这个思路。
四、招式三、四:每线程一个池、块头记大小
每线程一个池:多线程各用各的池,不用抢锁。代价:线程多的时候内存占用成倍上升(每个池都有余量)。 块头记大小:free 只给一个指针,分配器得知道这块多大——所以每块前面藏几个字节记尺寸。 代价:每个对象多几个字节;还带来一个重要后果——【越界写一个字节可能踩到下一块的头】。 这两招合起来说明一件事:分配器的每个设计都在还一笔账。
五、什么时候该绕开分配器
一、一次要几 MB 以上:直接 mmap,别走 malloc。 二、固定大小、数量巨大:自己开池子按索引拿,连分配器的记账都省了(游戏引擎、服务器连接对象都这么干)。 三、要多个进程共享:mmap 同一块(5.11)。 判断标准很简单:如果你能自己算清楚【要多少、什么时候还】,就别麻烦分配器。
六、动手 Demo:分配器怎么解决问题——四个招式
这一节把【一百万次请求要过几次系统】算出来,再把四个招式与它们的代价列成表。
n, blk = 1000000, 32
arena = 64 * 1024
per_arena = arena // blk
calls = (n + per_arena - 1) // per_arena
print("直接问系统", n, "次")
print("一块能装", per_arena, "块")
print("批着要", calls, "次")
print("差多少倍", n // calls)
实跑输出:
--------------------------------------------------------------------
分配器怎么解决问题——四个招式
--------------------------------------------------------------------
① 一百万次 32 字节的请求,算算各要过几次系统
做法 每次要多少 过几次系统
--------------------------------------------------
直接问系统 32 字节 1000000 次
按 64KB 一块批 2048 块 489 次
按 256 块的 slab 批 256 块 3907 次
**从 1000000 次降到 489 次,差了 2044 倍。**
**这就是分配器存在的第一理由:把过门次数压下来。**
② 四个招式
招式 对付什么 代价
--------------------------------------------------------------------
批量取页(arena) 系统调用太贵 内部会有浪费
分桶(slab/size class) 尺寸杂、碎片多 桶多占一点空间
每线程一个池 锁竞争 线程多时内存翻倍
块头记大小 free 不知道还多少 每块多几个字节
**记住这个取舍**:**分配器是拿【空间】换【时间】的。**
**它留的每一处空隙,都是在替将来的快买单。**
③ 反过来,什么时候该绕开它
场景 更好的做法
--------------------------------------------------
一次要几 MB 以上的大块 直接 mmap,别走 malloc
固定大小、数量巨大 自己开池子,按索引拿
要多个进程共享 mmap 同一块(5.11)
一块 64 KB 能装 2048 个 32 字节的块,所以一百万个请求只需要 489 次系统调用。 从 100 万降到 489,差 2044 倍。这就是【批量】这两个字的全部力量。 但注意批量不是越大越好:批量翻倍,过门次数减半,可是一次性压住的内存也翻倍。 所以真实分配器的 arena 大小是【几百 KB 到几 MB】这个量级,既摊掉了过门开销,又不至于一次压太多。
📋 Linux 上看 slab 用得怎么样:slabtop -o 或 cat /proc/slabinfo | head——每类对象有多少个、占多少内存、复用率多少。
30 秒实验
cat /proc/slabinfo | sort -k3 -n -r | head,看哪类内核对象占得最多。- 把上例的批量改成 16 块与 4096 块,算过门次数怎么变。
slabtop -o -s c | head,按缓存大小排序看内核的池子。
常见误区
- 【分配器只是包装了一下系统调用】。它自己是一套内存管理系统:取页、切块、合并、分桶、分线程池。
- 【批量越大越好】。批量大摊掉过门,但一次压住的内存也大,还增加浪费。
- 【块头不占地方】。每块多几个字节,小对象多的时候这个比例很可观;而且越界写会踩到它。
自测三问(点开对答案)
1. 批量取页把什么降下来了?代价是什么? —— **过门次数降到【请求数 ÷ 批量】**。代价是**一次压住的内存变大、末尾浪费**。 2. 按尺寸分桶为什么能做到 O(1)? —— **同尺寸的块从一个空闲链表里摘/挂**,不用找空闲区、不用切块。**代价是内部碎片。** 3. 什么情况下该绕开 malloc? —— **大块(直接 mmap)、固定大小且量巨大(自己开池子)、要跨进程共享(mmap 同一块)。**- ☐ 5.11 虚拟内存的应用
相关笔记
- 利用上层进行内存分配 — 分配器有多重要
- 简单的内存分配 — 碎片从哪来
- 虚拟内存的应用 — 换个角度用页表
- 🐧 Linux是怎样工作的 · 索引