一、为什么需要内存映射
用 read() 读文件时,数据先从磁盘拷贝到内核缓冲区,再从内核缓冲区拷贝到用户空间的 buffer 里。一次读操作,两份数据拷贝。对于几百 MB 的大文件,这种「读一份、拷两份」的模式既浪费 CPU,又浪费内存。
内存映射的想法很直接:既然文件最终要进内存,为什么不直接把文件映射到进程的地址空间?进程拿到一个指针,像访问内存一样访问文件内容,不需要额外的拷贝。操作系统在背后按需加载——当进程第一次访问某个页面时,触发缺页中断,内核把对应的文件块读进物理页,然后恢复进程执行。
除了减少拷贝,mmap 还天然支持共享内存。两个进程映射同一个文件,用的是同一块物理内存。一个进程写入的内容,另一个进程立刻可见。这是进程间通信(IPC)的高效手段,不需要管道、消息队列这些额外机制。
二、现实类比
去图书馆查资料有两种方式。第一种是把需要的页复印带回家看——这就像 read(),你拿到的是副本,原件还在图书馆。第二种是直接在书架前翻阅——这就像 mmap,你没有复制任何东西,只是得到了一个「位置」,需要的时候直接看。
共享映射像办公室里的白板。所有同事都能看到同一块白板,任何人在上面写的内容其他人立刻就能看到。不需要「把白板内容抄一份递给同事」——大家本来就看同一块板。
三、核心思想
3.1 映射的本质
mmap 在进程的虚拟地址空间中划出一块区域,让这块区域的虚拟页与文件内容建立对应关系。调用 mmap() 本身并不把文件读进内存——它只是修改页表。当进程访问某个尚未加载的虚拟页时,CPU 触发缺页中断(Page Fault),内核才真正从磁盘读取对应的文件块到物理页中。
3.2 mmap 系统调用
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_EXECflags:映射类型,最重要的两个是MAP_SHARED和MAP_PRIVATEfd:要映射的文件描述符offset:文件偏移量,必须是页大小的整数倍
调用成功返回映射区域的起始指针,失败返回 MAP_FAILED。
3.3 共享映射与私有映射
MAP_SHARED 和 MAP_PRIVATE 决定了写入行为:
| 标志 | 写入行为 | 典型用途 |
|---|---|---|
MAP_SHARED | 写入对其他进程可见,会更新文件 | 共享内存 IPC、数据库文件访问 |
MAP_PRIVATE | 写入触发复制(COW),对其他进程不可见,不更新文件 | 加载配置文件、动态库 |
MAP_PRIVATE 的写入本质上就是写时复制。进程 A 和进程 B 映射同一个文件,都用 MAP_PRIVATE。A 修改了某个页面,内核为 A 创建一份私有副本,B 看到的仍然是原始内容。
3.4 匿名映射
MAP_ANONYMOUS 不需要文件支持,映射的内存初始化为零。用途包括:
malloc()分配大块内存时,glibc 内部用mmap(MAP_ANONYMOUS)实现- 线程栈分配——每个线程的栈都是一块匿名映射
- 父子进程之间的私有共享内存(
MAP_SHARED|MAP_ANONYMOUS,fork()后共享)
// 分配 4MB 匿名内存,初始化为零void *mem = mmap(NULL, 4 * 1024 * 1024, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);3.5 缺页中断的生命周期
- 进程访问映射区域中尚未加载的虚拟地址
- CPU 查页表,发现该页不存在,触发缺页中断
- 内核的中断处理程序检查该地址属于 mmap 映射
- 内核从磁盘读取对应的文件块到物理页
- 内核更新页表,将虚拟页指向物理页
- 恢复进程执行,重新执行触发中断的那条指令
这个过程对进程完全透明——就像数据本来就在内存里一样。
3.6 性能特征
mmap 更快的场景:
- 顺序读取大文件:缺页中断预读(
readahead)可以有效利用磁盘带宽 - 随机访问大文件中分散的少量数据:不需要把整个文件
read()进来 - 多进程共享同一文件:共享映射避免重复加载
mmap 不一定更快的场景:
- 随机读取小文件:缺页中断的开销可能比一次
read()还大 - 文件远大于物理内存:频繁的页面换入换出导致性能骤降
- 只需要文件头几字节:映射整个文件浪费虚拟地址空间
3.7 注意事项
- 文件截断:如果文件在映射期间被截断,访问超出新文件末尾的页面会产生
SIGBUS信号,导致进程崩溃 - 32 位系统限制:32 位进程的虚拟地址空间只有 3GB(用户态),映射大文件可能耗尽地址空间
- 映射生命周期:进程退出或调用
munmap()后映射自动解除,MAP_SHARED的脏页会写回文件 - 对齐要求:
offset必须是页大小的整数倍,映射长度会向上取整到页大小
四、变体与对比
4.1 mmap vs read/write
| 维度 | mmap | read/write |
|---|---|---|
| 数据拷贝 | 零拷贝(直接访问物理页) | 两次拷贝(内核→用户) |
| 加载方式 | 按需加载(缺页中断) | 主动读取(立即加载) |
| 访问方式 | 指针直接操作 | 系统调用 + buffer |
| 适用场景 | 大文件、随机访问、共享内存 | 小文件、顺序读写、简单场景 |
4.2 mmap vs sendfile
sendfile() 在内核态直接把文件内容发送到 socket,全程不经过用户空间。适合「从磁盘到网络」的零拷贝传输,比如静态文件服务器。mmap 更通用——映射后可以任意读写、多进程共享,但 mmap 的写入需要经过用户空间,不算完全零拷贝。
4.3 共享内存机制对比
| 机制 | 特点 | 适用场景 |
|---|---|---|
mmap(MAP_SHARED) | 基于文件,持久化,简单 | 需要持久化的共享数据 |
POSIX 共享内存(shm_open) | 基于 tmpfs,不依赖文件路径 | 临时共享内存,更灵活的权限控制 |
System V 共享内存(shmget) | 老式 API,内核持久化 | 兼容老系统 |
POSIX 共享内存的底层实现其实就是 mmap 映射 tmpfs 上的文件。三种机制在数据传输效率上没有本质区别。
4.4 Go 中的 mmap
Go 通过 syscall 包提供 mmap 支持:
data, err := syscall.Mmap(fd, 0, length, syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_SHARED)返回的 data 是 []byte 切片,可以直接读写。第三方库 golang.org/x/exp/mmap 提供了更友好的只读映射接口。
4.5 Node.js/TypeScript 中的 mmap
Node.js 没有原生的 mmap API。常用的替代方案:
fs.readFileSync():把整个文件读进 Buffer,简单但不适合大文件fs.createReadStream():流式读取,适合顺序处理- 第三方库如
mmap-io:通过 N-API 封装 C 的 mmap 调用
// Node.js 中模拟 mmap 风格的文件访问import { openSync, readSync } from 'fs';
const fd = openSync('large.dat', 'r');const buffer = Buffer.alloc(4096);// 按需读取特定偏移量的数据readSync(fd, buffer, 0, 4096, offset);如果需要真正的 mmap,通常的做法是用 C++ 编写 N-API 插件,或者用子进程调用 C 程序。
五、多语言实现
5.1 Go:用 mmap 映射文件并读写
package main
import ( "fmt" "os" "syscall")
func main() { // 打开文件 f, err := os.OpenFile("test.dat", os.O_RDWR|os.O_CREATE, 0644) if err != nil { panic(err) } defer f.Close()
// 确保文件有内容 size := 4096 if err := f.Truncate(int64(size)); err != nil { panic(err) }
// 映射文件到内存 data, err := syscall.Mmap(int(f.Fd()), 0, size, syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_SHARED) if err != nil { panic(err) }
// 通过切片直接读写,就像操作普通内存 copy(data, []byte("hello mmap")) fmt.Printf("内容: %s\n", data[:10])
// 解除映射(MAP_SHARED 的脏页会写回文件) if err := syscall.Munmap(data); err != nil { panic(err) }}关键点:data 是 []byte 类型,对它的修改会直接反映到文件上(MAP_SHARED 模式)。调用 Munmap 后,内核将脏页写回磁盘。
5.2 TypeScript:Buffer 与按需读取
import { openSync, readSync, writeSync, closeSync } from 'fs';
class MmapLikeReader { private fd: number; private pageSize = 4096; private cache: Map<number, Buffer> = new Map();
constructor(filepath: string) { this.fd = openSync(filepath, 'r'); }
// 按页缓存,模拟 mmap 的按需加载 read(offset: number, length: number): Buffer { const pageStart = Math.floor(offset / this.pageSize) * this.pageSize; if (!this.cache.has(pageStart)) { const buf = Buffer.alloc(this.pageSize); readSync(this.fd, buf, 0, this.pageSize, pageStart); this.cache.set(pageStart, buf); } const page = this.cache.get(pageStart)!; const pageOffset = offset - pageStart; return page.subarray(pageOffset, pageOffset + length); }
close(): void { closeSync(this.fd); }}
// 使用const reader = new MmapLikeReader('large.dat');const data = reader.read(1024, 16);console.log(data.toString());reader.close();这不是真正的 mmap——数据仍然经过用户空间缓冲区。但在 Node.js 没有原生 mmap 支持的情况下,按页缓存的模式可以在一定程度上模拟按需加载的行为。
六、生产验证
6.1 SQLite
SQLite 是 mmap 最著名的生产级用户。当 PRAGMA mmap_size 设置为非零值时,SQLite 用 mmap 访问数据库文件,而不是传统的 read()/write()。对于只读查询,mmap 可以减少系统调用次数,提升小规模随机读取的性能。SQLite 在检测到 mmap 失败或文件被外部修改时,会自动回退到 read() 路径。
SQLite 的 WAL 模式下,写操作仍然使用 read()/write(),只有读操作走 mmap。这是为了在写入时保持对文件锁的精确控制。
6.2 PostgreSQL
PostgreSQL 的共享缓冲区(Shared Buffers)不是直接用 mmap 实现的——它用 shm_open + mmap 分配共享内存区域,然后在上面自行管理缓冲池。数据库页面的换入换出策略由 PostgreSQL 的缓存替换算法控制,而不是依赖操作系统的页面置换。这种设计让数据库对缓存行为有完全的控制权。
6.3 Redis
Redis 在 RDB 持久化和 AOF 重写时使用 mmap。bgrewriteaof 生成新的 AOF 文件后,Redis 用 mmap 将文件映射到内存,然后通过 write() 将映射内容发送给从节点。这种方式避免了额外的用户态拷贝。AOF 重写期间的子进程也通过 mmap 读取父进程的内存快照(配合 fork() 的 COW 机制),减少内存占用。
七、小结
mmap 的核心价值在于消除内核态和用户态之间的数据拷贝,让文件访问变成简单的内存操作。共享映射天然支持 IPC,匿名映射为内存分配提供了底层机制。
选择 mmap 还是 read()/write(),取决于使用场景:大文件的随机访问和进程间共享是 mmap 的强项;小文件顺序读写用 read()/write() 更简单可控。记住 mmap 的陷阱——文件截断的 SIGBUS、32 位系统的地址空间限制、以及映射生命周期管理。
下一篇文章我们讨论虚拟内存,看看操作系统如何用页表和缺页中断为每个进程制造「独占内存」的幻觉。
八、参考资料
- mmap(2) — Linux manual page - Linux mmap 系统调用的完整文档
- SQLite File Format - SQLite 数据库文件格式规范,包含 mmap 使用说明
- Advanced Programming in the UNIX Environment - W. Richard Stevens 与 Stephen A. Rago 著,第 14 章详细讲解 mmap 与共享内存
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






