深入 mmap:被严重低估的 Linux 黑科技,MySQL/Redis/Nginx 都在用它
Mmap 不是什么神秘的黑科技,它的本质是:用虚拟内存的页表映射,替代数据拷贝。
先来思考一个简单的问题。
免费影视、动漫、音乐、游戏、小说资源长期稳定更新! 👉 点此立即查看 👈
当你打开浏览器浏览一个网页时,数据从服务器传输到你的屏幕,中间经历了多少次“复制”?
或者,当你用 MySQL 查询一条数据时,从磁盘读取到返回结果,数据又被搬运了几次?
答案可能会让很多人感到意外:至少 4 次。
更关键的是,这其中至少有 2 次拷贝,是完全可以省掉的。而省掉它的核心技术,就是 mmap。
从 MySQL、Redis、Nginx,到你每天运行的每一个程序,背后都离不开它的支持。然而,即便写了多年代码,很多开发者对其核心原理依然一知半解。

一、先说说,没有 mmap 之前有多慢
平时读取文件,代码可能只有一行:
read(fd, buf, 4096);
看起来简洁明了,但背后发生了什么?
磁盘
│
│ 第①次拷贝(DMA 搬运)
▼
内核缓冲区(Page Cache)
│
│ 第②次拷贝(内核 → 你的程序)
▼
你的 buf[]
数据被完整地复制了两次。如果涉及写回操作,同样的过程还要再来一遍。一次完整的读写,数据至少被搬运了 4 次。
对于小文件,这种开销或许感知不强。但想象一下 MySQL 每秒处理数万次查询,Redis 承载数十万 QPS,或者 Nginx 发送一个 500MB 的视频文件……这 4 次拷贝,就成了系统性能难以逾越的天花板。
二、mmap 做了一件听起来很简单、但很优雅的事
它的核心思想可以用一句话概括:将文件直接映射到程序的虚拟地址空间,让读写内存等同于读写文件,从而消除中间那一次关键的数据拷贝。
对比一下两种方式:
传统 read():
┌──────────┐ 拷贝① ┌──────────┐ 拷贝② ┌──────────┐
│ 磁盘 │ ──────► │ 页缓存 │ ──────► │ 用户buf │
└──────────┘ └──────────┘ └──────────┘
(内核空间) (用户空间)
↑
多了这次拷贝!
mmap:
┌──────────┐ 拷贝① ┌──────────┐
│ 磁盘 │ ──────► │ 页缓存 │
└──────────┘ └────┬─────┘
│ 直接映射(零拷贝!)
▼
┌──────────┐
│ 进程虚拟 │
│ 地址空间 │ ← 用户直接读写这里
└──────────┘
程序直接“看到”并操作的,就是内核页缓存里的那份数据。省掉从内核到用户空间的那次拷贝,这正是 mmap 实现“零拷贝”的关键所在。
三、mmap 用起来是什么感觉?
先看函数签名:
void *mmap(void *addr, // 映射到哪(传 NULL 让内核决定)
size_t length, // 映射多少字节
int prot, // 权限(读/写/执行)
int flags, // 关键参数,下面讲
int fd, // 文件描述符
off_t offset); // 从文件哪里开始映射
其中,flags 参数是灵魂,主要分为两组:
MAP_SHARED → 修改会同步回文件,其他进程也能看到
MAP_PRIVATE → 写时复制,你改了不影响原文件
MAP_ANONYMOUS → 不关联文件,纯粹申请内存(fd 传 -1)
通过这几种标志的组合,可以覆盖 mmap 几乎所有的核心应用场景。
四、四种用法,一次全搞懂
1. 用法一:读一个大文件
传统方式需要循环调用 read(),管理缓冲区,处理边界条件。而 mmap 的方式则优雅得多:
int fd = open("data.bin", O_RDONLY);
struct stat st;
fstat(fd, &st);
// 把整个文件映射进来
char *p = mmap(NULL, st.st_size, PROT_READ, MAP_SHARED, fd, 0);
close(fd); // 映射建立后,fd 可以关了
// 直接用指针读,像操作数组一样
printf("文件头:%02x %02x %02x %02x\n", p[0], p[1], p[2], p[3]);
printf("第1000个字节:%c\n", p[1000]);
munmap(p, st.st_size);
需要随机访问任意位置?直接使用 p[offset] 即可,无需调用 lseek。内核自动管理缓存,开发者无需操心缓冲区细节。
2. 用法二:修改文件内容(MAP_SHARED 写回)
int fd = open("data.bin", O_RDWR);
ftruncate(fd, 1024); // 先确保文件有这么大
char *p = mmap(NULL, 1024, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
// 直接写,内核会自动同步回磁盘
p[0] = 'H';
p[1] = 'i';
// 想立刻刷盘,不等内核调度?
msync(p, 1024, MS_SYNC); // 同步等待刷完
munmap(p, 1024);
close(fd);
这正是 SQLite 等数据库的底层原理之一。它们通过 mmap 将数据库文件映射到内存,直接修改内存页,由操作系统负责将脏页刷回磁盘,从而省去了大量 write() 系统调用的开销。
3. 用法三:父子进程共享内存
过去使用 System V 的 shmget 接口,不仅繁琐,还需要手动清理内核资源。现代写法则简洁得多:
// 在 fork() 前创建,子进程自动继承
int *shared = mmap(NULL, sizeof(int),
PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_ANONYMOUS,
-1, 0);
*shared = 0;
if (fork() == 0) {
*shared = 42; // 子进程写入
printf("子进程写:%d\n", *shared);
} else {
wait(NULL);
printf("父进程读到:%d\n", *shared); // 输出 42
}
munmap(shared, sizeof(int)); // 进程退出自动释放,无需手动清理
这种方式简洁、优雅,且不会留下任何内核垃圾。
4. 用法四:你每天都在用,但完全不知道
当你敲下 ./my_program 执行一个程序时,内核是如何将代码加载进内存的?
答案就是:mmap。
ELF 可执行文件(磁盘)
┌──────────────────┐
│ .text 代码段 │ ──→ MAP_PRIVATE mmap ──→ 进程虚拟空间[代码段]
│ .rodata 只读数据 │ ──→ MAP_PRIVATE mmap ──→ 进程虚拟空间[只读段]
│ .data 数据段 │ ──→ MAP_PRIVATE mmap ──→ 进程虚拟空间[数据段]
└──────────────────┘
动态库 libc.so
│ │ ──→ MAP_SHARED mmap ──→ 所有进程共享同一份代码页
为什么使用 MAP_PRIVATE?这涉及到写时复制(Copy-on-Write)机制。100 个进程都使用 libc,但代码页在物理内存中只有一份。只有当某个进程试图写入(例如进行重定位)时,内核才会为其复制一份独立的物理页。
这就是为什么启动 100 个 Nginx worker 进程,其内存占用远小于单进程内存占用的 100 倍。
五、mmap 最聪明的地方:它是懒的
mmap 调用本身非常快。它并不会立刻将文件内容读入内存,而只是在进程的地址空间里“登记”一段映射关系,物理内存上什么也没发生。
调用 mmap()
↓
在进程虚拟地址空间登记一段映射(物理内存:什么都没发生)
↓
你第一次访问 p[0]
↓
CPU 触发【缺页中断】(Page Fault)
↓
内核:把文件对应那 4KB 从磁盘读进 Page Cache,建立映射
↓
你的程序继续执行,感知不到任何中断
即使映射一个 1GB 的文件,也只有程序真正访问过的内存页才会被加载。其余部分,则安静地留在磁盘上。
如果明确知道访问模式,还可以给内核一些“提示”来优化性能:
madvise(p, size, MADV_SEQUENTIAL); // 告诉内核:我要顺序读,提前预读
madvise(p, size, MADV_DONTNEED); // 告诉内核:这段我不需要了,释放吧
六、写时复制:fork 为什么这么快?
MAP_PRIVATE 标志配合着一个精妙的机制:写时复制(Copy-on-Write, COW)。
char *p = mmap(NULL, size, PROT_READ | PROT_WRITE,
MAP_PRIVATE, fd, 0);
p[0] = 'X'; // 这个修改,不会写回文件
这个过程发生了什么:
初始:进程 A 的虚拟地址 ──指向──→ 原始物理页(文件内容)
写入 p[0] = 'X' 时:
① 内核偷偷复制一份新的物理页
② 把进程 A 的页表指向新页
③ 在新页上写入 'X'
④ 原始文件的物理页,纹丝不动
进程 A 看到的:已修改的副本
文件里的:原始内容,未受影响
fork() 系统调用正是利用了这个机制。子进程创建时,与父进程共享所有物理内存页,不做任何复制。只有当某一方真正尝试写入某个内存页时,内核才会复制该页。
Redis 的 RDB 持久化就依赖于此。fork 一个子进程负责将数据写入磁盘,父进程继续处理请求。只有父进程修改的内存页才会被复制,其余部分保持共享。这就是 Redis 在执行快照时,服务几乎无停顿的秘密。
七、谁在用 mmap?(你每天都在打交道的)
1. MySQL / InnoDB
InnoDB 的 Buffer Pool 使用 mmap 分配大块内存。读写数据文件时直接操作内存页,由操作系统负责脏页刷盘,从而大幅减少了 write() 系统调用的开销。
2. Redis
如前所述,fork 子进程进行 RDB 快照时,父子进程通过 COW 共享内存页。主进程几乎无感知,子进程则从容地将数据写入磁盘。这一切都依赖于 mmap 和 COW 的协同工作。
3. Nginx
Nginx 的多个 worker 进程之间需要共享状态数据,例如限流计数、SSL 会话缓存、连接数统计。这些共享数据区域,底层都是通过 mmap(MAP_SHARED | MAP_ANONYMOUS) 分配的,使得所有 worker 进程能看到同一块内存,无需复杂的进程间通信。
顺带一提,Nginx 发送静态文件使用的是另一个技术——sendfile。它能在内核态直接将数据送到网卡,是比 mmap 更彻底的零拷贝。两者是独立的机制,各司其职。
4. 你的每一行代码
你编写的每一个 C/C++ 程序运行时,其代码段、数据段以及所有动态库,都是通过 mmap 映射进内存的,而非通过 read 加载。
当你使用 malloc 申请超过 128KB 的内存时,glibc 底层调用的也是 mmap(MAP_PRIVATE | MAP_ANONYMOUS),而非 brk()。
可以说,mmap 无处不在,只是它通常隐藏在系统底层,不被轻易察觉。
八、使用 mmap 的几个大坑,别踩
坑1:映射空文件会触发 SIGBUS
// 错误:文件是空的,一访问就崩
int fd = open("new.dat", O_RDWR | O_CREAT, 0666);
char *p = mmap(NULL, 4096, PROT_WRITE, MAP_SHARED, fd, 0);
p[0] = 'a'; // SIGBUS
// 正确:先设置文件大小
ftruncate(fd, 4096);
char *p = mmap(NULL, 4096, PROT_WRITE, MAP_SHARED, fd, 0);
p[0] = 'a'; // OK
坑2:offset 必须是页大小的整数倍
long page_size = sysconf(_SC_PAGESIZE); // 通常是 4096
mmap(NULL, len, PROT_READ, MAP_SHARED, fd, page_size * 2); // 正确
mmap(NULL, len, PROT_READ, MAP_SHARED, fd, 100); // 有问题: EINVAL
坑3:munmap 之后指针就是野指针
char *p = mmap(...);
munmap(p, size);
char c = p[0]; // Segfault,未定义行为
坑4:小文件用 mmap 反而更慢
mmap 建立映射、处理缺页中断都有固定开销。对于小于几十KB的文件,老老实实使用 read() 通常效率更高。mmap 的优势在于处理大文件、随机访问以及多进程共享的场景。
九、mmap vs 传统 read/write,一张表看懂
(此处保留原文中关于对比的表格或描述,因原文未提供具体表格内容,故保持结构提示)
十、最后,一句话把 mmap 讲透
传统 I/O:磁盘 ──DMA──→ 内核 Page Cache ──CPU拷贝──→ 你的 buf[]
↑
这次可以省掉
mmap:磁盘 ──DMA──→ 内核 Page Cache
↕ 页表映射(零拷贝)
你的程序直接“看到”这里
说到底,mmap 并非什么神秘的黑科技。它的本质在于:用虚拟内存的页表映射机制,替代了一次不必要的数据拷贝。
理解了 mmap,也就理解了:
- 为什么数据库倾向于直接操作内存页,而非反复调用 read/write。
- 为什么 100 个进程共用同一个动态库,内存占用不会简单乘以 100。
- 为什么 Redis 在 fork 子进程持久化时,主进程服务几乎不会卡顿。
- 为什么在 Linux 上运行一个程序,其速度远比想象中要快。
虚拟内存、物理内存、文件系统——在 Linux 中,这三者被统一在同一套精密的机制下管理。而 mmap,正是打通这三者壁垒的那把关键钥匙。
热门专题
热门推荐
司美格鲁肽:从“网红减肥针”到健康警示,真相究竟是什么? 最近几年,司美格鲁肽这个名字,在社交媒体上几乎成了“减肥神药”的代名词。但热度之下,争议和疑问也从未停歇。 就在近日,一条关于“94斤女生打司美格鲁肽减肥被送急诊”的话题冲上热搜,再次将这款药物推到了风口浪尖。这不禁让人追问:它到底是捷径,还
联发科下一代旗舰芯片的核心信息近日浮出水面 最近,关于联发科下一代旗舰芯片的消息在业内传得沸沸扬扬。据可靠博主爆料,这款芯片预计归属天玑9600系列,内部代号“Canyon”。光是这个代号,就让人浮想联翩,感觉大有乾坤。 采用台积电N2p工艺,首配双超大核 那么,这次天玑9600系列到底带来了哪些硬
异环奈布拉怎么获取? 在《异环》游戏的开荒阶段,一辆性能卓越的载具是探索广阔世界的关键助力。其中,奈布拉以其卓越的防御力与出色的全地形适应能力,成为众多玩家优先追求的目标。它不仅是一台高效的代步工具,更是能够穿越险恶环境、抵御异象侵袭的可靠移动堡垒。那么,这辆硬核载具究竟该如何获取并有效培养?本文将
一、形态幅度止盈法 这个方法的核心,是利用那些经典技术形态自带的“量尺”。当形态构筑完成、价格突破关键位置后,它会有一个理论上的目标区域。我们要做的,就是识别这个信号,然后在这个目标区域内分批“下车”。 具体怎么操作呢?首先,得确认股价已经有效突破了,比如双顶的颈线,或者头肩顶形态里右肩高点的连线。
无人叉车诡异启动撞向停放救护车 涉事公司员工:或是线路短路 近日,四川南充一处停车场内上演了颇为离奇的一幕:一辆无人操作的叉车,竟自己“活”了过来,启动后径直撞向了停在一旁的救护车。整个过程被监控清晰记录,视频流传网络后,甚至引来不少网友对“灵异事件”的调侃。 从监控画面看,事发时这辆叉车周围空无一





