当前位置:首页>Linux>深入 mmap:被严重低估的 Linux 黑科技,MySQL/Redis/Nginx 都在用它

深入 mmap:被严重低估的 Linux 黑科技,MySQL/Redis/Nginx 都在用它

  • 2026-09-18 19:29:00
深入 mmap:被严重低估的 Linux 黑科技,MySQL/Redis/Nginx 都在用它

大家好,我是小康。

先来问你一个问题。

你打开浏览器,看一个网页,数据从服务器到你眼前,经历了多少次"复制"?

你用 MySQL 查一条数据,从磁盘到你拿到结果,数据被搬运了几次?

答案出乎很多人意料:至少 4 次。

而且其中有 2 次,是完全可以省掉的。

省掉它的技术,叫做 mmap。

MySQL、Redis、Nginx、你每天运行的每一个程序——背后全靠它。

但 99% 的程序员,写了十年代码,也没真正理解过它。

注意:PC端阅读,图解会更清楚些!

先说说,没有 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 的所有核心用法:

组合
用途
MAP_SHARED + 文件
读写文件,改动自动刷盘(数据库爱用)
MAP_PRIVATE + 文件
加载动态库、程序代码段
MAP_SHARED + ANONYMOUS
父子进程共享内存(现代 IPC)
MAP_PRIVATE + ANONYMOUS
malloc 大内存时底层就是这个

四种用法,一次全搞懂

用法一:读一个大文件

传统方式,你要循环 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。

内核自动管理缓存,你不需要操心任何缓冲区。

用法二:修改文件内容(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() 系统调用。

用法三:父子进程共享内存

以前用 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 调用本身非常快。

它不会立刻把文件读进内存——它只是在你的进程地址空间"占了个位置",然后什么都不做。

调用 mmap()    ↓在进程虚拟地址空间登记一段映射(物理内存:什么都没发生)    ↓你第一次访问 p[0]    ↓CPU 触发【缺页中断】(Page Fault)    ↓内核:把文件对应那 4KB 从磁盘读进 Page Cache,建立映射    ↓你的程序继续执行,感知不到任何中断

映射 1GB 的文件,只有你真正访问过的页才会占内存。 其余的,一直在磁盘上睡觉。

如果你知道自己要顺序读,可以提前告诉内核预热:

madvise(p, size, MADV_SEQUENTIAL);  // 告诉内核:我要顺序读,提前预读madvise(p, size, MADV_DONTNEED);    // 告诉内核:这段我不需要了,释放吧

写时复制:fork 为什么这么快?

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 在做快照时几乎无停顿的秘密。


谁在用 mmap?(你每天都在打交道的)

MySQL / InnoDB

InnoDB 的 Buffer Pool 用 mmap 分配大块内存,读写数据文件时直接操作内存页,操作系统负责脏页刷盘,大量减少 write() 系统调用开销。

Redis

fork 子进程做 RDB 快照,父子进程内存页通过 COW 共享。主进程几乎无停顿,子进程慢慢把数据写到磁盘。全靠 mmap + COW 的魔法。

Nginx

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 无处不在,只是你没注意到。

使用 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);  // 通常是 4096mmap(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,一张表看懂

场景
read/write
mmap
小文件顺序读(< 64KB)
更快
建立映射有开销
大文件顺序读(> 1MB)
一般
省一次拷贝
大文件随机访问
反复 lseek
直接指针偏移
多进程共享同一文件
各自拷贝一份
共享同一 Page Cache
数据库/KV 存储引擎
—
工业界主流选择
修改写回文件
需要 write()
自动脏页回写

最后,一句话把 mmap 讲透

传统 I/O:磁盘 ──DMA──→ 内核 Page Cache ──CPU拷贝──→ 你的 buf[]                                  ↑                              这次可以省掉mmap:磁盘 ──DMA──→ 内核 Page Cache                    ↕ 页表映射(零拷贝)               你的程序直接"看到"这里

mmap 不是什么神秘的黑科技,它的本质是:用虚拟内存的页表映射,替代数据拷贝。

理解了 mmap,你就理解了:

  • 为什么数据库要直接操作内存页,而不是反复 read/write
  • 为什么 100 个进程用同一个库,内存不会乘以 100
  • 为什么 Redis fork 之后主进程几乎不卡顿
  • 为什么 Linux 上运行一个程序比你想象中快得多

虚拟内存、物理内存、文件系统——这三者在 Linux 里是被统一在同一套机制下管理的。

而 mmap,就是打通它们的那把钥匙。

小康最近开设C++项目课程:

如果你读完这篇还觉得 C、C++、Linux 有些陌生,别急——我也开设了这三门入门课程,从零带你打好地基,快速上手项目实战:

如果你已经有一定基础,想冲击更高的天花板,那下面这些工业级 C++ 项目正是为你准备的:

不讲玩具代码,全是工业级项目:

项目
亮点
线程池
经典并发基础设施
高性能日志库 MiniSpdlog
对标 spdlog
高性能内存池
彻底告别 malloc 瓶颈
多线程下载工具
分片并发实战
MySQL 连接池
数据库高并发必备
内存泄漏检测器
Hook malloc/free 原理
ReactorX
高性能事件驱动框架
无锁栈 / 无锁队列(SPSC/MPMC)
无锁编程精髓
工业级智能指针(shared_ptr)
手写引用计数
高性能网络库 NetCore
从 epoll 到框架
高性能异步日志库 ZephyrLog
异步 + 无锁
死锁检测工具
图算法实战
高性能 HTTP 服务器
性能吊打大多数开源项目
协程库 CoroForge
对标微信 libco
高性能 HTTP 压测工具
参考 wrk 实现
Redis 核心模块实战
从 0 到 1 实现

每个项目都是真实可用的工程代码,不是教学玩具。

👉 点击查看上面完整C++项目课程介绍

  • 对C++项目实战课程感兴趣的朋友,可以扫下方二维码添加小康微信(或微信搜索:jkfwdkf )
  • 备注「 项目实战 」

觉得有帮助,点赞、在看和转发,让更多程序员看到 🙏


下篇预告:Linux 零拷贝全解析 —— 内核是怎么让数据"自己流动"的?


觉得有收获,点赞、在看、转发给需要的人 🙏

每一个理解了底层的工程师,写出来的代码都不一样。

最新文章

随机文章