🔥 本文专栏:内存管理与高并发内存池
🌸作者主页:努力努力再努力wz



💪 今日博客励志语录 :
你和理想之间隔着的,从来不是天赋,而是你今天没做的那件事。
思维导图

从 open 到 read/write:传统文件 I/O 的底层执行流程
在此前的学习中,我们知道,如果程序想要对磁盘文件进行读写,最常见的方式就是先调用 open 系统调用打开文件。open 会接收一个路径字符串以及打开方式,例如只读、只写或读写模式等。打开成功后,内核会返回一个文件描述符,后续程序便可以通过 read、write 等系统调用完成文件内容的读取和写入。
不过需要注意的是,open 并不会把文件中的数据内容整体加载到内存中。对于一个已经存在的文件来说,文件的元信息早已由文件系统持久化保存在磁盘中,也就是我们常说的 inode 元数据。inode 记录的是文件本体相关的信息,例如文件类型、权限、大小、时间戳以及文件数据块的索引信息等。当程序调用 open 时,内核首先会根据传入的路径字符串进行路径解析。
路径解析的过程并不是直接根据字符串找到文件内容,而是逐级解析路径中的每一层目录。例如 /home/wangzhe/test.txt 这个路径会被拆分为 /、home、wangzhe、test.txt 等多个部分。每一层路径名都会对应一个目录项对象,也就是 dentry。可以将 dentry 理解为内核中缓存的"名字到 inode 的映射关系",它描述的是在某个父目录下,某个名字对应哪个 inode。多个 dentry 通过父子关系组织起来,就形成了类似目录树的结构。
text
路径字符串:/home/wangzhe/test.txt
│
▼
逐级路径解析
│
├── "/"
│ └── dentry("/", parent = 自身/空) -> inode(根目录)
│
├── "home"
│ └── dentry("home", parent = "/") -> inode(home目录)
│
├── "wangzhe"
│ └── dentry("wangzhe", parent = "home") -> inode(wangzhe目录)
│
└── "test.txt"
└── dentry("test.txt", parent = "wangzhe") -> inode(普通文件)
因此,open 的核心过程可以理解为:内核根据路径逐级查找 dentry,通过 dentry 找到目标文件对应的 inode。如果该 inode 已经存在于内核的 inode 缓存中,内核会直接复用;如果尚未加载,则会根据文件系统和 inode 编号从磁盘读取对应的 inode 元数据,并构造内存中的 struct inode 对象。随后,内核会为本次打开操作创建一个 struct file 对象,用来记录当前打开文件的上下文信息,例如打开方式、当前读写偏移以及文件操作函数等。最后,内核会在当前进程的文件描述符表中找到一个空闲下标,使该下标指向这个 struct file 对象,并将这个下标返回给用户程序。
也就是说,inode 描述的是文件本体,dentry 描述的是文件名与 inode 之间的路径关系,而 struct file 描述的是一次具体的打开行为。多次调用 open 打开同一个文件时,通常会创建多个独立的 struct file 对象,但它们底层可以指向同一个 dentry 和 inode。因此,多个文件描述符可以拥有各自独立的读写偏移,但最终操作的是同一个文件本体。
当后续程序调用 read 或 write 时,内核才会真正开始处理文件数据。
当程序真正调用 read 读取文件内容时,内核会先根据文件描述符找到对应的 struct file 对象。struct file 中保存了当前这次打开文件的上下文信息,其中一个非常重要的字段就是当前文件偏移量。这个偏移量表示本次读取应该从文件的第几个字节开始。
文件在内核中通常会按照页的粒度进行管理。假设一页大小为 4KB,那么文件偏移量就可以被换算成对应的文件页号。例如当前文件偏移量为 5000 字节,那么它对应的文件页号就是 5000 / 4096 = 1,页内偏移则是 5000 % 4096 = 904。也就是说,当前读取操作需要访问的是该文件的第 1 个文件页,并且从这个页内部的第 904 字节开始读取。
在得到文件页号之后,内核并不会立刻访问磁盘,而是会先去 page cache 中查找该文件对应的缓存页。page cache 可以理解为内核为文件数据维护的一层内存缓存,用来减少频繁访问磁盘带来的开销。对于某一个具体文件来说,内核需要维护"这个文件的哪些页已经被加载到了内存中"。这个管理关系在内核中通常由 inode 关联的 mapping,也就是 address_space 对象来维护。
可以将 address_space 理解为某个文件的 page cache 管理结构。它内部维护了一套按照文件页号组织的索引结构,用来记录该文件的哪些页已经存在于 page cache 中。读取文件时,内核会根据当前文件以及计算出的文件页号,在对应的 address_space 中查找目标页。如果查找命中,说明该文件页已经被加载到内存中,内核可以直接从 page cache 中拷贝数据到用户缓冲区;如果没有命中,说明该文件页尚未加载到内存,此时内核才会根据 inode 中记录的数据块索引,从磁盘读取对应的数据页,并将其放入 page cache 中,随后再拷贝到用户缓冲区。
因此,普通 read 的数据读取过程可以理解为:先通过 struct file 获取当前文件偏移量,再根据偏移量计算出文件页号,然后通过该文件对应的 page cache 管理结构查找目标文件页。如果目标页已经在内存中,就直接读取缓存;如果不在内存中,才触发真正的磁盘 I/O,将数据加载到 page cache 后再返回给用户程序。
text
read(fd, buf, size)
|
v
根据 fd 找到 struct file
|
v
读取 file 中的当前文件偏移量 f_pos
|
v
计算文件页号 page index
|
v
根据当前文件对应的 address_space
在 page cache 中查找该文件页
|
+----------------------+
| |
v v
查找命中 查找未命中
| |
v v
从 page cache 从磁盘读取对应数据页
拷贝到用户缓冲区 |
v
放入 page cache
|
v
再拷贝到用户缓冲区
所以,file->f_pos 表示的是当前打开文件的读写位置,page index 表示文件内部的页号,而 address_space 则可以理解为这个文件的 page cache 管理器。三者配合起来,内核就可以判断目标文件页是否已经在内存中,从而决定是直接读取缓存,还是从磁盘加载数据。
这种基于 read/write 的传统文件 I/O 方式本身没有问题,也是最常见的文件读写方式。但是它存在一定的开销。首先,每次调用 read 或 write 都需要触发系统调用,CPU 需要从用户态切换到内核态,并在系统调用完成后再返回用户态。在这个过程中,内核需要保存和恢复必要的用户态执行现场,例如程序计数器、用户栈指针、标志寄存器以及部分通用寄存器等信息。
除此之外,read/write 还会涉及用户空间和内核空间之间的数据拷贝。read 需要将数据从内核 page cache 拷贝到用户缓冲区,write 则需要将数据从用户缓冲区拷贝到内核 page cache。当程序需要频繁进行文件读写,或者读写的数据量较大时,系统调用切换和数据拷贝带来的开销就会逐渐明显。
因此,Linux 提供了另一种文件访问机制:mmap。而接下来的内容便会围绕mmap 来展开
mmap 文件映射机制与写文件实战
mmap 文件映射原理:从虚拟地址映射到零拷贝访问
根据上文的分析,我们已经知道,使用 open、read、write 是读写文件的一种常见方式。接下来,我们再来认识另一种文件访问方式,也就是 mmap。
首先需要明确的是,mmap 本身也是一个系统调用。它的核心作用并不是直接把文件内容全部读取到用户缓冲区中,而是将文件的某一个区间映射到进程的虚拟地址空间中。映射建立之后,用户程序就可以像访问普通内存一样,通过指针访问这段虚拟地址区间,从而间接访问文件中的数据。
对于一个进程来说,指针中保存的是虚拟地址。当程序通过指针访问内存时,CPU 会执行内存访问指令,并将虚拟地址交给 MMU 进行地址转换。MMU 会先查询 TLB,也就是页表项缓存;如果 TLB 命中,就可以快速得到对应的物理地址;如果 TLB 未命中,则需要继续查询内存中的页表,根据页表项完成虚拟地址到物理地址的转换。
mmap 的实现思路正是建立在这套虚拟内存机制之上的。它会在进程地址空间中建立一段虚拟内存区域,并记录这段虚拟地址区间和文件某个区间之间的映射关系。需要注意的是,mmap 调用完成后,文件内容通常并不会立刻全部加载到内存中。真正访问映射区中的某个地址时,如果该地址对应的文件页尚未加载,CPU 会触发缺页异常,随后由内核负责将文件对应的数据页加载到 page cache 中,并建立虚拟地址到物理页之间的映射关系。之后,用户程序就可以通过普通的内存访问指令读取或修改这段数据。
相比 read/write,mmap 的一个重要优势就在于它减少了一次用户空间和内核空间之间的数据拷贝。
对于普通 read 来说,文件数据并不是直接从磁盘拷贝到用户缓冲区中。用户程序调用 read(fd, buf, size) 时,首先会通过系统调用进入内核态。内核会根据传入的文件描述符 fd,在当前进程的文件描述符表中找到对应的 struct file 对象。struct file 中保存了本次打开文件的上下文信息,其中就包括当前文件偏移量 f_pos,它表示本次读取应该从文件的第几个字节开始。
得到文件偏移量之后,内核会根据页大小计算出本次读取涉及到的文件页号。例如一页大小为 4KB,如果当前文件偏移量为 5000 字节,那么它对应的文件页号就是 5000 / 4096 = 1,页内偏移则是 5000 % 4096 = 904。也就是说,当前读取操作需要访问该文件的第 1 个文件页,并且从这一页内部的第 904 字节开始读取。
接着,内核会根据当前文件以及计算出的文件页号,去该文件对应的 page cache 管理结构中查找目标页。这个管理结构通常由 inode 关联的 address_space 维护,可以理解为"当前文件的 page cache 索引结构",它记录了该文件哪些页已经被加载到了内存中。
如果目标文件页已经存在于 page cache 中,说明这部分文件数据已经在内存里了,内核可以直接从对应的 page cache 页中读取数据,然后通过 copy_to_user 将数据拷贝到用户提供的缓冲区 buf 中。此时不会发生真正的磁盘 I/O,但是仍然存在一次从内核 page cache 到用户缓冲区的数据拷贝。
如果目标文件页不在 page cache 中,说明这部分数据尚未被加载到内存。此时内核才会根据 inode 中记录的文件数据块索引信息,找到该文件页在磁盘上的对应数据块位置,然后向块设备层发起磁盘 I/O,将对应的数据从磁盘读取到内存中的 page cache。数据进入 page cache 之后,内核再将其拷贝到用户缓冲区中,最终返回给用户程序。
因此,普通 read 的完整数据读取过程可以理解为:
text
read(fd, buf, size)
|
v
根据 fd 找到 struct file
|
v
读取 file->f_pos,得到当前文件偏移量
|
v
根据文件偏移量计算文件页号 page index
|
v
根据当前文件的 address_space
在 page cache 中查找目标文件页
|
+----------------------+
| |
v v
page cache 命中 page cache 未命中
| |
v v
从 page cache 页中 根据 inode 中的数据块索引
读取文件数据 找到磁盘上的数据块
| |
v v
copy_to_user 从磁盘读取数据到 page cache
| |
v v
用户缓冲区 buf 再 copy_to_user 到用户缓冲区 buf
也就是说,普通 read 即使命中 page cache,也仍然需要将数据从内核管理的缓存页拷贝到用户缓冲区;如果没有命中 page cache,则还需要先发生磁盘 I/O,将文件数据加载到 page cache 中,再完成这次拷贝。
对于普通 write 来说,过程则是反过来的。用户程序先将数据写入用户缓冲区,然后通过 write 系统调用进入内核,内核再将用户缓冲区中的数据拷贝到 page cache 中,并将对应缓存页标记为脏页,后续再由内核在合适的时机将脏页刷回磁盘。因此,普通 write 的数据流可以理解为:
text
用户缓冲区 buf 用户空间
↓ copy_from_user
page cache 内核空间
↓ 后续刷盘
磁盘文件
而 mmap 的方式不同。mmap 会将文件的某个区间映射到进程的虚拟地址空间中。当映射建立后,用户进程中的某段虚拟地址就可以直接映射到文件对应的 page cache 页上。这样一来,用户程序访问这段虚拟地址时,本质上就是通过页表映射访问文件对应的缓存页,而不需要再额外准备一个用户缓冲区,也不需要像 read 那样将数据从 page cache 再拷贝一份到用户缓冲区。
mmap 的数据访问方式可以理解为:
text
磁盘文件
↓
page cache
↑
│ 页表映射
│
用户虚拟地址
因此,在目标文件页已经加载到内存并且页表映射已经建立的情况下,用户程序可以像访问普通内存一样访问文件内容。例如:
c
char c = p[0];
从这个角度来看,mmap 和我们之前学习过的共享内存机制有一定相似之处。它们都是通过虚拟地址映射的方式,让进程可以直接访问某一段内存区域。只不过在文件映射场景下,mmap 映射的是文件的某一段内容;而共享内存映射的对象,则是专门用于进程间通信的一段内存区域。
mmap 参数解析:为什么 offset 必须页对齐?
mmap 的函数原型如下:
c
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);
其中,addr 表示期望映射到的虚拟地址起始位置。通常我们会传入 NULL,表示让内核自动选择一个合适的虚拟地址。
length 表示需要映射的长度,也就是将文件中多大范围的数据映射到进程地址空间中。
prot 表示映射区域的访问权限,例如 PROT_READ 表示可读,PROT_WRITE 表示可写,PROT_EXEC 表示可执行,PROT_NONE 表示不可访问。
flags 表示映射方式,常见的有 MAP_SHARED 和 MAP_PRIVATE。其中,MAP_SHARED 表示共享映射,对映射区的修改可以被其他映射同一文件区域的进程看到,并且可以回写到文件中;MAP_PRIVATE 表示私有映射,通常采用写时拷贝机制,对映射区的修改不会直接回写到原文件。
fd 表示要映射的文件描述符。通常在调用 mmap 之前,需要先通过 open 打开目标文件,得到对应的文件描述符。
这里需要重点注意的是,mmap 的最后一个参数 offset 表示的是文件映射的起始偏移量,而不是页内偏移。也就是说,它表示从文件的哪个字节位置开始建立映射关系。
由于 mmap 底层依赖的是页表映射机制,而页表映射的基本单位是页。以 32 位机器、4KB 页面大小、两级页表为例,一个虚拟地址长度为 32 位,通常可以被拆分为三部分:高 10 位作为页目录索引,中间 10 位作为页表索引,低 12 位作为页内偏移。
text
32 位虚拟地址:
+------------------+------------------+----------------+
| 页目录索引 10 位 | 页表索引 10 位 | 页内偏移 12 位 |
+------------------+------------------+----------------+
其中,页目录本身也是一个 4KB 的内存页。由于每个页目录项占 4 字节,因此一个页目录页中可以存放 4096 / 4 = 1024 个页目录项,正好可以通过虚拟地址的高 10 位进行索引。页目录项中记录的是下一级页表的地址。找到对应的二级页表后,再使用虚拟地址中间的 10 位作为页表索引,定位到具体的页表项。页表项中记录的是目标物理页框的地址,而虚拟地址最低 12 位则表示该页内部的偏移量。
整个转换过程可以理解为:
text
虚拟地址
|
| 高 10 位
v
页目录索引
|
v
找到对应的页目录项
|
v
得到二级页表地址
|
| 中间 10 位
v
页表索引
|
v
找到对应的页表项
|
v
得到物理页框地址
|
| 加上低 12 位页内偏移
v
最终物理地址
在建立映射关系时,一个页表项只能把某个虚拟页映射到某个物理页框,而物理页框本身必须是页对齐的起始地址。由于页内偏移在地址转换过程中会原样保留,所以如果 mmap 的文件 offset 不是页对齐的,就相当于希望一个虚拟页的起始位置,对应到文件页内部的某个字节位置。但页表项只能表达"虚拟页号 → 物理页框起始地址"的映射关系,不能表达"虚拟页号 → 物理页框内部某个偏移位置"的映射关系。
当我们调用:
c
mmap(NULL, 4096, PROT_READ, MAP_PRIVATE, fd, 4096);
这里的 offset = 4096,表示从文件偏移 4096 字节的位置开始映射,也就是从文件的第 1 个页开始映射。由于 4096 正好是页大小的整数倍,所以这个映射起点是合法的。
但如果传入:
c
mmap(NULL, 4096, PROT_READ, MAP_PRIVATE, fd, 100);
这里的 offset = 100 就不是页对齐的偏移量。因为这相当于希望虚拟页的第 0 字节直接对应文件第 100 字节的位置,而普通页表映射无法表达这种"从文件页内部某个字节开始映射"的关系。因此,mmap 要求映射起点必须从文件页边界开始。
mmap 底层流程:VMA 建立、缺页异常与按需加载
根据上文的分析,我们已经认识了 mmap 的基本原理:它会将文件中的某个区间映射到进程的虚拟地址空间中,并返回这段映射区域的起始虚拟地址。之后,用户程序就可以通过指针访问这段虚拟地址区域,从而间接访问文件内容。
不过这里需要注意的是,mmap 并不是让程序完全绕过内核。mmap() 本身就是一个系统调用,建立映射关系时仍然需要进入内核。只是在映射建立之后,对于已经加载到内存并且已经建立好页表映射的文件页,用户程序不需要每次都显式调用 read/write 系统调用,从而不需要再将数据从内核 page cache 拷贝到用户缓冲区,而是可以通过普通的内存访问指令直接访问对应的数据页。
接下来,我们再进一步看一下 mmap 背后的底层行为。
对于一个进程来说,内核会使用 task_struct 描述进程或线程的基本信息,而其中会关联到一个 mm_struct 结构体,用来描述该进程的虚拟地址空间。mm_struct 中会管理多个虚拟内存区域,每一个虚拟内存区域通常由一个 VMA 结构描述。VMA 可以理解为进程地址空间中的一段连续虚拟地址区间,它会记录这段区间的起始地址、结束地址、访问权限、映射方式以及是否关联某个文件等信息。
cpp
task_struct
|
| mm
v
mm_struct
|
| 管理多个 VMA
v
+----------------------+----------------------+----------------------+
| vm_area_struct | vm_area_struct | vm_area_struct |
| 代码段 VMA | 堆区 VMA | mmap 文件映射 VMA |
+----------------------+----------------------+----------------------+
| vm_start / vm_end | vm_start / vm_end | vm_start / vm_end |
| vm_flags: r-x | vm_flags: rw- | vm_flags: r--/rw- |
| vm_file: 可执行文件 | vm_file: NULL | vm_file: 被映射文件 |
| vm_pgoff: 文件页偏移 | vm_pgoff: 无 | vm_pgoff: offset/页大小 |
+----------------------+----------------------+----------------------+
当程序调用 mmap 时,内核的主要工作并不是立刻把文件内容全部读入内存,也不是立即为整个映射区建立完整的页表项,而是在进程的虚拟地址空间中创建一段新的 VMA。这个 VMA 会记录:这段虚拟地址范围对应文件的哪个区间、映射权限是什么、映射方式是 MAP_SHARED 还是 MAP_PRIVATE、关联的是哪个文件对象以及文件中的起始偏移量是多少。随后,mmap 返回这段虚拟地址区间的起始地址。
也就是说,mmap 调用完成后,更多只是建立了"虚拟地址区间和文件区间之间的映射关系"。真正的数据加载和页表项建立,通常会延迟到用户程序第一次访问这段虚拟地址时才发生。
当程序通过返回的虚拟地址访问映射区中的数据时,CPU 会执行普通的内存寻址指令,并借助 MMU 将虚拟地址转换为物理地址。MMU 会先查询 TLB,也就是页表项缓存;如果 TLB 命中,就可以直接得到对应的物理地址。如果 TLB 未命中,MMU 会继续查询内存中的页表。
如果此时发现对应的页表项尚未建立,或者该虚拟地址对应的文件页还没有加载到内存中,就会触发缺页异常。缺页异常发生后,CPU 会切换到内核态,由内核接管处理。内核会根据发生缺页的虚拟地址,在当前进程的地址空间中找到对应的 VMA,并确认这个地址是否属于合法的映射区域。如果该地址确实落在 mmap 创建的 VMA 中,内核就可以根据 VMA 中记录的文件信息和文件偏移量,计算出当前虚拟地址对应的是文件中的哪一个页。
接下来,内核会去 page cache 中查找这个文件页是否已经被加载到内存。如果目标文件页已经存在于 page cache 中,内核就可以直接拿到对应的物理页;如果 page cache 未命中,说明该文件页还没有被加载到内存,此时内核才会根据 inode 中记录的数据块索引,从磁盘读取对应的数据页,并将其放入 page cache 中。
当目标文件页准备好之后,内核会为当前进程建立对应的页表映射,使这段用户虚拟地址能够映射到文件在 page cache 中对应的物理页。页表映射建立完成后,缺页异常处理结束,CPU 回到用户态,原来的内存访问指令会继续执行。此后,如果再次访问同一个已经映射好的文件页,就可以直接通过 TLB 或页表完成地址转换,不需要每次都重新触发 read/write 系统调用。
整个过程可以概括为:
text
mmap(fd, length, offset)
|
v
进入内核,创建一段 VMA
|
v
VMA 记录:
虚拟地址范围
访问权限
映射方式
关联文件
文件映射起始 offset
|
v
mmap 返回映射区起始虚拟地址
|
v
用户程序访问该虚拟地址
|
v
CPU / MMU 进行地址转换
|
v
发现页表项尚未建立或文件页尚未加载
|
v
触发缺页异常,进入内核
|
v
内核根据虚拟地址找到对应 VMA
|
v
根据 VMA 计算对应的文件页
|
v
查找 page cache
|
+----------------------+
| |
v v
page cache 命中 page cache 未命中
| |
v v
拿到对应物理页 从磁盘读取文件页到 page cache
| |
+----------+-----------+
|
v
建立页表映射
|
v
回到用户态继续执行
因此,mmap 的底层流程可以理解为:先通过系统调用在进程地址空间中建立一段 VMA,用来记录虚拟地址区间和文件区间之间的映射关系;随后,当用户程序真正访问这段虚拟地址时,再通过缺页异常按需加载文件页,并建立虚拟地址到物理页之间的页表映射。这样一来,文件内容就可以通过普通内存访问的方式被访问,而不需要每次都显式调用 read/write。
mmap 文件映射实战:从 VMA 定位到写入同步与资源释放
mmap 映射定位原理:open、VMA offset 与 file->f_pos 的区别
根据上文的分析,我们已经认识了 mmap 的基本原理以及底层机制。接下来,我们便结合 mmap 的原理,自己动手实现一个简单示例:将文件的某个区间映射到进程地址空间中,然后通过返回的虚拟地址直接读写文件内容。
在调用 mmap 之前,首先需要通过 open 系统调用打开目标文件。这里需要注意,open 并不会把文件中的数据页直接加载到内存中。对于文件数据页来说,它们通常是在后续真正访问文件内容时,才通过 read/write 路径或者 mmap 缺页异常路径按需加载到 page cache 中。
open 的核心作用是根据传入的路径字符串找到目标文件。内核会进行路径解析,通过 dentry 找到文件对应的 inode。如果目标 inode 尚未加载到内存中,内核会从磁盘读取 inode 元数据,并构造内存中的 inode 对象。随后,内核会创建一个 struct file 对象,用来描述本次打开文件的上下文信息,例如打开方式、文件操作函数表、当前文件偏移量以及关联的 dentry 和 inode 等。最后,内核会在当前进程的文件描述符表中分配一个未使用的下标,使该下标指向新创建的 struct file 对象,并将这个下标作为文件描述符返回给用户程序。
之所以在调用 mmap 之前需要先调用 open,本质上是因为 mmap 需要通过文件描述符找到目标文件对应的 struct file 对象,从而知道要映射的是哪个文件。需要特别注意的是,普通 read/write 会依赖 struct file 中的当前文件偏移量 f_pos,而 mmap 并不是通过 file->f_pos 来确定映射起点。mmap 系统调用本身就提供了 offset 参数,用来表示从文件的哪个偏移位置开始映射。
当调用 mmap 时,内核会根据传入的文件描述符 fd 找到对应的 struct file 对象,从而确定当前要映射的是哪个文件。随后,内核会在进程的虚拟地址空间中创建一段 VMA,用来描述这段文件映射区域。这个 VMA 会记录映射区的虚拟地址范围、访问权限、映射方式、关联的文件对象,以及文件映射的起始偏移量。
这里需要注意,VMA 中记录的文件 offset 本质上也是文件偏移量,但它和 struct file 中的 f_pos 不是同一个概念。file->f_pos 表示当前这次打开文件的读写位置,主要用于普通 read/write 操作,并且会随着 read/write 的执行自动向后推进。而 mmap 中的 offset 表示这段映射区域对应文件的起始位置,它是建立映射关系时确定下来的固定映射起点,不会因为用户访问映射区而自动变化。
例如,普通 read(fd, buf, size) 并没有显式指定从文件哪里开始读取,因此内核需要通过 file->f_pos 找到当前读取位置。而 mmap 在调用时已经通过 offset 参数明确指定了文件映射的起始位置,内核会将这个起始偏移记录到 VMA 中。后续访问映射区时,内核会根据访问的虚拟地址和 VMA 中记录的文件 offset,计算出当前访问的是文件中的哪一个位置。
具体来说,如果 mmap 返回的映射起始地址为 map_start,VMA 中记录的文件映射起始偏移为 offset,那么当程序访问映射区中的某个地址 addr 时,它对应的文件偏移可以理解为:
text
文件偏移 = offset + (addr - map_start)
而VMA 中记录的 offset 表示:这段映射区域从文件的哪个位置开始对应。之后程序访问映射区中的某个地址时,内核只需要计算这个地址距离映射起始地址有多远,再把这个距离加到 offset 上,就能得到实际访问的文件位置。
text
假设 mmap 从文件偏移 4096 字节处开始映射,并返回虚拟地址 map_start。
mmap offset = 4096
实际文件偏移 = mmap offset + (访问地址 - map_start)
示例 1:
访问地址 = map_start + 0
实际文件偏移
= 4096 + ((map_start + 0) - map_start)
= 4096 + 0
= 4096
所以:
map_start + 0 -> 文件偏移 4096
示例 2:
访问地址 = map_start + 100
实际文件偏移
= 4096 + ((map_start + 100) - map_start)
= 4096 + 100
= 4196
所以:
map_start + 100 -> 文件偏移 4196
示例 3:
访问地址 = map_start + 4096
实际文件偏移
= 4096 + ((map_start + 4096) - map_start)
= 4096 + 4096
= 8192
所以:
map_start + 4096 -> 文件偏移 8192
当程序访问映射区中的某个虚拟地址时,如果对应的页表项尚未建立,就会触发缺页异常。内核会根据缺页地址找到对应的 VMA,再结合 VMA 中记录的文件 offset,计算出当前访问的是文件中的哪一个页。随后,内核会去 page cache 中查找该文件页;如果命中,说明目标文件页已经在内存中,内核可以直接建立虚拟地址到对应物理页的页表映射;如果未命中,则需要先根据 inode 中记录的数据块索引,从磁盘读取对应文件页到 page cache,然后再建立页表映射。
因此可以总结为:普通 read/write 依赖 file->f_pos 这个动态读写游标来定位文件位置;而 mmap 依赖的是 VMA 中记录的文件映射起始 offset,再结合具体访问的虚拟地址来计算目标文件页。二者都和文件偏移有关,但使用场景和语义并不相同。
mmap 写文件前为什么需要 ftruncate:从 i_size 理解文件有效范围
所以,mmap 的整体流程可以理解为:open 负责打开文件并返回文件描述符,mmap 通过文件描述符找到文件对象,并创建 VMA 记录"虚拟地址区间"和"文件区间"之间的映射关系。真正的文件数据加载和页表建立,通常会延迟到用户程序第一次访问映射区时,通过缺页异常按需完成。
还需要注意的是,使用 mmap 写文件时,修改的内容是否能够正常回写到文件,关键取决于修改位置是否落在文件的有效长度 i_size 范围内。i_size 表示文件当前逻辑上的有效字节范围,只有位于 [0, i_size) 区间内的字节,才真正属于这个文件的有效数据。
如果通过 mmap 修改的是 i_size 范围内的字节,那么这些修改本质上是在修改文件已有的有效内容。对应的文件页会位于 page cache 中,在 MAP_SHARED 映射下,被修改的缓存页可以被标记为脏页,后续由内核同步回文件。
但是,如果修改的是文件 EOF 之后的区域,也就是超出 i_size 的部分,那么这部分内容并不属于文件当前的有效数据范围。即使这部分虚拟地址位于 mmap 返回的映射区中,也不能把它理解为真正的文件内容。对 EOF 之后区域的修改不会作为有效文件数据被正常回写到磁盘。
举个例子,如果当前文件大小只有 10 字节,但映射长度是 4096 字节,那么:
text
文件有效范围:
[0, 10)
mmap 映射范围:
[0, 4096)
此时,映射区中的前 10 个字节属于文件有效内容。修改这些字节,是在修改文件已有内容,对应的 page cache 页可以被标记为脏页,并最终同步回文件。但 [10, 4096) 这部分位于 EOF 之后,不属于文件有效内容。即使通过虚拟地址修改了这部分区域,也不能将其视为对文件有效内容的修改。
因此,在使用 mmap 写文件之前,需要先保证文件的有效长度 i_size 覆盖后续要映射和写入的范围。如果希望写入 [offset, offset + length) 这个文件区间,那么文件大小至少应该满足:
text
i_size >= offset + length
在示例代码中,由于使用 O_TRUNC 打开文件,文件会先被截断为 0 字节。此时文件没有任何有效内容,所以需要先调用 ftruncate 调整文件的 i_size,让文件有效范围覆盖后续要映射和写入的数据区间。这样,后续通过映射区写入的数据,才会落在文件的有效内容范围内,并在 MAP_SHARED 映射下被作为文件内容同步回去。
这里的 ftruncate 是一个系统调用,它的作用是根据文件描述符调整文件的大小。函数原型如下:
cpp
int ftruncate(int fd, off_t length);
其中,fd 表示已经打开的文件描述符,length 表示希望将文件调整到的目标大小。调用成功时返回 0,失败时返回 -1。
从底层语义来看,ftruncate 修改的是文件 inode 中记录的有效文件长度,也就是 i_size。如果当前文件大小小于 length,那么 ftruncate 会将文件扩展到 length 字节。扩展出来的区域在逻辑上属于文件的有效范围,读取时表现为 0。不过需要注意,这并不一定意味着文件系统会立刻为这些扩展区域分配真实磁盘块;很多文件系统可能会先形成空洞,等后续真正写入这些区域时,再分配对应的数据块。
如果当前文件大小大于 length,那么 ftruncate 会将文件截断到 length 字节,超出部分不再属于文件的有效内容。也就是说,ftruncate 既可以扩展文件,也可以缩小文件,本质上是在调整文件的 i_size。
在 mmap 写文件的场景下,我们通常会在调用 mmap 之前先调用 ftruncate。它的目的,是提前调整文件的有效大小 i_size,使文件大小至少包含后续要映射的文件区间。
例如,如果我们希望从文件偏移 0 开始映射 data_size 字节,那么映射的文件区间就是:
text
[0, data_size)
因此,在调用 mmap 之前,需要先通过:
cpp
ftruncate(fd, data_size);
将文件的有效大小调整到至少 data_size 字节。这样后续通过 mmap 访问和修改这段区域时,访问的位置才落在文件的有效内容范围内。
这样,后续调用:
cpp
mmap(NULL, data_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
时,映射区间 [0, data_size) 就已经落在文件的有效长度范围内。之后通过映射区写入数据时,修改的就是文件有效范围内对应的 page cache 页,在 MAP_SHARED 映射下,这些修改才可以作为文件内容被同步回去。
因此,ftruncate 在这里并不是用来建立映射关系的,它解决的是文件有效长度问题;mmap 负责建立虚拟地址区间和文件区间之间的映射关系。两者配合起来,才能保证我们通过虚拟地址写入的数据真正落在文件的有效内容范围内。
通过 mmap 映射区写入文件
当 mmap 调用成功后,内核会返回这段映射区域的起始虚拟地址。由于这段文件区间已经被映射到了进程的虚拟地址空间中,因此用户程序可以像访问普通内存一样访问这段区域,例如把它当作一个字符数组来读写。
需要注意的是,mmap 返回的是一段连续的虚拟地址区间,而不代表底层物理内存一定连续。程序第一次访问映射区中的某些地址时,如果对应的文件页尚未加载到内存,仍然可能触发缺页异常,由内核负责将文件页加载到 page cache 中,并建立虚拟地址到物理页之间的映射关系。
在代码中,我们可以直接调用 memcpy 将字符串写入这段映射区:
cpp
memcpy(map_ptr, data, data_size);
从用户程序的角度来看,这就是一次普通的内存拷贝;但从底层来看,如果使用的是 MAP_SHARED 映射,这些写入会作用到文件对应的 page cache 页上,并将对应页标记为脏页。之后可以由内核在合适的时机写回文件,也可以通过 msync 主动要求内核将修改同步回文件。
mmap 资源管理:msync、munmap 与 close 的职责划分
在使用 MAP_SHARED 方式建立文件映射时,如果程序通过映射区修改了文件内容,这些修改会作用到文件对应的 page cache 页上,并将对应的缓存页标记为脏页。脏页后续会由内核在合适的时机同步回文件,但这个写回时机并不一定是立即发生的。
如果希望主动要求内核将映射区中的修改同步回文件,可以调用 msync 系统调用。msync 的函数原型如下:
cpp
int msync(void* addr, size_t length, int flags);
其中,addr 表示需要同步的映射区起始地址,通常就是 mmap 返回的地址;length 表示需要同步的长度;flags 表示同步方式,常见取值有 MS_SYNC 和 MS_ASYNC。
MS_SYNC 表示同步写回,也可以理解为一种阻塞式同步方式。当程序调用:
cpp
msync(map_ptr, data_size, MS_SYNC);
时,进程会进入内核态,内核会查找这段映射区中已经被修改的脏页,并将这些脏页写回到对应磁盘文件中。只有当写回操作完成之后,msync 才会返回到用户态。因此,MS_SYNC 的特点是更加稳妥,但调用期间进程可能会被阻塞,性能开销也相对更高。
MS_ASYNC 表示异步写回。它的含义是通知内核这段映射区中的修改需要被同步回文件,但调用者不会等待写回完成。也就是说,MS_ASYNC 更像是发起一个写回请求,至于具体什么时候完成,则由内核后续调度决定。因此,MS_ASYNC 返回更快,但不能保证函数返回时数据已经同步完成。
text
MS_SYNC :写回并等待完成,调用期间可能阻塞
MS_ASYNC :发起写回请求,但不等待完成
需要注意的是,这里的"同步"指的是将内存中被修改过的 page cache 脏页写回到对应的磁盘文件中,而不是用户态和内核态之间的数据同步。对于 mmap 来说,用户程序修改映射区后,数据首先体现在 page cache 中;msync 的作用就是主动要求内核将这部分被修改的 page cache 页写回到文件中。
同时,msync 主要用于 MAP_SHARED 映射场景。因为 MAP_SHARED 表示对映射区的修改可以回写到底层文件。而如果使用的是 MAP_PRIVATE,写入映射区时通常会触发写时拷贝,修改的是当前进程的私有副本,一般不会回写到原文件中,因此此时调用 msync 并不能把私有修改同步回原文件。
在实际代码中,我们通常还需要检查 msync 的返回值:
cpp
if (msync(map_ptr, data_size, MS_SYNC) < 0)
{
perror("msync");
}
使用完映射区之后,还需要调用 munmap 解除映射关系。munmap 的函数原型如下:
cpp
int munmap(void* addr, size_t length);
其中,addr 必须是之前 mmap 返回的映射起始地址,length 通常应该和当初 mmap 的映射长度保持一致。调用成功时返回 0,失败时返回 -1,因此实际使用时也需要检查返回值。
cpp
if (munmap(map_ptr, data_size) < 0)
{
perror("munmap");
}
munmap 会将这段虚拟地址区间从当前进程地址空间中移除。底层来看,内核会清理或调整 mm_struct 中对应的 VMA,并释放相关的页表映射关系。调用 munmap 之后,程序就不能再继续访问这段虚拟地址,否则可能会产生非法内存访问。
此外,msync 和 munmap 的职责不同。msync 负责主动同步映射区中的修改,而 munmap 负责解除虚拟地址映射关系。也就是说,msync 不是解除映射,munmap 也不应该简单理解为专门的刷盘函数。
最后,还需要调用 close 关闭文件描述符,避免文件描述符泄漏:
cpp
close(fd);
因此,整个资源清理流程可以理解为:
text
写入 mmap 映射区
|
v
msync 主动同步修改到文件
|
v
munmap 解除虚拟地址映射关系
|
v
close 关闭文件描述符
所以,msync、munmap 和 close 分别负责不同层面的资源管理:msync 负责将修改过的脏页同步回文件,munmap 负责解除虚拟地址映射关系,close 负责关闭文件描述符。
源码
在这个示例中,我们通过命令行参数向程序传递要操作的文件路径。由于程序运行在 Linux 平台下,因此通常会在终端中输入命令来启动程序。当用户在终端中输入一行命令后,bash 会读取这行字符串,并对其进行解析,将命令本身以及后续参数拆分出来。
如果当前命令不是 shell 内置命令,bash 通常会创建子进程,并在子进程中通过 exec 系列接口进行程序替换。程序启动之后,命令行中的参数会以 argc 和 argv 的形式传递给 main 函数。其中,argc 表示命令行参数的个数,argv 则是一个字符指针数组,用来保存程序名以及各个参数字符串。
例如,我们通过下面的方式运行程序:
bash
./mmap_test log.txt
此时参数关系可以理解为:
text
argv[0] = "./mmap_test"
argv[1] = "log.txt"
argc = 2
其中,argv[0] 保存的是程序自身的启动路径,argv[1] 才是用户传入的文件路径。因此,在代码中需要判断 argc 是否等于 2。如果 argc != 2,说明用户没有按照要求传入文件路径,此时程序可以打印用法提示并直接退出。
对应代码如下:
cpp
if (argc != 2)
{
std::cout << "Usage: " << argv[0] << " <file_path>" << std::endl;
return -1;
}
这样一来,后续程序就可以通过 argv[1] 获取用户传入的文件路径,并将其传递给 open 系统调用,用来打开目标文件。
cpp
#include <iostream>
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
#include <cstring>
int main(int argc, char* argv[])
{
if (argc != 2)
{
std::cout << "Usage: " << argv[0] << " <file_path>" << std::endl;
return -1;
}
// 1. 打开文件
// O_RDWR :以读写方式打开,因为后续 mmap 需要 PROT_WRITE
// O_CREAT :文件不存在则创建
// O_TRUNC :如果文件存在,则先清空文件内容
int fd = open(argv[1], O_RDWR | O_CREAT | O_TRUNC, 0666);
if (fd < 0)
{
perror("open");
return -1;
}
const char* data = "Hello, mmap!";
size_t data_size = strlen(data) + 1;
// 2. 调整文件大小
// mmap 映射文件前,需要保证文件大小至少覆盖映射区间
if (ftruncate(fd, data_size) < 0)
{
perror("ftruncate");
close(fd);
return -1;
}
// 3. 建立文件映射
// addr = nullptr:让内核自动选择映射区起始虚拟地址
// length = data_size:映射长度
// prot = PROT_READ | PROT_WRITE:映射区可读可写
// flags = MAP_SHARED:共享映射,对映射区的修改可以回写到文件
// fd = fd:被映射文件的文件描述符
// offset = 0:从文件起始位置开始映射
void* map_ptr = mmap(nullptr,
data_size,
PROT_READ | PROT_WRITE,
MAP_SHARED,
fd,
0);
if (map_ptr == MAP_FAILED)
{
perror("mmap");
close(fd);
return -1;
}
// 4. 像访问普通内存一样写入映射区
memcpy(map_ptr, data, data_size);
// 可选:主动将修改同步到文件
// MAP_SHARED 表示修改可以回写文件,但具体刷盘时机由内核决定;
// 如果希望主动同步,可以调用 msync。
if (msync(map_ptr, data_size, MS_SYNC) < 0)
{
perror("msync");
}
std::cout << "Data written by mmap: "
<< static_cast<char*>(map_ptr) << std::endl;
// 5. 解除映射关系
if (munmap(map_ptr, data_size) < 0)
{
perror("munmap");
}
// 6. 关闭文件描述符
close(fd);
return 0;
}
这个程序的核心流程可以概括为:
text
open 打开文件,得到 fd
|
v
ftruncate 调整文件大小
|
v
mmap 建立文件区间到进程虚拟地址空间的映射
|
v
通过返回的虚拟地址直接写入数据
|
v
msync 主动同步修改到文件
|
v
munmap 解除映射
|
v
close 关闭文件描述符
需要注意的是,munmap 的作用是解除虚拟地址区间和文件映射之间的关系,它本身不应该被简单理解为"刷盘函数"。如果使用 MAP_SHARED 映射,对映射区的修改可以回写到文件,但具体什么时候写回磁盘通常由内核决定。如果希望主动要求内核将修改同步到文件,可以调用 msync。
运行截图:

mmap 匿名映射:不依赖文件的虚拟内存申请方式
根据上文的分析,我们已经认识了 mmap 系统调用的基本原理。mmap 不仅可以将文件中的某个区间映射到进程的虚拟地址空间中,也可以创建一段不依赖具体文件的匿名映射区域。
所谓匿名映射,就是这段映射区域不再对应磁盘上的某个文件区间。对于文件映射来说,mmap 建立的是"进程虚拟地址区间"和"文件某个区间"之间的映射关系;而对于匿名映射来说,mmap 建立的是一段普通的虚拟内存区域,这段区域没有文件作为后备存储。
调用匿名映射时,内核同样会在进程地址空间中创建一段 VMA,用来描述这段连续的虚拟地址区间。这个 VMA 会记录映射区域的起始地址、结束地址、访问权限、映射方式等信息。不同的是,匿名映射不关联具体文件,因此它不需要通过 fd 找到某个文件对象,也不需要根据文件 offset 去定位文件页。
需要注意的是,匿名映射并不意味着 mmap 调用时一定会立刻分配真实物理页。和文件映射类似,匿名映射通常也是按需分配的。也就是说,mmap 调用成功后,内核主要是先建立一段合法的虚拟地址区域;当程序第一次访问这段区域中的某个地址时,如果对应页表项尚未建立,就会触发缺页异常。随后,内核会为该虚拟页分配物理页,并建立虚拟地址到物理页之间的页表映射关系。
由于匿名映射不关联具体文件,因此映射区刚创建出来时没有来自文件的原始数据。程序第一次读取这段区域时,看到的内容通常都是 0;后续写入什么内容,这段内存中就保存什么内容。
因此,匿名映射可以理解为:通过 mmap 向内核申请一段不依赖文件的虚拟地址空间。至于这段虚拟地址最终映射到哪些物理页,则由内核在程序实际访问时按需分配和管理。
如果希望通过 mmap 创建匿名映射,需要在 flags 参数中指定 MAP_ANONYMOUS。由于匿名映射不关联具体文件,因此 fd 参数通常传入 -1,offset 参数传入 0。
示例调用如下:
cpp
void* ptr = mmap(nullptr,
length,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS,
-1,
0);
其中,nullptr 表示让内核自动选择映射区的起始虚拟地址;length 表示申请的映射长度;PROT_READ | PROT_WRITE 表示这段区域可读可写;MAP_PRIVATE | MAP_ANONYMOUS 表示创建一段私有匿名映射;fd = -1 和 offset = 0 则表示该映射不依赖任何文件。
在使用匿名映射时,MAP_ANONYMOUS 只表示这段映射区域不关联具体文件,但它并不决定这段映射区域是私有的还是共享的。真正决定映射区域修改是否共享的,是 MAP_PRIVATE 和 MAP_SHARED。
如果使用:
cpp
MAP_PRIVATE | MAP_ANONYMOUS
那么创建的是一段私有匿名映射。这种映射区域不关联任何文件,通常可以理解为当前进程申请到的一段私有内存空间。当前进程可以像访问普通内存一样读写这段区域。由于它是私有映射,所以如果后续发生 fork,父子进程虽然一开始可以看到相同的内容,但当其中某个进程写入这段区域时,通常会触发写时拷贝,最终父子进程对这段内存的修改互不影响。
而如果使用:
cpp
MAP_SHARED | MAP_ANONYMOUS
那么创建的是一段共享匿名映射。这种映射同样不关联具体文件,但是它可以在具有亲缘关系的进程之间共享。例如在 fork 之后,父进程和子进程可以共同访问这段匿名映射区域。此时,一个进程对这段内存的修改,另一个进程也可以看到。
因此,二者的区别可以简单理解为:
text
MAP_PRIVATE | MAP_ANONYMOUS:
私有匿名映射
不关联文件
更像当前进程私有的一段内存
fork 后写入通常触发写时拷贝,父子进程修改互不影响
MAP_SHARED | MAP_ANONYMOUS:
共享匿名映射
不关联文件
fork 后父子进程可以共享这段内存
一个进程修改,另一个进程可以看到
所以,MAP_ANONYMOUS 解决的是"是否关联文件"的问题,而 MAP_PRIVATE / MAP_SHARED 解决的是"这段映射区域中的修改是否对其他进程可见"的问题。如果只是当前进程自己申请一段内存使用,通常使用 MAP_PRIVATE | MAP_ANONYMOUS;如果希望 fork 之后父子进程共享这段内存,则可以使用 MAP_SHARED | MAP_ANONYMOUS。
可以将文件映射和匿名映射对比如下:
text
文件映射:
虚拟地址区间 <----> 文件中的某个区间
fd = 文件描述符
offset = 文件偏移量
匿名映射:
虚拟地址区间 <----> 内核按需分配的匿名物理页
fd = -1
offset = 0
所以,文件映射关注的是"如何通过虚拟地址访问文件内容",而匿名映射关注的是"如何通过 mmap 申请一段由内核管理的虚拟内存区域"。
glibc malloc 内存管理机制:从 brk/mmap 到 tcache、arena 与 bin
从匿名映射到 malloc:理解用户态内存分配与 brk/mmap 的关系
根据上文的分析,我们已经认识了 mmap 匿名映射的基本机制。它可以创建一段不关联具体磁盘文件的虚拟内存区域。当程序访问这段区域时,如果对应的页表项尚未建立,就会触发缺页异常,由内核按需分配物理页,并建立虚拟地址到物理页之间的映射关系。
认识到这一点之后,我们就可以进一步理解 malloc 函数和底层内存申请之间的关系。
对于 malloc 函数,读者应该并不陌生。在 C 语言中,如果我们希望在堆上申请一段空间,通常会调用 malloc。而在 C++ 中,我们更常使用 new 表达式来创建对象。需要注意的是,new 和 malloc 并不完全等价。对于 new T(...) 这样的表达式来说,它通常可以分为两个阶段:第一步是调用 operator new 申请一段原始内存;第二步是在这段内存上调用构造函数,完成对象初始化。
也就是说,malloc 只负责申请一段原始内存,并不会调用构造函数;而 new 不仅要申请内存,还要在这块内存上构造对象。因此可以简单理解为:
text
malloc:
只申请原始内存,不负责对象构造
new:
申请原始内存 + 调用构造函数完成初始化
接下来再看 malloc 本身。malloc 并不是一个系统调用,而是 glibc 在用户态提供的内存分配函数。glibc 是 Linux 系统中常见的一套 C 运行库实现,它不仅实现了 C 标准库中的常用接口,例如 printf 、malloc 、free ,还封装了一些和 Linux 系统调用相关的函数
虽然 malloc 本身运行在用户态,但当它需要向内核申请更多内存空间时,底层通常会涉及到两个重要的系统调用:brk 和 mmap。
其中,brk 系统调用主要用于调整进程传统堆区的结束位置,也就是修改 program break。可以简单理解为:brk 会将堆区的结束地址向后移动,从而扩大进程堆区的虚拟地址范围。需要注意的是,brk 扩大的是进程的虚拟地址空间,并不一定意味着对应的物理页会立刻全部分配。后续程序真正访问这些虚拟地址时,内核仍然可能通过缺页异常按需分配物理页,并建立虚拟地址到物理页之间的页表映射关系。
而 mmap 则可以创建一段新的虚拟内存区域。对于一些较大的内存申请,glibc 可能不会继续通过 brk 扩展传统堆区,而是通过 mmap 创建一段独立的匿名映射区域。这段区域不依赖具体磁盘文件,后续访问时同样由内核按需分配物理页,并建立对应的页表映射。
因此,在先不展开 malloc 内部分配策略的情况下,可以先这样理解:
text
malloc:
glibc 提供的用户态内存分配函数
brk:
用于扩展传统堆区的虚拟地址范围
mmap:
用于创建一段新的独立虚拟内存区域
也就是说,malloc 并不是直接等价于 brk 或 mmap,但当它需要向内核申请更多虚拟地址空间时,底层就可能通过 brk 扩展堆区,或者通过 mmap 创建新的匿名映射区域。至于 malloc 拿到这些内存之后如何管理、切分和复用,则属于后续内存分配器内部实现需要进一步讨论的内容。
malloc 的核心思想:用户态管理与空闲块复用
根据上文的分析,我们已经对 malloc 函数有了一个初步认识。malloc 是 glibc 提供的用户态内存分配函数,它本身并不是系统调用。但是,当 glibc 管理的内存区域不足时,malloc 需要向内核申请更多虚拟地址空间,这时底层就可能涉及到 brk 或 mmap 系统调用。
这里需要注意的是,malloc 的内存申请策略并不是"用户申请多少字节,就立刻向内核申请多少字节"。如果每次调用 malloc(size) 都直接向内核申请 size 字节的空间,那么每一次内存申请都可能伴随一次系统调用,而系统调用本身是有开销的。
系统调用会涉及用户态到内核态的切换,需要保存和恢复必要的执行现场,切换到内核栈执行等。因此,如果频繁的小块内存申请都直接通过系统调用完成,整体性能会受到明显影响。
所以,malloc 更合理的做法是:先向内核申请一片较大的虚拟内存区域,然后由 glibc 在用户态对这片内存进行管理。后续用户再次调用 malloc 申请内存时,很多情况下并不需要再次进入内核,而是由 glibc 在已经管理的内存区域中查找合适的空闲块,将其中一部分切分出来返回给用户。
也就是说,malloc 的核心思路并不是每次都向内核按需申请,而是尽量复用已经申请到的虚拟内存区域。只有当当前已有的内存区域无法满足新的分配需求时,malloc 才会再次通过 brk 或 mmap 向内核申请更多虚拟地址空间。
其中,brk 主要用于扩展传统堆区的虚拟地址范围,而 mmap 则可以创建一段新的独立匿名映射区域。无论是通过 brk 扩展堆区,还是通过 mmap 创建新的映射区域,本质上都是让进程获得更多可用的虚拟地址空间。至于这些虚拟地址最终对应的物理页,通常仍然可以在程序实际访问时,由内核通过缺页异常按需分配并建立页表映射。
因此,可以先把 malloc 的分配流程抽象理解为:
text
用户调用 malloc(size)
|
v
glibc 在自己管理的内存区域中查找合适的空闲块
|
+----------------------+
| |
v v
找到合适空闲块 没有合适空闲块
| |
v v
切分 / 复用该内存块 通过 brk 或 mmap 向内核申请更多虚拟地址空间
| |
+----------+-----------+
|
v
返回用户可用地址
malloc 空闲块管理:从切分复用到按大小分类
根据上文的分析,我们已经对 malloc 函数有了一个初步认识。malloc 是 glibc 提供的用户态内存分配函数,它本身并不是系统调用。只有当 glibc 当前管理的内存区域不足以满足分配需求时,malloc 才可能通过 brk 或 mmap 向内核申请更多虚拟地址空间。
这里需要注意的是,malloc 的分配策略并不是"用户申请多少字节,就立刻向内核申请多少字节"。如果每一次调用 malloc(size) 都直接通过系统调用向内核申请 size 字节空间,那么频繁的小块内存申请就会不断发生用户态和内核态之间的切换,从而带来明显的性能开销。
所以,malloc 更合理的做法是:先向内核申请一片较大的虚拟内存区域,然后由 glibc 在用户态对这片区域进行管理。后续用户再次调用 malloc 申请内存时,很多情况下并不需要再次进入内核,而是由 glibc 在已经管理的内存区域中查找合适的空闲块,将其中一部分切分出来返回给用户。
在最简单的模型中,可以先把这片内存区域理解为一段连续的虚拟地址空间。最初还没有发生释放时,malloc 可以从这片区域的头部开始,按照用户申请的大小不断向后切分。例如用户第一次申请一块内存时,malloc 从当前可用区域中划分出一块空间,并返回这块空间的起始地址;下一次申请时,再继续从剩余区域中划分新的空间。
但是,实际程序中不仅有内存申请,也会有内存释放。当某一块内存使用完毕后,用户会调用 free 将其释放。问题在于,释放顺序通常不会和申请顺序完全一致。例如程序先后申请了三块内存 A、B、C,随后只释放中间的 B,此时 B 这块空闲内存的前后仍然可能是正在使用的内存块。
text
申请顺序:
[A][B][C]
释放 B 后:
[A][空闲块 B][C]
这就意味着,随着程序不断申请和释放内存,空闲块可能会分散在已经使用的内存块之间,而不是全部集中在连续区域的末尾。如果 malloc 仍然只从后面的剩余空间继续分配,那么中间已经释放的空闲块就无法被复用,最终可能导致虚拟地址空间不断被消耗,而实际上内部还有很多已经释放但没有重新利用的空间。
因此,malloc 必须管理这些已经释放的内存块,使后续再次申请内存时,可以优先复用这些空闲块,而不是每次都向后切分新的空间。
既然空闲块可能分散在不同位置,一个自然的想法就是使用链表将这些已经释放的内存块串联起来。这样,当用户再次调用 malloc(size) 时,malloc 就可以在空闲链表中查找一块合适的内存,将其重新分配给用户。
不过,如果把所有不同大小的空闲块都放在同一个链表中,就会带来查找效率问题。因为用户每次申请的内存大小并不固定,而空闲链表中的块大小也各不相同。对于一次新的内存申请,malloc 不能随便返回任意一个空闲块。如果空闲块太小,就无法满足本次申请,强行使用会导致越界访问;如果空闲块远大于用户申请的大小,直接返回又可能造成明显浪费。
因此,malloc 需要找到一块大小足够并且相对合适的空闲块。如果所有空闲块都在同一个链表中,那么每次申请内存时,就可能需要遍历整个链表来寻找合适的块。这个查找过程的代价接近 O(N)。对于频繁申请和释放内存的程序来说,这种线性查找显然会影响性能。
为了解决线性遍历空闲链表带来的效率问题,malloc 不会简单地把所有不同大小的空闲块都挂到同一个链表中。因为这样一来,每次申请内存时,都需要在链表中从头遍历,寻找一个大小足够并且相对合适的空闲块,查找成本较高。
更合理的做法是:先对内存块的大小进行分类。也就是说,malloc 并不是完全按照用户传入的 size 原样分配一块任意大小的内存,而是会根据对齐规则和内部管理需要,将用户申请的大小向上调整到某个合适的大小类别中。
例如,用户申请的可能是 6 字节,但分配器内部不会真的专门维护一个 6 字节大小的空闲块类别,而是可能将其向上调整到 8 字节、16 字节或者其他合适的管理粒度。这样做虽然可能会带来一定的内部碎片,也就是实际分配的空间略大于用户真正需要的空间,但它可以让内存块的管理更加规整。
一旦内存块被划分成不同的大小类别,空闲块的组织方式也就可以从"一个大链表"变成"多个按大小分类的空闲链表"。每一类大小范围的空闲块都挂到对应的链表中。这样,当用户再次调用 malloc(size) 时,分配器可以先根据 size 计算出对应的大小类别,然后直接到对应类别的空闲链表中查找,而不需要在所有空闲块中进行全局遍历。
可以抽象理解为:
text
free_list[0] -> 某一类小块空闲链表
free_list[1] -> 另一类大小的空闲链表
free_list[2] -> 更大一类的空闲链表
...
因此,按大小类别组织空闲块的核心目的,就是降低查找合适空闲块的成本。它用一定程度的内部碎片,换来了更高效的空闲块定位和复用能力。
认识到"按大小类别组织空闲块"这一点之后,我们就可以进一步理解 malloc 查找空闲块的大致流程。
在这种模型下,某一个空闲链表通常负责管理某一类大小的空闲块。也就是说,malloc 会将内存块划分成多个不同的大小类别,例如较小的块、中等大小的块以及更大的块。为了管理这些不同类别的空闲链表,分配器可以维护一个类似"指针数组"的结构。数组中的每个元素都是一个指针,用来保存某一类空闲链表的入口地址,也就是指向该链表的头节点。
可以抽象理解为:
text
free_list[0] -> 某一类大小的空闲块链表
free_list[1] -> 另一类大小的空闲块链表
free_list[2] -> 更大一类的空闲块链表
...
这样,当用户调用 malloc(size) 申请一块内存时,分配器首先不会直接按照 size 原样分配,而是会根据对齐规则和内部管理开销,将这个 size 向上调整成内部更适合管理的块大小。随后,再根据调整后的大小计算它应该落在哪一个大小类别中,并定位到 free_list 数组中对应的下标。
如果对应的空闲链表不为空,说明当前已经存在可以复用的空闲块。此时,malloc 就可以直接从该链表中取出一个空闲块,通常是取出头节点,然后将这块内存返回给用户使用。这个过程不需要遍历所有空闲块,而是通过大小类别快速定位到目标链表,因此查找效率更高。
可以简单理解为:
text
malloc(size)
|
v
将 size 向上调整到合适的内部块大小
|
v
根据块大小定位到对应的 free_list[index]
|
v
判断该空闲链表是否为空
|
v
链表不为空
|
v
取出链表头节点进行复用
|
v
返回用户可用地址
因此,按大小类别组织空闲链表的核心价值就在于:malloc 不需要把所有不同大小的空闲块都放在一个大链表中线性查找,而是可以先根据申请大小定位到对应的空闲链表,再从该链表中快速取出可复用的内存块。这样既降低了查找合适空闲块的成本,也提高了频繁申请和释放内存时的整体效率。
malloc 的 chunk 结构:元数据与空闲链表节点复用
根据上文的分析,我们已经认识了空闲块的组织方式,也知道了 malloc 申请内存时的大致执行路径:首先会将用户申请的 size 向上调整到某个合适的内部块大小,然后根据这个大小定位到对应的空闲链表。如果该链表中存在可复用的空闲块,malloc 就可以取出链表头节点,并将这块内存返回给用户使用。
不过这里还需要注意一点:malloc 管理的内存块并不只是单纯用来存储用户数据。对于 glibc malloc 来说,一个内存块通常被称为一个 chunk,它不仅包含用户真正可用的数据区域,还会在前面保存一部分元数据。glibc 正是依靠这些元数据来记录当前块的大小、相邻块的状态以及后续释放和合并所需的信息。
因此,malloc 返回给用户的地址,并不是整个 chunk 的起始地址,而是跳过元数据之后的用户数据区起始地址。可以抽象理解为:
text
低地址 高地址
| |
v v
+----------------+----------------+-----------------------------+
| prev_size | size + flags | user data |
+----------------+----------------+-----------------------------+
^ ^
| |
chunk 起始地址 malloc 返回给用户的地址
也就是说,当用户调用:
cpp
void* p = malloc(100);
此时 p 指向的是 user data 的起始位置,而不是 chunk 头部
这样设计的好处是,用户只需要关心自己能使用的数据区域,而 glibc 可以在用户不可见的前置区域中保存管理信息。后续当用户调用:
cpp
free(p);
时,glibc 可以根据用户传入的地址 p 向前偏移,重新找到这个 chunk 的头部元数据。这样就可以知道当前块的大小、相邻块的状态,以及是否可以和前后相邻的空闲块进行合并。
在 chunk 头部的元数据中,prev_size 用来记录上一个相邻 chunk 的大小。这个字段主要用于当前一个 chunk 处于空闲状态时,帮助 glibc 向前定位并合并前一个空闲块。
而 size 字段则记录当前 chunk 的大小。由于 chunk 的大小会按照一定的对齐规则进行对齐,所以 size 的低几位天然不会参与表示真实大小。glibc 会复用这些低位来保存一些标志位,例如前一个相邻 chunk 是否正在使用、当前 chunk 是否来自 mmap、当前 chunk 是否属于非主 arena 等。
因此,可以先这样理解 chunk 头部的两个核心字段:
text
prev_size:
记录上一个相邻 chunk 的大小
size:
记录当前 chunk 的大小
同时利用低位保存一些标志位
所以,malloc 管理内存块的关键并不只是"把一段空间返回给用户"。它还需要在每个 chunk 中维护必要的元数据。用户拿到的是数据区地址,而 glibc 通过数据区前面的元数据完成块大小记录、释放管理、相邻块合并以及重新挂入空闲链表等操作。
认识了 chunk 的基本结构之后,还需要注意一个容易产生误解的地方:空闲链表并不是额外创建一批链表节点,然后在这些节点中保存空闲块的地址。实际上,malloc 管理空闲块时,通常会直接复用已经释放的 chunk 自身空间。
原因也很简单:当用户调用 free 释放某个 chunk 之后,这块内存原本的用户数据区就已经失效了,用户不应该再继续访问这部分内容。既然这部分空间对用户来说已经不再有效,glibc 就可以将其复用为内存分配器自己的管理区域,在里面保存链表指针,从而把多个空闲 chunk 串联起来。
也就是说,释放后的 chunk 本身就可以充当空闲链表中的节点。可以抽象理解为:
text
低地址 高地址
| |
v v
+----------------+----------------+-----------------------------+
| prev_size | size + flags | 原用户数据区 / 空闲区 |
+----------------+----------------+-----------------------------+
|
v
free 之后被分配器复用
用来保存链表指针等信息
对于采用双向链表组织的空闲块来说,原来的用户数据区中可能会保存两个指针,分别指向前一个空闲 chunk 和后一个空闲 chunk。可以简化理解为:
text
低地址 高地址
| |
v v
+----------------+----------------+----------------+--------------------+
| prev_size | size + flags | fd | bk |
+----------------+----------------+----------------+--------------------+
| |
| |
v v
后一个空闲 chunk 前一个空闲 chunk
其中,fd 可以理解为指向后一个空闲块的指针,bk 可以理解为指向前一个空闲块的指针。通过这两个指针,多个已经释放的 chunk 就可以被组织成一个空闲链表。
当然,并不是所有空闲链表都一定保存前驱和后继两个指针。对于一些小块快速缓存结构来说,空闲块可能只需要保存一个指向下一个空闲块的指针;而对于某些 bin 结构,则会使用双向链表来组织空闲块。这里先不展开具体细节,只需要先抓住一个核心思想:free 之后的 chunk,本身就会变成空闲链表中的节点。
这样设计的好处是,不需要额外申请新的链表节点来管理空闲块,而是直接复用空闲块自身的空间保存管理信息。后续再次调用 malloc 时,分配器就可以从对应的空闲链表中取出一个 chunk,再将其中的用户数据区返回给用户使用。
多线程 malloc 优化:tcache 线程本地缓存机制
认识了 chunk 的基本结构以及空闲链表的组织方式之后,接下来我们再进一步看一下 malloc 在多线程场景下的内存管理策略。
根据前面的分析,我们已经建立了一个初步模型:malloc 会将已经申请到的虚拟内存区域划分成一个个 chunk,其中一部分 chunk 正在被用户使用,另一部分 chunk 已经被释放。对于这些已经释放的 chunk,glibc 会按照不同的大小类别,将它们组织到不同的空闲链表中。后续再次申请内存时,malloc 可以先根据申请大小定位到对应的空闲链表,然后尝试从中取出一个空闲 chunk 进行复用。
不过,前面的模型更接近单线程视角。在实际程序中,进程往往不是只有一个线程,而是可能存在多个线程同时运行。不同线程都可能调用 malloc 申请内存,也可能调用 free 释放内存。如果所有线程都直接访问同一组空闲链表,那么这些链表就会成为共享资源。多个线程并发修改同一个链表时,就会产生数据竞争。
但是我们平时在多线程程序中直接调用 malloc 和 free,通常并不会手动加锁,也不会因此出现空闲链表被破坏的问题。这说明 glibc 的 malloc 本身是线程安全的。为了做到线程安全,当多个线程访问共享的内存管理结构时,底层就需要使用锁来保护这些结构。
问题在于,加锁虽然可以保证线程安全,但也会带来性能开销。对于高频的小块内存申请和释放来说,如果每一次 malloc / free 都要竞争同一把锁,那么多个线程之间就会频繁阻塞,从而影响并发性能。
为了解决这个问题,glibc malloc 引入了线程本地缓存,也就是 tcache。tcache 可以理解为每个线程私有的一组小块或中小块空闲 chunk 缓存。由于它是线程私有的,因此当前线程访问自己的 tcache 时,通常不需要和其他线程竞争锁,这样可以显著减少多线程场景下的锁竞争。
需要注意的是,tcache 并不是管理所有大小的内存块。它主要用于缓存一定大小范围内的小块或中小块 chunk。也就是说,当用户调用 malloc(size) 时,glibc 会先将用户申请的 size 转换成内部管理的 chunk 大小,然后判断这个 chunk 大小是否落在 tcache 支持的范围内。只有当这个大小属于 tcache 管理范围时,才会优先查询当前线程自己的 tcache。
tcache 通常存放在线程本地存储区域中,也就是 TLS。它内部可以抽象为两个数组:一个是 entries 数组,另一个是 counts 数组。
其中,entries 是一个指针数组。数组中的每个位置对应一种大小类别的空闲链表,保存该链表的头节点地址;counts 数组则用来记录对应大小类别中当前缓存了多少个空闲 chunk。
可以抽象理解为:
text
tcache
+-------------------+
| counts[] | 记录每个大小类别缓存的 chunk 数量
+-------------------+
| entries[] | 指向不同大小类别的空闲链表头节点
+-------------------+
进一步展开来看:
text
entries[0] -> 某一类大小的空闲 chunk 链表
entries[1] -> 另一类大小的空闲 chunk 链表
entries[2] -> 更大一类的空闲 chunk 链表
...
counts[0] -> entries[0] 对应链表中的 chunk 数量
counts[1] -> entries[1] 对应链表中的 chunk 数量
counts[2] -> entries[2] 对应链表中的 chunk 数量
...
因此,当线程调用 malloc(size) 时,glibc 会先将用户申请的 size 转换成内部管理的 chunk 大小。如果这个大小属于 tcache 管理范围,glibc 就会根据该大小计算出对应的 tcache 下标,并访问当前线程自己的 tcache->entries[index]。
如果 tcache->entries[index] 不为空,说明这个大小类别中已经缓存了可复用的空闲 chunk,也就是 tcache 命中。此时,malloc 就可以直接从该链表头部取出一个 chunk,并将其用户数据区地址返回给用户。
这个过程可以简化理解为:
text
malloc(size)
|
v
将 size 转换成内部 chunk 大小
|
v
判断该 chunk 大小是否属于 tcache 管理范围
|
v
属于 tcache 范围
|
v
根据 chunk 大小计算 tcache 下标 index
|
v
访问当前线程自己的 tcache->entries[index]
|
v
如果链表不为空,取出头节点
|
v
返回用户数据区地址
由于整个过程优先访问的是当前线程自己的 tcache,所以在 tcache 命中的情况下,通常不需要进入共享的 arena 路径,也就减少了加锁带来的开销。
如果调整后的 chunk 大小超出了 tcache 的管理范围,或者该大小虽然属于 tcache 管理范围,但是当前线程的 tcache 中没有可用的空闲 chunk,那么 malloc 才会继续进入更底层的 arena 分配路径。
malloc 多线程分配路径:tcache、fastbin 与普通 bin
根据上文的分析,我们已经认识了 tcache 这个结构。tcache 可以理解为线程私有的小块或中小块空闲 chunk 缓存。当线程调用 malloc(size) 时,glibc 会优先尝试访问当前线程自己的 tcache。如果对应大小类别的链表中存在可复用的空闲 chunk,就可以直接取出并返回。由于 tcache 是线程私有的,因此在命中的情况下通常不需要进入共享结构,也就减少了加锁带来的开销。
不过,tcache 并不是管理所有大小的内存块。它主要缓存一定大小范围内的小块或中小块 chunk。如果用户申请的大小超出了 tcache 的管理范围,或者虽然属于 tcache 管理范围,但当前线程对应的 tcache 链表为空,那么 malloc 就需要继续进入更底层的 arena 分配路径。
arena 可以理解为 glibc malloc 中管理堆内存的一套共享结构。相比线程私有的 tcache,arena 中会维护更加完整的内存管理信息,例如 fastbin、普通 bin、top chunk 以及用于快速判断部分 bin 是否为空的辅助结构等。多个线程可能共享某个 arena,因此访问 arena 时通常需要加锁保护。
cpp
// 简化版 arena 结构,并非 glibc 源码原样
struct malloc_state
{
// 保护 arena 的锁
mutex_t mutex;
// fastbin 数组:用于管理小块空闲 chunk
malloc_chunk* fastbins[NFASTBINS];
// 普通 bin 数组:
// 包含 unsorted bin、small bin、large bin 等空闲链表
malloc_chunk* bins[NBINS];
// binmap:用于快速判断某些 bin 是否为空
unsigned int binmap[BINMAPSIZE];
// top chunk:当前 arena 中尚未切分的顶部空闲区域
malloc_chunk* top;
// 其他管理信息
size_t system_mem;
};
这个结构不是 glibc 源码的完整定义,而是为了帮助理解 arena 的核心作用做出的简化抽象。可以看到,arena 内部并不是只维护一个空闲链表,而是维护了多套空闲块管理结构。其中,fastbins 用来快速管理小块空闲 chunk;bins 用来管理更通用的空闲块,例如 unsorted bin、small bin、large bin 等;binmap 则可以帮助 malloc 更快判断某些 bin 中是否存在空闲块;top 指向当前 arena 中尚未继续切分的顶部空闲区域。
在继续认识 fastbin 之前,我们还需要先理解一个问题:为什么 malloc 在管理空闲块时,需要考虑相邻空闲块的合并?
根据前面的分析,程序在运行过程中会不断申请和释放内存,而释放顺序通常不会和申请顺序完全一致。因此,堆空间中可能会出现很多离散的空闲 chunk。如果这些空闲 chunk 只是被简单地挂入不同的空闲链表,而不进行相邻合并,那么就可能产生外部碎片。
例如,假设堆中有两个相邻的空闲块:
text
[A 已使用][B 空闲 32B][C 空闲 32B][D 已使用]
如果不进行合并,那么在 malloc 看来,这里只是两个独立的 32B 空闲块。虽然它们的总空闲空间是 64B,但它们没有被组织成一个连续的 64B 空闲块。此时如果用户申请一个需要 64B 连续空间的内存块,这两个 32B 空闲块就不能直接拼起来返回给用户。
因此,空闲块合并的目的就是:当两个相邻的 chunk 都处于空闲状态时,将它们合并成一个更大的空闲 chunk。这样可以减少外部碎片,让原本分散的小空闲块重新变成更大的连续空闲区域,从而提高后续大块内存申请的成功率。
text
合并前:
[A 已使用][B 空闲 32B][C 空闲 32B][D 已使用]
合并后:
[A 已使用][BC 空闲 64B][D 已使用]
所以,按大小分类管理空闲链表解决的是"如何更快找到合适大小的空闲块";而相邻空闲块合并解决的是"如何减少外部碎片,形成更大的连续空闲块"。这两个问题并不冲突,而是互相配合的。
不过,合并虽然可以减少外部碎片,但它本身也不是没有成本。每次释放一个 chunk 时,如果都立刻检查前后相邻 chunk、执行合并、重新分类并挂入新的 bin,那么 free 的路径就会变重。尤其对于小块内存来说,申请和释放非常高频,如果每次释放都立即合并,反而会影响小块内存管理的效率。
基于这个考虑,glibc malloc 对小块空闲 chunk 设计了一条更快的路径,也就是 fastbin。
fastbin 可以理解为 arena 中专门服务小块空闲 chunk 的快速链表。它本质上也是一个指针数组,数组中的每个位置对应一种小块大小类别的空闲链表。与普通 bin 相比,fastbin 更强调快速释放和快速复用。对于一些小块 chunk,当它们被释放时,如果没有进入 tcache,就可能被快速挂入对应的 fastbin 链表中。
这里需要注意的是,tcache 和 fastbin 都偏向于服务小块内存的高频申请和释放场景,但二者的定位不同。tcache 是线程私有缓存,优先级更高,主要用于减少多线程场景下的锁竞争;而 fastbin 位于 arena 中,是 arena 内部针对小块空闲 chunk 设计的快速链表。
因此,在释放小块内存时,glibc 通常会优先尝试将其放入当前线程的 tcache 中。如果对应的 tcache 链表还没有满,就可以直接缓存到 tcache;如果 tcache 无法接收,例如对应链表已经达到上限,后续才可能进入 fastbin 等 arena 相关路径。
text
小块 chunk 被释放
|
v
优先尝试放入当前线程的 tcache
|
v
如果 tcache 对应链表已满或不适用
|
v
进入 arena 路径,可能挂入 fastbin
fastbin 的一个重要特点是:小块释放到 fastbin 后,通常不会立即和相邻空闲 chunk 合并。这样做的原因在于,小块内存的申请和释放非常高频,如果每次释放小块时都立刻检查前后相邻 chunk、执行合并、重新分类并挂入普通 bin,那么释放路径就会变重,反而影响小块内存管理的效率。
所以,fastbin 本质上是用"延迟合并"换取"小块释放和复用速度"。释放时,空闲 chunk 可以快速挂入对应的 fastbin 链表;后续再次申请相同大小类别的小块时,也可以较快地从 fastbin 中取出复用。
对于普通的小块申请来说,如果当前线程的 tcache 没有命中,并且申请大小属于 fastbin 管理范围,那么 glibc 通常会根据调整后的 chunk 大小,直接定位到对应的 fastbin[index],然后尝试从该链表头部取出一个空闲 chunk 进行复用。
text
malloc(size)
|
v
tcache 没有命中
|
v
进入 arena 路径
|
v
判断 size 是否属于 fastbin 管理范围
|
v
根据 chunk 大小定位 fastbin[index]
|
v
尝试从对应链表头部取出空闲 chunk
也就是说,普通小块申请并不是遍历整个 fastbin 体系,而是根据申请大小快速定位到某一个对应的 fastbin 链表。
但是,fastbin 中的空闲 chunk 也不会永远不合并。如果这些小块长期只挂在 fastbin 中而不进行整理,就可能导致堆空间中积累大量离散的小空闲块,从而增加外部碎片。因此,glibc 会在某些合适的时机触发 fastbin consolidation,将 fastbin 中积累的空闲 chunk 取出,并尝试和相邻的空闲 chunk 进行合并。
这样一来,fastbin 既避免了每一次小块申请都承担扫描和合并的开销,又可以在后续整理阶段减少外部碎片。可以简单概括为:
text
普通小块 malloc:
根据 size 快速定位对应的 fastbin[index]
不遍历所有 fastbin
fastbin consolidation:
在特定时机集中整理 fastbin
尝试合并相邻空闲 chunk
减少长期不合并带来的外部碎片
因此,fastbin 的设计本质上是一种折中:在高频的小块申请和释放路径上优先保证速度,把相邻空闲块合并的成本延后到特定的整理阶段再处理。
接下来还需要理解一个问题:为什么相邻的空闲 chunk 能够被合并?
这是因为每个 chunk 的头部都保存了必要的元数据。前面我们已经知道,chunk 头部通常包含 prev_size 和 size 等字段。其中,size 字段不仅记录当前 chunk 的大小,还会利用低位保存一些标志位,例如前一个相邻 chunk 是否正在使用。prev_size 字段则可以在前一个相邻 chunk 为空闲状态时,帮助 glibc 向前找到前一个 chunk 的起始位置。
当释放一个 chunk 时,glibc 可以通过当前 chunk 的元数据判断前一个相邻 chunk 是否处于空闲状态。如果前一个 chunk 也是空闲的,就可以根据 prev_size 向前定位到前一个 chunk 的起始地址,然后将两个相邻空闲 chunk 的大小相加,合并成一个更大的空闲 chunk。
text
释放当前 chunk
|
v
通过 size 字段中的标志位判断前一个 chunk 是否正在使用
|
v
如果前一个 chunk 也是空闲的
|
v
根据 prev_size 找到前一个 chunk 的起始位置
|
v
前一个 chunk size + 当前 chunk size
|
v
合并成一个更大的空闲 chunk
|
v
根据新的 chunk 大小挂入合适的 bin
因此,空闲块合并依赖的是 chunk 元数据中记录的大小信息和相邻块状态。只要两个 chunk 在地址上相邻,并且都处于空闲状态,glibc 就可以根据它们的大小计算出合并后的新 chunk,再将这个更大的空闲块按照新的大小重新归类,挂入对应的 bin 中。
所以,tcache、fastbin 和普通 bin 可以先这样理解:
text
tcache:
线程私有缓存
优先级最高
命中时通常不需要加锁
用来减少多线程场景下的锁竞争
fastbin:
arena 中的小块快速链表
释放时不立即合并
用延迟合并换取小块释放和复用速度
普通 bin:
arena 中更通用的空闲块管理结构
会涉及合并、切分和重新分类
更关注碎片控制和通用分配能力
因此,malloc 的多线程分配路径可以先抽象理解为:当前线程优先访问自己的 tcache;如果 tcache 不适用或者没有命中,再进入 arena 路径;在进入 arena 之后,小块空闲 chunk 可能会通过 fastbin 这种快速链表进行管理,而更通用的空闲块则会进入普通 bin 体系。这样的设计既兼顾了小块内存申请和释放的高频性能,又通过后续的合并和重新分类机制控制内存碎片。
malloc 普通 bin 分配路径:unsorted、small 与 large
认识了 fastbin 结构之后,接下来我们继续来看 arena 中另一类重要结构:普通 bin。
在前面的分析中,我们已经知道,fastbin 主要用于管理一部分小块空闲 chunk,它强调快速释放和快速复用。小块 chunk 释放到 fastbin 后,通常不会立即和相邻空闲块合并,而是先挂入对应的快速链表中,后续再在合适时机进行统一整理。
不过,fastbin 并不能管理所有空闲块。对于没有进入 tcache,并且大小也不适合进入 fastbin 快速路径的普通空闲 chunk,glibc 还需要一套更加完整的空闲块管理结构,这就是普通 bin 体系。
可以先把普通 bin 理解为 arena 中的一个统一的空闲链表数组 bins[]。注意这里是「一个数组」------unsorted bin、small bin、large bin 并不是各自独立开的三个数组,而是同一个 bins[] 数组里的不同下标区间,按用途和大小范围划分:
text
统一的 bins[] 数组
|
+-- unsorted bin (固定占用最前面的位置)
|
+-- small bin (一段连续下标区间)
|
+-- large bin (接在 small bin 之后的下标区间)
其中,unsorted bin 可以理解为普通空闲块的临时中转站。很多普通空闲 chunk 在释放后,或者在合并相邻空闲块之后,并不会立刻被精确分类到 small bin 或 large bin 中,而是会先进入 unsorted bin。这样做的目的,是让这些刚释放或刚合并出来的空闲块先保留一次被快速复用的机会。
也就是说,如果后续 malloc 申请的大小刚好适合 unsorted bin 中的某个空闲块,那么 glibc 就可以直接将其取出并复用;如果这些空闲块暂时不适合当前申请,再根据它们的大小进一步整理到 small bin 或 large bin 中。
因此,unsorted bin 并不是最终的精确分类结构,而是普通空闲块正式进入 small bin / large bin 之前的一个临时缓冲区。
text
普通空闲 chunk
|
v
释放或合并后先进入 unsorted bin
|
v
后续 malloc 先尝试复用
|
v
如果不适合当前申请
|
v
再根据大小整理到 small bin / large bin
接着来看 small bin。
small bin 是统一 bins[] 数组中的一段连续下标区间 ,用来管理较小尺寸的普通空闲 chunk。它最关键的特点是:这段区间里的每一个下标,对应一条固定大小的空闲链表 ------同一个下标(同一条链表)里挂的 chunk 大小完全相同 ,相邻下标之间相差 2 * SIZE_SZ(64 位下是 16 字节)。
正因为每个下标定长,glibc 处理小块申请时,可以根据调整后的 chunk 大小直接算出对应的 small bin 下标,然后 O(1) 地检查那条链表里有没有可复用的空闲块。这就是 small bin 高效的根本原因:大小一算,下标一定,链表头一看。
这里很容易产生一个疑问:前面已经讲过 fastbin 也是管理小块空闲 chunk 的,那么 fastbin 和 small bin 到底有什么区别?
需要注意的是,fastbin 和 small bin 在管理大小上并不是完全割裂的。但二者的关系不是「部分交叉」,而是包含 :fastbin 默认只管理到一个较小的上限,它覆盖的大小范围落在 small bin 范围之内 ,是 small bin 范围的一个子集。
真正的区别不在大小,而在它们属于不同的管理路径:
text
fastbin:
小块空闲 chunk 的快速路径
释放时通常不立即合并
更强调快速释放和快速复用
small bin:
普通 bin 体系中的小块管理区
属于更完整的空闲块管理流程
更关注合并、切分、重新分类和碎片控制
也就是说,同样一个小块 chunk,它的大小可能既属于 fastbin 支持范围,也属于 small bin 所管理的小块范围。但这并不意味着它会同时挂在两边。一个空闲 chunk 在某一时刻只能属于一个管理结构 :要么在 tcache 中,要么在 fastbin 中,要么进入普通 bin 体系。
再来看 large bin。
large bin 是接在 small bin 之后的下标区间,用来管理更大尺寸的普通空闲 chunk。和 small bin 最大的不同是:small bin 一个下标对应一个固定大小,而 large bin 一个下标对应的是一段大小区间 ------同一条 large bin 链表里,chunk 的大小并不完全相同,只要落在该区间内都挂在一起。因此 large bin 没法像 small bin 那样「算出下标就 O(1) 取到正好的块」,而需要在链表里查找一个「足够大」的空闲块。
和 small bin 桶内不需要排序不同,large bin 因为同一条链表里 chunk 大小不一,glibc 会把它们按大小降序排列 :链表头是这个 bin 里最大的块,沿 fd 方向往后依次变小。
之所以降序排,是为了配合 large 的 best-fit 查找------分配时需要找一个「足够大且最接近」的块,在降序链表上从小块那一端往前扫,遇到的第一个「够大」的块就是最合适的,不必扫完整条链。
至此,普通 bin 体系可以先这样总览:
text
unsorted bin:
普通空闲 chunk 的临时中转站
存放刚释放、刚合并或暂时还没有精确分类的空闲块
malloc 会先尝试从这里复用
small bin:
统一 bins[] 数组中的一段下标区间
每个下标对应一条定长链表,同下标 chunk 大小完全相同
适合 O(1) 定位固定大小的小块空闲 chunk
large bin:
接在 small bin 之后的下标区间
每个下标对应一段大小区间,桶内 chunk 大小不定
需要查找足够大的空闲块
下面把释放路径 和分配路径串起来------这是整套结构真正运转的地方,也是最容易绕晕的地方。
先看 free 的路径。
text
free(chunk)
|
v
优先尝试放入当前线程的 tcache
|
v
如果 tcache 放不下 / 不适用
|
v
进入 arena 路径
|
v
如果 chunk 大小属于 fastbin 范围
|
v
挂入 fastbin,暂时不立即合并
如果这个 chunk 没有进入 tcache,并且大小也不属于 fastbin 快速路径,那么它就会进入普通释放路径。在普通释放路径中,glibc 通常会尝试和相邻的空闲 chunk 合并。合并后的空闲块一般先放入 unsorted bin ,而不会马上精确分类到 small bin 或 large bin:
text
free 普通 chunk
|
v
没有进入 tcache
|
v
不属于 fastbin 快速路径
|
v
尝试和相邻空闲 chunk 合并
|
v
合并后的 chunk 一般先放入 unsorted bin
|
v
后续再根据大小整理到 small bin / large bin
例外提醒:如果合并时相邻的是堆顶(top chunk),合并结果是并回堆顶,而不是进
unsorted bin。
这也解释了为什么需要 unsorted bin:如果每次释放普通空闲块后都立刻精确分类,释放路径会多出一笔分类成本;而很多刚释放的块很快又会被下一次 malloc 复用。先放进 unsorted bin,就给了它一次直接被复用的机会。
接下来再看 malloc 的申请路径。
当用户调用 malloc(size) 时,glibc 会优先尝试从当前线程的 tcache 中查找可复用的空闲 chunk。如果 tcache 没有命中,才会进入 arena 路径。
进入 arena 后,如果申请大小属于 fastbin 管理范围,glibc 会根据调整后的 chunk 大小定位到对应的 fastbin[index],并尝试从该链表头部取出一个空闲 chunk。如果对应的 fastbin 链表为空,也就是没有可复用的空闲块,那么才会继续进入普通 bin 体系。
可以简化理解为:
text
malloc(size)
|
v
优先查当前线程的 tcache
|
v
tcache 未命中
|
v
进入 arena 路径
|
v
判断 size 是否属于 fastbin 管理范围
|
+-----------------------------+
| |
v v
属于 fastbin 范围 不属于 fastbin 范围
| |
v v
根据 size 定位 fastbin[index] 进入普通 bin 体系
|
v
尝试从对应链表取出空闲 chunk
|
v
如果对应链表为空
|
v
进入普通 bin 体系
进入普通 bin 体系之后,不能简单地理解为"先遍历 unsorted bin,不合适再访问 small bin"。这里需要根据申请大小区分不同情况。
对于小块申请来说,如果申请大小落在 small bin 管理范围内,glibc 可以先根据大小计算出对应的 small bin 下标,并检查该定长链表中是否已经存在可复用的空闲 chunk。由于同一个 small bin 中的 chunk 大小通常相同,所以如果对应链表不为空,就可以直接取出复用,不需要在 bin 内进行复杂查找。
也就是说,小块申请在进入普通 bin 体系后,可以先尝试一次 small bin 的精确命中:
text
small request
|
v
根据 size 计算 small bin 下标
|
v
检查对应 small bin 链表
|
v
如果非空,直接取出 chunk 复用
如果对应的 small bin 为空,那么 glibc 才会继续处理 unsorted bin。
处理 unsorted bin 时,glibc 会从中取出最近释放、最近合并或暂时还没有正式归类的空闲 chunk。如果某个 chunk 正好适合当前申请,就可以直接取出复用;如果不适合当前申请,则将它从 unsorted bin 中摘下,并按照大小归类到对应的 small bin 或 large bin 中。
可以简化理解为:
text
从 unsorted bin 取出一个 chunk
|
+-- 如果正好适合当前申请
| |
| v
| 直接复用并返回
|
+-- 如果不适合当前申请
|
v
从 unsorted bin 摘下
|
v
根据大小放入 small bin / large bin
|
v
继续处理下一个 unsorted chunk
对于大块申请来说,情况又有所不同。large bin 中的块不是定长管理,想找到一个合适的大块,需要等 unsorted bin 中最近释放或合并出来的空闲块被处理完之后,再从 large bin 中查找更合适的块。否则,最新产生的大块空闲 chunk 可能还停留在 unsorted bin 中,没有进入 large bin,此时直接查 large bin 就可能漏掉这些新产生的候选块。
因此,大块申请通常不能像小块申请那样先做一次 O(1) 的定长链表命中,而是需要先处理 unsorted bin,让其中不适合当前申请的块完成归类,然后再从 large bin 中寻找足够大并且更合适的空闲块。
可以把普通分配路径概括成这样:
text
普通 bin 分配路径
|
v
如果是 small request:
|
v
先尝试 small bin 精确命中
|
v
small bin 未命中后,处理 unsorted bin
如果是 large request:
|
v
先处理 unsorted bin
|
v
再从 large bin 等结构中查找合适 chunk
最后再整体总结一下:
text
fastbin:
小块空闲 chunk 的快速路径
释放时通常不立即合并
重点是快速释放和快速复用
unsorted bin:
普通空闲 chunk 的临时中转站
存放刚释放、刚合并或暂时还没有精确分类的空闲块
处理时能用就直接复用,不能用就归类到 small bin / large bin
small bin:
普通 bin 体系中的小块空闲链表
管理已经进入普通空闲块流程的小块 chunk
同一个 small bin 中的 chunk 大小通常相同
小块申请时可以先做一次精确命中
large bin:
普通 bin 体系中的大块空闲链表
管理更大尺寸的空闲 chunk
通常按照大小范围和大小关系组织
大块申请需要在 unsorted bin 处理后再做范围查找
因此,fastbin 和 small bin 的关系可以概括为:它们在管理大小上可能存在重合,但它们代表的是不同的管理路径。fastbin 是小块内存的快速通道,主要用来降低高频小块申请和释放的成本;small bin 则属于普通 bin 体系,用来管理已经进入普通空闲块流程的小块 chunk。
而 unsorted bin 则位于普通 bin 流程的中间位置,它保存的是刚释放、刚合并或暂时没有精确分类的普通空闲块。对于这些块,glibc 会先给它们一次被当前申请直接复用的机会;如果当前申请用不上,再将它们归类到 small bin 或 large bin 中。这样既减少了释放时的精确分类成本,又提高了最近释放空闲块被快速复用的机会。
示意图

多 arena 机制:main arena、non-main arena 与锁竞争优化
至此,我们已经认识了 tcache 和 arena 这两个结构。tcache 是线程私有的小块或中小块空闲 chunk 缓存,用来减少多线程环境下的锁竞争;而 arena 则是 glibc malloc 中更加完整的堆内存管理结构,其中会维护 fastbin、普通 bin、top chunk 等信息。
接下来还需要进一步理解一个问题:一个进程中是不是只有一个 arena?
答案是否定的。对于 glibc malloc 来说,一个进程中可能存在多个 arena。其中,主线程最开始使用的通常是 main arena,也就是主 arena。main arena 主要管理传统堆区,也就是进程地址空间中通过 brk / sbrk 扩展出来的那一段堆空间。
可以简单理解为:
text
main arena
|
v
管理传统堆区
|
v
堆区空间不足时,可能通过 brk 扩展
也就是说,当主线程调用 malloc 申请普通大小的内存时,glibc 通常会优先尝试从 main arena 当前已经管理的堆空间中切分出合适的 chunk。如果当前堆区剩余空间不足,glibc 才可能通过 brk 系统调用向后扩展堆区的虚拟地址范围,从而获得更多可管理的堆内存。
不过,在多线程程序中,如果所有线程都竞争同一个 main arena,那么多个线程同时调用 malloc / free 时,就会频繁争抢同一把锁,影响并发性能。因此,glibc malloc 还可以创建非主 arena,也就是 non-main arena。不同线程可以尽量绑定或复用不同的 arena,从而减少所有线程都竞争同一个 arena 的问题。
需要注意的是,非主 arena 通常不会通过 brk 去扩展进程的传统堆区,而是可以通过 mmap 向内核申请一段独立的虚拟内存区域。glibc 会将这段区域作为该 arena 可管理的内存来源,并在其中继续切分出一个个 chunk 进行分配和回收。
也就是说,main arena 和 non-main arena 获取内存的方式并不完全相同:
text
main arena:
主要管理传统堆区
堆区不足时,通常通过 brk 扩展
non-main arena:
多用于多线程场景
通常通过 mmap 获得新的 heap segment
用来减少多个线程竞争同一个 arena 的问题
最后,还需要理解为什么 glibc 不会无限制地为每个线程都创建新的 arena。
如果 arena 可以无限创建,那么多线程程序中可能会出现大量 arena。每个 arena 都需要维护自己的管理结构,也可能持有一部分已经从系统申请来的虚拟内存区域。如果某些线程只是偶尔申请内存,并没有频繁的内存分配需求,那么为它们单独创建 arena 就可能带来额外的空间浪费和管理成本。
因此,glibc 对 arena 的数量会进行限制。当 arena 数量还没有达到上限时,新线程可能会获得或创建新的 arena;当 arena 数量达到上限后,多个线程就需要复用已有的 arena。此时,如果多个线程同时访问同一个 arena,就仍然需要通过锁来保护 arena 中的共享结构。
可以概括为:
text
arena 数量未达到上限:
线程可以尽量使用不同 arena
减少锁竞争
arena 数量达到上限:
多个线程复用已有 arena
访问同一个 arena 时需要加锁
手写简化版 malloc/free:理解 chunk 元数据与用户数据区
根据上文的分析,我们已经知道,malloc 在某些情况下可能会通过 mmap 向内核申请一段匿名映射区域。glibc 拿到这段虚拟内存区域之后,并不会直接把整段区域原样返回给用户,而是会在这段区域中维护自己的元数据,然后将真正的用户数据区地址返回给用户。
也就是说,一个最简化的 chunk 可以先抽象成下面这种结构:
text
低地址 高地址
| |
v v
+----------------+---------------------------+
| size 元数据 | 用户数据区 |
+----------------+---------------------------+
^
|
malloc 返回给用户的地址
其中,size 用来记录这次实际映射区域的大小。这样,当用户调用 free(ptr) 时,即使用户只传入了用户数据区的起始地址,分配器也可以通过向前偏移,重新找到前面的元数据区域,并从中读出这块内存的大小,最终调用 munmap 释放整段映射区域。
基于这个思路,我们可以实现一个极简版的 myMalloc 和 myFree:
cpp
#include <iostream>
#include <cstring>
#include <cstddef>
#include <sys/mman.h>
#include <unistd.h>
// 简化版 chunk 头部:这里只记录当前映射区域的总大小
struct ChunkHeader
{
size_t size;
};
void* myMalloc(size_t size)
{
if (size == 0)
{
return nullptr;
}
// 实际申请大小 = 元数据大小 + 用户申请大小
size_t totalSize = sizeof(ChunkHeader) + size;
void* ptr = mmap(nullptr,
totalSize,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS,
-1,
0);
if (ptr == MAP_FAILED)
{
return nullptr;
}
// ptr 指向整个映射区域的起始位置,也就是 chunk 头部
ChunkHeader* header = static_cast<ChunkHeader*>(ptr);
header->size = totalSize;
// 返回用户数据区的起始地址
return static_cast<void*>(header + 1);
}
void myFree(void* ptr)
{
if (ptr == nullptr)
{
return;
}
// ptr 是用户数据区起始地址,向前偏移一个 ChunkHeader 找到元数据
ChunkHeader* header = static_cast<ChunkHeader*>(ptr) - 1;
// 根据元数据中记录的 size 解除整块 mmap 映射
munmap(static_cast<void*>(header), header->size);
}
int main()
{
char* p = static_cast<char*>(myMalloc(32));
if (p == nullptr)
{
std::cerr << "myMalloc failed" << std::endl;
return 1;
}
std::strcpy(p, "hello myMalloc");
std::cout << p << std::endl;
myFree(p);
return 0;
}

这段代码的核心逻辑可以分成两步。
首先,在 myMalloc 中,程序并不是只申请用户传入的 size 字节,而是额外加上了一个 ChunkHeader 的大小:
cpp
size_t totalSize = sizeof(ChunkHeader) + size;
这表示实际通过 mmap 申请的区域由两部分组成:前面是分配器自己使用的元数据区域,后面才是返回给用户使用的数据区域。
接着,mmap 返回的是整段映射区域的起始地址,也就是 chunk 的起始地址:
cpp
ChunkHeader* header = static_cast<ChunkHeader*>(ptr);
header->size = totalSize;
这里将起始地址解释成 ChunkHeader*,并在元数据中记录当前映射区域的总大小。随后通过 header + 1 跳过元数据区域,返回用户数据区的起始地址:
cpp
return static_cast<void*>(header + 1);
因此,用户拿到的地址并不是整段 chunk 的起始地址,而是元数据后面的用户数据区地址。
当用户调用 myFree(p) 时,传入的是用户数据区地址。此时只需要将这个地址向前偏移一个 ChunkHeader 的大小,就可以重新找到 chunk 头部:
cpp
ChunkHeader* header = static_cast<ChunkHeader*>(ptr) - 1;
找到元数据之后,就能读出当初记录的映射总大小,并调用 munmap 释放整段映射区域:
cpp
munmap(static_cast<void*>(header), header->size);
这个示例虽然非常简单,但它体现了 malloc/free 中一个非常关键的思想:malloc 返回给用户的是用户数据区地址,而不是整个 chunk 的起始地址;free 之所以只需要一个指针就能释放内存,是因为分配器可以通过这个指针向前找到 chunk 元数据。
需要注意的是,这个 myMalloc 只是一个用于理解原理的极简版本。它每次申请都会直接调用 mmap,每次释放都会直接调用 munmap,并没有实现空闲块复用、按大小分类、tcache、fastbin、small bin、large bin、切分和合并等机制。因此,它更适合用来理解 chunk 元数据 + 用户数据区 这一核心模型,而不能等同于真实 glibc malloc 的完整实现。

结语
那么这就是本篇文章的全部内容,我会持续更新,希望你能够多多关注,如果本文有帮助到你的话,还请三连加关注,你的支持就是我创作的最大动力!
