进程间通信:消息队列、信号量与内核管理
一、System V 消息队列消息队列 - OS维护了一个队列 - 通过队列的形式让不同进程之间看到同一份资源消息队列提供了一种一个进程给另一个进程发送有类型数据块的方式每个数据块都被认为是有一个类型接收者进程接收的数据块可以有不同的类型值OS需要对消息队列进行管理 -【先描述再组织】两个进程需要需要如何保证自己看到的是同一份资源 上层约定同一个keyIPC资源必须手动/代码删除否则不会自动清除除非重启System V IPC资源生命周期随内核1.1 理解消息队列管道和共享内存传输的都是无格式字节流就像你把一堆信扔进一个麻袋对方拿出来不知道哪封是先写的、哪封是重要的。消息队列则给每封信贴上了“类型标签”接收方可以按类型取信。特点数据有结构每条消息包含一个正整数的类型 任意字节的数据体。支持按类型接收进程可以只接收自己关心的消息。内核管理队列存在于内核中不随进程退出而消失除非手动删除或重启系统。1.2 核心数据结构与函数内核为每个消息队列维护一个msqid_ds结构里面记录了权限、时间戳、消息数量、字节数等。ftok—— 生成唯一 keykey_t ftok(const char *pathname, int proj_id);通过一个路径和项目ID生成一个几乎唯一的 key供msgget使用。️msgget—— 创建或获取消息队列int msgget(key_t key, int msgflg); // msgflg: IPC_CREAT | 0666 (不存在则创建权限 rw-rw-rw-)返回值消息队列标识符 msqidmsgsnd—— 发送消息int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg); // msgp 指向一个结构体第一个成员必须是 long mtype struct msgbuf { long mtype; // 消息类型必须 0 char mtext[100]; // 消息数据可自定义大小 }; // msgsz 是 mtext 的字节数不含 mtypemsgrcv—— 接收消息ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg); // msgtyp 0 : 取队列中第一条消息 // msgtyp 0 : 取类型等于 msgtyp 的第一条消息 // msgtyp 0 : 取类型小于等于 |msgtyp| 的最小类型的第一条消息️msgctl—— 控制消息队列int msgctl(int msqid, int cmd, struct msqid_ds *buf); // cmd: IPC_RMID 删除队列1.3 简单例子服务端与客户端comm.hpp公共头#pragma once #include sys/types.h #include sys/ipc.h #include sys/msg.h #include string.h #include iostream #include unistd.h #define PATHNAME /tmp #define PROJ_ID 0x66 #define CLIENT_TYPE 1 #define SERVER_TYPE 2 struct Message { long mtype; char mtext[256]; };server.cpp接收客户端消息#include comm.hpp int main() { key_t key ftok(PATHNAME, PROJ_ID); int msqid msgget(key, IPC_CREAT | 0666); if (msqid -1) { perror(msgget); exit(1); } Message msg; while (true) { // 接收类型为 CLIENT_TYPE 的消息 ssize_t s msgrcv(msqid, msg, sizeof(msg.mtext), CLIENT_TYPE, 0); if (s 0) { std::cout Server recv: msg.mtext std::endl; if (strcmp(msg.mtext, quit) 0) break; } } msgctl(msqid, IPC_RMID, nullptr); // 删除队列 return 0; }client.cpp发送消息#include comm.hpp int main() { key_t key ftok(PATHNAME, PROJ_ID); int msqid msgget(key, IPC_CREAT | 0666); // 获取已有队列 if (msqid -1) { perror(msgget); exit(1); } Message msg; msg.mtype CLIENT_TYPE; while (true) { std::cout Input: ; std::cin.getline(msg.mtext, sizeof(msg.mtext)); msgsnd(msqid, msg, strlen(msg.mtext) 1, 0); if (strcmp(msg.mtext, quit) 0) break; } return 0; }⚠️注意System V IPC 资源消息队列、共享内存、信号量的生命周期随内核如果不主动删除IPC_RMID即使所有进程退出它们依然存在直到系统重启。可以用ipcs -q查看ipcrm -q msqid删除。1.4 查看System V IPC二、信号量也称信号灯本质是一个计数器用来表明临界资源中资源的数量的多少信号量是 System V IPC 中最抽象但最核心的机制它不传递数据只负责解决多进程对共享资源的同步与互斥问题是实现进程间 “协作规则” 的关键。2.1 为什么需要信号量共享内存是最快的 IPC但它没有同步互斥机制。两个进程同时写同一块共享内存数据会乱掉 —— 这就是并发问题。2.2 核心概念概念通俗解释经典例子共享资源临界资源多个进程能同时访问的公共资源内存、文件、设备等多进程操作的同一块共享内存、同一个打印机临界区进程中访问共享资源的代码段进程中对共享内存写数据的那几行代码互斥任何时刻只允许一个进程进入临界区访问共享资源打印机同一时间只能给一个进程打印同步多个进程访问共享资源时必须按预定的顺序执行生产者进程生产数据后消费者进程才能读取不能反着来信号量本质上就是一个计数器记录当前还有多少个资源可用。进程访问资源前先“申请”P操作计数器减1用完“释放”V操作计数器加1。如果计数器为 0申请进程就会阻塞等待直到其他进程释放资源。核心原则对共享资源的保护本质是对访问共享资源的临界区代码的保护而非资源本身。2.3 理解信号量电影院买票的经典比喻放映厅有 100 个座位 → 信号量初值为 100。每个观众买票 → 信号量减 1预订一个座位。票卖光了信号量 0→ 后来的观众必须等待。观众离场 → 信号量加 1唤醒等待的人。买票就是“预订资源”而不是真的要坐上去的那一刻才占用。信号量的本质是内核维护的一个计数器结合P/V 原语操作原子操作不可被中断实现对临界区的进入控制。计数器的数值代表当前可用的共享资源数量P 操作申请资源计数器减 1若结果 0进程阻塞等待若≥0进程可进入临界区V 操作释放资源计数器加 1若结果≤0唤醒一个阻塞的等待进程。最经典的信号量二元信号量互斥锁当信号量的计数器只能取0 或 1时就是二元信号量对应我们常说的 “互斥锁”初始值为 1表示共享资源可用进程进入临界区前执行 P 操作计数器变为 0资源被占用进程退出临界区后执行 V 操作计数器变回 1资源释放若其他进程此时执行 P 操作计数器变为 - 1进程阻塞直到持有锁的进程执行 V 操作。通俗比喻信号量就像停车场的入口闸机计数器是停车场的剩余车位初始值为 1010 个车位每进一辆车P 操作车位减 1每出一辆车V 操作车位加 1车位为 0 时后续车辆进程只能排队等待阻塞直到有车开出V 操作。二元信号量就是只有 1 个车位的停车场同一时间只能停一辆车。2.4 二元信号量VS多元信号量原子操作P/V 操作是内核实现的原子操作不会被进程调度中断避免了多进程操作计数器时的竞态问题生命周期随内核与消息队列、共享内存一致进程退出后信号量不会自动释放需手动删除用于同步 / 互斥二元信号量实现互斥多元信号量计数器 1实现同步资源预订机制进程必须先通过 P 操作申请信号量才能访问共享资源本质是对共享资源的 “预订”避免冲突。2.5 信号量与共享内存的配合共享内存是最快的 IPC 方式但本身没有同步互斥机制多个进程同时读写会导致数据混乱比如一个进程写数据时另一个进程同时读得到残缺数据。因此实际开发中共享内存必然与信号量配合使用用信号量保护共享内存的临界区实现 “一人写多人读” 或 “一人读无人写” 的规则。通俗理解共享内存是一间公共书房共享资源信号量是书房的门钥匙二元信号量同一时间只有一个人能拿到钥匙进入书房临界区其他人只能在门口等待。2.6 相关函数semget—— 创建/获取信号量集int semget(key_t key, int nsems, int semflg); // nsems: 信号量集中信号量的个数一般写 1 // 返回值信号量集标识符 semidsemctl—— 控制信号量初始化、删除等int semctl(int semid, int semnum, int cmd, ...); // 初始化cmd SETVAL第四个参数是 union semun union semun { int val; struct semid_ds *buf; unsigned short *array; };// 删除cmd IPC_RMIDsemop—— P/V 操作int semop(int semid, struct sembuf *sops, size_t nsops); struct sembuf { unsigned short sem_num; // 信号量编号0 表示第一个 short sem_op; // 1 是 V操作释放-1 是 P操作申请 short sem_flg; // 一般 0阻塞或 IPC_NOWAIT };创建、初始化分离semget() 创建信号量只分配资源semctl() 初始化值SETVAL设置初始三、内核是组织管理IPC资源所有 System V IPC 资源消息队列、共享内存、信号量都有三个共同属性内核通过这三个属性实现统一管理键值key_tIPC 资源的 “全局唯一标识”由ftok函数生成用于进程间查找对应的 IPC 资源标识idIPC 资源的 “内核局部标识”由shmget/msgget/semget返回进程通过 id 操作具体的 IPC 资源权限结构kern_ipc_perm所有 IPC 资源的公共属性结构体包含资源的所有者uid/gid、创建者cuid/cgid、访问权限mode、键值key等是内核管理 IPC 资源的基础。3.1 三种 IPC 资源的统一抽象关键理解shmid,msgid,semid本质上都是数组下标它们不是指针不是句柄而是ipc_id_ary数组的索引通过下标找到kern_ipc_perm *再强制类型转换成具体结构3.2 用 C 实现多态Polymorphism// 1. 基类所有 IPC 资源共有的权限信息 struct kern_ipc_perm { key_t key; // IPC键值 uid_t uid; // 所有者 gid_t gid; // ... 其他权限字段 }; // 2. 柔性数组技巧结构体内部带数组 struct ipc_id_ary { int size; // 数组大小 struct kern_ipc_perm *p[0]; // 柔性数组 }; // 3. 具体 IPC 资源结构以消息队列为例 struct msg_queue { struct kern_ipc_perm q_perm; // 放在第一个兼容基类 long q_stime; // 发送时间 long q_rtime; // 接收时间 struct list_head q_messages; // 消息链表 // ... 其他特有字段 }; struct sem_array { struct kern_ipc_perm sem_perm; // 同样放在第一个 struct sem *sem_base; // 信号量数组 int sem_nsems; // ... }; struct shmid_kernel { struct kern_ipc_perm shm_perm; // 同样放在第一个 struct file *shm_file; // 共享内存文件 int id; // ... };所有结构体以相同字段开头强制类型转换实现继承3.3 什么是柔性数组// 传统固定数组浪费空间或不够 struct fixed { int cnt; char buffer[100]; // 固定100字节可能浪费或不够 }; // 柔性数组运行时决定大小 struct flexible { int cnt; char buffer[0]; // 或 char buffer[]; (C99标准) // 不占空间只是一个标记 };char buffer[0];不占空间只是一个标记IPC 用柔性数组节省内存不需要指针间接跳转数组和结构体在一起缓存友好访问p[i]时数组数据在附近命中率高动态扩容可以realloc重新分配更大的空间总结对比IPC 类型主要作用是否传输数据同步机制生命周期典型函数消息队列有类型的数据块是自带类型随内核msgget/msgsnd/msgrcv共享内存最快的数据交换是需额外锁随内核shmget/shmat/shmdt信号量同步与互斥否原子 P/V随内核semget/semop/semctl匿名管道亲缘进程流式数据是自动同步随进程pipe命名管道任意进程流式数据是自动同步随文件系统mkfifo/open/read/write