
1. 从一次共享内存踩坑说起mmap到底解决什么问题很多做Linux系统编程的朋友第一次接触mmap大概率是在读文件或者做进程间通信IPC的时候。我当年也是看到mmap能把文件直接映射到内存第一反应是这不就是个高级read/write吗直到后来在一个多进程数据处理的项目里被现实狠狠教育了一顿才真正理解mmap在进程间通信这个场景下的分量。先说结论mmap做IPC的核心价值在于零拷贝的共享内存。传统的管道、消息队列、socket数据都要在内核缓冲区和用户缓冲区之间来回倒腾一次通信至少两次拷贝。而mmap把同一块物理内存映射到多个进程的虚拟地址空间进程A写进去进程B直接就能读到中间没有内核中转没有系统调用开销这是它最本质的优势。但这里有个特别容易踩的坑匿名映射MAP_ANONYMOUS默认是不共享的。很多人写了个mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_ANONYMOUS|MAP_PRIVATE, -1, 0)然后fork出子进程发现子进程改了数据父进程看不到就以为mmap不能做IPC。其实问题出在MAP_PRIVATE上——私有映射用的是写时复制COW父子进程各改各的。要做IPC必须用MAP_SHARED。这篇文章我打算把mmap做进程间通信这件事从头到尾捋一遍它凭什么快、匿名映射和文件映射怎么选、共享内存的同步问题怎么处理、实际项目里那些文档不会告诉你的坑。适合已经会写基本C程序、想深入理解Linux IPC机制的开发者也适合正在做多进程架构选型的朋友。2. mmap共享内存的底层逻辑为什么它比管道快一个数量级2.1 虚拟内存映射一次映射多进程可见要理解mmap为什么快得先搞清楚Linux的虚拟内存机制。每个进程都有自己独立的虚拟地址空间虚拟地址通过页表映射到物理内存页。正常情况下进程A的虚拟地址0x1000和进程B的虚拟地址0x1000指向的是不同的物理页互不干扰。mmap的魔法在于它可以让两个进程的不同虚拟地址映射到同一块物理内存页。当进程A往自己的虚拟地址写数据时硬件MMU通过页表找到物理页数据就落在了那块共享的物理内存上进程B读自己的虚拟地址页表指向同一个物理页自然就读到了进程A写的数据。这个过程完全由硬件MMU完成不需要内核参与数据搬运。对比一下管道进程A调用write()数据从用户空间拷贝到内核缓冲区进程B调用read()数据从内核缓冲区拷贝到用户空间。两次拷贝两次系统调用每次都要陷入内核态。数据量大的时候这个开销非常可观。我做过一个粗略的测试传输1MB数据管道大概需要几十微秒而mmap共享内存的写入几乎是纳秒级的——因为压根没有传输这个动作就是一次内存写。2.2 匿名映射与文件映射两条路各有各的适用场景mmap做IPC有两条路匿名映射MAP_ANONYMOUS | MAP_SHARED不关联任何文件直接向内核申请一块共享内存。这种方式只能用于有亲缘关系的进程比如fork出来的父子进程因为匿名映射没有名字其他无关进程没法通过路径找到它。// 父进程创建匿名共享映射 void *shm mmap(NULL, SIZE, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); if (shm MAP_FAILED) { perror(mmap); exit(1); } pid_t pid fork(); if (pid 0) { // 子进程直接继承映射写数据 strcpy((char *)shm, hello from child); exit(0); } else { wait(NULL); // 父进程能读到子进程写的数据 printf(parent read: %s\n, (char *)shm); }文件映射MAP_SHARED 文件描述符把一个真实文件映射到内存多个无关进程只要打开同一个文件就能共享这块映射。这种方式适合需要持久化或者无亲缘关系进程间通信的场景。// 进程A和进程B都执行类似操作 int fd open(/tmp/shared_file, O_RDWR | O_CREAT, 0666); ftruncate(fd, SIZE); // 关键文件必须足够大 void *shm mmap(NULL, SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);选哪种我的经验是临时数据、父子进程通信用匿名映射简单干净需要跨无关进程、或者数据要落盘用文件映射。文件映射有个额外好处——即使所有进程都退出了数据还在文件里下次可以接着用。2.3 页对齐与长度参数那些让人抓狂的细节mmap的offset参数必须是页大小的整数倍这是硬性要求。页大小一般是4096字节可以用sysconf(_SC_PAGESIZE)获取。如果你传了个非对齐的offsetmmap直接返回MAP_FAILEDerrno是EINVAL。长度参数length倒是不要求对齐内核会自动向上取整到页边界。但这里有个坑映射区域超出文件实际大小的部分访问会触发SIGBUS信号。比如文件只有100字节你映射了4096字节访问第200字节时进程直接崩。所以文件映射前一定要ftruncate把文件撑到足够大。int fd open(/tmp/shm_file, O_RDWR | O_CREAT, 0666); // 必须先设置文件大小否则映射区域访问越界会SIGBUS if (ftruncate(fd, MAP_SIZE) -1) { perror(ftruncate); exit(1); }这个坑我踩过不止一次尤其是刚开始写的时候觉得文件大小无所谓结果一访问就崩调试半天才发现是文件没撑大。3. 共享内存的同步难题mmap不负责的事情3.1 数据竞争共享内存最大的陷阱mmap只负责把内存共享出去它完全不管同步。多个进程同时读写同一块共享内存数据竞争是必然的。举个最简单的例子两个进程同时对共享内存里的一个计数器做counter这个操作在汇编层面是读-改-写三步两个进程交错执行结果必然出错。我见过一个真实案例某数据处理系统用共享内存传递任务队列生产者进程往队列里写任务消费者进程从队列里读。因为没做同步消费者经常读到写了一半的数据导致解析崩溃。后来加了信号量才解决。所以用mmap做IPC同步机制是必须配套的。常用的有POSIX信号量sem_open/sem_wait/sem_post适合跨进程接口友好System V信号量semget/semop老牌方案功能强但接口繁琐文件锁fcntl适合简单的互斥场景原子操作__atomic系列适合简单的计数器、标志位3.2 用POSIX信号量保护共享内存完整示例下面是一个生产者-消费者的完整示例用POSIX信号量做同步#include stdio.h #include stdlib.h #include string.h #include unistd.h #include fcntl.h #include sys/mman.h #include sys/wait.h #include semaphore.h #define SHM_SIZE 4096 #define SEM_MUTEX /shm_mutex #define SEM_EMPTY /shm_empty #define SEM_FULL /shm_full typedef struct { int write_idx; int read_idx; char data[SHM_SIZE - 2 * sizeof(int)]; } SharedBuf; int main() { // 创建共享内存 int fd shm_open(/my_shm, O_CREAT | O_RDWR, 0666); ftruncate(fd, sizeof(SharedBuf)); SharedBuf *buf mmap(NULL, sizeof(SharedBuf), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); // 创建信号量 sem_t *mutex sem_open(SEM_MUTEX, O_CREAT, 0666, 1); sem_t *empty sem_open(SEM_EMPTY, O_CREAT, 0666, 10); sem_t *full sem_open(SEM_FULL, O_CREAT, 0666, 0); pid_t pid fork(); if (pid 0) { // 生产者 for (int i 0; i 10; i) { sem_wait(empty); sem_wait(mutex); buf-data[buf-write_idx] A i; buf-write_idx (buf-write_idx 1) % sizeof(buf-data); sem_post(mutex); sem_post(full); usleep(100000); } exit(0); } else { // 消费者 for (int i 0; i 10; i) { sem_wait(full); sem_wait(mutex); char c buf-data[buf-read_idx]; buf-read_idx (buf-read_idx 1) % sizeof(buf-data); sem_post(mutex); sem_post(empty); printf(consumed: %c\n, c); } wait(NULL); } // 清理 munmap(buf, sizeof(SharedBuf)); shm_unlink(/my_shm); sem_unlink(SEM_MUTEX); sem_unlink(SEM_EMPTY); sem_unlink(SEM_FULL); return 0; }这里用了三个信号量mutex保护缓冲区读写指针的原子性empty表示空槽位数full表示已填充槽位数。这是经典的生产者-消费者模型mmap提供共享内存信号量提供同步。注意sem_wait和sem_post的顺序不能乱。必须先sem_wait(empty)再sem_wait(mutex)如果反过来可能死锁——持有mutex等empty而empty的释放又需要别人拿到mutex。3.3 信号量没清理干净会怎样POSIX信号量是内核持久化的即使进程退出了信号量还在。如果程序异常退出没调用sem_unlink下次运行sem_open会打开旧的信号量初始值可能不对导致程序行为诡异。我遇到过最坑的一次调试时程序崩溃信号量残留重新运行后sem_wait一直阻塞因为信号量初值被上次的运行改成了0。排查了半天才想起来是信号量没清理。后来养成了习惯程序启动时先sem_unlink再sem_open确保干净。// 启动时先清理可能残留的信号量 sem_unlink(SEM_MUTEX); sem_unlink(SEM_EMPTY); sem_unlink(SEM_FULL); // 再重新创建 sem_t *mutex sem_open(SEM_MUTEX, O_CREAT, 0666, 1);4. 文件映射实战从ftruncate到msync的完整链路4.1 文件映射的完整生命周期文件映射做IPC的流程比匿名映射多几步但适用场景更广。完整链路是open→ftruncate→mmap→ 读写 →msync可选 →munmap→close。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include fcntl.h #include sys/mman.h #include sys/stat.h #define FILE_PATH /tmp/ipc_shared.dat #define MAP_SIZE 4096 int main() { // 1. 打开或创建文件 int fd open(FILE_PATH, O_RDWR | O_CREAT, 0666); if (fd -1) { perror(open); exit(1); } // 2. 设置文件大小关键步骤 if (ftruncate(fd, MAP_SIZE) -1) { perror(ftruncate); exit(1); } // 3. 映射 char *shm mmap(NULL, MAP_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (shm MAP_FAILED) { perror(mmap); exit(1); } // 4. 读写共享内存 strcpy(shm, shared data from process A); printf(written: %s\n, shm); // 5. 强制同步到磁盘可选但重要数据建议加 if (msync(shm, MAP_SIZE, MS_SYNC) -1) { perror(msync); } // 6. 解除映射 munmap(shm, MAP_SIZE); close(fd); return 0; }4.2 msync到底要不要调msync的作用是把映射区域的修改强制刷回磁盘。很多人问不调行不行答案是行但你不确定什么时候刷。Linux的页缓存机制会在合适的时机自动回写但时机不可控。如果进程崩溃或者系统断电没回写的数据就丢了。对于IPC场景如果只是进程间传递临时数据msync可以省。但如果数据需要持久化或者你希望进程B能尽快看到进程A的修改msync就很有必要。MS_SYNC是同步等待写完MS_ASYNC是异步不等待。提示msync的地址参数也必须是页对齐的长度会向上取整到页边界。如果传了非对齐地址返回EINVAL。4.3 文件映射的坑文件被截断会怎样如果映射期间另一个进程把文件ftruncate变小了访问超出新文件大小的映射区域会触发SIGBUS。这个行为很隐蔽因为文件大小变化可能发生在任意时刻。我建议的做法是映射期间不要动文件大小。如果确实需要动态调整先munmap调整完再重新mmap。另外可以用fstat定期检查文件大小但这也只能降低风险不能完全避免。还有一个坑文件映射的共享内存所有进程都退出后数据还在文件里。下次启动如果直接mmap读到的是上次的数据。如果不想这样启动时先unlink再创建或者用O_TRUNC打开。5. 性能对比与选型mmap、管道、消息队列到底怎么选5.1 几种IPC方式的横向对比特性mmap共享内存管道POSIX消息队列Unix域socket数据拷贝次数0222同步机制需自行实现内核自带内核自带内核自带跨无关进程文件映射可以需传递fd可以可以数据持久化文件映射可以否否否适用数据量大小中中编程复杂度高低中中从表里能看出来mmap的优势是零拷贝和大数据量代价是同步要自己做、编程复杂度高。管道和消息队列虽然拷贝多但内核帮你处理了同步和边界用起来省心。5.2 什么场景该用mmap我的经验是以下几种情况优先考虑mmap第一数据量大且频繁读写。比如视频帧处理、大规模矩阵运算、实时数据采集。数据量越大零拷贝的优势越明显。传输1KB数据管道和mmap差别不大传输1MBmmap快一个数量级。第二需要随机访问共享数据。管道是流式的只能顺序读。mmap共享内存就是个数组想读哪读哪适合需要随机访问的数据结构比如共享的哈希表、环形缓冲区。第三多进程共享只读数据。比如多个worker进程共享一份配置或字典用MAP_SHARED映射同一个文件每个进程都能读内存里只有一份物理副本省内存。反过来如果只是传递简单的控制消息、数据量小、对实时性要求不高用管道或消息队列更省事。别为了追求性能把简单问题复杂化。5.3 一个容易忽略的点共享内存的大小限制mmap共享内存的大小受几个因素限制/dev/shm的大小如果用shm_open、系统物理内存、进程的地址空间限制。/dev/shm默认一般是物理内存的一半可以用df -h /dev/shm查看。如果映射很大一块内存mmap本身会成功因为只是建立映射不分配物理页但实际访问时如果物理内存不够会触发OOM。所以大内存映射要谨慎最好用mlock锁定关键区域防止被换出。// 锁定共享内存防止被换出到swap if (mlock(shm, MAP_SIZE) -1) { perror(mlock); // 注意mlock有RLIMIT_MEMLOCK限制 }mlock有资源限制普通用户默认只能锁几十KB需要ulimit -l调整或者用root。这个限制经常被忽略导致mlock失败。6. 那些文档不会告诉你的实战经验6.1 共享内存的初始化竞争多个进程同时启动都去创建共享内存谁先谁后如果进程A创建并初始化了共享内存进程B在A初始化完成前就映射了可能读到未初始化的数据。解决方案是用一个初始化完成标志配合信号量// 共享内存头部放一个初始化标志 typedef struct { volatile int initialized; // ... 其他数据 } SharedHeader; // 进程启动时 sem_wait(init_mutex); if (!header-initialized) { // 执行初始化 header-initialized 1; } sem_post(init_mutex);这个模式在多个进程竞争初始化时特别有用避免重复初始化或者读到半成品数据。6.2 指针不能跨进程传递这是新手最容易犯的错在共享内存里存了个指针另一个进程拿到指针直接解引用结果崩溃。因为指针是虚拟地址每个进程的地址空间不同同一个虚拟地址在不同进程里指向不同的物理内存。正确的做法是存偏移量不存指针。比如共享内存基地址是base要指向偏移offset处的数据就存offset用的时候base offset。// 错误做法 struct Node { struct Node *next; // 指针跨进程无效 }; // 正确做法 struct Node { size_t next_offset; // 存偏移量 }; // 使用时Node *next (Node *)((char *)base node-next_offset);这个坑我在一个链表共享的项目里踩过调试了一整天才定位到。后来所有共享内存里的数据结构一律用偏移量代替指针。6.3 缓存一致性与内存屏障现代CPU有多级缓存不同核心的缓存可能不一致。虽然硬件层面有缓存一致性协议如MESI但编译器和CPU的指令重排可能导致意外的执行顺序。在共享内存场景下如果同步机制不够强可能读到旧数据。对于简单的标志位传递可以用volatile加内存屏障// 写端>