大家好,我是小康。
先来问你一个问题。
你打开浏览器,看一个网页,数据从服务器到你眼前,经历了多少次"复制"?
你用 MySQL 查一条数据,从磁盘到你拿到结果,数据被搬运了几次?
答案出乎很多人意料:至少 4 次。
而且其中有 2 次,是完全可以省掉的。
省掉它的技术,叫做 mmap。
MySQL、Redis、Nginx、你每天运行的每一个程序——背后全靠它。
但 99% 的程序员,写了十年代码,也没真正理解过它。
你平时读文件,代码可能就这一行:
read(fd, buf, 4096);看起来很简单。但背后发生了什么?
磁盘 │ │ 第①次拷贝(DMA 搬运) ▼内核缓冲区(Page Cache) │ │ 第②次拷贝(内核 → 你的程序) ▼你的 buf[]数据被复制了两次。
读完还要写回?再来两次。一次完整的读写,数据至少被搬了 4 次。
对于小文件,感知不到。但你想想 MySQL 每秒处理几万次查询,Redis 扛着几十万 QPS,Nginx 一次发送一个 500MB 的视频文件……
4 次拷贝,是系统性能的天花板。
它的核心思想只有一句话:
把文件直接映射到你的程序内存里,读写内存 = 读写文件,中间那次拷贝,消失了。
对比一下:
传统 read():
┌──────────┐ 拷贝① ┌──────────┐ 拷贝② ┌──────────┐│ 磁盘 │ ──────► │ 页缓存 │ ──────► │ 用户buf │└──────────┘ └──────────┘ └──────────┘ (内核空间) (用户空间) ↑ 多了这次拷贝!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 的所有核心用法:
传统方式,你要循环 read(),管理 buf,处理边界条件。
mmap 的方式:
int fd = open("data.bin", O_RDONLY);structstatst;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。
内核自动管理缓存,你不需要操心任何缓冲区。
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() 系统调用。
以前用 shmget 那套 System V 接口,又丑又麻烦,还得手动清理内核资源。
现代写法:
// 在 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)); // 进程退出自动释放,无需手动清理简洁、优雅、不留垃圾。
当你敲下 ./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,代码页只有一份物理内存。谁要写(比如重定位 GOT 表),内核才给它复制一份。
这就是为什么你开 100 个 Nginx worker,内存占用远比 100 × 单进程小。
mmap 调用本身非常快。
它不会立刻把文件读进内存——它只是在你的进程地址空间"占了个位置",然后什么都不做。
调用 mmap() ↓在进程虚拟地址空间登记一段映射(物理内存:什么都没发生) ↓你第一次访问 p[0] ↓CPU 触发【缺页中断】(Page Fault) ↓内核:把文件对应那 4KB 从磁盘读进 Page Cache,建立映射 ↓你的程序继续执行,感知不到任何中断映射 1GB 的文件,只有你真正访问过的页才会占内存。 其余的,一直在磁盘上睡觉。
如果你知道自己要顺序读,可以提前告诉内核预热:
madvise(p, size, MADV_SEQUENTIAL); // 告诉内核:我要顺序读,提前预读madvise(p, size, MADV_DONTNEED); // 告诉内核:这段我不需要了,释放吧MAP_PRIVATE 有个让人拍案叫绝的机制:写时复制(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 在做快照时几乎无停顿的秘密。
InnoDB 的 Buffer Pool 用 mmap 分配大块内存,读写数据文件时直接操作内存页,操作系统负责脏页刷盘,大量减少 write() 系统调用开销。
fork 子进程做 RDB 快照,父子进程内存页通过 COW 共享。主进程几乎无停顿,子进程慢慢把数据写到磁盘。全靠 mmap + COW 的魔法。
Nginx 的多个 worker 进程之间需要共享状态数据——比如限流计数、SSL session 缓存、连接数统计。这些共享数据区域,底层全是用 mmap(MAP_SHARED | MAP_ANONYMOUS) 分配的,让所有 worker 看到同一块内存,不需要进程间通信。
顺带一提,Nginx 发送静态文件用的是另一个技术 sendfile——直接在内核态把数据送到网卡,比 mmap 更彻底的零拷贝,两者是独立的机制,各司其职。
你写的每一个 C/C++ 程序跑起来,代码段、数据段、所有动态库——都是被 mmap 进来的,不是 read 进来的。
你用 malloc 申请超过 128KB 的内存?glibc 底层用的是 mmap(MAP_PRIVATE | MAP_ANONYMOUS),不是 brk()。
mmap 无处不在,只是你没注意到。
// 错误:文件是空的,一访问就崩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'; // OKlong page_size = sysconf(_SC_PAGESIZE); // 通常是 4096mmap(NULL, len, PROT_READ, MAP_SHARED, fd, page_size * 2); // 正确mmap(NULL, len, PROT_READ, MAP_SHARED, fd, 100); // 有问题: EINVALchar *p = mmap(...);munmap(p, size);char c = p[0]; // Segfault,未定义行为mmap 建立映射、处理缺页中断都有开销。文件小于几十KB,老实用 read() 就好。mmap 的优势在大文件、随机访问、多进程共享。
传统 I/O:磁盘 ──DMA──→ 内核 Page Cache ──CPU拷贝──→ 你的 buf[] ↑ 这次可以省掉mmap:磁盘 ──DMA──→ 内核 Page Cache ↕ 页表映射(零拷贝) 你的程序直接"看到"这里mmap 不是什么神秘的黑科技,它的本质是:用虚拟内存的页表映射,替代数据拷贝。
理解了 mmap,你就理解了:
虚拟内存、物理内存、文件系统——这三者在 Linux 里是被统一在同一套机制下管理的。
而 mmap,就是打通它们的那把钥匙。
如果你读完这篇还觉得 C、C++、Linux 有些陌生,别急——我也开设了这三门入门课程,从零带你打好地基,快速上手项目实战:
如果你已经有一定基础,想冲击更高的天花板,那下面这些工业级 C++ 项目正是为你准备的:
不讲玩具代码,全是工业级项目:
每个项目都是真实可用的工程代码,不是教学玩具。

觉得有帮助,点赞、在看和转发,让更多程序员看到 🙏
下篇预告:Linux 零拷贝全解析 —— 内核是怎么让数据"自己流动"的?
觉得有收获,点赞、在看、转发给需要的人 🙏
每一个理解了底层的工程师,写出来的代码都不一样。