【Linux进程间通信】硬核剖析:消息队列、信号量、内核IPC资源统一管理与mmap加餐
上篇文章Linux共享内存原理与实战从内核到C实现|附源码目录前言从共享内存的局限性说起一、System V 消息队列带标签的数据包裹1. 深入内核消息队列的底层数据结构2. 用户层协议设计与 msgrcv 的精准读取二、System V 信号量临界资源的“预订机制”1. 从“去电影院看电影”通俗理解信号量2. 硬核剖析Dijkstra 原语与信号量底层理论3. System V 信号量的内核结构与繁琐 API三、最强硬核探秘内核如何用“多态”统一管理 IPC1. 惊人的重合基类 kern_ipc_perm2. 顶层管理全局大字典 ipc_ids 与柔性数组3. 偷天换日C 语言实现“多态”的神级操作四、拓展加餐mmap 文件映射与底层内存分配1. 什么是 mmap2. 硬核 API 解析3. 降维打击用 mmap 极简模拟 malloc结语前言从共享内存的局限性说起在上一篇文章中我们深度探讨了 System V 共享内存。我们得出一个重要结论共享内存是最快的 IPC 方式。但它存在两个明显的痛点它只能传递无差别的字节流数据如果我们想在进程间传递带有特定类型的数据块共享内存就显得力不从心。它没有任何自带的保护机制。如果进程 A 正在写入还没写完进程 B 就跑来读取必然会导致读取到半截的“脏数据”。如果说消息队列解决了“发什么类别的数据”的问题那么信号量解决的就是“怎么安全地并发读写”的问题。本文将带你补齐 System V IPC 的最后两块拼图深入 Linux 内核源码结合底层结构体揭秘操作系统是如何用 C 语言的“多态”思想统一组织shm、msg、sem等 IPC 资源的并在文末带来重磅加餐剖析 mmap 文件映射并用其极简模拟底层的malloc内存分配一、System V 消息队列带标签的数据包裹如果说管道Pipe和共享内存是毫无感情的字节流那么消息队列Message Queue则提供了一种从一个进程向另外一个进程发送带有特定“类型”的数据块的方法。由于接收者可以根据类型选择性地读取这使得消息队列天然具备了类似“多路复用”的特性。1. 深入内核消息队列的底层数据结构在 Linux 内核/usr/include/linux/msg.h中操作系统为每一个消息队列维护了一个严密的数据头结构struct msqid_dsstruct msqid_ds { struct ipc_perm msg_perm; /* Ownership and permissions (权限信息) */ time_t msg_stime; /* Time of last msgsnd (最后发送时间) */ time_t msg_rtime; /* Time of last msgrcv (最后接收时间) */ time_t msg_ctime; /* Time of last change (最后修改时间) */ unsigned long msg_cbytes; /* Current number of bytes in queue (当前队列字节数) */ msgqnum_t msg_qnum; /* Current number of messages in queue (当前队列消息数) */ msglen_t msg_qbytes; /* Maximum number of bytes allowed (队列允许最大字节数) */ pid_t msg_lspid; /* PID of last msgsnd (最后发送的进程PID) */ pid_t msg_lrpid; /* PID of last msgrcv (最后接收的进程PID) */ };消息队列的本质是一个内核级的双向链表。当我们调用发送函数时内核并非简单地拷贝字节而是将我们的数据封装成一个struct msg_msg结构体并将其挂载到链表q_messages上// 内核真实的双向链表节点记录单个消息 struct msg_msg { struct list_head m_list; // 包含 *next 和 *prev双向链表指针 long m_type; // 消息类型 size_t m_ts; // 消息正文大小 // ... 后面紧接着的实际上是消息正文 (sg_msgseg) };2. 用户层协议设计与 msgrcv 的精准读取我们在用户层写代码时必须定义一个特定的“数据包协议”结构体注意必须以long类型的mtype开头struct msgbuf { long mtype; /* 必须大于0的消息类型 */ char mtext[1024]; /* 消息正文大小可以自定义 */ };核心系统调用msgget获取消息队列 ID。msgctl(msqid, IPC_RMID, NULL)操作msqid_ds如删除队列。msgsnd(msqid, msg, sizeof(msg.mtext), 0)将用户空间的msgbuf拷贝到内核链表。msgrcv(msqid, msg, sizeof(msg.mtext), msgtyp, 0)接收消息。msgrcv的msgtyp参数设计得非常硬核msgtyp 0直接读取队列的第一条信息FIFO 模式。msgtyp 0精准打击返回队列中第一条类型等于msgtyp的消息。msgtyp 0优先级调度返回队列中类型小于或等于msgtyp绝对值的消息中类型最小的那一条。二、System V 信号量临界资源的“预订机制”解决了数据类型传递的问题我们要面对更棘手的并发安全问题。试想一下共享内存没有任何保护进程 A 正在写进程 B 跑来读岂不是乱套了为了保护这种临界资源我们需要引入信号量。补充概念多进程要通信先要让多个进程看到同一份资源对于资源如果我们提供的是一块内存块那他就相当于共享内存如果提供的是一个队列那他就相当于消息队列如果提供的是一个文件那就相当于管道不同进程看到了同一个资源 - 新的问题也就从这里展开比如并发访问出现错误。并发编程概念铺垫多个执行流流程能看到的同一份公共资源共享资源。被保护起来的共享资源叫做临界资源。保护的方式常见互斥与同步任何时刻只允许一个执行流访问资源叫做互斥多个执行流访问临界资源的时候具有一定的顺序性叫做同步系统中某些资源一次只允许一个进程使用称这样的资源为临界资源或互斥资源互斥与同步共享 临界资源在进程中涉及到互斥资源的程序段叫做临界区。写的代码 访问临界资源的代码临界区不访问临界资源的代码非临界区所谓的对共享资源进行保护本质是对访问共享资源的代码进行保护1. 从“去电影院看电影”通俗理解信号量为了不一开始就陷入冷冰冰的代码中我们先来想一个生活中的场景去电影院看电影。电影院的放映厅就是一个【共享资源】。你是怎么保证你走进去看电影时一定有属于你的座位呢 答案是因为你买到了票。“买票”的本质就是对座位资源的一种预订机制。只要你买到了票放映厅里面必然有一个位置是留给你的。对于整个放映厅而言座位数是有限的这个总票数就可以看作是一个信号量Semaphore。当你买票成功票数减少计数器--这叫P 操作申请资源。如果票数为 0 了后续想买票的人只能排队等候进程阻塞挂起。当电影散场人群退场座位空了出来票数增加计数器这叫V 操作释放资源。如果这是一个超级 VIP 影厅里面只有一个座位这意味着总票数信号量的初始值为 1。这就形成了所谓的互斥锁二元信号量——同时只能有一个人进程进去因此信号量的本质就是对临界资源的一种预定机制。2. 硬核剖析Dijkstra 原语与信号量底层理论这个精妙的机制由计算机科学家 Dijkstra迪杰斯特拉提出。在操作系统底层信号量不仅仅是一个简单的整型变量它还带有一条进程等待队列。我们可以用以下伪代码来硬核解剖信号量的本质结构struct semaphore { int value; // 资源计数器 pointer_PCB queue; // 进程等待队列 (存放处于阻塞状态的进程 PCB) };value值的深刻含义S 0表示当前可用资源的个数。S 0表示无可用资源且暂无等待进程。S 0表示无可用资源且|S|绝对值代表当前等待队列中阻塞挂起的进程个数P/V 原语的原子操作// P操作 (Passeren - 申请资源) P(s) { s.value--; // 申请一个资源 if (s.value 0) { // 资源不足将当前进程状态置为等待状态 // 将该进程插入到相应的等待队列 s.queue 末尾 } } // V操作 (Verhogen - 释放资源) V(s) { s.value; // 归还一个资源 if (s.value 0) { // 注意如果加1后仍 0说明原本是负数即有人在排队 // 唤醒相应等待队列 s.queue 中的一个进程 // 将其改变为就绪态投入 OS 就绪队列 } }信号量就是一段属性数据和代码的集合。细节为了保证不同进程访问的是同一个信号量我们将信号量技术归类到IPC范畴。信号量本身也是共享资源为了保证信号量自身的安全需要将PV操作设计为自身安全的也就是原子状态指一件事要么不做要做就一定做完所有进程访问公共资源(shm都要先申请信号量绝对不能让部分进程直接访问。我们是通过将临界区保护起来的方式去保护临界资源的。要想知道访问共享资源哪一部分是通过我们程序员自己维护的3. System V 信号量的内核结构与繁琐 API在 Linux 中System V 信号量并不是单个存在的而是以“信号量集合Set”的形式存在的。对应内核中定义了struct semid_dsstruct semid_ds { struct ipc_perm sem_perm; /* Ownership and permissions (权限信息) */ time_t sem_otime; /* Last semop time (最后执行 PV 操作的时间) */ time_t sem_ctime; /* Last change time (最后修改时间) */ unsigned long sem_nsems; /* No. of semaphores in set (集合中信号量的个数) */ };操作信号量需要使用三个极其繁琐的系统调用这也是为什么实际开发中常需要用到建造者模式Builder Pattern去封装它的原因① 申请集合semgetint semget(key_t key, int nsems, int semflg);这里的nsems就是你要申请的信号量集合里有几个信号量。② 初始化/控制集合semctlint semctl(int semid, int semnum, int cmd, ...);大坑预警信号量的申请和初始化是分开的。初始化时我们需要用到第四个可变参数union semun。最坑的是系统标准库通常不提供这个联合体需要程序员自己在代码里显式定义union semun { int val; /* 用于 SETVAL 命令设置某个信号量的初始值 */ struct semid_ds *buf; /* 用于 IPC_STAT 提取属性或 IPC_SET 修改属性 */ unsigned short *array; /* 用于 GETALL, SETALL */ struct seminfo *__buf; };要给第一个信号量下标0设置初始值为1做互斥锁你需要union semun un; un.val 1; semctl(semid, 0, SETVAL, un);③ 执行 P/V 操作semopint semop(int semid, struct sembuf *sops, size_t nsops);你需要向内核传递struct sembuf结构体struct sembuf { unsigned short sem_num; /* 要操作的是集合中的第几个信号量 (下标) */ short sem_op; /* -1 代表P操作 1 代表V操作 */ short sem_flg; /* 标志位强烈建议设为 SEM_UNDO */ };硬核细节SEM_UNDO是神来之笔。如果进程申请了锁P操作还没来得及释放V操作就意外崩溃了会导致所有等待的进程死锁。加上SEM_UNDO操作系统会在内核记录这笔账当进程异常退出时自动撤销它对信号量所做的改变三、最强硬核探秘内核如何用“多态”统一管理 IPC前面我们学完了 System V 的“三剑客”共享内存shm、消息队列msg、信号量sem。细心的你会发现它们在用户层的 API 出奇地一致都是get、ctl但它们的功能却天差地别。操作系统底层究竟是如何统一组织和管理这三种截然不同的资源的接下来我们直接翻开 Linux 内核源码参考内核版本 2.6.x 源码剥开宏观外衣直击本质1. 惊人的重合基类kern_ipc_perm我们在用户态看到的shmid_ds、msqid_ds、semid_ds只是给用户看的接口结构体在内核深处真正描述这三个对象的结构体分别是shmid_kernel、msg_queue和sem_array。来看这三段硬核源码// 1. 共享内存的内核结构 struct shmid_kernel { struct kern_ipc_perm shm_perm; // 注意它是第一个成员 struct file *shm_file; int id; unsigned long shm_nattch; // ... }; // 2. 消息队列的内核结构 struct msg_queue { struct kern_ipc_perm q_perm; // 注意它也是第一个成员 time_t q_stime; time_t q_rtime; // ... }; // 3. 信号量集合的内核结构 struct sem_array { struct kern_ipc_perm sem_perm; // 注意它还是第一个成员 time_t sem_otime; time_t sem_ctime; // ... };你发现了什么它们结构体的第一个成员完完全全一模一样都是struct kern_ipc_perm那么这个kern_ipc_perm究竟装了什么struct kern_ipc_perm { spinlock_t lock; // 自旋锁保护该IPC对象 int deleted; // 删除标记 key_t key; // 我们非常熟悉的 key 值 uid_t uid; // 拥有者用户ID gid_t gid; // 拥有者组ID mode_t mode; // 权限 (比如 0666) unsigned long seq; // 序列号 };不难看出kern_ipc_perm提取了所有 IPC 资源共有的“公共属性”。这在面向对象编程中叫什么这叫基类Base Class而那三个具体的结构体就是派生类Derived Class。2. 顶层管理全局大字典ipc_ids与柔性数组既然有了“基类”内核是如何把系统中创建的成百上千个 IPC 资源统一管理起来的呢在内核中存在一个全局的管理者结构struct ipc_ids这里面藏着 IPC 资源管理的核心灵魂——指针数组struct ipc_id_arystruct ipc_ids { int in_use; int max_id; unsigned short seq; // ... struct ipc_id_ary *entries; // 指向资源数组的指针 }; struct ipc_id_ary { int size; struct kern_ipc_perm *p[0]; // 柔性数组C语言神兵利器 };操作系统把所有创建出来的共享内存、消息队列、信号量统统把它们内部的第一个成员kern_ipc_perm的地址存入了这张p[]数组表中为了让你更直观地看懂底层的内存布局和指针关系我为你绘制了下面这张内核 IPC 资源管理链表与数组的拓扑图[ 全局 IPC 管理结构 ] struct ipc_ids ------------------- | int in_use; | | int max_id; | | ... | | entries * --------|----. ------------------- | | V [ IPC 资源指针数组 ] struct ipc_id_ary ----------------------- | int size; | |-----------------------| .------| p[0] (kern_ipc_perm*) | -- 下标 index 0 (例如 shmid 0) | |-----------------------| | .---| p[1] (kern_ipc_perm*) | -- 下标 index 1 (例如 msgid 1) | | |-----------------------| | | .-| p[2] (kern_ipc_perm*) | -- 下标 index 2 (例如 semid 2) | | | ----------------------- | | | | | ---------------------------------------------------. | | | | -------------------------------. | V V V [ 派生类共享内存 (shm) ] [ 派生类消息队列 (msg) ] [ 派生类信号量 (sem) ] struct shmid_kernel struct msg_queue struct sem_array | 基类 kern_ipc_perm | | 基类 kern_ipc_perm | | 基类 kern_ipc_perm | | (首地址偏移量为 0) | | (首地址偏移量为 0) | | (首地址偏移量为 0) | --------------------- --------------------- --------------------- | struct file *shm_.. | | time_t q_stime; | | time_t sem_otime; | | int id; | | time_t q_rtime; | | time_t sem_ctime; | | ... | | ... | | unsigned long ... | 这张p数组里存放的是什么全部都是struct kern_ipc_perm *类型的基类指针当进程创建一个新的共享内存/消息队列/信号量时操作系统会在物理内存中malloc出对应的完整子类结构体比如shmid_kernel然后把这块内存的起始地址强制转换为kern_ipc_perm *基类指针塞进p数组里3. 偷天换日C 语言实现“多态”的神级操作你可能会问数组里只存了kern_ipc_perm *基类指针那操作系统需要去操作某个共享内存特有的属性比如释放shm_file时该怎么找回原来的派生类呢这里利用了 C 语言几何学上的一个硬核事实结构体第一个成员的内存地址完完全全等于整个结构体对象的起始内存地址场景重现当我们在用户态调用删除共享内存的命令shmctl(shmid, IPC_RMID, NULL)时底层发生了怎样曼妙的化学反应内核拿着你传进来的用户层shmid经过特定的位运算算出了这个资源在内核大数组中的下标index。内核访问p[index]提取出里面的基类指针struct kern_ipc_perm *。内核知道这是一个共享内存操作于是直接祭出强制类型转换struct shmid_kernel *shm (struct shmid_kernel *)p[index];瞬间原本指向内部第一个成员的指针就被“偷天换日”地还原成了指向整个shmid_kernel对象的完整指针接着内核就可以为所欲为地调用shm-shm_file去释放底层的物理资源了。如果是操作消息队列呢强转成(struct msg_queue *)p[index]即可。 终极总结在没有任何class、virtual关键字的纯 C 语言时代Linux 内核黑客们硬生生地通过**“首成员地址对齐”的内存布局特性结合“指针数组 强制类型转换”实现了令人拍案叫绝的面向对象继承与多态**。 这种“先描述、再组织”的顶层架构思维不仅极大减少了代码冗余更为后世留下了不朽的代码典范四、拓展加餐mmap 文件映射与底层内存分配在进程间通信中位于进程地址空间栈和堆之间的共享区发挥了巨大作用。这不得不提另一个操作共享区的高级利器——mmap(Memory Mapped Files)。1. 什么是 mmapmmap允许我们将一个文件的内容直接映射到进程的虚拟地址空间中。它的威力在于“零拷贝”进程可以像访问普通内存数组一样直接读写文件内容内核会在后台透明地将修改同步到磁盘文件中。彻底省去了传统的read/write带来的内核态与用户态数据拷贝开销。2. 硬核 API 解析#include sys/mman.h void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);length映射字节数。底层必定向物理页大小通常 4KB即 4096 Bytes对齐向上取整。prot保护属性如PROT_READ | PROT_WRITE。flags至关重要MAP_SHARED修改会反映到底层文件中其他映射此文件的进程可见。MAP_PRIVATE私有写时拷贝Copy-On-Write。修改不会反映到底层文件。MAP_ANONYMOUS匿名映射。不关联任何文件fd传 -1直接向系统要一块纯粹的物理内存3. 降维打击用 mmap 极简模拟 malloc平时我们用的malloc在申请小块内存时调用brk扩展堆但在申请大块内存时底层调用的正是mmap 利用MAP_ANONYMOUS | MAP_PRIVATE组合我们可以让操作系统凭空在共享区分配一块内存#include stdio.h #include stdlib.h #include sys/mman.h #include unistd.h // 极简版 my_malloc void* my_malloc(size_t size) { // 匿名映射私有内存不需要 fd (传 -1) void* ptr mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (ptr MAP_FAILED) { perror(mmap); exit(EXIT_FAILURE); } return ptr; } // 极简版 my_free void my_free(void* ptr, size_t size) { if (munmap(ptr, size) -1) { perror(munmap); exit(EXIT_FAILURE); } } int main() { size_t size 1024; // 申请 1KB 内存 char* ptr (char*)my_malloc(size); printf(Allocated memory at address: %p\n, ptr); my_free(ptr, size); return 0; }如果你使用 GDB 运行这段代码输入info proc mapping你会硬核地看到由my_malloc申请出的内存明确落在了共享映射区地址介于堆和栈之间并且其大小被严格按 4KB (0x1000) 进行了对齐结语从基础的共享内存到带有路由类型的消息队列再到保护资源的信号量原语我们全面击穿了 System V IPC 机制。深入内核底层我们惊叹于 Linux 使用 C 语言ipc_id_ary数组与kern_ipc_perm结构体实现底层“多态”的架构艺术。最后我们用mmap揭开了文件映射和malloc内存分配的终极面纱。希望这篇极度硬核的剖析能让你对操作系统的设计之美有一次醍醐灌顶的体悟