【Linux】共享内存为什么快?System V 的 key、shmget、shmat 与进程通信实战

📅 2026/8/10 8:49:28
【Linux】共享内存为什么快?System V 的 key、shmget、shmat 与进程通信实战
个人主页爱和冰阔乐专栏传送门《数据结构与算法》 、C学习方向C方向学习爱好者⭐人生格言得知坦然 失之淡然博主简介文章目录前言一、System V IPC 先认识一下二、共享内存到底共享了什么三、顺手修正一个容易混淆的内存知识四、key 和 shmid 分别解决什么问题4.1 key不同进程约定“我要找哪一个对象”4.2 shmid内核返回给进程的操作句柄五、shmget()创建和获取共享内存六、为什么程序退出了共享内存还在七、shmat()把共享内存接进自己的地址空间八、shmdt() 和 shmctl(IPC_RMID) 不是一回事九、写一个最小 Server / Client9.1 公共代码 comm.hpp9.2 Server创建、读取、删除9.3 Client获取并写入十、为什么共享内存快但也最容易把数据读乱10.1 用 FIFO 做一次“通知实验”十一、共享内存大小为什么经常和 4 KiB 一起出现十二、shmid_ds 让我们看到内核需要记录什么总结前言管道通信的路径是进程 A 用户空间 ↓ write() 内核管道缓冲区 ↓ read() 进程 B 用户空间每次发送和接收都要经过系统调用并在用户空间与内核空间之间传递数据。System V 共享内存换了一个思路同一批共享物理页 ↙ ↘ 进程 A 进程 B 地址空间 地址空间只要映射建立完成两个进程就可以通过各自的虚拟地址直接访问这份共享区域。共享内存快关键就在这里映射建立后进程直接访问共享区域不需要每次收发数据都走一遍管道式的数据搬运。一、System V IPC 先认识一下传统 System V IPC 主要包括三类共享内存 消息队列 信号量它们都是由内核维护的 IPC 对象。和匿名管道最大的感觉区别之一是进程退出不代表 System V IPC 对象一定自动消失。例如共享内存没有执行删除操作时可以在进程结束后继续通过ipcs-m看到它。所以写 System V IPC 时除了“创建”和“使用”还要把“谁负责清理”设计进去。二、共享内存到底共享了什么共享内存不是“两个进程拥有同一个虚拟地址”。更准确的说法是进程 A 某段虚拟地址 ─┐ ├── 映射到同一份共享内存页 进程 B 某段虚拟地址 ─┘两边的虚拟地址值甚至可以不同。重要的是最后落到同一份共享内存对象。映射以后mem[0]A;映射成功以后对这段地址的普通读写就是内存访问不需要每次再调用write()/read()完成 IPC 数据搬运。共享内存快不是因为“没有内核”而是建立映射后每次数据读写不再通过内核 IPC 系统调用做一次额外的数据搬运。创建、映射、解除映射、删除资源本身仍然需要系统调用。三、顺手修正一个容易混淆的内存知识原稿中把malloc()后第一次访问内存写成了“写时拷贝”。这两个概念不要混在一起。malloc()取得一段虚拟地址后操作系统可能采用按需分配 / 缺页分配先保留虚拟地址范围 ↓ 第一次真正访问 ↓ 触发缺页 ↓ 内核准备物理页并建立映射而Copy-On-Write写时拷贝更典型的场景是fork()后父子进程暂时共享只读映射页某一方写入时再复制物理页。所以malloc 首次触碰页面 ≠ fork 后 COW四、key和shmid分别解决什么问题这一块最容易把key和shmid混在一起。4.1key不同进程约定“我要找哪一个对象”假设 Server 和 Client 都需要连接同一块共享内存。在任何通信发生之前它们就要有一个共同约定。System V IPC 使用key_t key;作为这种 rendezvous key。常见做法是通过ftok(pathname,proj_id);生成ftok()#includesys/ipc.hkey_tftok(constchar*pathname,intproj_id);注意ftok()不能保证全局绝对不冲突。它只是根据文件信息和proj_id组合一个key_t。所以工程里要选择稳定、双方都能访问的 pathname并做好创建冲突处理。4.2shmid内核返回给进程的操作句柄调用intshmidshmget(key,size,flags);成功后返回的整数才是后续shmat shmctl等接口使用的共享内存标识符。所以可以这样记key → 用来“找” shmid → 找到以后用来“操作”共享内存对象创建后内核会返回shmid供后续接口使用。原稿里“不能由 OS 分配 id”这个说法需要删掉。五、shmget()创建和获取共享内存#includesys/ipc.h#includesys/shm.hintshmget(key_t key,size_t size,intshmflg);常见创建方式intshmidshmget(key,4096,IPC_CREAT|IPC_EXCL|0666);含义IPC_CREAT 不存在就创建 IPC_EXCL 和 IPC_CREAT 一起使用时 如果已经存在则失败 0666 创建时的访问权限如果 Server 要保证“我创建到的一定是一块新的共享内存”用IPC_CREAT|IPC_EXCL|0666很合适。Client 只想获取已经存在的对象时不需要再写IPC_CREAT可以直接intshmidshmget(key,4096,0);注意“获取共享内存就使用IPC_CREAT”这个说法不严谨。IPC_CREAT的语义是“不存在就创建”并不是“只获取”。六、为什么程序退出了共享内存还在创建shmget(...)然后程序直接退出。再次运行创建者时如果使用IPC_CREAT|IPC_EXCL可能看到File exists查看ipcs-m这个现象正好说明 System V IPC 的生命周期和普通进程内资源不一样进程退出 ≠ 共享内存对象自动删除测试时可以ipcrm-mshmid手动删除。正式程序应该由拥有资源生命周期管理职责的一方调用shmctl(shmid,IPC_RMID,nullptr);七、shmat()把共享内存接进自己的地址空间void*shmat(intshmid,constvoid*shmaddr,intshmflg);最常见void*addrshmat(shmid,nullptr,0);让内核选择映射地址。shmat()的失败判断按接口返回值直接写if(addr(void*)-1){perror(shmat);}不要写if((int)addr0)也不要为了 64 位指针改成if((longlong)addr0)shmat()的失败返回值就是(void*)-1直接按接口约定比较最稳。八、shmdt()和shmctl(IPC_RMID)不是一回事解除当前进程的映射shmdt(addr);它只表示当前进程不再挂接这块共享内存并不等于删除共享内存对象真正请求删除shmctl(shmid,IPC_RMID,nullptr);在 Linux 上IPC_RMID会把共享内存段标记为删除如果仍有进程挂接实际资源会等最后一个挂接解除后再完成回收。把创建、使用和清理串起来就是shmget ↓ shmat ↓ 读写共享内存 ↓ shmdt ↓ 拥有者 shmctl(... IPC_RMID ...)九、写一个最小 Server / Client9.1 公共代码comm.hpp#pragmaonce#includecstdio#includecstdlib#includesys/ipc.h#includesys/shm.hconstexprconstchar*PATHNAME.;constexprintPROJ_ID0x66;constexprstd::size_t SHM_SIZE4096;inlinekey_tMakeKey(){key_t keyftok(PATHNAME,PROJ_ID);if(key-1){perror(ftok);std::exit(1);}returnkey;}9.2 Server创建、读取、删除#includecomm.hpp#includecstdio#includecstring#includeunistd.hintmain(){key_t keyMakeKey();intshmidshmget(key,SHM_SIZE,IPC_CREAT|IPC_EXCL|0666);if(shmid0){perror(shmget);return1;}char*memstatic_castchar*(shmat(shmid,nullptr,0));if(mem(void*)-1){perror(shmat);shmctl(shmid,IPC_RMID,nullptr);return2;}while(true){std::printf(client# %s\n,mem);if(std::strcmp(mem,quit)0){break;}sleep(1);}shmdt(mem);shmctl(shmid,IPC_RMID,nullptr);return0;}9.3 Client获取并写入#includecomm.hpp#includecstring#includeiostream#includestringintmain(){key_t keyMakeKey();intshmidshmget(key,SHM_SIZE,0);if(shmid0){perror(shmget);return1;}char*memstatic_castchar*(shmat(shmid,nullptr,0));if(mem(void*)-1){perror(shmat);return2;}while(true){std::string message;std::coutPlease Enter# ;if(!std::getline(std::cin,message)){break;}std::snprintf(mem,SHM_SIZE,%s,message.c_str());if(messagequit){break;}}shmdt(mem);return0;}原稿里的实验让 Client 逐个写入A-ZServer 能直接看到共享区内容变化十、为什么共享内存快但也最容易把数据读乱共享内存映射成功后mem[0]A;写入会直接作用在共享区域。它没有像管道一样天然提供“没有数据时 read 阻塞” “写完一段以后读端再消费”这意味着Client 正写到一半 ↓ Server 同时开始读取 ↓ Server 可能看到一个中间状态共享内存解决的是“共享数据放在哪里”不是自动解决“什么时候读、什么时候写”。所以通常还需要信号量 互斥锁 条件变量 其他通知机制配合使用。10.1 用 FIFO 做一次“通知实验”你原稿里有一个很有价值的实验共享内存负责真正的数据 命名管道只负责通知“现在可以读了”例如 Client 先把一批数据写完整再write(fifo_fd,token,sizeof(token));Server 先阻塞read(fifo_fd,token,sizeof(token));收到 token 后才读共享内存。这个实验很好地说明了数据通道 和 同步/通知通道 可以是两套机制但它只解决当前一写一读场景里的顺序通知。如果以后有多个写者同时修改同一片共享区还需要真正的互斥保护。这就会自然过渡到下一篇的信号量。十一、共享内存大小为什么经常和 4 KiB 一起出现你的实验里申请4097 字节ipcs -m可能仍显示用户请求的4097但底层内存管理以页为基本单位实际映射和分配会涉及页粒度向上取整。所以不要写成“shmget 的 size 必须是 4096 的整数倍。”API 接受的size不要求调用者必须写成整页大小。更准确的说法是用户请求大小可以不是页大小整数倍但内核底层建立映射和准备页时仍然受页粒度约束。十二、shmid_ds让我们看到内核需要记录什么用户空间可以通过shmctl(shmid,IPC_STAT,ds);取得共享内存状态。典型结构包括structshmid_ds{structipc_permshm_perm;size_t shm_segsz;time_t shm_atime;time_t shm_dtime;time_t shm_ctime;pid_t shm_cpid;pid_t shm_lpid;shmatt_t shm_nattch;// ...};其中shm_segsz 请求的共享内存大小 shm_atime 最近一次 attach 时间 shm_dtime 最近一次 detach 时间 shm_cpid 创建者 PID shm_lpid 最近操作进程 PID shm_nattch 当前挂接数量ipc_perm又把key uid/gid creator uid/gid mode sequence这些 System V IPC 公共管理信息抽了出来。这个“公共头”在消息队列和信号量里还会再次出现。总结共享内存要理解的是两层第一层是key ↓ shmget ↓ shmid它解决两个进程如何找到同一个 System V 共享内存对象。第二层是shmid ↓ shmat ↓ 虚拟地址 ↓ 普通内存访问它解决进程怎样真正读写共享区域。最后把几个容易混淆的结论放在一起IPC_CREAT不是“只获取”它表示不存在时创建Client 只获取已存在对象时可以使用shmget(key, size, 0)ftok()可能碰撞不提供绝对唯一性保证shmat()失败要和(void*)-1比较shmdt()只是解除当前进程映射不是删除IPC_RMID负责请求删除共享内存共享内存本身不提供互斥和同步。下一篇就解决这个遗留问题消息队列怎样保存“带类型的消息”信号量的 P/V 到底做了什么以及 Linux 内核为什么能用一套公共框架管理三种 System V IPC。资源分享【Linux】两个毫无关系的进程怎么通信命名管道 FIFO 从原理到 Server/Client 实战【Linux】从匿名管道到进程池任务派发、fd 继承 Bug 与完整实现【Linux】进程为什么不能直接通信匿名管道原理、fd 继承与读写规则