《从零入门Linux系统篇(四十二):进程间通信篇·七——System V信号量详解:从同步互斥到内核IPC组织与共享内存底层实现》

发布时间:2026/9/11 18:34:34
《从零入门Linux系统篇(四十二):进程间通信篇·七——System V信号量详解:从同步互斥到内核IPC组织与共享内存底层实现》 这是System V IPC三部曲的收官篇。但今天我们不会只盯着信号量这一个角色它更像是块跳板借着它我们要顺便踏进并发编程那扇更宽的门。信号量这玩意儿跟前两位画风都不一样。共享内存和消息队列好歹是在“传数据”信号量呢它不运货只站岗。它的全部使命就是给共享资源当“信号灯”管谁先谁后谁进谁出。到了本文后半段我们还会把System V这三大通信手段拉到一起看清它们在内核里的关系。你会发现它们表面上各玩各的底层却共享着同一套“身份证”体系。坦白说这篇的内容会比前两篇复杂一些有点费脑子。但我会尽量用最通俗的话把最硬核的东西讲明白。别慌跟着节奏走。好的我们开始吧。目录一、并发编程的基础——同步、互斥与临界资源1.1 什么是互斥资源与临界资源1.2 为什么共享资源需要被保护1.3 保护临界资源的核心方式1.3.1 同步与互斥到底解决什么问题1.3.2 同步与互斥是如何实现的1.3.2.1 原子性——为什么操作不能被“打断”1.3.2.2 从“写之中”理解临界区保护1.3.2.3 互斥锁——用锁保证操作的原子性二、深入理解信号量——从资源管理到PV操作2.1 信号量是什么——用计数管理资源2.1.1 当资源不能被整体占用时2.1.2 当资源作为整体进行使用时2.1.3 从资源计数总结信号量2.1.4 用伪代码理解信号量操作三、System V信号量的创建、控制与操作3.1 从“通信”重新理解进程间协作3.2 创建信号量集——semget3.3 信号量的初始化与控制——semctl3.3.1 信号量初始化——建立正确的初始状态3.4 信号量操作——用semop完成P/V操作3.4.1 struct sembuf——描述一次信号量操作3.4.2 批量操作——用伪代码理解semop执行过程3.5 命令行管理信号量集3.5.1 查看系统中的信号量集3.5.2 从命令行删除信号量集四、从内核源码看System V IPC的组织方式4.1 IPC对象在内核中的组织结构4.1.1 全局控制结构与IPC对象指针数组4.1.2 IPC底层如何借助多态统一管理不同对象4.2 从内核源码中追踪IPC结构4.3 一个容易忽略的细节——共享内存为什么背后依赖文件4.3.1 核心实现——基于tmpfs的内存文件系统4.3.2 为什么内核需要复用文件这一载体4.3.3 从shmget到shmat——共享内存的底层执行流程一、并发编程的基础——同步、互斥与临界资源1.1 什么是互斥资源与临界资源还是从IPC的老底子出发进程间通信的前提是让多个进程看到同一份资源。这“同一份资源”我们就叫它共享资源。但共享资源也分命好不好。有的共享资源“裸奔”谁都能碰碰了也不一定出事有的共享资源“有保镖”不是谁想动就能动的。那些被保护起来的共享资源就有了一个更专业的名字临界资源或者叫互斥资源。“互斥”这两个字很直白同一时刻只允许一个进程碰它其他人靠边等。1.2 为什么共享资源需要被保护先说说“被保护”是什么意思。设想两个进程同时去访问一块没有保护的共享资源最经典的后果就是数据不一致。这个坑我们在共享内存那篇里已经踩过一次写端写了一半读端冲上来读读到的就是半成品加脏数据。那要保护共享资源该从哪里下手很多人会本能地想资源在哪我就保护哪。但资源是死物代码才是活物。真正会去读写共享资源的不是资源本身而是进程里那一段段代码。所以保护共享资源其实就等价于保护那些会访问共享资源的代码。这些被保护起来的代码段就叫临界区。准确地说进程中涉及互斥临界资源的程序段叫临界区。你写下的每一行代码都可以分成两类访问临界资源的代码 —— 临界区不碰临界资源的代码 —— 非临界区。你可能一时绕不过来“保护资源”怎么就成了“保护代码”了来我们举个很实在的例子。公园里有把长椅这椅子就是共享资源。总有些人素质堪忧喜欢往椅子下面塞垃圾。我们要防止这种事发生请问我们该保护的是这把椅子还是坐在椅子上的人答案很明白保护椅子本质上是约束人。你总不能在椅子上焊个铁罩子吧你只能是给“坐椅子的人”立规矩。放在进程里看那把公园长椅就是临界资源而那个“坐椅子的人”就是临界区。我们说保护共享资源说白了就是约束那段访问共享资源的代码。1.3 保护临界资源的核心方式1.3.1 同步与互斥到底解决什么问题保护临界资源手段就两种互斥和同步。这俩词看着像干的活儿可不一样。同步多个执行流去碰临界资源时必须排出一个先后顺序。谁先谁后得按规矩来。互斥任何时刻只允许一个执行流访问资源。你进去了别人就只能在门外等着。听着有点抽象打个医院看病的比方立马就通。诊室和医生就是临界资源。门口走廊上坐着一排患者护士叫一个号进去一个。这种“按顺序、一个一个来”的排队就是同步。而诊室里头一次只允许一位患者问诊你正看着病别人不能推门就进这叫互斥。一个管“谁来谁后到”一个管“屋里只留一个人”。两套规矩配合起来诊室才不会乱成一锅粥。临界资源也一样光有互斥没有同步访问顺序就靠抢光有同步没有互斥资源里面就挤成一团。所以这俩往往是搭档上阵。1.3.2 同步与互斥是如何实现的知道了要用“同步”和“互斥”来保护临界资源下一个问题就是计算机底层具体怎么实现这就必须引出两个核心概念原子性和锁。1.3.2.1 原子性——为什么操作不能被“打断”一句话概括原子性要么不做要么一口气做完中间绝不掉链子。在多线程环境里当一个执行流申请到了某项资源或者正在执行某段核心操作时其他执行流绝对不能中途打断它。对它们来说这个操作没有“做了一半”的中间状态你要么看到它还没开始要么看到它已经彻底完成。就像原子一样不可再分。1.3.2.2 从“写之中”理解临界区保护我们把一次数据写入拆成三个瞬间写之前、写之中、写之后。写之前数据没动别的执行流读它读到的还是旧值没问题。写之后数据完整落位别的执行流再读读到的就是新值也没问题。写之中问题就出在这里。大部分底层代码都没法保证“写到一半”的时候不被打断。如果执行流A写到一半执行流B冲进来读读到的就是半新半旧、半真半假的脏数据。这就是我们反复提到的“数据不一致”。最危险的就是这“写之中”的一瞬间。1.3.2.3 互斥锁——用锁保证操作的原子性要保护“写之中”不被打扰靠嘴说没用得靠互斥锁。当代码准备进入那段核心的“临界区”去改数据时三个动作加锁Lock谁先拿到这把锁临界区就归谁。执行临界区代码锁在手这个执行流就能安心地在临界区里改数据。此时其他执行流呢它们一看锁被占了只能在门外排队死等。于是这个“写之中”的过程对它们来说就变成了一个不可见的黑盒——你只能看到它加锁前和加锁后中间的“写”被锁隔离起来了。这就变相实现了原子性。解锁Unlock操作完把锁放掉叫醒下一个排队的执行流进来。但这里有个容易被忽略的关键锁是保护别人的那锁自己安全吗必须安全。因为“加锁”和“解锁”这两个动作本身也必须绝对原子。试想如果加锁这个动作还能被打断两个执行流同时冲上来都以为锁是自己的那一把锁就形同虚设了锁本身就成了新的冲突点。所以锁的实现必须保证“要么你抢到要么你没抢到”中间没有任何模糊地带。二、深入理解信号量——从资源管理到PV操作2.1 信号量是什么——用计数管理资源信号量又有个外号叫“信号灯”。它本质上就是一个计数器用来标记某块临界资源里还有多少个子资源可用。2.1.1 当资源不能被整体占用时别把“资源”想得太粗。真正上锁的时候你会发现很多资源不是铁板一块而是由一堆“子资源”拼出来的。这就需要一个更精细的计数工具信号量。打比方你老板开了家电影院二号放映厅就是一块临界资源。放映厅里坐着一大堆座位每个座位就是一个“子资源”。观众买票买的不是整个放映厅而是某一个座位的使用权。买票的本质是对资源的预定。换句话说你想进去享受资源得先“买票”也就是先申请信号量。那老板卖票最怕什么怕两件事票卖多了放进去太多进程座位不够坐票号重了两个进程拿着同一张票冲的是同一个座位。这两件事都会让“并发访问”变成“并发事故”。所以信号量要干的第一件事就是看清楚自己对应的临界资源里到底有几个子资源。有16个座位计数器初始就是16。每当一个进程成功预定一个座位计数器就减一减到0说明座位卖光了后面的人再想进就只能在门口等着。用伪代码来表达这个过程假设信号量初值是16// 申请信号量 if (--sem 0) { // 申请成功 // 预留资源块 } else if (--sem 0) { // 申请失败 // 阻塞挂起 }注意这里我故意把sem写成减完再判断是为了让你好理解“票数减少”的感觉。真正的信号量实现要复杂得多但核心思想就是这个先扣减再判断够数就放行不够就挂起。信号量就是这样一个小小计数器它不说废话只干一件事记录“还剩几个位置”。进程来了先看它它说还有你就进它说没了你就等。整个放映厅的秩序就靠这个数字维持着。2.1.2 当资源作为整体进行使用时除了那些普通放映厅你老板还搞了几个 Super VIP 放映厅。这放映厅里就摆一把椅子一次只卖给一个人。包场独享。​这时候信号量的初值就该设成1。因为它对应的“座位数”就一个。于是这个信号量这辈子只会在两个数字之间横跳0和1。要么有位置要么没位置要么放你进去要么让你等着。这种只有两态的货色有个专门的名字叫二元信号量。听名字就知道它天生只干一件事互斥。一次只放一个进去别人都在门外等。这跟我们前面讲的“锁”本质上是同一个东西。所以你也可以把二元信号量理解成内核版的互斥锁。反过来上面那种“一个资源里套着一堆小座位”的场景信号量的取值就丰富多了16、15、14……一路能减到0。这种多值信号量就叫多元信号量或者叫计数信号量。它管的不是“能不能进”而是“还剩几个名额”。所以信号量家族的谱系也清楚了多元信号量计数器管资源的“余量”二元信号量只有0和1管资源的“独占”。一个偏“量”一个偏“权”。电影院里普通厅卖票靠多元VIP厅包场靠二元。用哪种全看你那临界资源是个什么路数。2.1.3 从资源计数总结信号量做个总结信号量就是一个计数器专门用来描述临界资源里还剩下多少“货”。所有进程哪怕只是想碰临界资源里的一小块都得先申请信号量。你把它想成预订电影票申请信号量就是先占个座。座位一旦预订成功资源就是你的随时来访问没人和你抢。但别忘了一件事信号量本身也是共享资源。两个进程同时伸手去改它那不乱套了所以对信号量的操作必须是一套原子性的“预订”和“退订”流程。这套流程我们叫它PV 操作。​​​​​​申请资源sem--原子性这就是P操作。归还资源sem原子性这就是V操作。P就是“申请”V就是“归还”。一减一加一个占座一个退票。整个临界资源的分配就靠这俩动作维持秩序。2.1.4 用伪代码理解信号量操作这里得纠正一下前面为了讲解方便撒的一个小谎信号量绝不可能就是一个简单的整数。如果它只是一个int那这玩意儿就没法保证原子性了。你想想两个进程同时去--sem都以为自己是第一个减的数据直接乱套。所以信号量不能只是“一个数”它得是个结构体而且里面至少得有三样东西struct sem { // 锁保证 count 的加减操作是原子的 // int count计数器记录还剩多少个资源 // task_struct *waitqueue等待队列用来挂起没抢到资源的进程 };再配上完整的申请流程逻辑就顺了进程申请信号量先抢到那把锁一看count 0说明还有资源。于是count--占下一块然后开锁走人资源就是你的了。要是count 0说明资源被抢光了。这时候内核把这个进程的PCB从运行队列里摘出来挂到信号量的等待队列上去让它暂时休眠。于是一个信号量里既有锁又有计数器还有等待队列。锁管原子计数器管余量等待队列管“没抢到的人去哪等着”。这三样凑齐了信号量才真正配得上“内核级同步工具”的名号。三、System V信号量的创建、控制与操作聊了这么多信号量跟“通信”到底有什么关系它一不传字符串二不搬数据块凭什么叫 IPC3.1 从“通信”重新理解进程间协作我们之前可能把“通信”想窄了。IPC不只是传输数据。只要是信息或事件的传递都算通信。比如“通知”“同步”“互斥”这些全在通信的范畴里。信号量传的就是“控制信息”。怎么理解这个“控制信息传递”来看一个场景。进程A想进临界区先申请信号量。它一进去count从1减到0。此时资源被占信号量告诉外面的进程“满了等着。”进程B在门口排队申请信号量时发现count已经是0于是被挂起塞进等待队列里睡觉。过了一会进程A用完资源出来了执行V操作count从0加回1。这个“加”的动作本身就是一条控制信息“有人让位了可以进来了。”进程B收到这条信息被内核唤醒从阻塞挂起里爬起来进临界区干自己的活。整个过程中A和B没交换一个字节的数据但它们通过信号量完成了一次“你等等我好了”的沟通。但这里还有个前提每一个进程都得看到同一个信号量。如果A看的是信号量XB看的却是信号量Y那这个“让位”的信息就传不到B那里。而System V信号量解决的正是这个问题怎么让多个进程看到同一个信号量对象。它依然是那句老话的践行者让不同进程看到同一份资源。只不过这份资源是信号量本身。3.2 创建信号量集——semget想用信号量第一步就是把它“请”出来。这个活儿交给semget系统调用。#include sys/types.h #include sys/ipc.h #include sys/sem.h int semget(key_t key, int nsems, int semflg);还是熟悉的配方还是熟悉的味道三个参数跟共享内存、消息队列如出一辙。key靠ftok生成的键值。它的作用老生常谈保证多个进程看到的是同一个信号量。nsems这里要敲一下黑板了。这个参数不是信号量的编号而是你要创建的信号量集里信号量的个数。注意操作系统创建的不是“一个”信号量而是一个信号量数组也就是“信号量集”。你可以把它理解成int sem[nsems]这样一块数组一次申请整块交付。semflg权限和标志位。IPC_CREAT、IPC_EXCL的含义跟共享内存、消息队列完全一致会走路径了就不用再背。返回值也照旧成功返回一个非负整数这就是semid信号量集的标识符失败返回-1。这里有个极容易绕晕的点必须单独说清楚semid是信号量集的 ID不是某个具体信号量的 ID。你想操作这个集里某个信号量时得用“下标”来指定就像用数组下标访问sem[2]一样。而拿这个下标去干活的操作靠的是下面要讲的semop和semctl。所以哪怕你只需要一个信号量nsems传1底层照样会把它当成“只有1个元素的信号量集”来统一管理。别再天真地以为nsems1就是“单个信号量”了它只是个长度为1的数组。这跟共享内存和消息队列的“单点资源”模型都不太一样。System V的信号量天生就是成组管理的。为什么因为有些场景下一个临界区不止一把锁可能得同时拿好几把一套组合锁一次性分配才够用。这个设计后面你用到了自然会懂。3.3 信号量的初始化与控制——semctl信号量的“创建”和“初始化”是分成两步走的而且这两步合在一起并不是原子性的。创建好信号量集后必须显式给它赋值它才能正常使用。int semctl(int semid, int semnum, int cmd, ...);参数逐个看semidsemget返回的信号量集标识符。semnum你要操作的是信号量集里的哪一个信号量注意这是数组下标从0开始。你之前申请了nsems 3那这三个信号量就分别对应下标0、1、2。想操作哪个就填哪个下标。cmd控制命令。最常用的是这两个SETVAL初始化某个信号量的初值IPC_RMID把整个信号量集删掉。第四个可选参数如果你用了SETVAL就必须传一个自定义联合体union semun把初值塞进去。这个联合体是你在用户层自己定义的内核通过它接收你想设的初始值。这里有一个特别容易踩的坑值得单拎出来讲创建不等于初始化。 很多人以为semget一成功信号量就能用了。错。semget只是在内核里开了片地地里埋的初始值是不确定的。你必须再补一刀semctl(..., SETVAL, ...)亲手把初值写进去这个信号量才算真正就绪。所以正常的使用流程是三步走semget创建信号量集↓semctl SETVAL 初始化某个信号量的值↓semop开始真正的P/V操作而且你还可以用semctl的IPC_RMID来销毁整个信号量集。跟共享内存、消息队列一样信号量的生命周期也随内核不手动删它就一直占着资源。用完记得清。3.3.1 信号量初始化——建立正确的初始状态这里有个很经典的坑得先说清楚union semun这个联合体在很多Linux系统的头文件里压根没给你定义。内核的意思是“你要初始化信号量可以但别指望我白送你结构体自己写。”所以这个定义得你亲手敲进代码里。整个过程分三步走一步都不能省。第一步在C文件开头手动把这个联合体定义出来。union semun { int val; /* 使用 SETVAL 时要传的初值 */ struct semid_ds *buf; /* IPC_STAT、IPC_SET 时用到的缓冲区 */ unsigned short *array; /* GETALL、SETALL 时用到的数组 */ struct seminfo *__buf; /* Linux 特有的缓存区 */ };这几个成员各管一个命令。val是给SETVAL用的buf是查状态、设属性用的array是批量操作用的。我们这次要干的是初始化所以只看val就行。第二步实例化这个联合体把你想设的初值填进去。union semun arg; arg.val 1; // 二元信号量互斥锁就设 1多元信号量设成子资源数量这里填几取决于你的信号量是什么脾气。想要互斥锁那就给1一次只放一个。想要计数信号量比如有16个座位那就给16。第三步调用 semctl正式完成初始化。// 假设要把semid信号量集里下标为 0 的那个信号量初始化成 arg 里的值 semctl(semid, 0, SETVAL, arg);semid是信号量集0是要操作的下标SETVAL是命令arg是装着初值的联合体。四样凑齐这个信号量才算真正“开了张”。说到底初始化这一步就是给一个刚出生、数值还不确定的信号量写上一个你说了算的起点。写1它是把锁写N它是张有N个座位的电影票。别小看这个数后面所有进程的先后次序全由它起头。3.4 信号量操作——用semop完成P/V操作信号量的全部灵魂就压在两个动作上P操作申请资源计数器减一V操作释放资源计数器加一。在Linux里这俩动作全靠一个系统调用完成semop。它既能单次操作一个信号量也能一口气批量操作一堆。int semop(int semid, struct sembuf *sops, size_t nsops);semid你要对哪个信号量集下手。sops一个指向struct sembuf结构体数组的指针用来描述“具体怎么操作”。nsops这一批操作里总共包含了多少个struct sembuf结构体。想操作一个填1想一口气操作五个填5。3.4.1 struct sembuf——描述一次信号量操作真正干活的是这个结构体struct sembuf { unsigned short sem_num; /* 信号量在集合中的下标 */ short sem_op; /* 核心操作-1 代表 P 操作1 代表 V 操作 */ short sem_flg; /* 操作标志通常设为 0或设为 SEM_UNDO 防止进程挂掉后锁死 */ };sem_num回答“操作哪个”sem_op回答“加还是减”sem_flg管些杂项。第三个参数我们一般不用理会填0就是最省心的姿势。3.4.2 批量操作——用伪代码理解semop执行过程有时候一段临界区需要同时拿好几把锁。这时候semop的批量能力就派上用场了。// 批量对多个信号量做操作的场景 struct sembuf sops[N]; for (int i 0; i N; i) { sops[i].sem_num sem_nums[i]; // 确定要操作的信号量下标 sops[i].sem_op -1; // 对当前信号量进行 P 操作释放则设为 1 sops[i].sem_flg 0; } // 一口气把这批 P 操作交给内核由内核保证原子执行 semop(semid, sops, N);这么多锁一个一个去申请又慢又容易卡死在半路比如你刚拿到第一把第二把却被别人占着那你是把第一把还回去还是攥着等还回去不甘心攥着又可能跟别人互相等死。批量操作的好处就在这里要么一次全拿到要么一次全拿不到中间没有任何“只拿到一半”的尴尬状态。这种“要拿一起拿”的语义正是复杂同步场景里最需要的东西。3.5 命令行管理信号量集信号量和共享内存一个德性生命周期随内核。进程跑完了只要没显式释放它就赖在内核里不走。除非重启系统或者手动清理否则这些“孤儿信号量”就会一直占着资源。3.5.1 查看系统中的信号量集想看当前系统里漂着哪些信号量集一条命令搞定ipcs -s输出里的每一列都是在给你报户口key申请时传入的那个底层键值就是ftok生成的“暗号”。​​​​semid内核分配的使用标识符。以后想操作这个信号量集认准它。owner创建者是谁。perms权限属性。nsems这个信号量集里躺着多少个信号量。一眼扫过去哪个信号量集还在谁建的里面有几个“灯”清清楚楚。3.5.2 从命令行删除信号量集想手动清掉某个信号量集用这条ipcrm -s semid跟共享内存和消息队列的删法如出一辙用的是semid不是key。 key只是找资源用的暗号semid才是内核里的正式编号。删东西必须报正式编号不然容易删错对象。至此System V三部曲全部讲完了。从共享内存的“裸奔猛男”到消息队列的“标签邮局”再到今天信号量这个“信号灯”三个角色三种性格却共享着同一套底层逻辑共享内存追求极致速度把IPC变成指针读写但同步得自己管消息队列讲究秩序给数据块贴类型标签按需取用信号量不运货只站岗用计数器管资源余量用 PV 操作管进程先后。而它们三个又都踩在同一个地基上让不同的进程看到同一份资源。共享内存看到的是“同一块内存”消息队列看到的是“同一条队列”信号量看到的是“同一个计数器”。玩法不同暗号一样ftok生成keyget函数拿id后续全靠id干活。生命周期也都随内核用完不删就是资源泄漏。四、从内核源码看System V IPC的组织方式如果讲完三个接口就拍拍屁股收工那这篇文章也确实太没深度了。我们再往下挖一层看看这“三兄弟”在内核里到底是什么关系。这一层才是真正拉开功力的地方。你可能会问既然共享内存、消息队列、信号量用的是同一套算法、同一套接口那我同时创建一套“System V 全家桶”它们各自生成的 key会不会打架答案是会。而且可能性还不小。因为在内核眼里共享内存、消息队列、信号量它们根本不是三种资源而是一种资源。你可以把它们理解成同一种东西的不同“形态”。既然是同一种资源那区分谁是谁靠的就不是“类型”而是那个共同的 key。key 是唯一的谁来都按 key 认人。共享内存用了 0x01消息队列也得用 0x01 才能被别人找到——但如果你不巧给消息队列也发了 0x01冲突就来了。4.1 IPC对象在内核中的组织结构那这些IPC资源在内核里到底是怎么组织起来的你创建的所有System V IPC资源不管是共享内存、消息队列还是信号量都会被内核统一管理。它们不会因为你用的接口不同就住进不同的“小区”。相反它们全被塞进了同一个IPC命名空间里由同一种结构体、同一套逻辑来登记、查找、删除。这张图是System V的内核架构图。别小看它它只是整个操作系统内核架构的冰山一角但也足够我们看清“三兄弟”的户籍关系了。4.1.1 全局控制结构与IPC对象指针数组内核里System V的三大IPC资源全被一个“总管家”看着。这个总管家就是struct ipc_ids。它不直接存资源而是管着一张大表这张表由struct ipc_id_ary来落地。它们的关系可以这样理解struct ipc_idsIPC机制的全局控制结构体相当于总管理员。struct ipc_id_ary真正存放IPC对象指针的内部数组结构体。这个数组本身也是动态的里面有一个柔性数组用来容纳所有IPC资源的指针。struct ipc_id_ary { int size; char buffer[0]; // 柔性数组真正存储资源指针的地方 }这里值得专门聊聊“为什么是柔性数组”。System V的共享内存、消息队列、信号量它们底层都靠“数组下标”来定位资源。既然都是下标那就存在一个很现实的问题id会越界吗会不会某天取到数组外面去内核给了两道安保措施第一道柔性数组自动扩容。资源多了数组就自己变大不让下标越界。第二道设个上限满了就回绕。当数组涨到最大容量后新分配的 id 不会无限往上飙而是按“真实 id 模最大 id”的方式回绕。也就是说它会把那些已经被释放掉、腾出来的旧 id 重新利用起来。为什么非得用柔性数组而不是一开始就开一块固定大小的数组因为资源数量是动态的。开小了不够用开大了又浪费内存。柔性数组的好处就在这里你需要多少它就撑多大按需生长不铺张也不憋屈。所以System V这套在内核里的组织方式本质就是在解决一个问题怎么安全、高效地管理一大群id不断变化的IPC资源。上层你看到的是一个一个shmid、msqid、semid底层它们全挂在同一个柔性数组里被同一个ipc_ids管着。这也是三兄弟能共用一个 key 池、共用一个id空间的原因。如果结构体里直接定义成固定大小的数组比如struct sem sem_base[10];那每个信号量集就永远只能装10个信号量多一个不行少一个浪费。这显然不符合实际——有时候你只需要1个信号量有时候却需要50个。所以Linux内核没有这么死板。在System V信号量的实现里用户调用semget(key, nsems, flags)创建信号量集时传入的nsems就决定了这个集合里要装多少个信号量。内核会根据nsems的大小动态分配一块连续内存用来存放正好nsems个struct sem结构体。分配完成后把这块内存的首地址交给sem_base。于是你要1个内核就给1个的空间你要50个内核就腾出50个的连续内存。内存利用率最大化又没有固定上限的束缚。4.1.2 IPC底层如何借助多态统一管理不同对象现在进入最精彩的部分三种不同类型的资源居然被塞进了同一个数组里管理。这事儿是怎么做到的答案就一个字头一样。这三种资源结构体的头部都是同一种类型就是前面说的那个“老祖宗”kern_ipc_perm。内核数组根本不需要关心这三种资源内部到底长什么样它只需要看它们的头部。因为头部是同一个类型数组就可以用一种“掩耳盗铃”的方式把不同资源塞进来不管你真实类型是什么我统统把你的指针强制转换成公共类型先插进数组再说。p[0] (kern_ipc_perm*)某个共享内存对象;p[1] (kern_ipc_perm*)某个消息队列对象;p[2] (kern_ipc_perm*)某个信号量对象;于是p[0]、p[1]、p[2]看起来都是同一种指针。数组这边不费吹灰之力就把三类资源统一纳入了管理。你的shmid、msqid、semid本质上就是这些数组下标。不同的id指向同一个数组里的不同位置。但要真正访问资源内容怎么办容易。用的时候再强制转换回原来的类型((msg_queue*)p[0])-other_field;把它从公共指针转回消息队列专属的结构体指针然后就能访问它内部的独有字段了。来的时候“乔装打扮”用的时候“验明正身”。那内核怎么知道一个下标里塞的到底是哪类资源该转回shmid_kernel还是msg_queue还是sem_array答案藏在那个公共头部里。kern_ipc_perm内部维护了一个type字段它会忠实记录我是共享内存、消息队列还是信号量。内核取出指针后先看一眼这个type就知道该把它转成什么类型。if (perm-type MSG) { msg_queue *mq (msg_queue*)perm; // 按消息队列处理 } else if (perm-type SHM) { shmid_kernel *shm (shmid_kernel*)perm; // 按共享内存处理 } else if (perm-type SEM) { sem_array *sem (sem_array*)perm; // 按信号量处理 }这就是用C语言实现的多态。没有虚函数没有继承靠的就是一个公共头部、一个类型字段、两次强制类型转换硬生生在C里把“一个数组管理三类资源”这件事干成了。4.2 从内核源码中追踪IPC结构我用的是比较老版本的源代码4.3 一个容易忽略的细节——共享内存为什么背后依赖文件你可能没想到System V共享内存这块“纯内存”底层居然是以文件为载体搞出来的。它本质上是基于文件形成的内存共享。4.3.1 核心实现——基于tmpfs的内存文件系统System V 共享内存底层抱的是内核里一个隐藏的内存文件系统的大腿tmpfs。这玩意儿通常挂在/dev/shm背后不显山不露水但共享内存的命根子就攥在它手里。当你调用shmget创建共享内存时内核在背后偷偷干了几件事在tmpfs文件系统里私下创建了一个虚拟文件。这个文件不落盘只活在内存里但形式上跟普通文件一样。把这个文件的struct file指针存进共享内存核心结构体的shm_file成员里。于是所谓的“共享内存操作”在内核眼里本质就变成了多个进程通过mmap把同一个文件映射到各自的虚拟地址空间里。你以为是内存其实是文件你以为在读写内存其实在读写一个映射进来的文件。4.3.2 为什么内核需要复用文件这一载体你可能会问共享内存不是号称“快”吗绕一圈搞个文件不是脱裤子放屁恰恰相反这是内核最聪明的一步。如果不引入文件当载体内核就得自己从零写一堆逻辑去解决这些硬核问题页面管理物理内存不够了哪些页该换出到Swap哪些页已经分配了进程生命周期进程挂了怎么保证它占用的内存不泄漏权限控制谁有资格读写这块内存这些问题每一个单拎出来都是地狱难度。但Linux的文件子系统 虚拟内存系统早就把这些坑全填平了文件天然自带权限检查跟shm_perm一脉相承文件的Page Cache机制天生就支持多个进程映射同一个Page而且物理内存里只留一份不浪费。所以复用文件载体不是偷懒是借力。内核不需要重新发明轮子直接站在文件系统这个巨人的肩膀上就把共享内存最难的那部分问题给解决了。4.3.3 从shmget到shmat——共享内存的底层执行流程那shmget和shmat在底层到底干了啥我们把流程拆开看。shmget做的事在物理内存里开辟一块空间在tmpfs上创建一个虚拟文件用struct file描述它把这个struct file指针塞进共享内存核心结构体shmid_kernel里。shmat做的事在进程的mm_struct里分配一个vm_area_struct让这个vm_area_struct通过mmap机制去指向刚才创建的那个文件用vm_file指针指向共享内存对应的那个文件把vm_start 和 vm_end赋值为对应的虚拟地址动态建立页表映射关系与文件缓冲区最后返回这个虚拟地址给你。整个流程走下来你拿到手的就是一个普通指针但背后已经串起了“文件 → 页表 → 物理内存”的整条链路。如果这篇文章对你有帮助别忘了点个赞、点个收藏、点个关注。你的每一次反馈都是我继续硬核输出的最大动力。我们下篇见。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询