mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2697 字
7 分钟
内存映射 mmap(Memory Mapping)
2026-06-13

一、为什么需要内存映射#

read() 读文件时,数据先从磁盘拷贝到内核缓冲区,再从内核缓冲区拷贝到用户空间的 buffer 里。一次读操作,两份数据拷贝。对于几百 MB 的大文件,这种「读一份、拷两份」的模式既浪费 CPU,又浪费内存。

内存映射的想法很直接:既然文件最终要进内存,为什么不直接把文件映射到进程的地址空间?进程拿到一个指针,像访问内存一样访问文件内容,不需要额外的拷贝。操作系统在背后按需加载——当进程第一次访问某个页面时,触发缺页中断,内核把对应的文件块读进物理页,然后恢复进程执行。

除了减少拷贝,mmap 还天然支持共享内存。两个进程映射同一个文件,用的是同一块物理内存。一个进程写入的内容,另一个进程立刻可见。这是进程间通信(IPC)的高效手段,不需要管道、消息队列这些额外机制。

二、现实类比#

去图书馆查资料有两种方式。第一种是把需要的页复印带回家看——这就像 read(),你拿到的是副本,原件还在图书馆。第二种是直接在书架前翻阅——这就像 mmap,你没有复制任何东西,只是得到了一个「位置」,需要的时候直接看。

共享映射像办公室里的白板。所有同事都能看到同一块白板,任何人在上面写的内容其他人立刻就能看到。不需要「把白板内容抄一份递给同事」——大家本来就看同一块板。

三、核心思想#

3.1 映射的本质#

mmap 在进程的虚拟地址空间中划出一块区域,让这块区域的虚拟页与文件内容建立对应关系。调用 mmap() 本身并不把文件读进内存——它只是修改页表。当进程访问某个尚未加载的虚拟页时,CPU 触发缺页中断(Page Fault),内核才真正从磁盘读取对应的文件块到物理页中。

flowchart LR A[进程虚拟地址空间] --> B[页表] B --> C[物理页] C --> D[磁盘上的文件] A -.->|"首次访问触发缺页中断"| C C -.->|"按需加载"| D

3.2 mmap 系统调用#

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

关键参数:

  • addr:建议的映射起始地址,通常传 NULL 让内核选择
  • length:映射长度
  • prot:访问权限——PROT_READPROT_WRITEPROT_EXEC
  • flags:映射类型,最重要的两个是 MAP_SHAREDMAP_PRIVATE
  • fd:要映射的文件描述符
  • offset:文件偏移量,必须是页大小的整数倍

调用成功返回映射区域的起始指针,失败返回 MAP_FAILED

3.3 共享映射与私有映射#

MAP_SHAREDMAP_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_ANONYMOUSfork() 后共享)
// 分配 4MB 匿名内存,初始化为零
void *mem = mmap(NULL, 4 * 1024 * 1024,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);

3.5 缺页中断的生命周期#

  1. 进程访问映射区域中尚未加载的虚拟地址
  2. CPU 查页表,发现该页不存在,触发缺页中断
  3. 内核的中断处理程序检查该地址属于 mmap 映射
  4. 内核从磁盘读取对应的文件块到物理页
  5. 内核更新页表,将虚拟页指向物理页
  6. 恢复进程执行,重新执行触发中断的那条指令

这个过程对进程完全透明——就像数据本来就在内存里一样。

3.6 性能特征#

mmap 更快的场景

  • 顺序读取大文件:缺页中断预读(readahead)可以有效利用磁盘带宽
  • 随机访问大文件中分散的少量数据:不需要把整个文件 read() 进来
  • 多进程共享同一文件:共享映射避免重复加载

mmap 不一定更快的场景

  • 随机读取小文件:缺页中断的开销可能比一次 read() 还大
  • 文件远大于物理内存:频繁的页面换入换出导致性能骤降
  • 只需要文件头几字节:映射整个文件浪费虚拟地址空间

3.7 注意事项#

  • 文件截断:如果文件在映射期间被截断,访问超出新文件末尾的页面会产生 SIGBUS 信号,导致进程崩溃
  • 32 位系统限制:32 位进程的虚拟地址空间只有 3GB(用户态),映射大文件可能耗尽地址空间
  • 映射生命周期:进程退出或调用 munmap() 后映射自动解除,MAP_SHARED 的脏页会写回文件
  • 对齐要求offset 必须是页大小的整数倍,映射长度会向上取整到页大小

四、变体与对比#

4.1 mmap vs read/write#

维度mmapread/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() 路径。

Note

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(Memory Mapping)
https://blog.souloss.com/posts/programming/memory/memory-memory-mapping/
作者
Tsukimi
发布于
2026-06-13
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时