K08 —— 进程间共享内存:shm 和 mmap(MAP_SHARED)

金句:最快的进程间通信,是根本不需要通信——直接共享同一块内存。


进程间共享内存的三种方式

Linux 支持三种进程间共享内存的机制:

Bash
1. System V Shared Memory(shmget/shmat)
   - 历史悠久,POSIX 之前的 API
   - 通过内核对象(shmid_kernel)管理

2. POSIX Shared Memory(shm_open/shm_unlink)
   - 基于 tmpfs 的文件映射
   - 通过文件描述符操作

3. mmap(MAP_SHARED)
   - 匿名共享(父子进程)
   - 文件映射共享(文件作为后端)

三种方式的共同点:
  - 都是把物理页映射到多个进程的虚拟地址空间
  - 写入一块,所有进程立即看到(无复制)

Bash
性能对比:

                    System V shm    POSIX shm(/dev/shm)    mmap(MAP_SHARED)
创建开销           高(内核对象)    中(tmpfs 文件)        低(匿名)或中(文件)
访问延迟           同上(都走页表)  同上                    同上
生命周期           显式删除          最后一个引用关闭          最后一个映射关闭
调试便捷性         ipcs -m          ls /dev/shm             /proc/PID/maps
POSIX 标准         否               是                      是
跨节点(NUMA)     支持             支持                    支持

System V Shared Memory 的内核路径

Bash
shm 的内核数据结构路径:

  用户态:shmget(key, size, IPC_CREAT|0666)
              ↓
  内核:shmid_kernel 结构体(struct shmid_kernel)
              ↓
          关联一个匿名 tmpfs 文件(没有真实文件名)
              ↓
          struct file → struct address_space
              ↓
          物理页(来自 buddy system,纳入 page cache)

  用户态:shmat(shmid, NULL, 0)
              ↓
  内核:为当前进程创建一个 VMA(虚拟内存区域)
              ↓
          VMA.vm_ops → shm_vm_ops(文件操作)
              ↓
          页表建立映射:虚拟地址 → struct page → 物理页

关键点:
  - shm 的物理页是 page cache 的一部分
  - 可以被 tmpfs 的回收机制回收(如果内存紧张)
  - shmctl(IPC_RMID) 只是标记删除,真正的页释放在最后一个进程分离后

查看共享内存(ipcs):
  $ ipcs -m
  ------ Shared Memory Segments --------
  key     shmid   owner   perms   bytes   nattch   status
  0x1234  65536   user    666     1048576   2        dest

  nattch = 附加本段的进程数
  status = dest 表示"标记删除,等待最后一个进程分离"

System V Shared Memory

Bash
shmget / shmat 的使用:

key_t key = ftok("/some/path", 1);
int shmid = shmget(key, 1024*1024, IPC_CREAT | 0666);

// 附加到进程地址空间
void *addr = shmat(shmid, NULL, 0);

// 使用共享内存
memcpy(addr, "hello", 6);

// 分离(不再使用,但共享区域还在)
shmdt(addr);

// 删除共享区域(标记删除,最后一个进程分离后真正释放)
shmctl(shmid, IPC_RMID, NULL);

内部实现:
  - shmget 创建/获取内核对象(shmid_kernel)
  - shmat 通过 shm_npages 个 VMA 映射到进程空间
  - 物理页来自 buddy system,通过 page cache / tmpfs 文件管理

Bash
shm 的生命周期:
  - shmget 创建,shmctl(IPC_RMID) 删除(标记删除)
  - 即使所有进程都 shmdt,共享区域仍然存在
  - 除非显式删除,且所有进程都已分离

系统限制(/proc/sys/kernel/shm*):
  - shmmax:最大段大小(通常 8GB)
  - shmmni:最大段数量(通常 4096)
  - shmall:最大总内存(pages)

ipcs -m 查看共享内存:
  $ ipcs -m

  ------ Shared Memory Segments --------
  key     shmid   owner   perms   bytes   nattch   status
  0x1234  65536   user    666     1048576   2        dest

POSIX Shared Memory(shm_open)

Bash
shm_open 的使用:

#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>

// 创建共享内存对象(基于 /dev/shm tmpfs)
int fd = shm_open("/my_shm", O_CREAT | O_RDWR, 0666);
ftruncate(fd, 1024*1024);  // 设置大小

// 映射到进程地址空间
void *addr = mmap(NULL, 1024*1024, PROT_READ|PROT_WRITE,
                  MAP_SHARED, fd, 0);

// 使用
memcpy(addr, "hello", 6);

// 清理
munmap(addr, 1024*1024);
close(fd);
shm_unlink("/my_shm");  // 删除

特点:
  - 路径名在 /dev/shm(tmpfs)
  - 可以用 ls /dev/shm 查看(调试友好)
  - 更简单,符合 POSIX 标准

Bash
/dev/shm(tmpfs):

  - tmpfs 是一种内存文件系统
  - 存储在 Page Cache 中(file-backed)
  - 读取写入实际是内存操作(无磁盘 I/O)
  - 物理页来自 Page Cache,可以被回收(内存紧张时)

  - 缺点:大小受 tmpfs 配额限制,默认是物理内存的一半
  - 如果 /dev/shm 写满,shm_open 会失败(返回 ENOMEM)
  - 调整大小:mount -o size=10G -o remount tmpfs /dev/shm

优点:
  - 不用 key,路径名更直观
  - 基于文件,容易调试(可以用 ls 查看)
  - 符合 POSIX 标准(新代码建议用这个)

mmap(MAP_SHARED) 的两种用法

Bash
匿名共享(父子进程):

void *addr = mmap(NULL, 4096,
                  PROT_READ|PROT_WRITE,
                  MAP_SHARED|MAP_ANONYMOUS,
                  -1, 0);

fork();
memcpy(addr, "hello", 6);  // 父子都看到(COW 关闭)
wait();
munmap(addr, 4096);

原理:
  - MAP_ANONYMOUS:没有文件后端
  - MAP_SHARED:fork 后共享(COW 关闭)
  - 物理页在所有进程间共享

---

文件共享:

int fd = open("/tmp/file", O_RDWR);
void *addr = mmap(NULL, 4096, PROT_READ|PROT_WRITE,
                  MAP_SHARED, fd, 0);

fork();
memcpy(addr, "hello", 6);  // 写回磁盘文件
wait();
munmap(addr, 4096);
close(fd);

原理:
  - 文件内容作为共享内存的后端
  - 写入会立即反映到文件中(MAP_SHARED)
  - 磁盘文件是持久化的
  - 需要 msync(MS_SYNC) 才保证数据落盘(可选)

fork 后三种方式的行为差异

Bash
方式                    fork 后行为                          COW ?
System V shm           自动共享(所有进程见同一物理页)        否(天然共享)
POSIX shm              打开的 fd fork 后继承,映射后共享        否(天然共享)
mmap(MAP_SHARED, fd)   fd 继承,映射后共享                    否(天然共享)
mmap(MAP_ANONYMOUS)   MAP_SHARED:父子共享                    否
mmap(MAP_ANONYMOUS)   MAP_PRIVATE:COW(各自独立副本)        是(默认 COW)

关键区别:
  - 有文件/段后端的三种(shm + mmap shared):fork 后天然共享
  - MAP_ANONYMOUS + MAP_PRIVATE(默认):fork 后 COW,各自独立

这意味着:
  - System V shm:fork 后子进程直接看到父进程写入的内容(无 COW)
  - mmap 匿名 MAP_PRIVATE:fork 后子进程有独立副本,除非触发 COW 写入

msync:何时需要强制刷盘

Bash
MAP_SHARED 写入文件时的行为:

  - 写入共享内存 → Page Cache 脏页 → 由内核 writeback 机制刷盘
  - 不是每次写入都立刻落盘(会有延迟)

msync 的作用:

  #include <sys/mman.h>
  msync(addr, length, MS_SYNC);   // 同步等待落盘
  msync(addr, length, MS_ASYNC);   // 异步刷盘,立即返回

  MS_INVALIDATE:同时通知内核重新从磁盘读取(很少用)

典型场景:
  - 数据库日志:写入后必须立刻落盘 → 用 msync(MS_SYNC)
  - 共享内存缓存:不需要立即落盘 → 用默认的延迟写回即可

注意:
  - 对 MAP_ANONYMOUS 共享内存调用 msync 是无效的(无文件后端)

共享内存的同步问题

Bash
共享内存本身不提供同步机制!
多个进程同时读写同一块内存会导致数据竞争。

解决方案:

1. 原子操作(atomic)
   - POSIX atomics(<stdatomic.h>)
   - 只适合简单计数器

2. 互斥锁(mutex)
   - 进程间可以用 pthread_mutex(需要 PTHREAD_PROCESS_SHARED 属性)
   - 或者 System V semaphore

3. 信号量(semaphore)
   - System V semaphore(semget/semop)
   - POSIX semaphore(sem_open)

4. 文件锁(F_SETLKW)
   - flock():简单文件锁
   - fcntl():更强

示例(System V semaphore):
  key_t semkey = ftok("/tmp", 1);
  int semid = semget(semkey, 1, IPC_CREAT | 0666);
  semctl(semid, 0, SETVAL, 1);  // 初始化为 1

  // P 操作(原子减 1,如果值为 0 则阻塞)
  struct sembuf p = {0, -1, 0};
  semop(semid, &p, 1);

  // V 操作(原子加 1)
  struct sembuf v = {0, 1, 0};
  semop(semid, &v, 1);

  // 配合共享内存使用:
  semop(semid, &p, 1);   // 进入临界区
  // 操作共享内存
  semop(semid, &v, 1);    // 离开临界区

/dev/shm 大小限制——生产环境容易踩的坑

Bash
/dev/shm 的默认大小:

  - 通常是物理内存的 50%(可通过 df 查看)
  - $ df -h /dev/shm
    tmpfs                 7.7G  0  7.7G   0% /dev/shm

  - 7.7G = 物理内存的一半

如果共享内存写满(超过 /dev/shm 配额):
  - shm_open 失败,返回 errno=ENOMEM
  - 程序可能崩溃或进入异常状态

调整 /dev/shm 大小(需要 root):

  # 临时调整
  mount -o size=10G -o remount /dev/shm

  # 永久调整(/etc/fstab)
  tmpfs /dev/shm tmpfs size=10G 0 0

生产建议:
  - 预留足够 /dev/shm 空间(或明确知道上限)
  - 监控 /dev/shm 使用率
  - 如果需要大共享内存,考虑 System V shm(shmmax 可以很大)

总结

  • shmget/shmat:System V 共享内存,key-based,内核对象,有 ipcs 可见
  • shm_open/shm_unlink:POSIX 共享内存,路径名,基于 /dev/shm tmpfs
  • mmap(MAP_SHARED|MAP_ANON):匿名共享,父子进程,关闭 COW
  • mmap(MAP_SHARED, fd):文件映射共享,文件作为后端,写会持久化
  • fork 行为:有文件后端的共享内存(shm + mmap shared)fork 后天然共享;匿名 MAP_PRIVATE 才是 COW
  • msync:MAP_SHARED 写文件后默认延迟写回,需要强制落盘时用 msync
  • 同步:共享内存不提供同步,需要 mutex/semaphore/原子操作等
  • /dev/shm 配额:默认物理内存 50%,用满会 ENOMEM,需要提前规划

下篇预告(K09):什么是内存泄漏?为什么 malloc 不 free 会导致内存无法回收?泄漏的检测方法和防止措施。


关注公众号「AI不着急」,回复”资料”获取内存学习路线图。

最后修改: 2024年3月13日

作者

评论

发表评论

您的邮箱地址不会被公开。