尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

System V IPC 三件套详解:共享内存、消息队列与信号量实战

System V IPC 三件套详解:共享内存、消息队列与信号量实战 1. 为什么还要花时间搞懂 System V IPC做 Linux 后台开发的人对“进程通信IPC”这个词一定不陌生但一提System V IPC很多人的反应却是“太老了”。可事实是这套由 ATT System V 在几十年前设计出来的进程间通信方案到今天仍然在内核里原封不动地待着几乎每一个 Linux 发行版都支持它包括共享内存、消息队列、信号量三大件。你写嵌入式固件、做多进程缓存、设计网关程序甚至只是想搞明白ipcs命令到底输出了一堆什么东西都绕不开这几个系统调用。我最早接触 System V IPC 是在一个老项目里项目里同时跑着十几个 C 进程需要共享一份热数据。当时用的就是shmget加semget。后来换到现代框架发现很多“新东西”根本没有想象中那么新内核里那几个原语反而一直在用。这篇文章就想把这三件套彻底讲透——含完整调用流程、参数含义、底层逻辑以及我在实际项目中踩过的坑。建议阅读人群对 Linux 系统编程感兴趣的开发者、正在维护老代码的工程师以及想搞明白“消息队列/互斥信号量到底怎么回事”的人。1.1 为什么老的 API 还能活到今天先说个反直觉的事实System V IPC 并不是性能最好的方案也不是最容易上手的方案但它却是兼容性和持久性都很稳的方案。它不需要文件描述符不需要连接建立过程只要两个进程能拿到同一个key就能访问同一个内核对象。这意味着它不受 fork 前后文件描述符继承的限制也不依赖父子关系多个完全不相关的进程只要约定了 key 就能通信。还有一个原因让它难以被彻底替代它由内核管理生命周期比任何单个进程都长。进程崩了共享内存对象还在消息队列还在信号量集合还在。对守护进程类应用来说这种“系统级资源”的语义很有用——重启应用不用重建通信基础设施或者反过来崩溃后能通过ipcs快速发现残留对象。很多数据库、缓存组件、监控代理到现在的实现里还能看到 System V IPC 的痕迹。1.2 三件套分别是干什么用的可以把进程想象成公司里的不同部门。部门之间要协作就得有传话的方式共享内存相当于一个公共白板。任何部门都能在上面写内容其他部门抬头就能看到不需要中间人转述。它吞吐量最高、延迟最低但有一个大前提——两个部门不能同时乱写否则内容会被覆盖。放在系统层面说多个进程映射同一块物理内存读写都直接操作这块内存没有内核拷贝。消息队列相当于内部信箱。发件人把信投进箱里收件人想什么时候来取就什么时候来取。信有一封封的边界不会因为同时写了多份内容而混在一起。内核负责排队、管理和边界保护。信号量相当于出入口的灯。进门前先看灯绿灯才放行出来时再换成绿灯。它本身不传数据只用来协调“谁先进去、谁后出来”解决的是并发访问的秩序问题。一句话概括共享内存负责“搬运”消息队列负责“通知”信号量负责“秩序”。大多数称得上“熟练”的 C/C 工程师最终都会把这三者组合起来用。1.3 System V IPC 和 POSIX IPC 怎么选很多人在学习时会把两组 API 混在一起System V 的shmget/semget/msgget和 POSIX 的shm_open/sem_open/mq_open。两者解决的问题基本一样但设计风格不同。POSIX IPC 更贴近文件描述符模型用mmap、sem_wait、mq_send等接口语义上更简洁也更加线程友好。System V 则是一套独立命名空间用ipcs能直接查看所有对象。对比项System V IPCPOSIX IPC创建方式shmget/semget/msgget keyshm_open/sem_open/mq_open 名字查看手段ipcsls /dev/shm 或 /dev/mqueue生命周期直到 IPC_RMID 或系统重启直到 unlink 或系统重启可移植性几乎所有 Unix/Linux主流系统可用但不保证老 Unix同步阻塞semop 阻塞sem_wait 阻塞从我的实际感受来看新项目优先 POSIX IPC 是合理的因为接口更友好配合poll/select也方便。但如果是维护老系统、或者目标内核镜像非常精简System V 依然是那个“永远在场”的选择。更关键的是很多线上排障工具和文档都以 System V 为基准比如ipcs的输出格式。所以我建议把 System V 学透POSIX 顺手学一下即可。2. 共享内存最快的通道但不保护就是最乱的通道共享内存之所以快是因为它在进程之间传递数据时完全绕过了内核拷贝。一个进程往内存里写入数据另一个进程看到的立刻就是新数据。这一点跟管道和 socket 有本质区别管道每次读写都要经过内核缓冲区发送方拷贝一次接收方再拷贝一次共享内存则是所有进程直接映射同一块物理页我写入你看到中间零拷贝。2.1 一次完整的共享内存生命周期整个流程只有四个关键函数shmget创建或获取共享内存对象shmat把对象附加到自己进程的地址空间shmdt解除附加shmctl可以查询或标记删除。看一段最简单的代码#include sys/ipc.h #include sys/shm.h #include stdio.h int main(void) { key_t key ftok(/tmp/app, S); if (key -1) { perror(ftok); return 1; } int shmid shmget(key, 4096, 0644 | IPC_CREAT); if (shmid -1) { perror(shmget); return 1; } char *buf shmat(shmid, NULL, 0); if (buf (void *)-1) { perror(shmat); return 1; } snprintf(buf, 4096, hello from process %d, getpid()); shmdt(buf); return 0; }很多人第一次看会有一个疑问既然shmget已经拿到了shmid为什么还要shmat一次因为shmid只是一个内核对象标识符不是进程可以直接使用的指针。进程必须把这块内存映射进自己的虚拟地址空间才嫩像普通数组一样访问。shmat的第二个参数传NULL是让内核选择合适的映射地址如果你不传NULL也可以指定一个地址但很可能失败。还有个细节shmget的第二个参数是字节数内核会按页对齐。如果你只申请 1 个字节它也会占一个完整的内存页通常 4KB。所以实际开发中尽量按页大小申请避免浪费共享内存页。2.2 权限位和 IPC_CREAT|IPC_EXCL 的逻辑shmget的第三个参数里包含了权限和创建标志这点和open系统调用相似。一般用0644 | IPC_CREAT表示“没有则创建”。这里有一个高频翻车点如果对象已经存在IPC_CREAT不会改变它的权限和大小。也就是说你第二次想用更大尺寸重新创建同一个 key 的对象会发现shmget直接返回了旧的shmidshmat之后看到的还是旧大小。想强制重新创建就要用IPC_CREAT | IPC_EXCL。当对象已存在时它会返回EEXIST。这在初始化代码里很有用你可以先尝试IPC_EXCL如果拿到EEXIST就说明另外的进程已经初始化好了直接shmget拿现有对象即可。权限方面一个小提醒System V 共享内存的权限跟文件系统权限类似但 owner 是根据创建者 uid 决定的。如果服务进程以不同用户运行很容易出现“明明创建了另一个用户却连不上”的问题。线上排障时先ipcs -m看一眼权限位往往比看日志更快。2.3 一个共享内存对象能活多久共享内存的生命周期不是“最后一个进程 detach 就销毁”而是显式调用shmctl(shmid, IPC_RMID, NULL)或者系统重启。这里有个重要机制Linux 默认是“延迟删除”lazy delete。一旦你调用了IPC_RMID这块共享内存就不能再被新进程shmat了但已经 attach 的进程还能继续访问直到它们全部shmdt内核才真正释放物理页面。这个机制有好处也有隐患。好处是避免了正在使用内存的进程忽然崩溃隐患是如果你调用了IPC_RMID后又想重新shmget会失败因为你已经删了标识符。另外如果进程忘了清理共享内存对象会一直占用物理内存而且不占虚拟内存也不占文件系统空间很容易被忽略。我在生产环境里见过一台机器最后 OOM排查半天发现是某个服务反复创建共享内存但从未释放ipcs -m一看几百个对象每个几 MB。2.4 共享内存为什么一定要配信号量共享内存本身不提供任何同步手段。两个进程同时写一个内存区域最后结果取决于谁后写到这叫数据竞争。曾经我负责过一个网关程序用共享内存做配置热更新主进程写配置业务进程读配置。最初没加锁结果线上偶尔出现配置读到一半的撕裂值——比如一个 long 类型的高 4 字节是新版本低 4 字节是旧版本校验永远对不上。解决办法就是在写之前加互斥锁。最简单的做法是用二元信号量当互斥锁初始值为 1写之前做一次“P 操作”信号量减一写完之后做一次“V 操作”信号量加一。这一块的完整代码我在后面的组合实战里展开。这里先强调一个原则共享内存是数据通道信号量是秩序通道两者搭配才是合格的进程间通信设计。2.5 在共享内存里存数据指针是最危险的敌人不同进程shmat之后同一块物理内存在各自的虚拟地址空间里可能映射到不同位置。所以千万不要在共享内存结构体里保存绝对指针。比如你写了一个链表节点next字段指向另一个节点的地址进程 A 里是0x7f...进程 B 里可能完全不同B 拿这个地址直接访问就段错误。正确的做法是用偏移量offset代替指针。假设第一个节点始终位于内存头部那next就存“下一个节点相对于头部的偏移字节数”访问时用base_addr offset。另一种方案是约定所有进程都用相同地址映射比如shmat时指定一个固定地址但在现代 Linux 上地址空间布局随机化会让这个做法变得脆弱我不推荐。除了指针还要注意对齐和字节序。跨机器共享数据时字节序问题也要提前约定结构化数据建议用固定宽度整数类型比如uint32_t不要直接塞int。3. 消息队列进程间的异步传话消息队列跟共享内存最大的区别是它有一个明确的消息边界并且自带“暂存”能力。发送方把消息塞进队列接收方不在线也没关系消息会留在内核队列里等接收方来取。对“任务派发”这类场景特别合适因为你不希望一个进程没准备好就把另一个进程的数据搞丢。3.1 msgget、msgsnd、msgrcv 的基本用法消息队列的创建和共享内存很像先ftok拿 key再msgget创建之后发消息用msgsnd收消息用msgrcv。看一下基本结构#include sys/ipc.h #include sys/msg.h struct mymsg { long mtype; // 消息类型必须大于 0 char mtext[128]; // 消息正文 }; int main(void) { key_t key ftok(/tmp/app, M); int qid msgget(key, 0644 | IPC_CREAT); if (qid -1) { perror(msgget); return 1; } struct mymsg msg {1, hello}; if (msgsnd(qid, msg, sizeof(msg.mtext), IPC_NOWAIT) -1) { perror(msgsnd); } struct mymsg rcv; ssize_t n msgrcv(qid, rcv, sizeof(rcv.mtext), 1L, 0); if (n -1) { perror(msgrcv); return 1; } printf(recv %zd bytes: %s\n, n, rcv.mtext); return 0; }这里有一个非常容易踩的细节msgsnd的第三个参数msgsz只表示mtext的长度不含mtype这 4 个字节。很多新手把sizeof(struct mymsg)传进去发送和接收都错位读出来的字符串全乱。记住这条规则msgsz sizeof(mtext)。消息类型字段mtype必须是一个正整数。它承担的功能远超“类型”两个字后面我会专门讲它的路由能力。3.2 消息队列的限制和阻塞行为消息队列不是无限大的。Linux 默认单个消息正文最大长度kernel.msgmax通常是 8192 字节整个消息队列的总字节上限kernel.msgmnb通常是 16384 字节。当队列满时msgsnd默认会阻塞直到队列腾出空间接收方为空队列时msgrcv默认也会阻塞直到有消息到达。如果不想阻塞就在msgsnd或msgrcv的标志位里加IPC_NOWAIT。收到EAGAIN时说明资源暂时不可用。看内核当前限制可以这样sysctl kernel.msgmax kernel.msgmnb kernel.msgmni cat /proc/sys/kernel/msgmax一个容易忽略的点是即使接收方用IPC_NOWAIT 队列为空msgrcv也不会返回 0 表示成功而是返回-1errno是ENOMSG。很多刚上手的人在这里误判为空消息导致逻辑异常。如果你需要“没有消息时做别的事情”记得显式判断errno。3.3 消息类型不只是标号还能做优先级和路由msgrcv的第四个参数msgtyp有三个规则这是 System V 消息队列最奇妙的地方msgtyp 0接收队列里的第一条消息按照先进先出的顺序。msgtyp 0接收类型等于该值的消息。msgtyp 0接收类型小于等于abs(msgtyp)的第一条消息并且按类型最小的优先。第三个规则意味着你可以在系统层面上实现优先级队列。比如把消息类型定为 1、2、3其中 1 是最高优先级紧急告警3 是最低优先级日常处理。接收方用msgtyp -1就能保证每次都先取走类型最小的消息也就是优先级最高的消息。多个消费者还可以用不同正msgtyp实现类似“订阅路由”的效果进程 A 只收 type1进程 B 只收 type2各取所需。但要注意System V 消息队列没有“广播”能力一条消息被某个进程msgrcv取走后其他进程就看不到了。它天然是点对点消费模型这一点在后面对比 RabbitMQ/Kafka 时尤为重要。3.4 消息队列的重复消费和持久性问题关于“消息队列重复消费”这个问题System V 有自己的答案它压根不会重复消费。msgrcv一旦成功消息就从内核队列里移除了。所以不存在 Kafka 那种消费组里的 offset 提交失败导致的重复消费。这让它在语义上更简单但也意味着没有“重试机制”消息被谁拿走谁就得负责处理完。持久性方面消息队列对象本身驻留内核进程退出不删对象消息也不会丢。但只要系统重启队列就会清空。想要跨重启持久化还是要落到文件或数据库。它的可靠性介于共享内存和现代消息中间件之间适合“同一台机器上、进程之间传临时任务”的场景。3.5 和 RabbitMQ、Kafka、RocketMQ 能比吗经常在网上看到有人拿 System V 消息队列和 RabbitMQ、Kafka、RocketMQ 对比。我的观点是它们根本不在一个维度上。System V 消息队列是单机内核对象延迟极低几微秒级别但无法跨机器无复杂路由无持久化。RabbitMQ 适合跨服务、跨机器的可靠异步通信Kafka 适合海量日志和流处理RocketMQ 在金融场景下以事务消息见长。你不可能用 System V 消息队列搭一个分布式订单系统也不可能用 Kafka 在两个进程间传一个微秒级信号。场景推荐方案同一台机器多进程任务分发System V 消息队列 / 共享内存跨机器服务间调用gRPC、HTTP、消息中间件海量日志采集Kafka分布式事务消息RocketMQ高吞吐可靠投递RabbitMQ、Kafka选择工具前先想清楚边界你需要“内核级的速度”还是“业务级的灵活”。System V 消息队列的定位很清晰它是给单机高性能场景准备的。4. 信号量多进程入口处的红绿灯信号量是 IPC 三件套里最抽象、最容易出错的一个。它不传输数据却决定其他两个通路能不能正常工作。很多人写共享内存程序出问题最后发现根因都是信号量逻辑不对。4.1 先理解信号量集合模型System V 信号量跟你想的那种“一个信号量”不太一样。每次semget创建的是一个信号量集合semaphore set里面可以包含多个信号量每个信号量用自己的下标sem_num来访问。这一点是为了支持“一次原子操作多个信号量”这种复杂场景。看代码#include sys/ipc.h #include sys/sem.h int main(void) { key_t key ftok(/tmp/app, E); // 创建包含 2 个信号量的集合 int semid semget(key, 2, 0644 | IPC_CREAT); if (semid -1) { perror(semget); return 1; } // 把第 0 号信号量初始化为 1 if (semctl(semid, 0, SETVAL, 1) -1) { perror(semctl); } struct sembuf op { .sem_num 0, // 操作第 0 号信号量 .sem_op -1, // 减一 .sem_flg SEM_UNDO }; if (semop(semid, op, 1) -1) { perror(semop); } return 0; }sem_op可以是负数、正数或零负数申请资源。如果当前信号量值减到负数无法满足进程阻塞直到其他进程释放。正数释放资源给信号量加回指定值。零等待信号量值变为 0不申请也不释放常用于“等所有任务结束”。一次semop可以传入一个sembuf数组内核会保证整个数组里的所有操作在逻辑上原子执行。比如你要同时等两个资源或者同时申请多个这些都通过数组一次完成避免“先拿到第一个、再等第二个时死锁”的问题。4.2 互斥信号量、计数信号量分别怎么用最常见的用法是互斥信号量二元信号量初始化值为 1sem_op -1表示加锁sem_op 1表示解锁。但这里要注意一个区别信号量不具备“所有权”概念。任何一个进程都能对信号量做 V 操作哪怕它根本没有加锁。如果用pthread_mutex系统会提醒“解锁的不是持有锁的线程”System V 信号量不会管这些所以纯粹做互斥时要靠业务逻辑约束不要跨进程乱释放。如果需要控制“最多同时 N 个进程访问”就把初始化值设为 N。这种计数信号量常用来做连接池、限流。信号的增减用同一个semop模式写不需要额外的锁。4.3 SEM_UNDO 是保命用的但别指望它修数据SEM_UNDO是我最喜欢、也最觉得隐患重重的一个标志。它表示“这个进程修改信号量时记住这个修改如果进程异常退出自动回滚”。举个例子一个进程拿锁时信号量从 1 减为 0如果它没来得及释放就崩溃了没有SEM_UNDO的信号量会永远停在 0其他进程全部锁死。加上了SEM_UNDO内核会在进程退出时自动加回 1其他进程就能继续运行。这设计非常贴心但注意它只回滚信号量的值不会回滚你在临界区里写到一半的共享内存数据。如果你把一段结构写了一半崩溃了即使信号量恢复数据依然是脏的。所以实际项目中我还会在共享内存头里放“序列号 校验和”读进去先校验不合法就丢弃这次的更新。4.4 信号量 vs 进程共享互斥锁有人会问既然要互斥为什么不用pthread_mutex加PTHREAD_PROCESS_SHARED属性或者直接用 C11 原子变量这些当然可以。pthread_mutex进程共享模式在多核机器上性能很好API 也熟悉。但它要求互斥锁本身必须放在共享内存里并且所有进程对锁的初始化只能由一方完成这本身就绕不开共享内存管理。System V 信号量的优势在于它独立于共享内存存在你可以单独创建、单独释放还能用ipcs -s直观地看到当前值是 0 还是 1。另一方面信号量天然支持计数而 mutex 只支持互斥。我的选择标准很简单只是多个进程写一块内存优先pthread_mutex 进程共享属性代码直白类型安全。需要控制并发数或等待特定数量任务完成用 System V 信号量。老系统/跨平台内核兼容System V 信号量最稳。4.5 死锁、EINTR 和信号量残留信号量死锁的经典场景是“两个进程互拿对方的锁”。进程 A 持有锁 1等着锁 2进程 B 持有锁 2等着锁 1。解决方法是让所有进程按同样的顺序申请锁比如先锁共享内存头部的锁再锁数据槽的锁不允许倒过来。还有一个很多人忽略的坑semop是阻塞调用当进程收到信号时它会返回-1并且errno是EINTR。如果你的服务在循环里做锁操作别忘记在EINTR时重试。很多线上偶现的“锁失败”最后都是因为没处理EINTR。残留问题就更常见了。程序被kill -9信号量集合还在如果没用SEM_UNDO锁就永远锁死。这时候可以用ipcs -s找到semid用ipcrm -s semid删除。平时我会在启动脚本里做一次“定向清理”只删自己应用名对应的 key 范围的信号量绝不能一把梭把所有信号量删干净系统里可能还有别的服务在用。5. 组合实战共享内存 消息队列 信号量做出一个任务池光讲单个 API 没意思我把三件套组合起来做一个常见的“跨进程任务池”一个生产者进程往池子里提交任务多个消费者进程取任务执行。任务数据量比较大不适合直接塞消息队列消费者需要并发执行必须靠信号量控制并发数。这是很典型的“共享内存存数据 消息队列做通知 信号量管并发”结构。5.1 总体设计整体数据流是这样生产者从外部接收一批任务每个任务可能有几 KB 的元数据。生产者把任务数据写进共享内存里的一片“任务槽”。生产者往 System V 消息队列发一条小消息内容只包含“任务槽索引”。多个消费者进程竞争接收消息队列里的消息拿到索引后去共享内存读任务。消费者执行任务前必须获取信号量限制同时执行的消费者数量。为什么用消息队列而不是信号量做通知因为消息队列自带排队能力消费者谁闲着谁抢任务永远不会丢。为什么任务数据要放共享内存而不是塞进消息队列因为单个消息长度只有 8192 字节几 KB 勉强可以但十兆的大任务就完全不行了。共享内存天然支持大块数据。5.2 初始化时最容易被忽略的“谁先建谁先初始化”问题多个进程同时启动时有竞争大家都尝试shmget(key, size, IPC_CREAT)其中只有一个是真的创建者其他人只是拿到同一个shmid。创建者必须负责把共享内存头部和信号量初始化好。我常用的标准写法是int shmid shmget(key, sizeof(struct pool_header) slots * slot_size, 0644 | IPC_CREAT); struct pool_header *hdr shmat(shmid, NULL, 0); if (shmctl(shmid, IPC_STAT, buf) 0 buf.shm_nattch 1) { // 我是第一个 attach 的进程承担初始化任务 memset(hdr, 0, sizeof(*hdr)); hdr-magic 0x504F4F4C; // POOL // 初始化信号量集合 semctl(semid, 0, SETVAL, 1); // 槽位写锁 semctl(semid, 1, SETVAL, max_workers); // 并发名额 }判断“我是不是创建者”可以通过shmget是否返回EEXIST但更稳妥的做法是在shmget里带上IPC_CREAT | IPC_EXCL。如果成功说明我就是创建者初始化如果失败说明对象已经存在我直接shmget引用即可。这种方式比shm_nattch 1更可控。5.3 生产者写入任务的核心步骤生产者逻辑分成三步每一步都要考虑失败恢复struct task_msg { long mtype; int slot_index; }; // 1. 申请槽位写锁 struct sembuf lock {0, -1, SEM_UNDO}; semop(semid, lock, 1); // 2. 找到共享内存头里的空闲链表/环形 buffer写入任务数据 int idx hdr-write_pos; memcpy(slot_base(idx), task, sizeof(task)); hdr-slots[idx].magic 0xDEAD; hdr-slots[idx].size task.size; __sync_synchronize(); // 内存屏障确保其他进程看到完整数据 // 3. 释放锁 struct sembuf unlock {0, 1, SEM_UNDO}; semop(semid, unlock, 1); // 4. 通知消费者 struct task_msg msg {1, idx}; msgsnd(qid, msg, sizeof(msg.slot_index), IPC_NOWAIT);这里有个细节内存屏障之后才发消息。消费者是在收到消息后才去读共享内存的所以只要消费者按这个顺序执行就不会读到半写状态。写锁保证两个生产者不会写同一个槽位。5.4 消费者执行任务的核心步骤消费者逻辑是在一个循环里等消息struct task_msg msg; while (1) { if (msgrcv(qid, msg, sizeof(msg.slot_index), 0, 0) -1) { if (errno EINTR) continue; break; } // 获取并发名额防止启动太多消费者同时打满 CPU struct sembuf permit {1, -1, SEM_UNDO}; semop(semid, permit, 1); // 从共享内存读任务先验证 magic再拷贝到本地栈 struct task t; memcpy(t, slot_base(msg.slot_index), sizeof(t)); if (t.magic ! 0xDEAD) { /* 数据污染此处应告警 */ } process_task(t); struct sembuf release {1, 1, SEM_UNDO}; semop(semid, release, 1); }如果不申请并发名额所有消费者就会无限制地同时冲进来系统的 CPU 和 IO 可能被打爆。信号量在这里起到了“限流”的作用。同时因为使用了SEM_UNDO即使某个消费者进程崩溃并发名额也会自动归还不会让整个任务池慢慢失去并发能力。5.5 这套设计里我踩过的三个坑第一消息队列的msgsz只传slot_index有的人写代码时顺手传了sizeof(struct task_msg)结果接收端和发送端认为的消息长度不一致数据全乱。这个问题排查起来特别隐蔽因为不是每次必现。第二共享内存槽位释放后没有重新初始化magic导致消费者读到旧的垃圾任务。我把释放槽位的逻辑加上memset(slot_base(idx), 0, slot_size)每一步都能追踪状态。第三信号量初始化的顺序问题。如果生产者先启动消费者后启动只要信号量集合在创建时已经初始完成就没事但如果是“每个进程启动时都尝试 SETVAL”后启动的进程会重置并发名额把正在执行的任务数量清零导致其他消费者被卡住。正确做法是只有创建者初始化一次其他进程只用不碰。6. 内核参数、残留对象和排查实操System V IPC 对象是内核级资源有数量和大小的限制而且不像文件描述符那样在进程退出时自动全部关闭。这意味着线上环境如果不好好管理迟早会遇到“共享内存不够”“消息队列创建失败”“信号量集合数满”这类问题。6.1 和 System V IPC 相关的内核参数Linux 把它们拆成了几组系统参数看当前值用sysctl或者直接读/proc/syskernel.shmmax单个共享内存段允许的最大字节数。默认在 64 位系统上通常是18446744073692774399大约 16EB实际上只是理论值在 32 位系统或者容器里会被限制得比较明显。kernel.shmall系统允许的共享内存页总数一页通常是 4KB。如果容器里频繁创建共享内存失败先看它。kernel.msgmax单个消息正文最大字节数默认 8192。kernel.msgmnb单个消息队列总字节上限默认 16384。kernel.msgmni系统允许的消息队列标识符数上限默认通常 32000。kernel.sem四个数字分别是semmsl每个集合最大信号量数、semmns系统信号量总数、semopm每次 semop 最多操作数、semmni系统信号量集合总数。生产环境临时修改可以用sysctl -w永久修改写/etc/sysctl.conf。我在容器环境里经常遇到的一个问题是宿主机看起来参数很大但容器内 namespace 不允许修改程序shmget直接返回ENOSPC。遇到这类问题不要只看报错先cat /proc/sys/kernel/shm*确认容器内的真实限制。6.2 用 ipcs 快速排查问题ipcs是排查 System V IPC 的第一工具。常用组合ipcs -m # 查看共享内存 ipcs -q # 查看消息队列 ipcs -s # 查看信号量集合 ipcs -u # 查看资源使用情况汇总ipcs -m的输出里nattch表示当前有多少进程 attach 了这块共享内存cpid是创建者 PID。如果有一块共享内存的nattch始终是 0 但对象还在说明它是上次运行留下的孤儿。ipcs -s里的nsems表示这个集合包含几个信号量。查具体信号量的当前值可以用cat /proc/sysvipc/sem里面会列出每个信号量的当前值和上次操作进程。要删除对象ipcrm -m shmid ipcrm -q msqid ipcrm -s semid也可以用ipcrm -M key、ipcrm -Q key、ipcrm -S key按 key 删。我写过一个简单的清理脚本只清理指定 key 前缀的对象for id in $(ipcs -m | awk $1 ~ /0x0000abcd/ {print $2}); do ipcrm -m $id done注意 awk 的匹配写法因发行版输出格式不同会有差异最好是先看一眼ipcs -m再写脚本。6.3 无主对象和延迟删除带来的“幽灵内存”System V 共享内存最危险的地方在于进程退出后如果没有调用shmctl(IPC_RMID)共享内存对象就一直存在但它又不是普通文件你看du和df都看不到它只有free命令里 used 内存涨着。很多线上 OOM 都跟这种“看不见的内存泄漏”有关。延迟删除机制也容易造成误判。当你调用IPC_RMID之后用ipcs -m可能还在列表里看到它nattch却不为 0。这不是删除失败而是还有进程 attach 着。要等所有进程shmdt后它才消失。所以在写清理脚本时ipcrm执行完别急着期望立刻消失检查nattch才是准确状态。6.4 “共享内存需要管理员权限吗”这个问题的统一回答网上经常有人问共享内存是不是必须 root 权限才能用从设计上说不是。它和文件权限一样只要进程对该对象有读写权限就能 attach。大多数 Linux 发行版默认允许普通用户在/dev/shm下创建共享内存因为这些对象最终由 tmpfs 承载。真正受限的通常是创建 key 的系统调用ftok依赖的文件路径不能被别的用户篡改shmget的权限位如果写得太宽松任何用户都能读存在安全风险容器环境里 cgroup 和内核参数会限制总容量表现为“普通用户无法创建”。很多浏览器或 GPU 工具提到的“共享内存”比如笔记本电脑的 Intel 共享 GPU 内存跟 System V 共享内存是两个完全不同的概念。前者是指显卡驱动从系统内存中划走一部分当作显存属于硬件资源调度后者是进程间通信机制。排查问题时思路不能混淆。6.5 和 Electron、前端里提到的 IPC 有什么关系经常有前端同学看到“IPC”这个词把它和 Electron 主进程/渲染进程通信混在一起。Electron 里的 IPC 其实是框架内部封装的进程间消息通道底层可能用的是 Chromium 的 mojo 或管道/套接字跟 System V 消息队列、共享内存是两种完全不同的实现。你不需要在 Electron 里写shmget也不需要关心内核消息队列参数你只要理解“主进程和渲染进程通过异步消息互传数据”这个模型就够了。我把两者放在一起提是想说明一件事IPC 是一个很宽泛的概念System V IPC 只是其中一套经典实现。当你理解了共享内存、消息队列、信号量各自的语义以后看到任何 IPC 框架都能快速拆解出它到底用的是哪种传输模型以及它要怎么处理并发和丢消息。这才是学习 System V IPC 最大的收益不是会用三个 API而是建立一套分析进程通信问题的思维框架。我在做过的所有多进程系统里几乎都能找到一套“共享内存 信号量”或“消息队列 共享内存”的影子。与其在各种中间件里绕来绕去不如先把这三件套的脾气摸清楚——它们拗口但到底是最贴近内核的一层。
返回列表