在我们的 Linux 课程系列里,进程间通信(IPC,Inter Process Communication)算是"压轴大戏"之一。前面我们见过了管道(pipe)、命名管道(FIFO)、信号(signal)这些或简单或受限的通信方式——管道只能单项流动、还自带缓冲区,信号能传的信息量又太小。今天我们要请出一位重量级选手:mmap,内存映射。它的特殊之处在于,它不只是"另一种通信手段",它更是一种"把内存直接摊在每个进程面前的魔法"。理解了它,你对"进程到底怎么看到同一块数据"这件事,会有一层全新的认知。

在开始之前,先交代一下这篇加餐的定位。我们采用的是"代码为主、理论为辅"的教学思路——真本事都藏在代码里。所以我会先花几节把 mmap 的概念和坑讲透(这些是看懂代码的地基,不能省),然后端出一整套完整的、可以直接编译运行的工程:用共享内存映射 + 进程间互斥锁 + 条件变量,实现"一个服务端 + 多个子进程 + 一个客户端"的定向唤活通信。你敲完、跑起来,亲手输入几条消息,看着指定进程被"唤醒",那一瞬间你会真正懂 mmap。

你应该有的知识准备

在往下读之前,你最好已经有这几样在手——它们大多在这系列前面的文章里讲过,这里只做一句话回顾:

  • 进程地址空间:每个进程都有一份从 0 开始的"虚拟地址空间",它和物理内存通过页表建立映射。mmap 干的事,本质上就是在玩这套映射。
  • 虚拟内存与页面(page):操作系统管理内存的最小单位是"页",通常 4KB。这一节的"页对齐"就建立在它之上。
  • 父子进程(fork):fork() 会复制出一个几乎一模一样的子进程,两者共享打开的文件描述符、共享内存映射(细节今天细讲)。
  • 互斥锁与条件变量(pthread):我们后面要给共享内存加上同步,会用到 pthread 的互斥锁和条件变量。它们的"进程间共享"变体是今天的重点。

如果你是带着 C/C++ 基础进来的,这些你已经会了;缺哪条,回头翻翻讲进程和 pthread 的那几篇再回来。


进程间通信,为什么要动用 mmap

先站在全局看问题:进程默认是"隔离"的。你写了一个 int x = 5;,它就是属于你这个进程的,别的进程根本看不见,物理上它们可能各占一份内存。要想让两个进程交换数据,方案就那么几类:

  • 管道/命名管道:一个进程往里写,另一个往外读,数据像水流过一根管子。优点是简单,缺点是一次只能一个方向、数据要经过内核缓冲区、还得有"谁是写谁读"的分工。
  • 消息队列:像邮箱,把消息封成一个个包投递。不用像管道那样死等,但同样要经过内核。
  • 信号:通知性质的,能传的信息量极小,主要用于"有事发生"的提醒。
  • 套接字:网络或本机的 socket,功能强大但是重量级。
  • 共享内存:让两个进程真的看到同一块物理内存。一方写,另一方立刻看到,不需要经过内核搬数据,是速度最快的 IPC 手段。

而 mmap 正是实现"共享内存"最主流的底层机制之一(另外还有经典的 SysV 共享内存 shmget 一族,今天先不讲)。大道至简:如果你能让两个进程的地址空间里各自出现一块"纸面上地址不同、但底层指向同一物理内存"的区域,那么只要往那块区域里写,对方就能读——通信就这样完成了,而且快得惊人,因为没有内核在中间复制数据。

打个比方:管道通信像是两个人隔着墙用对话筒喊话(声音要经墙里的线传过去);共享内存则像是两个人直接站在同一块白板前,一个人写,另一个人直接看,笔迹实时体现在同一块板子上。谁写、谁读,全靠你们自己对白板的约定——这正是它快,却也最需要"同步约定"的原因。

mmap 是什么:把"文件或内存"照进进程的内存

mmap 是 memory map(内存映射)的缩写,它由同名的系统调用 mmap() 提供。它的作用一句话就能讲清:把一段"内核里的东西"——通常是一个文件的内容,或者一块匿名的内存——映射到当前进程的虚拟地址空间里。 映射完成之后,你访问这块内存,就像直接访问那个文件/那块内存本身一样。

它的函数签名长这样(首现的每个参数都很关键,我们逐个拆):

#include <sys/mman.h>
 
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
  • addr:希望内核把映射放到哪个虚拟地址。绝大多数时候我们不关心,直接传 nullptr,让内核自己挑一个空闲地址,返回出来的就是真正的地址。
  • length:要映射的长度(字节数)。注意:内核实际是按"页"分配和映射的,所以这个长度会向上取整到页的整数倍(后面的"页对齐"一节细讲)。
  • prot:访问权限,是下面几个值的按位或:
    • PROT_NONE:不可访问;
    • PROT_READ:可读;
    • PROT_WRITE:可写;
    • PROT_EXEC:可执行。 我们做共享内存,几乎总是 PROT_READ | PROT_WRITE。
  • flags:最重要的一组标志,决定了"映射的语义"。最核心的是 MAP_SHARED 和 MAP_PRIVATE(下一节专门讲),以及用来做匿名映射的 MAP_ANONYMOUS。
  • fd:要映射的文件描述符。当 flags 里带了 MAP_ANONYMOUS(匿名映射)时,这里传 -1,表示没有文件。
  • offset:从文件的哪个偏移开始映射,单位是字节,且通常要求是页大小(sysconf(_SC_PAGESIZE))的整数倍。我们一般从 0 开始。

返回值:成功返回映射区的起始地址;失败返回 MAP_FAILED(其实就是 (void *)-1),并设置 errno。所以每次 mmap 之后都要判断一次 addr == MAP_FAILED。

有映射,就必然有"反操作"。与之配套的是函数 munmap(),用于解除映射:

int munmap(void *addr, size_t length);

它把从 addr 开始、长度 length(同样会按页取整)的映射解除。之后你再碰那块地址就是非法的了。

mmap 的三种典型用途

理解了签名,你可能立刻想知道:它到底能干什么?我把 mmap 的用途归成三类,平时你遇到的几乎都属于这三类:

  1. 把普通文件映射进内存——用"读写内存"代替"read/write"。 把磁盘上的一个文件 mmap 进来后,你读这段内存就是在读文件,写这段内存就是在改文件(配合 msync 或内核回写),文件操作从"系统调用 + 缓冲区拷贝"变成了"直接访问内存",省事也省拷贝。很多大文件的高性能读法就是这么干的。

  2. 提供"匿名内存池"——替代/辅助 malloc 的大块内存分配。 用 MAP_PRIVATE | MAP_ANONYMOUS、fd = -1 映射一大块内存,得到一块"初始化为零、只属于自己的大块内存",可以当作大数组、甚至当作自己的内存分配器来用。glibc 的 malloc 在分配超大块时,底层就经常走 mmap 匿名映射这一招。

  3. 进程间共享内存(今天的主题)。 让多个进程映射到同一块物理内存,实现跨进程通信。这正是 MAP_SHARED 的用武之地,也是我们要重点落地的场景。

可以看到,用途 2 和用途 3 都用到了"匿名映射",区别只在于 flags 用的是 MAP_PRIVATE 还是 MAP_SHARED——这一个词的差别,决定了"这块内存是私有的还是共享的",也决定了最终能不能用于 IPC。

MAP_SHARED 与 MAP_PRIVATE:谁改谁看得见

这是今天最核心的两个参数,值得单独开一节。

  • MAP_SHARED(共享映射):对映射区的修改会被写回到"被映射的对象"(文件或共享内存对象),并且对所有映射了同一对象、且也用 MAP_SHARED 的其他进程可见。换句话说,改动是"大家一起看得到"的。要想用 mmap 做进程间通信,就必须用这个。

  • MAP_PRIVATE(私有映射):对映射区的修改不会写回文件,也不会影响其他进程。它给每个进程一份"看起来一样、实际上各自独立"的拷贝。所以它做不了进程间共享,但非常适合"读同一份数据、各自偷偷改自己那份"的场景(比如程序加载共享库、文本段映射)。

一句话记忆:MAP_SHARED 是"共用的白板",MAP_PRIVATE 是"人手一份的复印件"。

这里必须提醒一个常见误区:MAP_PRIVATE 并不是"两个人各自维护一份独立数据"那么简单,它还牵扯到写时拷贝(下一节)。 很多人以为"既然私有,那我改我自己的,天经地义"——对,但数据是怎么从"共享的那份"变成"我自己的那份"的,中间藏了写时拷贝这道坎。

写时拷贝(Copy-On-Write)

先抛出一个反直觉的事实:用 MAP_PRIVATE 映射之后,在写入发生之前,这些进程其实仍然共享着同一块物理内存。 真正让它们"各走各路"的,是写时拷贝(Copy-On-Write,简称 COW)这套机制。

所谓写时拷贝,字面意思就是"等到要写的时候才拷贝"。它的工作流程是这样:

  1. 多个进程(或同一个进程 fork 出的父子进程)用 MAP_PRIVATE 映射了同一块内容,此时页表里大家指向的是同一页物理内存,谁也别改,就这么共享着。
  2. 一旦有进程尝试写入这块内存,CPU 会发现"这块内存是只读共享的",于是触发一次"缺页异常/保护异常"。
  3. 内核接到这个异常,立刻偷偷为这个进程复制一份新的物理页,把这个进程的页表改指向那页新拷贝,然后放行写入。而其他没写的进程,还指在原来那页上,不受影响。

于是结果就是:"读"永远共享,"写"才各建各的。 这也是为什么 MAP_PRIVATE 又叫"私有写拷贝"的原因——它充分利用了"多数内存是只读的"这个事实,省下一大堆拷贝开销。

把这个概念和 MAP_SHARED 对比,你就能彻底分清两者了:

维度MAP_SHAREDMAP_PRIVATE
写入是否回写文件/共享对象是否
其他进程能否看到我的修改能不能
底层物理页的处理共享同一页,直接写写时拷贝,各自独立
能否用于 IPC能(这是它的命根子)不能
典型用途共享内存、文件映射修改同步加载可执行/共享库、私有大块内存

记住这张表,市面上 99% 的 mmap 困惑都能在这里找到答案。

文件映射与匿名映射:有没有"背后那个文件"

按"背后有没有一个看得见摸得着的文件",mmap 又分成两大类。这个区分直接决定了"谁能用这个映射来通信"。

  • 文件映射:有实际文件(普通的磁盘文件,或 POSIX 共享内存对象)垫底。你把文件 fd 传给 mmap(fd >= 0),映射内容就是文件内容。多个进程各自打开同一个文件、各自 mmap,指到的是同一块数据,天然能共享。甚至可以做到"通信结束,数据还留着",也就是持久化。

  • 匿名映射:没有文件,fd 传 -1,并在 flags 里加上 MAP_ANONYMOUS(旧系统上可能写作 MAP_ANON,含义相同)。它只是给你一块"干净的、全零的内存"。因为背后没有文件路径,别的进程没法凭一个"名字"去找它——它只能靠"父进程 fork 子进程"这种方式,随内存布局一起被继承下去。

这个对照引出一个极其重要的结论,也是很多初学者最常踩的坑,这里郑重划重点:

匿名映射(fd = -1 + MAP_ANONYMOUS)只能在"父子/同源"进程之间共享——因为子进程通过 fork 继承了这份映射,大家天然指着同一块(或写时拷贝的)内存;而真正要让两个没有亲缘关系、分别启动的进程通信,必须用"有名字/有对象"的文件映射或 POSIX 共享内存对象,否则另一个进程根本没地方 "my name is" 去找到这块内存。

你今天要学的实战例子,服务端和客户端是两个各自独立启动的程序(没有任何亲缘关系),所以它俩之间不能用纯匿名映射,必须用 POSIX 共享内存对象(等价于一个"有名字的内存文件")。这就是为什么例子开头会先 shm_open("/shm", ...) ——先给这块共享内存起个名字。

页对齐:mmap 的"按页办事"

前面我没少提"按页",现在系统讲清它,因为它和好几个坑都直接相关。

操作系统把物理内存和虚拟内存都切成固定大小的块,叫页(page)。在绝大多数 x86/Linux 上,一页是 4096 字节(获取方式:sysconf(_SC_PAGESIZE) 或 getpagesize())。mmap 映射的本质,是"在这块区域上建立虚拟页到物理页/文件的映射",而映射的粒度只能是页。由此带来三条你必须知道的规则:

  1. 长度向上取整:你让 mmap 映射 length = 4000 字节,内核不会"映射 4000",而是会找到至少能盖住 4000 的一整页,也就是 4096 字节。多出来的 96 字节不会报错,纯粹是"浪费"掉了。
  2. 偏移必须页对齐(通常):offset 参数一般要求是页大小的整数倍。如果传了非页对齐的偏移,很多实现会返回 EINVAL。
  3. 返回地址页对齐:mmap 成功返回的地址,本身就会落在页边界上,方便内核管理。

在写共享内存代码时,"按页规则"会悄悄影响你:你会看到有人习惯把共享区大小设计成 4096(一页)或者它的整数倍,就是不想要那点"取整浪费";而用 sizeof(SafeObj) 这种动态大小也没问题,内核会自动向上取整。这只是美观与否的区别,不是对错。真正的坑反而是"映射的长度大于对象实际长度"——如果 ftruncate 给的底层对象长度没跟上 length,你访问超出对象长度的部分就会撞上 SIGBUS(总线错误),这一点在"坑点集锦"里我们还会再见到。

用共享内存通信的前提:POSIX 共享内存对象

讲到这里,你一定已经意识到:要让两个独立进程共享,关键是要有一个"有名字的兜底物"。Linux 提供了一族专门为此而生的接口,叫 POSIX 共享内存对象。它有三个主力函数:

#include <sys/mman.h>
#include <sys/stat.h>   /* 权限常量 */
#include <fcntl.h>      /* O_* 常量 */
#include <unistd.h>
 
int shm_open(const char *name, int oflag, mode_t mode);
int shm_unlink(const char *name);

这套 API 和普通文件的 open/unlink 长得几乎一模一样,因为它本质就是创建/打开一个"位于内存里的文件":

  • shm_open:创建一个新的、或打开一个已有的共享内存对象。name 必须以单个 / 开头(比如 /shm),它是这个对象在系统里的"名字";oflag 沿用 O_CREAT、O_RDWR、O_RDONLY 等;mode 是权限,只有低 9 位被使用(如 0666)。成功返回一个 fd,失败返回 -1。
  • shm_unlink:移除这个共享内存对象(等价于 unlink 一个文件)。移除后,之前已打开它的进程还能继续用,但新的进程再也打开不到它了。

在 Linux 上,这些共享内存对象实际落在 /dev/shm(tmpfs,一种"内容在内存里、重启即失"的文件系统),所以它快、不落盘持久化、但重启会消失——这正好适合做一次运行期间的多进程通信。

光有一个"对象"(一个空文件)还不够,孤儿得有"长度"才能映射。这就轮到另一个函数:

int ftruncate(int fd, off_t length);

人话讲,ftruncate 把 fd 所指对象(文件或共享内存对象)的长度截断或扩展成指定大小。对于共享内存对象,这一步几乎必不可少:shm_open 刚创建的对象长度是 0,如果不先 ftruncate 到一定长度,稍后 mmap 要么失败,要么访问时 SIGBUS。它的名字是 "file truncate"(文件截断)的缩写——虽然名字里有"截断",但传一个比当前大的 length 时,它的作用是"扩张"。记住它的口诀:要先 ftruncate 给了长度,mapping 才靠谱。

一次标准的"用共享内存映射做 IPC"的完整流程,主体是这样(以服务端为例):

#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
 
int fd = shm_open("/shm", O_CREAT | O_RDWR, 0666);   // 1. 创建/打开有名字的共享内存对象
ftruncate(fd, 4096);                                  // 2. 把对象长度设为 4096
void *addr = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
                  MAP_SHARED, fd, 0);                 // 3. 以共享方式映射
// 之后直接读写 addr 指向的内存即可
munmap(addr, 4096);   // 4. 用完后解除映射
close(fd);            // 5. 关掉 fd
shm_unlink("/shm");   // 6. 移除共享内存对象(通常是最后一个进程删)

客户端那边则只做"打开已有对象 → mmap",不做 ftruncate(对象已经有了、长度已定)——这一点在实战代码里也会体现。

共享内存自己不会同步:锁、条件变量与信号量

共享内存速度最快,也最"野"。原因很朴素:两个进程同时读写同一块内存,这和"单进程里两个线程同时改一个全局变量"是同一回事,都会产生数据竞争。 而共享内存本身不提供任何同步机制——它不会自动帮你在"写一半"和"读一半"之间划清界限。所以"共享内存 + 各自的读写时机",同步必须由你亲自补上。可选的同步手段无非三种:

  • 互斥锁(mutex):保证同一时刻只有一个进程在写/读共享区,防止写一半被读走。
  • 条件变量(condition variable):让进程"等到有消息了再起来干活",而不是空转轮询浪费 CPU。这样既能同步数据的到位,又能高效地"叫醒"目标进程。
  • 信号量(semaphore):适合管理"资源还剩几份"的计数场景(比如生产者-消费者模型里的空位/满位)。

但在"多进程共享内存"里用 pthread 的锁和条件变量,有一个必须做的动作:设置"进程间共享"属性。pthread 默认的锁是"进程内私有"的(PTHREAD_PROCESS_PRIVATE),只在同一进程的线程间有效;要让锁在多个进程间生效,就必须在初始化时把属性设为 PTHREAD_PROCESS_SHARED:

// 让一把互斥锁具备"跨进程"能力的关键三步
pthread_mutexattr_t attr;                        // 锁属性
pthread_mutexattr_init(&attr);                   // 先初始化属性对象
pthread_mutexattr_setpshared(&attr,
        PTHREAD_PROCESS_SHARED);                 // 关键:设为进程间共享
pthread_mutex_init(&lock, &attr);                // 用这份"共享属性"创建锁
pthread_mutexattr_destroy(&attr);                // 属性用完可以销毁(不影响锁)

条件变量同理,也有一套 pthread_condattr_setpshared(&attr, PTHREAD_PROCESS_SHARED) 的对应做法。

这里有个微妙的细节值得说透:这块共享区里放的"锁"和"条件变量",本身必须躺在共享内存里,不能是某个进程的局部变量。为什么?因为锁是多个进程共用的,它得住在"大家都能看到的那块物理内存"里。实战代码里的 SafeObj 就是把 pthread_mutex_t lock、pthread_cond_t cond 和一块 char buffer[] 一起塞进了映射区——于是锁、条件变量、数据在同一个对象里,天然配套。

父子进程与 mmap:fork 之后发生了什么

实战例子里,服务端会 fork 出一堆子进程,每个子进程都要参与共享。这里就必须把"fork 和 mmap 的关系"讲透:

  • MAP_SHARED 映射在 fork 后是"真共享"的。 fork 出的子进程会继承父进程的所有 mmap 区段。对于 MAP_SHARED 的映射,父子进程指着的是同一块物理内存,谁写谁改,互相可见。所以只要父进程先把共享内存映射好、锁初始化好,fork 出来的所有子进程天然带上这份"白板",大家共写一块板子。
  • MAP_PRIVATE 映射在 fork 后是"写时拷贝"的。 fork 的瞬间父子共享那份"复印件母本",一旦任何一方写入,就该进程各自复制一份。所以私有映射在 fork 后各改各的,互不打扰。这正是系统加载代码段、以及 fork 本身默认环境下的一种性能优化——fork 不再需要立刻复制全部内存,真正写时才拷贝。
  • 打开的文件描述符在 fork 后是"共享偏移"的。 顺带提一句:fd 这个句柄本身是共享的、引用了同一个"文件打开描述",所以注意别在父子进程里稀里糊涂地互抢同一个 fd 做 I/O。

实战里的流程正是借了这个特性:主进程先构造 MmapMemoryServer(此时已完成 shm_open → ftruncate → mmap → InitObj),然后连续 fork() 10 次。因为映射是 MAP_SHARED,这 10 个子进程与父进程就共同拥有那一个 SafeObj,共享同一把锁、同一个条件变量、同一块 buffer。之后无论谁来 broadcast,大家都能收到。这就是"一个服务端管着一群子进程"的共享基础。

实战:用 mmap + 进程间锁/条件变量做一套完整的 IPC

概念齐了,咱们真刀真枪。这一节实现的目标是:一个服务端程序 server,一上台就 fork 出 10 个子进程;一个客户端程序 client,由你往终端里输入命令;服务端的各个进程根据命令决定谁"被激活"。 消息通过共享内存 + 广播唤醒来传递,进程间靠互斥锁和条件变量保持安全。

共享结构 SharedMem.hpp

这是整个工程的灵魂。它一口气定义了三样东西:

  • SafeObj:放进共享内存里的对象,装着锁、条件变量、缓冲区;
  • MmapMemory:封装"打开共享内存对象 + 设长 + 映射 + 解除映射 + 删除对象"的公共基础类;
  • MmapMemoryServer / MmapMemoryClient:继承 MmapMemory,分别是"服务端"和"客户端"的视角。
// SharedMem.hpp —— 单一头文件就把共享对象、mmap 封装全装下了
#pragma once                                 // 防止同一文件被重复包含
 
#include <iostream>                          // std::cout / std::cerr
#include <string>                            // std::string
#include <cstdio>                            // perror
#include <cstring>                           // memset / strncpy
#include <cstdlib>                           // exit
#include <sys/mman.h>                        // mmap / munmap / shm_open / shm_unlink
#include <sys/stat.h>                        // 权限常数(OSS 统一)
#include <sys/types.h>                       // 传统类型定义
#include <fcntl.h>                           // O_CREAT / O_RDWR
#include <unistd.h>                          // close / ftruncate
#include <pthread.h>                         // 互斥锁 / 条件变量
 
#define BUFFER_SIZE 4096                     // 消息缓冲 4096,正好一页,干净利落
 
// ---------------------------------------------------------------------------
// SafeObj:整个对象会被放进共享内存。
// 它把【互斥锁】+【条件变量】+【数据缓冲区】打包在一起,
// 于是“谁有资格写”“什么时候提醒读”“数据放哪”这三件事被统一管理。
// 关键:这把锁和这个条件变量要能跨进程,必须设置 PTHREAD_PROCESS_SHARED。
// ---------------------------------------------------------------------------
class SafeObj
{
public:
    SafeObj() = default;                     // 默认构造(真身逻辑在 InitObj)
 
    void InitObj()                           // 在共享内存里初始化同步件和数据
    {
        // ---- 1. 初始化“进程间共享”的互斥锁 ----
        pthread_mutexattr_t mattr;           // 定义一个互斥锁属性对象
        pthread_mutexattr_init(&mattr);      // 先初始化属性为默认值
        pthread_mutexattr_setpshared(&mattr, PTHREAD_PROCESS_SHARED); // 关键:跨进程
        pthread_mutex_init(&lock, &mattr);   // 用该属性创建共享锁
        pthread_mutexattr_destroy(&mattr);   // 属性对象用完销毁(不影响锁本身)
 
        // ---- 2. 初始化“进程间共享”的条件变量 ----
        pthread_condattr_t cattr;            // 定义一个条件变量属性对象
        pthread_condattr_init(&cattr);       // 初始化默认属性
        pthread_condattr_setpshared(&cattr, PTHREAD_PROCESS_SHARED); // 关键:跨进程
        pthread_cond_init(&cond, &cattr);    // 用该属性创建共享条件变量
        pthread_condattr_destroy(&cattr);    // 属性对象用完销毁
 
        // ---- 3. 缓冲区彻底清空,展示一个“干净的初始状态” ----
        memset(buffer, 0, sizeof(buffer));
    }
 
    void CleanupObj()                        // 程序结束时销毁共享区里的同步件
    {
        pthread_mutex_destroy(&lock);        // 销毁互斥锁
        pthread_cond_destroy(&cond);         // 销毁条件变量
    }
 
    // 加锁:进入临界区前调用(能跨进程)
    void LockObj()
    {
        ::pthread_mutex_lock(&lock);
    }
 
    // 解锁:离开临界区前调用
    void UnlockObj()
    {
        ::pthread_mutex_unlock(&lock);
    }
 
    // 等待:把锁交给系统并睡过去,信号来临时再被唤醒并重新拿回锁
    void Wait()
    {
        ::pthread_cond_wait(&cond, &lock);   // 原子地释放锁 + 挂起等待
    }
 
    // 唤醒一个等待者
    void Signal()
    {
        ::pthread_cond_signal(&cond);
    }
 
    // 广播:把当前所有正在等待的进程全部唤醒,让它们各自去判断
    void BroadCast()
    {
        int n = ::pthread_cond_broadcast(&cond);
        if (n == 0)
            std::cout << "broadcast success" << std::endl;
        else
            std::cerr << "broadcast error" << std::endl;
    }
 
    // 读取共享缓冲区的当前内容(要求调用方已加锁)
    void GetContent(std::string *out)
    {
        *out = buffer;
    }
 
    // 往共享缓冲区写入一条消息(要求调用方已加锁)
    void SetContent(const std::string &in)
    {
        memset(buffer, 0, sizeof(buffer));          // 先清空,保证读到的是干净快照
        size_t n = in.size();                       // 本轮要写入的字节数
        if (n >= sizeof(buffer))                    // 越界保护:最多写到 buffer-1,留 \0
            n = sizeof(buffer) - 1;
        strncpy(buffer, in.c_str(), n);             // 拷贝 n 个字符(不自动加 \0)
        buffer[n] = '\0';                           // 手动补一个字符串结尾,杜绝“无结尾”读越界
    }
 
private:
    pthread_mutex_t lock;                 // 互斥锁(跨进程共享)
    pthread_cond_t  cond;                 // 条件变量(跨进程共享)
    char            buffer[BUFFER_SIZE];  // 真正承载消息的数据区
};
 
// ---------------------------------------------------------------------------
// MmapMemory:把“共享内存对象”的一生封装殆尽。
// ----------- 关键常量与服务端/客户端共用的流程 ------------------------------
// mmap 约定:共享内存对象的名字必须以 '/' 开头,否则行为未定义/出错。
// ---------------------------------------------------------------------------
#define SHARED_MEMORY_FILE  "/shm"                     // 共享内存对象的名字(必须以/开头)
#define SHARED_MEMORY_SIZE  sizeof(SafeObj)            // 映射长度 = 整个 SafeObj 的大小
 
class MmapMemory
{
public:
    MmapMemory(const std::string &file, int size)
        : _file(file), _size(size), _fd(-1), _mmap_addr(nullptr)
    {
        // 构造只做记录,真正的“开对象 / 映射”由派生类按角色去调
    }
 
    // 1. 打开(必要时创建)共享内存对象,拿到 fd
    void OpenFile()
    {
        // O_CREAT:不存在就创建;O_RDWR:可读可写;0666:权限(只用低 9 位)
        _fd = ::shm_open(_file.c_str(), O_CREAT | O_RDWR, 0666);
        if (_fd < 0)
        {
            perror("shm_open");      // 打印失败原因
            exit(1);                 // 致命错误,直接退出
        }
    }
 
    // 2. 把共享内存对象截断/扩张到目标大小——mmap 之前必做,否则映射不靠谱
    void TruncSharedMemory()
    {
        if (ftruncate(_fd, _size) < 0)
        {
            perror("ftruncate");
            exit(2);
        }
    }
 
    // 3. 真正映射。返回映射区起始地址
    void *Mmap()
    {
        // MAP_SHARED:多个进程映射到这里,修改互相可见(IPC 的关键所在)
        _mmap_addr = ::mmap(nullptr, _size,
                            PROT_READ | PROT_WRITE,   // 可读可写
                            MAP_SHARED,               // 共享映射
                            _fd, 0);                  // 从文件偏移 0 开始
        if (_mmap_addr == MAP_FAILED)                 // 失败会返回 (void*)-1
        {
            perror("mmap");
            exit(3);
        }
        return _mmap_addr;
    }
 
    // 4. 删除共享内存对象(通常是服务端收尾时调用)
    void RemoveFile()
    {
        if (shm_unlink(_file.c_str()) < 0)
        {
            perror("shm_unlink");
            exit(4);
        }
    }
 
    // 访问映射区起始地址
    void *MmapAddr() { return _mmap_addr; }
 
    // 生命周期收尾:关 fd、解除映射(派生类析构会先执行,再轮到本基类析构)
    ~MmapMemory()
    {
        if (_fd > 0)                                   // fd 有效才关闭
        {
            ::close(_fd);
            std::cout << "已关闭共享内存对象的 fd" << std::endl;
        }
        if (_mmap_addr != nullptr)                     // 映射过才解除映射
        {
            int n = ::munmap(_mmap_addr, _size);
            if (n == 0)
                std::cout << "munmap 完成" << std::endl;
        }
    }
 
private:
    int         _fd;          // 共享内存对象 fd
    int         _size;        // 映射长度
    std::string _file;        // 对象名字,如 "/shm"
    void       *_mmap_addr;   // 映射地址
};
 
// ---------------------------------------------------------------------------
// MmapMemoryServer:服务端视角。它“创建 + 初始化”,是这台戏的主宰/编剧。
// 唯一的生产者属性:负责 ftruncate、映射、并在共享区里初始化锁与条件变量。
// ---------------------------------------------------------------------------
class MmapMemoryServer : public MmapMemory
{
public:
    MmapMemoryServer()
        : MmapMemory(SHARED_MEMORY_FILE, SHARED_MEMORY_SIZE)
    {
        MmapMemory::OpenFile();          // 1. 创建/打开共享内存对象
        MmapMemory::TruncSharedMemory(); // 2. 设长度,mmap 的前提
        MmapMemory::Mmap();             // 3. 以 MAP_SHARED 映射
        obj = static_cast<SafeObj *>(MmapMemory::MmapAddr()); // 4. 指向共享区里的 SafeObj
        obj->InitObj();                  // 5. 在共享区里初始化锁/条件变量/缓冲区
        // 注意:只有服务端能这样初始化。若多个进程重复 InitObj,锁会被重复 init(未定义)
    }
 
    // 等待一条消息——空等(睡着)直到有客户端 broadcast 唤醒
    void RecvMessage(std::string *out)
    {
        obj->LockObj();      // 加锁进临界区
        obj->Wait();         // 释放锁并睡着;被唤醒后重新拿回锁,再继续
        obj->GetContent(out); // 把共享缓冲区内容取出来
        obj->UnlockObj();    // 解锁
    }
 
    ~MmapMemoryServer()
    {
        obj->CleanupObj();          // 先清理共享区里的锁/条件变量
        MmapMemory::RemoveFile();   // 再删除共享内存对象(“拆台”)
        // 基类析构会继续负责 close(fd) 和 munmap
    }
 
private:
    SafeObj *obj;   // 指向共享区里那个 SafeObj 的指针
};
 
// ---------------------------------------------------------------------------
// MmapMemoryClient:客户端视角。它“只开不建”,是台下的观众/撰稿人。
// 它打开已存在的对象直接映射,绝不去 ftruncate、绝不 InitObj(锁由服务端初始化好)。
// ---------------------------------------------------------------------------
class MmapMemoryClient : public MmapMemory
{
public:
    MmapMemoryClient()
        : MmapMemory(SHARED_MEMORY_FILE, SHARED_MEMORY_SIZE)
    {
        MmapMemory::OpenFile();      // 1. 打开已存在的共享内存对象
        MmapMemory::Mmap();          // 2. 映射,看到与 Server 同一块 SafeObj
        obj = static_cast<SafeObj *>(MmapMemory::MmapAddr());
        // 注意:这里绝不能再 InitObj(),锁应当由 Server 那边初始化一次即可
    }
 
    // 发布一条消息:加锁 → 写内容 → 广播唤醒所有在等的接收端 → 解锁
    void SendMessage(const std::string &in)
    {
        obj->LockObj();      // 进临界区(与接收端互斥,保护共享 buffer)
        obj->SetContent(in); // 把消息写进共享缓冲区
        obj->BroadCast();    // 把正在 Wait 的进程统统叫醒,让它们自己去判断
        obj->UnlockObj();    // 出临界区
    }
 
    ~MmapMemoryClient() = default;
 
private:
    SafeObj *obj;   // 指向共享区里那个 SafeObj 的指针
};

服务端程序 Server.cc

服务端先建好共享内存和锁,再 fork 出 10 个子进程。因为映射是 MAP_SHARED,所有进程共用同一个 SafeObj,于是可以并行地接收广播并按自己的"名字"判断是否要被激活。

// Server.cc —— 服务端:负责初始化共享区,并拉起 10 个"自愿报名"的子进程
#include "SharedMem.hpp"     // 引入上面的头文件
#include <sys/wait.h>        // wait
#include <sys/types.h>       // pid_t
 
// 每个接收端进程的“活性逻辑”:收到消息后判断点到名没有
void Active(MmapMemoryServer &svr, const std::string &processname)
{
    std::cout << "process is running: " << processname << std::endl;
    std::string who;                       // 收到的消息内容
    while (true)
    {
        svr.RecvMessage(&who);             // 空等一条新消息(阻塞在条件变量上)
        if (who == processname || who == "all")
        {
            // 消息与自己名字相同,或点名全体 → 本进程“激活”
            std::cout << processname << " is active!" << std::endl;
        }
        if (who == "end")
        {
            // 收到全局收工信号 → 退出循环
            std::cout << processname << " is quit!" << std::endl;
            break;
        }
    }
}
 
int main()
{
    MmapMemoryServer svr;               // 服务端:此时已完成 创建对象+ftruncate+mmap+InitObj
 
    // fork 出 10 个子进程,每个都继承那份 MAP_SHARED 的映射与锁
    for (int i = 0; i < 10; i++)
    {
        pid_t id = fork();              // fork 返回:子进程收到 0,父进程收到子进程 pid
        if (id == 0)
        {
            // 只有子进程进来:给自己起个编号名字,然后作为接收端“站岗”
            std::string name = "process-" + std::to_string(i);
            Active(svr, name);          // 站岗:循环等待消息
            exit(0);                    // 收工后子进程退出(返回码 0)
        }
        // 父进程不进这个分支,继续下一轮 fork
    }
 
    // 主进程自己也作为一个接收端参与站岗(名字叫 process-main)
    Active(svr, "process-main");
 
    // 10 个子进程都退出后才返回;wait 防止子进程变“孤儿/僵尸”
    for (int i = 0; i < 10; i++)
    {
        wait(nullptr);
    }
    return 0;
}

客户端程序 Client.cc

客户端是个"发令员":它只打开对象、映射,然后从键盘读入命令,通过 SendMessage 广播出去。你输入 process-3,就是点名"3 号进程干活";输入 all,全体干活;输入 end,全场收工。

// Client.cc —— 客户端:向共享内存里发布命令,唤醒目标进程
#include "SharedMem.hpp"     // 引入同一个头文件
#include <string>            // std::getline / std::string
 
int main()
{
    MmapMemoryClient cli;    // 打开并映射共享内存,看到与 Server 同一块 SafeObj
    std::string who;         // 待发送的命令
    while (true)
    {
        std::cout << "Please Enter# ";
        std::getline(std::cin, who);   // 读入一整行命令
        cli.SendMessage(who);          // 写进共享区并广播唤醒所有等待者
        if (who == "end")              // 发完收工命令,客户端自己也退出
            break;
    }
    return 0;
}

Makefile 与编译运行

源材料用 Makefile 组织两个可执行文件。注意两点:一要链接 pthread(用到锁/条件变量),二要链接 librt(shm_open/ftruncate 等实现在实时库中;多数现代 glibc 已并入 libc,但带上 -lrt 更保险兼容老系统)。

# Makefile —— 一条 make 编出 server 和 client
.PHONY: all
all: server client
 
server: Server.cc SharedMem.hpp
	g++ -o $@ Server.cc -lpthread -lrt -std=c++11 -g
 
client: Client.cc SharedMem.hpp
	g++ -o $@ Client.cc -lpthread -lrt -std=c++11 -g
 
.PHONY: clean
clean:
	rm -f server client

编译与运行:

$ make
$ ./server          # 终端1:起服务端,看到 11 个进程先后站岗
process is running: process-0
process is running: process-1
...
process is running: process-main
$ ./client          # 终端2:起客户端,输入命令
Please Enter# process-3

你会在服务端看到 process-3 is active!。如果输入 process-3 那行按下回车后,服务端屏幕上 process-3 is active! 和 broadcast success(由客户端打出)同时出现——因为广播唤醒了所有在等的人,但只有名字匹配的 process-3 满足 who == processname 而打印 is active!。这正是"点名唤活"的通信逻辑。

你可以继续试:all 会让所有进程都打印 is active!;end 会让所有进程打印 is quit! 并退出,客户端也在发完 end 后退出,服务端回收完子进程后收尾删掉 /shm。

这段代码里信号流动的时序

为了让这段代码真正"入脑",把一次 client 输入的完整时序在脑中走一遍:

  1. 服务端主进程构造 MmapMemoryServer svr:shm_open 建对象 → ftruncate 定长 → mmap(MAP_SHARED) → InitObj 在共享区初始化锁和条件变量。
  2. fork ×10:子进程全部继承那份 MAP_SHARED 映射和已初始化好的锁。随后主进程和 10 个子进程一共 11 个进程,都进入 Active() 里的 RecvMessage() → 它 LockObj() 后调用 Wait(),把锁交还给系统并集体睡在条件变量上。
  3. 你启动 client:OpenFile 拿到同一个对象 → mmap → 得到同一个 SafeObj 指针。
  4. 你输入 process-3 回车 → SendMessage 加锁 → SetContent 把内容写进共享 buffer → BroadCast() 唤醒所有等待者 → 解锁。
  5. 被唤醒的 11 个进程逐个重新拿到锁,各自 GetContent() 读出 process-3,判断:只有名字是 process-3 的进程满足 who == processname,打出 is active!;其余进程条件不满足,静悄悄回到循环继续等下一条。
  6. 你输入 end → 广播后所有进程读到 end,逐个打印 is quit! 并 break 退出。主进程 wait 回收 10 个子进程,svr 析构里 CleanupObj、shm_unlink 把 /shm 删掉。

至此,一次完整的"点对点单元的共享内存通信"闭环了。你会发现:共享内存负责"数据有没有在",条件变量负责"何时叫醒谁",互斥锁负责"同一时刻只有一个人动白板"——三者缺一不可。

mmap 弄出来的东西不会自动消失,收尾不当会留下僵尸对象或资源泄漏。三个清理动作各有分工,千万别混:

  • munmap(addr, length):把"映射"解除。解除后这块虚拟地址不再指向那块物理内存,你不能再碰它。它只是"我不看这块板子了",并不删除板子本身。
  • close(fd):把"文件描述符"关掉。关掉后这个句柄不能再用,但只要映射还在、其他进程还开着,对象依旧存在。
  • shm_unlink(name):真正"拆掉板子"。它会把这个共享内存对象的名字从系统里移除,之后新的进程再也打不开它。但要注意:已映射、已打开的进程不受影响,仍然继续用,直到最后一个引用(映射/句柄)被释放,物理内存才真正回收。

所以一个干净的收尾顺序通常是:先 munmap(或者依赖析构顺序)解除映射,再 close 关句柄,最后由负责拆台的进程 shm_unlink。例子里这条链路被散落在 ~MmapMemoryServer(拆台)和 ~MmapMemory(关 fd + munmap)里,保证"先是子对象析构,再是基类析构",顺序是对的。

还有一点,共享内存对象在 /dev/shm 下是具名的,如果你程序中途崩了(比如 Ctrl-C),对象名可能残留,下次 shm_open(O_CREAT) 会打开一个旧对象。开发时若发现"数据怎么是上次的",先 ls /dev/shm 看看残留对象,rm 掉再跑。

坑点集锦:把这一路可能踩的雷排干净

代码写通了,不等于能写出稳定的共享内存。我把我见过的坑集中到这一节,你日后能少走很多弯路:

  1. 匿名映射必须用 -1 当 fd,并显式给 MAP_ANONYMOUS。两个动作缺一不可:fd 传 -1、flags 里带 MAP_ANONYMOUS。别想在"忘了 -1"或"忘了 MAP_ANONYMOUS"的情况下蒙混过关——那会把一个无关文件描述符当映射源,行为未定义。

  2. 共享之后必须自己同步。共享内存不会自动上锁。不加锁,两个进程同时写同一块 buffer,轻则读到半截数据,重则崩溃。我们的 SafeObj 把锁、条件变量、数据打包在一起,正是为了强制"读之前加锁"这个纪律。

  3. 锁要能跨进程,必须 PTHREAD_PROCESS_SHARED。默认锁只在进程内的线程间有效。这是最容易"玄学失败"的点:代码看起来对,但多个进程之间锁完全不生效。请务必对照 InitObj 里那三行属性设置检查。

  4. 进程间要在同一侧初始化,别重复 InitObj。锁只能初始化一次。所以设计成"服务端 InitObj、客户端绝不再 InitObj"。如果有人让客户端也去 InitObj,就是把一把已在用的共享锁重新初始化,属于未定义行为,什么奇怪现象都可能出。

  5. ftruncate 给了长度才敢访问映射区。对象刚创建长度是 0,不先 ftruncate 就 mmap,很多实现里访问就会 SIGBUS。口诀:"先设长,再映射,最后才是碰内存。"

  6. 共享缓冲区要防越界、防"无结尾"。直接把用户输入 strcpy/strncpy 进共享 buffer 时,长度不够会写爆相邻字段,拷少了又不保证 \0 结尾,读出来就是一堆垃圾甚至越界。SetContent 里先 memset 清零、再限制写入上限、最后手动补 \0,就是同时防这两类问题。

  7. 父子进程选 MAP_SHARED,别贪 MAP_PRIVATE 图省事。想让 fork 出的孩子与父进程共用数据、改谁谁见效,必须 MAP_SHARED。用 MAP_PRIVATE 的"共享"是假象——写时拷贝会让各自分家,改得稀里糊涂。

  8. 无亲缘的进程通信,别指望匿名映射。两个/多个独立启动的进程,谁也找不到对方那块匿名内存,只能用"有名字"的文件映射或 shm_open 出的具名共享内存对象。今天这个例子的服务端与客户端,正是靠 "/shm" 这个共享名字牵线。

  9. 收尾顺序和崩溃残留。程序正常死亡,munmap → close → shm_unlink 讲得清;但要是崩了/被 Ctrl-C,/dev/shm 下可能残留对象。开发习惯:跑前 ls /dev/shm,看到脏对象先清。


思考题(带详解答案)

下面的题,建议你合上教程先自己想,再对照详解。多想一步,比多抄十行代码涨功力。

题一:假设你 mmap 时传 length = 4000,页大小是 4096。问:这次 mmap 实际占用了多少虚拟内存?你为什么这么认为?

详解:实际占用 4096 字节(恰好一整页)。因为 mmap 是按页映射的,内核会以"页"为粒度进行映射,length = 4000 会被向上取整到页的整数倍,也就是 4096。这是"页对齐"的规则:映射大小只认页的倍数,不会给你半个页。所以用 length = 4096(一页整数倍)比用 4000 更"干净"——不浪费那 96 字节的取整空间。实测中你可以用 getpagesize() 或 sysconf(_SC_PAGESIZE) 拿到当前系统的页大小(通常是 4096)。

题二:两个进程用 MAP_PRIVATE 映射同一文件,A 往自己那块写数据,B 能看到 A 的修改吗?为什么?如果换成 MAP_SHARED 呢?

详解:MAP_PRIVATE 下,B 看不到 A 的修改。原因是写时拷贝(COW):一开始 A、B 的页表都指向同一页只读物理页;当 A 尝试写这一页时,内核为 A 单独复制一份新物理页,把 A 的页表改指到那页新拷贝并放行写,B 仍指向原页,因此互不可见、各改各的。换成 MAP_SHARED 后,A 的写入直接落在共享的同一个物理页上,B 立刻能看到——这正是共享映射能用于 IPC 的原因,也是今天例子里必须 MAP_SHARED 的依据。

题三:为什么匿名映射(fd = -1 + MAP_ANONYMOUS)只能在父子/同源进程间共享,却不能用于两个独立启动的进程?我们今天的服务端与客户端是怎么绕过这个限制的?

详解:匿名映射背后没有具名的对象,它既没有文件路径也没有共享内存对象的"名字",纯粹是一块"无名内存"。子进程能共享它,纯靠 fork 时继承了父进程的内存映射布局——双方生下来就指同一块(或写时拷贝)内存。而两个分别独立启动的进程之间没有任何"继承"关系,A 进程手里的无名内存,B 进程根本没有凭据去"找到"它。今天这个例子里服务端和客户端是两个独立程序,所以不能靠匿名映射,而是改用 POSIX 共享内存对象(shm_open("/shm", ...) 创建、双方用同一个名字 /shm 去找),这就给共享区装了个"门牌号",谁都认路。总结一句:有亲缘用匿名映射省事,无亲缘务必用具名对象/文件。

题四:在 SendMessage 里明明已经 BroadCast 唤醒所有人了,为什么接收端还要用 LockObj / UnlockObj 把手包住 Set 和 Get?

详解:因为共享内存本身不提供原子性。BroadCast 只负责"叫醒",它不管"数据写到一半被另一个进程读了怎么办"。如果没有锁,两个(或多个)进程可能在 SetContent/GetContent 交错执行,读到半截内容。LockObj/UnlockObj 保证同一时刻只有一个进程能进入"读或写共享 buffer 的临界区",彻底避免数据竞争。实际上,pthread_cond_wait 在等待期间会原子地释放锁,被唤醒后又自动重新持有锁,这保证"拿锁 → 读数据 → 解锁"是串行且安全的。所以锁是共享内存通信的"安全带",缺了它再快的白板也留不住正确的字。

题五:假如我们的 SharedMem.hpp 里忘了设置 PTHREAD_PROCESS_SHARED,只用默认属性初始化锁,会发生什么?为什么这个 bug 特别难排查?

详解:锁会退化成"进程内私有"(PTHREAD_PROCESS_PRIVATE)的锁。在同一个进程里多线程用这个锁还正常,但跨进程时锁完全失效——多个进程可以同时进入临界区,造成对共享 buffer 的竞争读写,出现随机性的数据错乱、崩溃。它难排查在于:代码结构看起来完全正确、单进程下测不出问题、只有多进程并发时才偶发诡异现象,而且不同进程的锁变量虽然地址看起来"一样",但语义上是各管各的。排查手段通常是把共享区内容打出来、看是不是出现"你写 I 我写 A"这种交错,或用 strace/ltrace 观察,最终往往才定位到"缺了跨进程属性"这一处。这也再次印证:做跨进程共享,PTHREAD_PROCESS_SHARED 三个字,一个都不能少。

详解:不会立刻泄漏,但语义上有讲究。shm_unlink 只是"拆掉名字/门牌",它让新的进程再也打不开这个对象,但已经映射、已经打开它的进程仍然持有对该物理内存的引用,照样能用。物理内存的真正释放,要等最后一个引用(映射或句柄)被释放才发生。也就是说:正确顺序是"先 munmap(我不用了)→ 再 close(我关句柄)→ 最后 shm_unlink(拆名字)",这样既能保证在用的进程不受影响,又能确保没有任何进程引用后资源被回收。反过来把 shm_unlink 提前也没什么安全问题(只是别人马上打不开了),真正要小心的是永远不清理——那会导致 /dev/shm 里残留一堆占着内存的具名对象,重启前它们不会消失。顺带一提,被 SIGSEGV 或 Ctrl-C 打断而没走到清理代码,也是 /dev/shm 残留对象的常见来源,所以开发期才有那句"跑前先 ls /dev/shm"。


小结

回顾这一讲,我们从"为什么要 mmap"出发,知道了它本质是把文件或不具名的内存"照进"进程的虚拟地址空间;分清了两对关键概念——MAP_SHARED(共用白板)与 MAP_PRIVATE(人手一份复印件,靠写时拷贝各自分家)、文件映射(有凭据、可跨无关进程)与匿名映射(只靠 fork 继承、无门牌);理解了 mmap 按页办事的"页对齐"规则,记住了 shm_open → ftruncate → mmap 这条把共享内存对象"从造到用"的流水线,也明白了为什么必须给锁加上 PTHREAD_PROCESS_SHARED 才能跨进程。

然后我们用一份完整可编译的工程,把上面所有知识役使了起来:SafeObj 把互斥锁、条件变量、数据缓冲区打包进共享区;服务端创建并初始化,fork 出的子进程因 MAP_SHARED 共享同一块白板;客户端只开不建,发布命令、广播叫醒、让目标进程"按需活动"。最后我们把清理的先后顺序和那九条要命的坑一一排清,再用六道带详解的思考题把这些边界钉死。

mmap 是一片很深的主题,我们今天走通了"共享内存映射"这条最主要的线。它和 SysV 共享内存、以及 mmap 在文件 I/O、共享库加载里的应用,还有很多值得深挖的内容。不过本课的地基——MAP_SHARED vs MAP_PRIVATE、写时拷贝、文件/匿名映射之分、页对齐、ftruncate 设长、以及"共享必同步、进程锁靠 SHARED 属性"——已经夯得很实。把代码敲一遍、把命令试一遍、把思考题重做一遍,这套 mmap 的肌肉记忆就真正烙进你脑子里了。我们下一篇见。