【Linux系统】进程间通信:System V IPC——消息队列和信号量
上一篇文章我们详细介绍了System V 共享内存,这篇文章我们就来认识一下System V IPC的另外两种消息队列和信号量,本篇文章暂只对原理进行介绍
1. 消息队列
System V 消息队列是 Unix System V 引入的一种经典的进程间通信机制。它允许不相关的进程(甚至在不同机器上的进程,如果配合其他机制)通过在内核中维护的一个队列结构来交换数据块(称为消息)。
1.1 核心概念与特性
消息:
通信的基本单位,是一个结构化的数据块。
每个消息由两部分组成:
消息类型 (
long mtype
): 一个大于 0 的长整型值。它不是队列的标识符,而是用于在接收时选择特定消息的关键字段。发送者可以自由定义其含义(例如,表示消息来源、优先级、消息种类等)。消息正文 (
char mtext[]
): 实际传输的数据内容。长度可变(最大长度由系统限制决定,可通过msgmax
参数配置)。
队列:
内核维护的链表结构,存储着进程发送过来的消息。
每个消息队列由一个唯一的 IPC 标识符标识。
队列是持久的:即使当前没有进程在使用它,只要没有被显式删除(
msgctl(..., IPC_RMID, ...)
)或系统重启,队列及其中的消息就会一直存在于内核中。 这点与管道不同。队列有容量限制:总字节数(
msgbnb
)和总消息数(msgmni
)都有系统级上限。
键值 (
key_t key
):用于在系统中定位或创建一个特定的消息队列。
通常使用
ftok()
函数根据一个路径名(必须存在且可访问)和一个项目标识符(一个字符)生成一个唯一的键值。特殊值
IPC_PRIVATE
(值为 0) 用于创建一个新的、键值唯一的队列(通常用于父子进程间)。
权限与所有者:
消息队列拥有者(创建者)、所属组和权限位(类似文件权限
rw-rw-rw-
),控制哪些进程可以访问队列(发送、接收、控制)。这些在创建队列 (msgget
) 时设置。
1.2 操作系统如何管理消息队列
一、描述:单个消息队列的结构体(struct msqid_ds
)
操作系统为每个消息队列创建 元数据结构体(如Linux中的 msqid_ds
),存储队列的完整状态信息(见):
struct msqid_ds {struct ipc_perm msg_perm; // 权限控制(所有者、读写模式等)time_t msg_stime; // 最后发送消息的时间戳time_t msg_rtime; // 最后接收消息的时间戳time_t msg_ctime; // 队列最后修改时间unsigned long msg_cbytes; // 当前队列总字节数unsigned long msg_qnum; // 当前消息数量unsigned long msg_qbytes; // 队列最大容量(字节)pid_t msg_lspid; // 最后发送消息的进程PIDpid_t msg_lrpid; // 最后接收消息的进程PIDstruct msg *msg_first; // 指向队列首消息(内核链表)struct msg *msg_last; // 指向队列尾消息
};
关键成员解析:
msg_perm
- 类型:
struct ipc_perm
- 作用:存储队列的访问权限(用户/组ID、读写模式),是IPC资源的通用权限控制结构。
struct ipc_perm {key_t key; // 队列键值uid_t uid; // 所有者用户IDgid_t gid; // 所有者组IDmode_t mode; // 读写权限(如0666) };
- 类型:
消息链表指针
msg_first
和msg_last
分别指向队列中第一条和最后一条消息,构成消息链表(FIFO基础)。- 每个消息节点由内核结构体
msg_msg
描述:struct msg_msg {long m_type; // 消息类型size_t m_ts; // 消息长度struct msg_msg *next; // 指向下一条消息char mtext[]; // 消息数据(柔性数组) };
二、组织:全局消息队列的管理
操作系统通过 链表或数组 组织所有消息队列的 msqid_ds
结构体:
- 全局管理链表
- 所有
msqid_ds
结构体通过内部指针(如ipc_ids.entries
)链接成链表。 - 链表头由内核维护的
ipc_ids
结构管理,实现高效遍历。
- 所有
- 键值映射
- 进程通过唯一键值(
key_t key
)标识队列,msgget()
系统调用根据键值在链表中查找或创建队列。
- 进程通过唯一键值(
组织示意图:
全局管理链表
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ msqid_ds 1 │ ──→ │ msqid_ds 2 │ ──→ │ msqid_ds 3 │
├─────────────┤ ├─────────────┤ ├─────────────┤
│ msg_perm │ │ msg_perm │ │ msg_perm │
│ msg_first → ├────→│ msg_first → ├────→│ msg_first → ├─→ 消息链表
│ ... │ │ ... │ │ ... │
└─────────────┘ └─────────────┘ └─────────────┘
三、运作机制:队列操作与内核协作
1. 消息存储
- 发送(
msgsnd()
):- 从内核空闲内存池分配
msg_msg
节点,拷贝用户数据到mtext
。 - 将节点追加到目标队列的链表尾部(更新
msg_last
)。
- 从内核空闲内存池分配
- 接收(
msgrcv()
):- 从队列头部(
msg_first
)或按类型匹配节点,拷贝数据到用户空间。 - 释放节点回内存池。
- 从队列头部(
2. 阻塞控制
- 当队列空/满时,调用
msgsnd()
/msgrcv()
的进程可能被挂起:- 进程加入队列的等待链表(
msqid_ds.suspend_thread
)。 - 当队列状态变化(如新消息到达),内核唤醒等待进程。
- 进程加入队列的等待链表(
3. 资源回收
- 显式删除:
msgctl(msqid, IPC_RMID)
释放队列内存。 - 隐式泄漏:未删除的队列永久占用内核资源(需管理员清理)。
四、设计思想:先描述,再组织
- 描述:
- 每个资源(如消息队列)用结构体(
msqid_ds
)描述其属性。
- 每个资源(如消息队列)用结构体(
- 组织:
- 所有结构体通过链表/数组全局管理,实现统一调度。
- 优势:
- 模块化:新增队列只需扩展链表。
- 高效性:键值映射快速定位队列。
- 可维护性:权限、状态集中存储。
1.3 工作原理
创建或获取队列 (
msgget
):进程首先调用
msgget
。如果指定
key
对应的队列不存在,且msgflg
参数包含了IPC_CREAT
,则内核创建一个新队列。如果队列已存在,则返回该队列的标识符。
返回的是一个整数形式的 IPC 标识符 (
msqid
),后续操作(发送、接收、控制)都使用这个标识符来指定操作哪个队列。
发送消息 (
msgsnd
):进程准备好一个包含
mtype
和mtext
的消息结构体。// 定义消息结构 struct msgbuf {long mtype; // 消息类型char mtext[100]; // 消息内容 };
调用
msgsnd
,指定目标队列的msqid
、消息结构体指针、消息正文长度(不包括mtype
的长度)、以及可选的阻塞标志msgflg
。内核动作:
检查队列权限(发送进程是否有写入权限)。
检查队列是否有足够空间容纳新消息(考虑队列的
msgbnb
和msgmni
限制)。如果空间不足且
msgflg
设置了IPC_NOWAIT
: 立即返回错误 (EAGAIN
)。如果空间不足且
msgflg
未设置IPC_NOWAIT
(阻塞模式): 发送进程被挂起(睡眠),直到队列中有足够空间或队列被删除。若有空间,内核将消息复制到内核空间,并追加到该队列的末尾(FIFO,除非按类型接收)。
唤醒任何可能正在此队列上等待接收消息而被阻塞的进程。
接收消息 (
msgrcv
):进程调用
msgrcv
,指定源队列的msqid
、用于存放接收到的消息的缓冲区指针、缓冲区大小、期望接收的消息类型 (msgtyp
)、以及可选的标志msgflg
。内核动作:
检查队列权限(接收进程是否有读取权限)。
根据
msgtyp
在队列中查找符合条件的消息:msgtyp == 0
: 读取队列中的第一条消息(FIFO)。msgtyp > 0
: 读取队列中第一条mtype
等于msgtyp
的消息。用于选择特定类型的消息。msgtyp < 0
: 读取队列中mtype
小于等于msgtyp
绝对值的消息中,mtype
值最小的第一条消息。可用于实现某种形式的优先级接收(较小绝对值的mtype
被视为较高优先级)。
如果找到符合条件的消息:
检查接收缓冲区大小是否足以容纳消息正文(
mtext
部分)。如果缓冲区太小且
msgflg
设置了MSG_NOERROR
,则消息正文被截断以适应缓冲区(超出部分丢失)。如果缓冲区太小且未设置
MSG_NOERROR
,则返回错误 (E2BIG
)。若大小匹配或截断被允许,内核将找到的消息复制到用户提供的缓冲区中,并将该消息从队列中移除。
如果未找到符合条件的消息:
如果
msgflg
设置了IPC_NOWAIT
: 立即返回错误 (ENOMSG
)。如果未设置
IPC_NOWAIT
(阻塞模式): 接收进程被挂起(睡眠),直到队列中出现符合条件的消息或队列被删除。
控制队列 (
msgctl
):用于对队列执行控制操作,如:
IPC_RMID
: 立即删除整个消息队列及其中的所有消息。所有阻塞在该队列上的msgsnd
和msgrcv
调用将立即失败返回 (EIDRM
)。IPC_SET
: 修改队列的属性(主要是所有者、组、权限)。IPC_STAT
: 获取队列的状态信息(填充到一个struct msqid_ds
结构中),包括权限、大小、时间戳、当前消息数和字节数等。
1.4 总结与示例
关键特点与优缺点:
优点:
解耦生产者和消费者: 发送者和接收者可以独立运行,不需要同时存在(得益于队列的持久性)。
结构化的消息:
mtype
提供了一种简单的消息分类和选择机制。支持优先级(间接): 通过
msgtyp < 0
的接收方式可以实现基于mtype
值的优先级选择。异步通信(对发送者):
msgsnd
通常很快完成(除非阻塞),发送者不需要等待接收者。跨进程通信: 允许任意进程(只要有权限)通过同一个队列通信。
缺点/局限:
内核空间开销: 消息在内核和用户空间之间需要复制,且队列本身由内核维护,存在内核空间开销和上下文切换开销。不适合传输超大消息或极高频率通信。
编程接口较复杂: 相比管道,API 使用
key
,msqid
,msgctl
等,略显繁琐。系统范围限制: 队列总数、每个队列的大小、每条消息的大小受系统参数限制,可能需要管理员调整。
持久性可能非预期: 队列不自动销毁,如果忘记删除,会造成内核资源泄漏(需要
ipcrm
命令或msgctl(IPC_RMID)
手动清理)。POSIX 替代: 现代 Linux 也支持 POSIX 消息队列 (
mq_open
,mq_send
,mq_receive
),其 API 更符合文件操作习惯(使用路径名标识),支持通知机制,但 System V 消息队列依然广泛使用。
简单应用场景示例:
一个日志服务系统:
日志生成进程(多个): 将日志条目作为消息(
mtype
可表示日志级别,如 1=DEBUG, 2=INFO, 3=ERROR)发送到同一个消息队列 (msgsnd
)。日志收集进程: 从队列中读取消息 (
msgrcv
)。可以用
msgtyp=0
按顺序处理所有日志。可以用
msgtyp=3
优先只处理 ERROR 日志。可以用
msgtyp=-2
读取优先级高于 INFO(即 DEBUG 和 INFO)的日志。
日志收集进程可以将收到的日志写入文件、数据库或转发到网络。
常用命令:
ipcs -q
: 查看系统中所有的 System V 消息队列及其状态(IPC ID, Key, 拥有者, 权限, 当前消息数, 当前字节数)。ipcrm -q msqid
: 删除指定的消息队列(msqid
从ipcs -q
获取)。
2. 信号量前置知识铺垫
核心概念体系:
共享资源 (Shared Resource):
- 定义: 在并发环境中,可以被多个执行流(如进程、线程、协程)同时访问或修改的数据、硬件设备或任何形式的资源。
- 关键特征: “同一份” + “多个执行流能看到”。例如:
- 内存中的全局变量、静态变量、堆内存块。
- 文件。
- 数据库连接。
- 网络套接字。
- 打印机、显示器等硬件设备。
- 问题: 不加控制地并发访问共享资源会导致 数据竞争 (Data Race) ,产生不可预测的结果(如数据损坏、计算结果错误、程序崩溃)。
临界资源 (Critical Resource) / 互斥资源 (Mutually Exclusive Resource):
- 定义: 共享资源的一个子集。特指那些 一次只允许一个执行流 使用的共享资源。或者说,对这些资源的访问必须是 互斥 的。
- 关键特征: “一次只允许一个执行流使用”。
- 为什么需要区分? 并非所有共享资源都需要互斥访问。有些资源天生是线程安全的(如只读数据),或者可以通过其他机制(如无锁编程、原子操作)安全访问。临界资源特指那些 必须 通过互斥机制保护才能安全访问的共享资源。
为什么需要“一次只允许一个”? 因为对这些资源的并发访问(特别是写入)如果不加限制,几乎必然导致数据损坏或逻辑错误。
- 例子:
- 一个需要被多个线程更新的共享计数器(
count++
)。 - 一个需要被多个进程写入的日志文件。
- 一个需要被多个线程修改的链表结构。
- 一个需要被多个线程更新的共享计数器(
临界区 (Critical Section):
- 定义: 进程中(或线程中)访问临界资源的那段 代码。
- 关键特征: 访问临界资源的代码段。
- 重要说明:
- 临界区是 代码,不是资源本身。
- 一段代码是否是临界区,取决于它是否访问了临界资源。
- 一个进程/线程内部,可以包含多个临界区(访问不同的临界资源)。
- 对于同一个临界资源,所有访问它的执行流中,访问该资源的代码段都构成该资源的临界区。
核心原则: 任何时刻,只允许一个执行流进入其与同一个临界资源相关的临界区。如果多个执行流都试图进入自己的临界区(访问同一个临界资源),除了一个执行流外,其他都必须被阻塞 (Blocked) 或等待 (Waiting)。
临界区 VS 临界资源:
临界资源是实体(数据、设备)。
临界区是代码段(操作该实体的指令集合)。
保护临界资源本质就是控制执行流进入访问该资源的临界区代码。
- 总结:
你写的代码 = 访问临界资源的代码(临界区) + 不访问临界资源的代码(非临界区)
- 非临界区 (Non-Critical Section):
进程中(或线程中)不访问任何临界资源的那部分代码。
多个执行流可以安全地、不受限制地并发执行非临界区代码,因为它们不会操作共享状态或资源。
互斥 (Mutual Exclusion):
- 定义: 一种保护临界资源的方式。它确保在 任何时刻,最多只有一个执行流 处于访问同一临界资源的临界区中。
- 目标: 防止多个执行流同时进入同一个临界区,从而避免对同一临界资源的并发访问。
- 机制: 常见的互斥机制包括:
- 锁 (Lock): 如互斥锁 (
Mutex
),自旋锁 (Spinlock
)。 - 信号量 (Semaphore): (当初始值为1时,称为二元信号量,用作互斥锁)。
- 原子操作 (Atomic Operations): 硬件支持的不可中断的简单操作(如CAS),可用于构建更高级的互斥或无锁结构。
- 锁 (Lock): 如互斥锁 (
类比: 公共厕所的一个隔间门锁。一个人进去锁门(进入临界区),其他人必须等待(阻塞),直到里面的人出来解锁(退出临界区)。
同步 (Synchronization):
- 定义: 一种协调多个执行流执行顺序的机制。它确保执行流在访问共享资源(不一定是互斥资源)时,遵循某种 特定的、预期的顺序。
- 目标: 解决执行流之间的 依赖关系 和 协作问题。
确保执行流在正确的时机访问资源(例如,生产者生产了数据,消费者才能消费)。
确保执行流在满足特定条件后才继续执行(例如,等待某个任务完成、等待数据可用)。
- 关键特征: “访问临界资源的时候,具有一定的顺序性”。
- 与互斥的区别:
- 互斥 关心的是 同一时间谁可以进入(独占访问权)。
- 同步 关心的是 谁在什么时候进入(执行顺序)。
- 互斥是一种特殊的同步(顺序要求是“不能同时”),但同步的范围更广。
- 例子:
- 生产者-消费者问题: 消费者必须在生产者生产出数据后才能消费。需要同步来协调生产者和消费者的执行顺序(可能也需要互斥保护共享缓冲区)。
- 读者-写者问题: 允许多个读者同时读,但写者必须独占访问。需要同步来管理读者和写者的访问顺序和并发度。
- 屏障 (Barrier): 让一组线程在某个点等待,直到所有线程都到达该点后才一起继续执行。
- 机制: 常见的同步机制包括:
- 条件变量 (Condition Variable): 通常与互斥锁配合使用,用于等待某个条件成立。
- 信号量 (Semaphore): (计数信号量可用来控制并发数量或表示资源可用量)。
- 事件 (Event)、栅栏 (Barrier)、管道 (Pipe) 等。
保护的本质 (The Essence of Protection):
- 核心观点: 所谓的对共享资源进⾏保护,本质是对访问共享资源的代码进⾏保护。
- 解释:
- 资源(数据、文件句柄等)本身是静态的。真正导致问题的是 执行流动态执行访问这些资源的代码。
- 互斥锁保护的并不是内存地址本身,而是 试图访问那个地址的代码段(临界区)的执行权。
- 条件变量通知的也不是数据本身发生了变化,而是 等待该变化的代码(在临界区外等待)可以尝试重新进入临界区检查了。
- 因此,所有并发控制机制(锁、信号量、条件变量等)最终作用的对象都是 代码的执行流,通过控制执行流进入/离开临界区的时机或顺序,来间接保护共享资源的状态一致性。
概念关系图:
+----------------------+| 共享资源 (Shared) |+----------+-----------+|| (其中需要互斥访问的)v+------------------+ +-----v-----+ +------------------+| 非临界区代码 | | 临界资源 |<----| 临界区代码 || (Non-Critical) | | (Critical)| | (Critical Section)|+------------------+ | Resource | +---------+---------++-----------+ || 访问v+----------+-----------+| 执行流 || (Process/Thread) |+----------+-----------+|| 需要控制v+----------------------+ +--------------+--------------+| 互斥 (Mutex) | | 同步 (Sync) || (保证独占访问) | | (保证执行顺序/协作) |+----------------------+ +----------------------------+
总结与串联:
并发是源头: 多个执行流同时/交替运行。
共享是基础: 执行流能看到共同的资源。
临界是关键: 有些共享资源(临界资源)一次只能一个执行流安全使用。
临界区是战场: 访问临界资源的代码段是需要保护的核心区域。
互斥是盾牌: 最基本的保护手段,确保任何时候只有一个执行流在“战场”(临界区)上操作特定的临界资源。
同步是指挥: 更高级的协调,确保执行流进入“战场”和推进任务的顺序符合逻辑要求(谁先谁后,谁等谁)。
目标是安全与正确: 所有保护(互斥与同步)的最终目的都是为了保证程序在并发环境下执行的正确性 (Correctness) 和数据的一致性 (Consistency),避免竞态条件 (Race Condition) 和死锁 (Deadlock) 等问题。
理解要点:
识别临界资源: 分析你的程序,哪些数据/设备会被多个执行流并发访问且可能导致问题?这些就是临界资源。
界定临界区: 精确找出访问这些临界资源的代码段。临界区必须尽可能短,以提高并发性能。
选择合适的机制:
只需要保证“一次一个”访问? -> 互斥 (Mutex)
需要控制执行顺序或等待条件? -> 同步 (Condition Variables, Semaphores)
保护的本质: 保护共享资源,归根结底是通过互斥和同步机制来控制执行流进入和执行访问该资源的代码(临界区)。
3. 信号量
3.1 信号量是什么?
本质:信号量是一种受保护的整型变量,用于协调多个执行流(进程/线程)对共享资源的访问。其核心包含两部分:
- 整型计数器:表示可用资源数量。
- 原子操作:
P()
(wait
/获取资源)和V()
(signal
/释放资源),由操作系统保证其执行不可中断。
核心作用:
控制并发访问共享资源(实现互斥)
协调多个执行流的执行顺序(实现同步)
类型:
- 二元信号量:值仅取0或1,用于互斥锁。
- 计数信号量:值≥0,表示资源实例数量(如空闲缓冲区数量)。
📖 操作系统通过内核数据结构(如Linux的
semid_ds
)封装信号量属性,确保操作的原子性和安全性。
3.2 如何理解信号量?——以电影院为例
场景:电影院有100个座位(共享资源),观众(进程)需购票入场。
信号量初始化:
S.value = 100
(初始空闲座位数)。P()操作(购票):
void P(semaphore S) {S.value--; // 尝试占用一个座位if (S.value < 0) { // 无空闲座位block(S.wait_queue); // 观众排队等待(阻塞)} }
当第101位观众购票时,
S.value=-1
,该观众被加入等待队列(阻塞机制)。V()操作(离场):
void V(semaphore S) {S.value++; // 释放一个座位if (S.value <= 0) { // 有观众在等待wakeup(S.wait_queue); // 唤醒队列中第一位观众} }
每离场一人,唤醒一名等待观众(唤醒机制)。
互斥场景(二元信号量):
检票闸机:一次只允许一人通过(类似锁)。
初始化
sem_mutex = 1
。观众通过闸机前执行
P(sem_mutex)
(占用闸机,sem_mutex=0
)。离开闸机后执行
V(sem_mutex)
(释放闸机,sem_mutex=1
)。
关键点:
- 资源不足时阻塞:避免忙等(CPU空转),符合"让权等待"原则。
- 等待队列:管理阻塞进程,确保公平性。
- P操作(
wait
):申请资源(可能阻塞)。 - V操作(
signal
):释放资源(可能唤醒等待者)。 - 原子性:P/V 操作是不可中断的(由操作系统或硬件保证)。
3.3 共享资源使用的问题
当多个进程并发访问共享资源(如打印机、内存变量)时:
- 数据竞争(Data Race):
- 例:两个进程同时执行
count++
,实际可能只增加1次(因汇编指令被打断)。
- 例:两个进程同时执行
- 解决方案需求:
- 互斥:任一时刻仅一个进程访问资源(的互斥信号量)。
- 同步:控制执行顺序(如生产者必须在消费者之前写数据)。
💡 信号量通过对临界区代码的封装(P/V操作),解决上述问题。
3.4 信号量和通信有什么关系?
信号量是进程间通信(IPC)的同步机制,而非数据传输工具:
信号量 ≠ 通信机制:
它不传递数据,只传递控制信号(“资源可用”或“继续执行”)。
如何支持通信?
协调通信时序:
- 生产者-消费者模型中:
- 空缓冲区信号量
empty
:生产者写入前执行P(empty)
。 - 满缓冲区信号量
full
:消费者读取后执行V(full)
。
- 空缓冲区信号量
- 生产者-消费者模型中:
- 保护通信载体:
- 消息队列/共享内存本身是共享资源,需信号量保护其访问。
🌰 两个项目组共享打印机:
- 信号量
S=1
(互斥锁),确保任一时刻仅一组使用打印机。
3.5 信号量的工作原理
核心:PV操作的原子性
- P() 操作流程:
- 减少资源计数(
S.value--
)。 - 若资源不足(
S.value < 0
),进程加入等待队列并阻塞。
- 减少资源计数(
- V() 操作流程:
- 增加资源计数(
S.value++
)。 - 若有进程等待(
S.value ≤ 0
),唤醒队首进程。
- 增加资源计数(
原子性实现
操作系统通过 屏蔽中断 或 硬件原子指令(如CAS)保证PV操作不可分割:
// 伪代码:PV操作的原子性保障
void P(semaphore S) {disable_interrupts(); // 屏蔽中断(单CPU)S.value--;if (S.value < 0) {enqueue(S.wait_queue, current_process);enable_interrupts();block(); // 主动让出CPU} else {enable_interrupts();}
}
解决互斥与同步
- 互斥:初始化
mutex=1
,临界区前后分别执行P(mutex)
/V(mutex)
。 - 同步:设置信号量
sync=0
,在需等待的操作前执行P(sync)
,在条件满足后执行V(sync)
(前驱关系)。
3.6 操作系统如何管理信号量?——"先描述,再组织"
1. 描述:定义信号量结构体
操作系统为每个信号量集合创建元数据 semid_ds
:
struct semid_ds {struct ipc_perm sem_perm; // 权限控制(所有者/权限位)time_t sem_otime; // 最后一次操作时间time_t sem_ctime; // 最后一次修改时间struct sem *sem_base; // 指向信号量数组struct sem_queue *sem_pending; // 等待队列头指针
};
其中单个信号量 struct sem
包含:
struct sem {int semval; // 当前计数值int sempid; // 最后一次操作的进程PID
};
2. 组织:全局统一管理
- 存储结构:
- 所有
semid_ds
通过链表或数组组织。 - 共享内存、消息队列、信号量共用
ipc_ids
全局数组,通过首个成员struct ipc_perm
统一寻址。
- 所有
- 键值映射:
semget(key, ...)
根据key
查找或创建信号量集合,返回唯一标识符semid
。- 内核通过
semid
定位到对应的semid_ds
。
操作系统对信号量的组织:
系统级管理:
所有信号量通过内核对象表(如
sem_array
)统一管理。用户通过信号量标识符(如 System V 的
semid
)访问。
操作流程:
创建信号量:
semget()
分配内核对象,初始化count
和wait_list
。P/V 操作:
通过系统调用(如
semop()
)进入内核。关中断 → 用自旋锁保护临界区 → 修改
count
→ 操作等待队列。
阻塞/唤醒:
若需阻塞:将当前进程加入
wait_list
,调度其他进程。执行 V 操作时:从
wait_list
取出一个进程唤醒。
销毁信号量:
semctl(IPC_RMID)
释放内核资源。
3. 生命周期管理
操作 | 系统调用 | 内核行为 |
---|---|---|
创建 | semget() | 分配 semid_ds ,初始化 sem_base 数组 |
操作 | semop() | 原子执行PV操作,更新 semval 并管理阻塞队列 |
删除 | semctl(..., IPC_RMID) | 释放 semid_ds 结构体 |
命令行工具:
ipcs -s
:查看所有信号量集合的semid_ds
信息。ipcrm -s semid
:删除指定信号量。
3.7 信号量接口与系统调用
一、信号量核心系统调用
Linux 通过 System V IPC 提供三类信号量操作接口(需包含头文件 <sys/sem.h>
):
1. semget
- 创建/获取信号量集
int semget(key_t key, int nsems, int semflg);
参数解析:
key
:唯一标识符(ftok()
生成或IPC_PRIVATE
)nsems
:信号量数量(集合大小)semflg
:标志位(IPC_CREAT | 0666
创建可读写信号量)
返回值:信号量标识符(semid
)或 -1(失败)
典型场景:
key_t key = ftok("/tmp", 'A');
int semid = semget(key, 1, IPC_CREAT | 0666); // 创建含1个信号量的集合
2. semop
- 原子操作信号量
int semop(int semid, struct sembuf *sops, size_t nsops);
关键结构体:
struct sembuf {unsigned short sem_num; // 信号量索引short sem_op; // 操作值(P:-1, V:+1)short sem_flg; // 标志(IPC_NOWAIT/SEM_UNDO)
};
操作类型:
sem_op | 行为 | 等效原语 |
---|---|---|
负数 | 申请资源(P操作) | wait() |
正数 | 释放资源(V操作) | signal() |
0 | 等待信号量归零 | 同步检查 |
示例(生产者-消费者模型):
struct sembuf op_wait = {0, -1, 0}; // P操作
struct sembuf op_signal = {0, +1, 0}; // V操作
semop(semid, &op_wait, 1); // 申请资源
3. semctl
- 控制信号量
int semctl(int semid, int semnum, int cmd, ...);
核心命令:
命令 | 功能 | 参数要求 |
---|---|---|
IPC_RMID | 立即删除信号量集 | 忽略semnum |
SETVAL | 设置单个信号量初始值 | 需union semun 的val |
GETVAL | 获取信号量当前值 | - |
参数联合体(需自定义):
union semun {int val; // SETVAL用struct semid_ds *buf; // IPC_STAT用unsigned short *array; // SETALL用
};
4. System V IPC的关系和区别
4.1 核心关系
统一管理机制
- 三者均属于System V IPC标准(也称SysV IPC),是Unix/Linux中进程间通信(IPC)的核心机制。
- 内核使用相同的 全局标识符(key) 管理它们,通过
ftok()
生成key,并通过xxxget()
(如shmget
/msgget
/semget
)创建或获取对象标识符(ID)。 - 生命周期与内核绑定(非进程):除非显式删除(
IPC_RMID
)或系统重启,否则对象会持续存在。
共性操作接口
- 均使用类似的函数模式:
- 创建/获取:
xxxget(key, ...)
- 控制:
xxxctl(id, cmd, ...)
(如删除IPC_RMID
、修改权限) - 操作:专属函数(如
shmat
/msgsnd
/semop
)。
- 创建/获取:
- 权限管理均通过
struct ipc_perm
结构体实现(包含所有者、权限位等)。
- 均使用类似的函数模式:
工具命令统一
- 均通过
ipcs
查看对象信息,ipcrm
删除对象(如ipcrm -m [shmid]
)。
- 均通过
4.2 核心区别
1. 功能与设计目的
机制 | 核心功能 | 典型场景 |
---|---|---|
共享内存 | 多个进程直接访问同一块物理内存,无需数据拷贝,速度最快。 | 高频数据交换(如视频处理、数据库缓存)。 |
消息队列 | 进程间通过带类型标记的消息通信,消息按链表存储,支持优先级和选择性接收。 | 异步通信、结构化数据传输(如任务调度)。 |
信号量 | 本质是计数器,用于同步/互斥(如控制共享资源访问权限)。 | 保护临界区(如防止共享内存数据竞争)。 |
2. 数据交互方式
机制 | 数据传递方式 |
---|---|
共享内存 | 零拷贝:进程直接读写内存,但需自行处理同步(如配合信号量)。 |
消息队列 | 内核复制:发送方复制数据到内核队列,接收方从内核复制数据,有额外开销。 |
信号量 | 无数据传输:仅通过计数器状态协调进程行为(如P/V操作)。 |
3. 技术实现差异
特性 | 共享内存 | 消息队列 | 信号量 |
---|---|---|---|
核心结构 | struct shmid_ds (含物理页映射) | struct msqid_ds (含消息链表头) | struct semid_ds (含信号量集合) |
操作函数 | shmat /shmdt (附加/分离内存) | msgsnd /msgrcv (发送/接收) | semop (P/V操作) |
同步需求 | 必须外部同步(如信号量) | 内置消息阻塞机制 | 自身实现同步 |
容量限制 | 受物理内存大小限制 | 队列长度和消息大小有限制 | 信号量数量有限制 |
4. 性能对比
- 共享内存:速度最快(免拷贝),但需额外同步成本。
- 消息队列:速度中等(内核复制开销),但支持结构化通信。
- 信号量:无数据传输开销,但PV操作涉及内核切换。
4.3 协同工作关系
- 典型组合:
共享内存(高效数据交换) + 信号量(互斥访问)是最常见组合,例如:- 进程A申请信号量(P操作)→ 访问共享内存 → 释放信号量(V操作)。
- 消息队列可用于传递控制指令(如“开始处理”),而共享内存传递实际数据。
- 独立性:
消息队列和信号量可独立使用(如仅用消息队列通信,或仅用信号量同步线程)。
4.4 内核是如何组织管理IPC资源的
一、核心设计:统一描述与多态管理
1. 描述结构:struct ipc_perm
所有 IPC 资源的内核描述结构体(如 shmid_ds
、msqid_ds
、semid_ds
)首个成员均为 struct ipc_perm
。该结构存储资源的通用属性:
- 唯一标识:
key_t __key
(创建时指定的键值) - 权限控制:
uid
/gid
(所有者)、cuid
/cgid
(创建者)、mode
(读写权限位) - 序列号:
__seq
(避免 ID 重用冲突)
struct ipc_perm {key_t __key; // IPC 键值uid_t uid; // 所有者有效 UIDgid_t gid; // 所有者有效 GIDuid_t cuid; // 创建者有效 UIDgid_t cgid; // 创建者有效 GIDunsigned short mode; // 权限位(如 0666)unsigned short __seq; // 序列号
};
2. 多态性实现
- 地址一致性:因
ipc_perm
位于结构体首部,其地址与整个资源描述结构体(如shmid_ds
)地址相同。 - 类型转换:内核通过强制类型转换(如
(struct shmid_ds *)perm_ptr
)访问资源专属属性(如共享内存的页映射表)。
例如:共享内存结构体实际为:
struct shmid_ds {struct ipc_perm shm_perm; // 首成员size_t shm_segsz; // 共享内存大小// 其他专属属性... };
二、组织架构:全局资源表与 ID 分配
1. 资源表管理(ipc_ids
)
内核为每类 IPC 资源(共享内存/消息队列/信号量)维护一个全局表 ipc_ids
,其核心成员包括:
entries
:指向资源指针数组的柔性数组(实际存储struct kern_ipc_perm*
)。in_use
:当前资源数量。max_idx
:最大索引号(动态扩容)。seq
:全局序列计数器(避免 ID 重用)。
2. ID 分配机制
- 创建资源(如
shmget(key, ...)
):- 根据
key
查找或新建资源描述结构体(如shmid_ds
)。 - 从
ipc_ids.entries
中分配空闲槽位,存入资源指针(kern_ipc_perm*
)。 - 生成 IPC ID =
槽位索引 × SEQ_MULTIPLIER + ipc_ids.seq
,确保全局唯一。
- 根据
- 资源回收(如
shmctl(IPC_RMID)
):- 释放描述结构体内存,清空
entries
对应槽位。 - 更新
ipc_ids.in_use
计数。
- 释放描述结构体内存,清空
新旧内核差异:
- 旧内核:使用静态数组
struct ipc_id_ary
管理槽位,大小固定。- 新内核:改用 IDR 树(基数树)动态管理槽位,支持高效扩容。
三、权限控制与安全机制
- 权限校验:
进程调用shmat()
/msgsnd()
等操作前,内核通过ipc_perm.mode
检查其 UID/GID 是否具备权限(如SHM_R
读权限)。 - 特权覆盖:超级用户(root)可绕过权限限制。
- 生命周期:
IPC 资源随内核持续存在(非进程生命周期),需显式删除(IPC_RMID
)或重启系统才能释放。
四、内核数据结构全景
+---------------+
| ipc_ids | (全局资源表,如 shm_ids)
|---------------| +------------+ +---------------+
| entries: ptr |---->| slot[0] | ----> | shmid_ds | (共享内存实例)
| in_use: 5 | | slot[1] | |---------------|
| max_idx: 128 | | ... | | ipc_perm | --> key, uid, mode...
| seq: 0xab | | slot[N] | | shm_segsz | --> 专属属性
+---------------+ +------------+ +---------------+↑| (同结构存储 msqid_ds/semid_ds)
五、设计优势与约束
优势
- 统一接口:三类资源通过相似的
get
/ctl
/op
接口管理(如semget
/semctl
/semop
)。 - 高效检索:IDR 树实现 O(1) 复杂度资源定位。
- 权限集中:通过
ipc_perm
统一安全策略。
约束
- 内核限制:
- 消息队列最大数量(
msgmni
)、信号量集大小(semmsl
)等受内核参数限制。
- 消息队列最大数量(
- 手动释放:资源泄露需管理员介入(
ipcrm
命令)。
六、用户层工具
ipcs
:查看所有 IPC 资源状态(ID、键值、权限)。ipcrm
:删除指定资源(如ipcrm -m [shmid]
)。
4.5 OS管理IPC资源的关键
内核为每类 IPC 资源(共享内存/消息队列/信号量)维护一个全局表 ipc_ids,
其核心成员包括:
entries
:指向资源指针数组的柔性数组(实际存储struct kern_ipc_perm*
)。in_use
:当前资源数量。max_idx
:最大索引号(动态扩容)。seq
:全局序列计数器(避免 ID 重用)。
其中entries指针指向struct ipc_id_ary(
柔性数组),详细如下:
一、柔性数组的技术实现
1. 数据结构定义
柔性数组在代码中体现为 struct ipc_id_ary
(旧内核)或 IDR树(新内核),其核心结构如下:
// 旧内核静态方案(Linux 2.6.18前)
struct ipc_id_ary {int size; // 数组容量struct kern_ipc_perm *p[0]; // 柔性指针数组
};// 新内核动态方案(Linux 2.6.18+)
struct ipc_ids {struct idr ipcs_idr; // 基数树(IDR)动态管理指针unsigned short seq; // 序列号防ID重用
};
- 柔性数组
p[0]
:
本质是长度可变的指针数组,每个元素指向一个struct kern_ipc_perm
(资源描述符基类)。
内存分配时通过kmalloc(sizeof(struct ipc_id_ary) + size * sizeof(void*))
动态扩展 。
2. 资源存储方式
资源类型 | 实际内核结构体 | 与kern_ipc_perm 的关系 |
---|---|---|
共享内存 | struct shmid_kernel | 首成员为kern_ipc_perm |
消息队列 | struct msg_queue | 首成员为kern_ipc_perm |
信号量 | struct sem_array | 首成员为kern_ipc_perm |
访问逻辑:
// 通过柔性数组定位资源
struct kern_ipc_perm *base = entries->p[id]; // 获取基类指针// 多态转换:根据资源类型强转为具体结构体
if (resource_type == SHM) {struct shmid_kernel *shm = (struct shmid_kernel*)base; // 地址一致access(shm->shm_segsz); // 访问共享内存专属属性
}
因
kern_ipc_perm
位于子结构体首部,其地址与子结构体地址相同,通过强制类型转换即可访问完整资源 。
这就是C语言实现的多态行为!!
二、设计原理与核心优势
1. 动态扩容机制
- 旧内核方案:
柔性数组大小通过size
字段预设上限。当资源数量超过size
时,采用 回绕策略(id % size
)复用槽位,但易导致ID冲突 。 - 新内核方案:
引入 IDR树(基数树) 动态管理指针:- 按需分配槽位,无固定容量限制。
- ID生成公式:
id = 槽位索引 × SEQ_MULTIPLIER + ipc_ids.seq
(SEQ_MULTIPLIER
通常为 2^16,seq
防重用)。
2. 高效检索与安全控制
操作 | 实现方式 | 复杂度 |
---|---|---|
资源创建 | 在IDR树中分配新槽位,生成唯一ID | O(log n) |
资源查找 | 通过 id 计算槽位索引,检索IDR树 | O(1) |
权限校验 | 检查 kern_ipc_perm 中的 mode 和 uid/gid 字段 | O(1) |
防ID冲突 | seq 计数器每次分配后递增,确保旧ID无法被新资源复用 | — |
3. 多态统一管理
- 基类抽象:
kern_ipc_perm
包含所有资源的共性属性(Key、UID、权限等),实现描述符标准化 。 - 子类扩展:
资源专属属性(如共享内存大小shm_segsz
)存储在子结构体中,通过首地址一致性访问 。
三、内核版本演进对比
特性 | 旧内核(ipc_id_ary) | 新内核(IDR树) | 优势 |
---|---|---|---|
容量管理 | 静态数组,需预设 size | 动态扩容,无上限 | 避免资源耗尽 |
ID分配策略 | 线性递增,回绕复用(id % size ) | 全局唯一ID(索引+序列号) | 彻底解决ID冲突 |
内存开销 | 每个槽位固定占用指针空间(即使为空) | 仅存储非空指针,稀疏场景省内存 | 节省50%+内存 |
查找效率 | 数组下标直接访问(O(1)) | IDR树哈希检索(O(1)) | 维持高效性 |
代码维护 | 需手动处理回绕逻辑 | 内核通用IDR API管理 | 降低复杂度 |
注:Linux 4.0+ 已全面采用IDR树方案 。
四、典型工作流程示例
以创建共享内存为例:
- 分配资源描述符
内核创建struct shmid_kernel
,其首部shm_perm
(kern_ipc_perm
类型)初始化权限和Key。 - 存储至柔性数组
- 旧内核:将指针存入
ipc_id_ary.p[id]
,id
为数组下标。 - 新内核:调用
idr_alloc()
在IDR树中分配槽位,生成唯一ID。
- 旧内核:将指针存入
- 用户层返回ID
将内核ID(如0x10023
)返回给用户进程,作为shmget
返回值。 - 进程访问资源
进程调用shmat(id)
时:- 内核用
id
从柔性数组/IDR树中检索到kern_ipc_perm*
。 - 强转为
shmid_kernel*
,完成内存映射 。
- 内核用
注意:柔性数组中存储的是所有申请的IPC资源(包括共享内存,消息队列,信号量,不单单只存储其中某一个),而柔性数组下标就是我们用户层拿到的ID。这其实和文件描述符表有异曲同工之妙。
五、设计思想总结
- 以空间换统一
通过基类首地址一致性,以零拷贝转换实现三类资源的多态管理。 - 以动态性换扩展性
IDR树替代静态数组,突破资源数量上限,适应大规模应用场景。 - 以冗余数据换安全性
seq
序列号虽增加ID长度,但彻底杜绝了ID重用导致的安全风险 。 - 面向硬件优化
指针数组连续存储,利用CPU缓存局部性提升访问效率 。
一张图总结: