1、System-V 消息队列
1.1 消息队列的概念
有内核维护的一个队列容器,属于 System‑V IPC
消息队列是一种由操作系统内核维护的、支持双向通信的队列结构,进程A、进程B不直接互相拷贝数据,A把带类型的数据块(消息节点)丢进内核队列,B从队列读取消息,双向都可以收发
消息队列每个数据块(消息)必须包含一个类型字段(type),用于区分消息的发送者或类别,接收方可以根据类型有选择的接收消息
和管道的区别:
- 管道是字节流,没有边界,消息队列是数据块,一条一条独立消息
- 管道单向,消息队列队列全双工
- 消息节点自带消息类型
1.2 消息队列接口
1.2.1msgget:创建获取资源

- key:ftok生成,用来让不同进程找到同一个消息队列
- flag:
IPC_CREAT:不存在则创建;存在直接获取,IPC_CREAT | IPC_EXCL:不存在创建;存在返回出错,保证新建 - 返回值:
msgid,消息队列用户标识符
1.2.2 msgctl:控制队列(删除/获取属性)

- msqid:消息队列ID,是
msgget()调用成功后返回的整形标识符 - op:控制命令,告诉内核要做什么操作
- buf:
struct msqid_ds *结构体指针,op命令是IPC_STAT表示作为输出参数,内核把队列信息填充进这个结构体,IPC_SET表示作为输入参数,程序把要修改的属性填进去传给内核,IPC_RMID表示不需要结构体,直接传NULL - 返回值:成功返回0,失败返回-1
描述消息队列的结构体:


1.2.3 msgsnd:发送数据,msgrcv接收数据
用户自定义消息结构体模板:
cpp
struct msgbuf{
long mtype; //消息类型,必须>0
char mtext[1]; //消息数据,大小自定义
};

- msqid:表示消息队列的ID,msgget函数的返回值
- msgp:发送的数据块,是一个struct msgbuf结构体类型,用户可以自己定义
- msgsz:数据块的大小
- msgtyp:数据块类型
- msgflg:一般设置为0,消息队列满了就阻塞等待
2、信号量
2.1 并发
2.1.1 共享资源
多个执行流可以同时看见并且访问的公共资源,比如共享内存、显示器stdout、管道缓冲区等,但多个执行流并发访问共享资源会产生数据不一致,需要引入保护机制
2.1.2 互斥
任意时刻,只允许一个执行流访问共享资源,一个执行流访问期间,别的执行流不能进来打扰
2.1.3 临界资源和临界区
临界资源:被互斥机制保护起来的共享资源被称为临界资源
程序代码中,访问临界资源的那一部分代码被称为临界区
2.1.4 原子性
原子性指一个操作,要么完全做完,要么完全不做,不存在中间状态,中途不能打断,在计算机中,原子操作不会被CPU时间片打断,信号量内部的加减操作,必须是原子操作,是实现互斥的底层保障
2.2 信号量
信号量的本质是一个内核维护的计数器,用来统计可用临界资源的数量
举例:一个放映厅一共100个座位
- 放映厅相当于共享/临界资源
- 如果放入101人,必然两个人抢同一个座位,对应进程中:多个进程同时读写同一个共享资源,发生数据冲突,互相覆盖,程序出错
- 买票相当于申请信号量,买票的本质是对共享资源的预定机制,一旦购票成功即使未实际入座,该座位他人也无法使用,票卖完再来的人只能阻塞等待
类比信号量:
- 信号量初始值为100
- 申请资源为P操作,计数器--
- 释放资源V操作,计数器++
- 等待:P操作阻塞,进程挂起等待
所以信号量本质是一个内核维护的计数器,用来记录可用临界资源的数量,核心作用:资源预定机制
信号量本身就是共享资源,信号量的申请是原子操作,信号量的释放也是原子操作
2.3 二元信号量
临界资源只有一份,计数器的值只有1和0,如果申请资源计数器从1变为0,释放资源操作计数器从0变成1。这种只有0和1两种状态的计数器叫做二元信号量
二元信号量的作用是实现互斥,同一时刻只允许一个进程进入临界区,保护整块共享资源,整体申请,整体释放
2.4 信号量的问题
信号量伪代码:
cpp
sem = 100; //初始100个资源
P() {
if(sem > 0) {
sem--; //买票成功
继续执行,访问临界资源;
} else {
进程阻塞等待; //票卖完,等别人释放
}
}
V() {
sem++; //看完电影,归还票
唤醒等待的进程;
}
为什么不能用普通int变量充当信号量:
- P操作和V操作不是原子操作,进程调度会打破逻辑循环
如果有两个进程A和B,sem=1,进程A判断sem大于0,判断后操作系统时间片到,进程A被切走,进程B上CPU,同样判断sem大于0,B进入临界区,切回进程A后,A执行后面的代码,sem变成-1,这会使A、B两个进程同时进入临界资源,互斥失效
- sem--C语言的代码底层汇编有三步
把内存中 sem 的值读到 CPU 寄存器;CPU 寄存器内部做自减;把计算结果写回内存。这三步中间的任意时刻进程都有可能被切换
- 多进程之间,普通全局变量无法共享
每个进程有独立虚拟地址空间。A 进程定义的全局 int 变量,B 进程看不到;就算父子进程,fork 之后发生写时拷贝,修改互不影响。
2.5 信号量的接口
2.5.1 semget:创建/获取信号量集

- key:键值,用
ftok()生成。多个进程使用相同 key,就能打开同一个内核信号量集,实现多进程看到同一个信号量 - nsems:信号量集里面信号量的个数,不是信号量初始值
- semflg:标志位
2.5.2 semctl:信号量集控制接口

- semid:semget 返回的 id
- semnum:要操作的信号量在集合里的下标;只用 1 个信号量就填 0
- op:命令
常用命令:
SETVAL:初始化某一个信号量的 sem_val,设置计数器初值,
需要传入联合体union semun

eg:把0 号信号量初始化为 1
cpp
union semun un;
un.val = 1;
semctl(semid, 0, SETVAL, un);
2.5.3 semop:执行 P、V 操作、申请释放信号量

- semid:信号量集 id
- sops:
struct sembuf结构体数组,可以一次批量操作多个信号量 - nsops:数组元素个数;只操作 1 个信号量填 1
struct sembuf 结构体:
cpp
struct sembuf {
unsigned short sem_num; //信号量下标,只用一个填0
short sem_op; //核心,操作数
short sem_flg; //标志位,一般填0
};
- sem_op:sem_op = -1 表示P操作,sem_op = 1 表示V操作
3、内核数据结构设计
3.1 三个 IPC 资源
共享内存:struct shmid_kernel
cpp
struct shmid_kernel /* private to the kernel */
{
struct kern_ipc_perm shm_perm; // IPC权限块
struct file * shm_file; // 关联shmem临时文件对象(内核私有!用户态看不见)
int id; // ipc id下标
unsigned long shm_nattch; // 当前attach挂载的进程计数
unsigned long shm_segsz; // 共享内存段大小(字节)
time_t shm_atim; // 最后shmat挂载时间
time_t shm_dtim; // 最后shmdt卸载时间
time_t shm_ctim; // 最后修改权限/属性时间
pid_t shm_cprid; // 创建该shm段的进程pid
pid_t shm_lprid; // 最后一次执行shmat/shmdt的进程pid
};
- 消息队列:
struct msg_queue
cpp
struct msg_queue {
struct kern_ipc_perm q_perm; // IPC权限块(key、uid、gid、mode)
time_t q_stime; // 最后一次 msgsnd 发送时间
time_t q_rtime; // 最后一次 msgrcv 接收时间
time_t q_ctime; // 最后修改队列属性时间
unsigned long q_cbytes; // 队列当前总字节数
unsigned long q_qnum; // 当前队列消息条数
unsigned long q_qbytes; // 队列允许最大总字节
pid_t q_lspid; // 最后执行msgsnd的pid
pid_t q_lrpid; // 最后执行msgrcv的pid
struct list_head q_messages; // 消息链表头,挂struct msg消息节点
struct list_head q_receivers; // 阻塞等待读消息的进程链表
struct list_head q_senders; // 阻塞等待写消息的进程链表
};
- 信号量集:
struct sem_array
cpp
struct sem_array {
struct kern_ipc_perm sem_perm; /* IPC权限 */
time_t sem_otime; /* 最后一次semop操作时间 */
time_t sem_ctime; /* 最后修改权限时间 */
unsigned long sem_nsems; /* 集合内信号量的个数 */
struct sem *sem_base; /* 指向数组:每个struct sem代表一个信号量 */
struct list_head sem_pending; /* 等待信号量的阻塞任务队列(等待条件满足) */
struct list_head sem_pending_alter; /* 等待修改信号量值的阻塞任务队列 */
};
这三个结构体都有一个共同的数据类型:struct kern_ipc_perm
cpp
struct kern_ipc_perm {
spinlock_t lock;
int delete;
key_t __key;
uid_t uid;
gid_t gid;
uid_t cuid;
gid_t cgid;
mode_t mode;
unsigned long seq;
void *security;
};
struct kern_ipc_perm用来存储 key、权限、uid/gid等
C 语言规则:结构体变量地址 = 结构体第一个成员的起始地址
于是内核设计一个指针数组,数组中存储kern_ipc_perm*
struct ipc_ids:
cpp
struct ipc_ids {
int in_use;
int max_id;
unsigned short seq;
unsigned short seq_max;
struct semaphore sem;
struct ipc_id_ary nullentry;
struct ipc_id_ary* entries;
};
不管是消息队列、共享内存、信号量对象,都把对象的地址直接赋值给数组指针
需要读取 key、权限:直接通过kern_ipc_perm*访问成员
需要访问资源特有的字段:把基类指针强制类型转换回原本完整结构体指针
这就是C 语言手写实现多态思想 :基类指针指向不同子类对象,kern_ipc_perm相当于基类shmid_kernel/msg_queue/sem_array 相当于子类,内核用一个统一数组,管理全部三类 IPC 资源
cpp
struct ipc_id_ary {
int size;
struct kern_ipc_perm *p[NUM]; // p[] 就是图右侧蓝色方框数组
};
struct ipc_ids内部就持有这个ipc_id_ary ,用户层面上使用的shmid、msqid、semid等本质就是内核中数组p的下标