我认识的一个的朋友,在一家做图像处理的公司上班。有段时间他负责优化图片处理模块的性能,把原来 open/read/write 这套流程换成了 mmap。效果确实好——内存占用少了,CPU 也省了。但没过多久,线上开始出现图片显示乱码的 bug,排查了两天才发现是 mmap 的共享模式和私有模式混着用,改文件的人以为内存不会同步,结果别人一改文件,他的内存也跟着变了。
这事挺典型的。mmap 是 Linux 里最强大的系统调用之一,但它的四种组合——共享/私有 × 文件/匿名——把很多人搞懵了。你以为你在读内存,实际上可能读的是别人的改动;你以为改了内存不会影响磁盘,结果写操作偷偷同步了。最扯的是,这种 bug 在线上往往表现为间歇性数据损坏——不触发条件时一切正常,触发一次就丢数据,排查起来能让人怀疑人生。
根本原因在于没人讲清楚一件事:mmap 的本质不是"把文件读进内存",而是"让一段虚拟地址空间和某种资源建立映射关系"。资源可以是文件,也可以是匿名内存;关系可以是共享的,也可以是私有的。
先看基本盘:文件映射 vs 匿名映射
这俩的区别最直接。
文件映射(file mapping):把磁盘上的一个文件映射到进程的虚拟地址空间。映射建立之后,读这片内存就读文件,写这片内存就写文件。内核负责把页表项和文件的 offset 对应起来,缺页中断时从磁盘加载。
匿名映射(anonymous mapping):不跟任何文件挂钩,映射的是一段纯内存。常见用途是 malloc() 的实现——glibc 底层就是用 mmap 来分配大块内存的,小块才走 brk/sbrk。
// 文件映射示例int fd = open("data.bin", O_RDWR);void *ptr = mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);// 匿名映射示例void *ptr = mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
这两个维度——"跟不跟文件挂钩" × "共享还是私有"——就是四种映射的完整分类。拆开来看。
私有文件映射:读时共享,写时拷贝
MAP_PRIVATE + 文件映射的组合。
核心机制是 COW(Copy-On-Write,写时拷贝)。内核建立映射的时候,页表项指向文件对应的物理页——这个页是共享的。但你第一次写这片内存的时候,内核会截获这个写操作,分配一个新的物理页,把数据复制过来,然后把你的页表项改成指向新页。
读操作不触发拷贝,写操作才触发。这是私有文件映射最核心的行为特征。
这意味着什么?意味着如果你有 10 个进程都用 MAP_PRIVATE 映射同一个文件,前 10 个进程读的物理页是同一份——节省内存。谁先写,谁就拿到自己的副本,后面的写操作互不影响。
这个机制在动态链接库里被广泛使用。你启动一个程序,几十个共享库用私有映射加载进地址空间。同一个 libc.so.6 的读段被几百个进程共享,但每个进程自己的写段(.data、.bss)有独立的副本。这就是 COW 的价值——你不需要为每个进程复制一份完整的库文件。
但有一个大坑:写拷贝完成后,你的修改不会回写到磁盘。 私有映射的本质是"我只想看文件的某个版本,改了也不影响源文件"。所以如果你 mmap 一个配置文件,改了之后希望持久化——别想了,munmap() 之后文件原封不动。想写回磁盘,得用 msync(),而且即使是 MAP_PRIVATE,msync() 的行为也取决于实现,有些系统直接忽略。我见过有人因为这个 bug 排查了整整一下午——改完配置重启服务,发现配置根本没变,查了一圈才发现是 mmap 的私有属性在作祟。
共享文件映射:一写全写,一改全改
MAP_SHARED + 文件映射。这才是真正的"内存就是文件"。
没有任何拷贝机制。所有映射了同一片区域的进程,看到的物理页是同一份。一个进程改了内存,其他进程马上能看到变化——因为是同一块物理内存。反过来,磁盘上的文件也会被修改(延迟写入,内核会在适当时机 flush)。
这个特性有什么用?
进程间通信。 多个进程映射同一个文件,相当于共享了一块内存。不用 pipe,不用 socket,不用 shared memory 系统调用——mmap 本身就能完成通信。Redis 的持久化、一些高性能缓存系统、甚至数据库的 buffer pool,底层都有这个思路的影子。
但共享也意味着风险,而且是那种让人头皮发麻的风险。你改了内存,其他进程可能正在读——没有锁的情况下,读到一半的数据可能是脏的。共享文件映射的典型用法是在映射之前先对文件加锁,或者用信号量做同步。我见过最离谱的 bug 是一个共享映射的生产服务,某个 worker 进程崩溃后没有释放映射,其他进程继续读写那片内存——结果整个服务的数据都乱了,排查的时候日志里全是"数据不一致",找了一圈才发现是映射没释放导致的内存踩踏。
另一个风险是延迟写入。共享映射的修改不会立即落盘,内核有自己的 flush 策略。如果你写完之后期望数据立刻持久化——它不一定在。这也是为什么数据库用共享映射做 buffer pool 的时候,会有专门的 checkpoint 线程来确保数据落盘。
共享匿名映射:进程间的隐形成约
MAP_SHARED + 匿名映射。这组组合比较特殊,因为匿名映射没有 backing file,共享的只是物理内存,没有磁盘持久化。
实际场景不多,但有一个经典用法:** fork 之后的父子进程共享内存**。
// 父进程创建共享匿名映射void *ptr = mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0);pid_t pid = fork();if (pid == 0) {// 子进程:ptr 指向和父进程相同的物理页// 修改这里,父进程也能看到}
fork 之后,父子进程的页表指向同一组物理页。如果用 MAP_SHARED,子进程对这片内存的修改对父进程可见——这就是共享匿名映射的核心价值。
但要注意:如果用 MAP_PRIVATE 做同样的事,fork 之后走的是 COW 机制——父子进程各自有独立副本,互不影响。这是 MAP_PRIVATE 匿名映射最常见的用途,也是 malloc() 在 fork 之后的行为基础。
私有匿名映射:malloc 的底层真相
MAP_PRIVATE + 匿名映射。这是 Linux 内存分配最核心的组合。
glibc 的 malloc() 对大于某个阈值(通常是 128KB,可以通过 mallopt 调整)的分配,底层走的就是 mmap with MAP_PRIVATE|MAP_ANONYMOUS。小分配走 ptmalloc 管理的 chunk,大分配直接 mmap——这两套机制并存,是 Linux 内存管理的基本事实。
为什么大分配走 mmap 而不是 brk?原因很简单:brk 扩展的是进程的 data segment,它是一次性的、从低地址往高地址推的。mmap 可以分散在地址空间的任意位置,而且 munmap() 之后可以直接归还给内核,不需要等整个进程退出。对于大对象来说,这种灵活性说句实在话——brk 那种方式根本办不到。
// 等价于一个 4MB 的 malloc,底层是 mmapvoid *ptr = mmap(NULL, 4*1024*1024, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);// 用 free() 释放——glibc 会把这块转成 munmapfree(ptr);
一个容易忽视的细节:MAP_ANONYMOUS 在 Linux 上不需要文件描述符(传 -1),但 POSIX 标准要求用一个有效的 fd。所以在跨平台代码里,你可能会看到 MAP_ANONYMOUS|MAP_SHARED 配合一个 /dev/zero 的 fd——这是为了让代码在 macOS 和其他 Unix 系统上也能编译通过。我写过一段代码,在 Linux 上跑得好好的,部署到 macOS 上直接 segfault——就是因为忘了加 /dev/zero 这个 fd。
一张表理清四种组合
实际选型:别凭感觉,看场景
回到开头那个朋友的 bug——他混淆了共享和私有。如果他的图片处理模块只需要自己进程读写,用 MAP_PRIVATE 就够了;如果要让多个 worker 进程共享处理结果,才需要 MAP_SHARED。
选型的核心判断标准就两个问题:
第一,是否需要持久化? 要持久化到磁盘(比如数据库页缓存)→ 文件映射。不要持久化(比如临时计算结果)→ 匿名映射。
第二,是否需要进程间共享? 需要 → MAP_SHARED。不需要 → MAP_PRIVATE。
两个问题交叉,就是四种组合的唯一正确选择。没有灰色地带,也没有"性能更好"的选项——共享和私有的性能差异主要来自 COW 拷贝和页锁竞争,而不是映射方式本身。
大多数性能问题不是选错了映射类型,而是选对了类型但没用对同步机制。 共享映射没有锁——这是 90% 的共享映射 bug 的根源。我见过太多人用共享映射做进程间通信,以为"反正我们进程都好好的,不会出问题",结果一旦某个进程 crash,其他进程的数据全乱套。这种问题在线上出现的时候,基本就是灾难性的。
再问一句:你现在的代码里,有没有在用 mmap?用的是哪种组合?