目录
- 开头
- 一.管道
-
- 1.匿名管道
-
- (1)单工/半双工/全双工
- (2)匿名管道
- [(3)pipe() 系统调用](#(3)pipe() 系统调用)
- (4)4种通信情况
- 2.进程池------基于匿名管道
- 3.命名管道
-
- (1)相关属性
- [(2)mkfifo 创建命名管道](#(2)mkfifo 创建命名管道)
- [二.system V](#二.system V)
-
- 1.共享内存
-
- (1)介绍
- (2)相关接口
-
- [a.shmget:创建 / 获取共享内存段](#a.shmget:创建 / 获取共享内存段)
- [b. shmat:挂载(映射到进程地址空间)](#b. shmat:挂载(映射到进程地址空间))
- c.shmdt:卸载(解除映射)
- d.shmctl:控制与管理
- (3)共享内存代码封装
- 2.消息队列
-
- (1)介绍
- (2)相关接口
-
- [a.msgget:创建 / 获取消息队列](#a.msgget:创建 / 获取消息队列)
- b.msgsnd:发送消息
- c.msgrcv:接收消息
- [d. msgctl:控制与管理](#d. msgctl:控制与管理)
- (3)代码展示
- 3.信号量
-
- (1)介绍
- (2)相关接口
-
- [a.semget:创建 / 获取信号量集](#a.semget:创建 / 获取信号量集)
- [b.semop:执行 PV 操作](#b.semop:执行 PV 操作)
- c.semctl:控制与管理
- 三.内核是如何组织管理IPC资源的
- 结尾
- 往期回顾
开头
ok了,今天我们继续高强度的学习Linux内容,如标题所见,这一期我们要一起学习的是Linux进程间通信的相关知识,包含匿名管道/命名管道,以及system V ,内容较长,废话不多说我们直接开始!
之前我们就学习过,在Linux系统中,进程是有独立性的,每个进程的用户地址空间完全隔离,一个进程的全局变量、堆空间在另一个进程中无法直接访问,但为了实现多进程协同工作、数据交互,操作系统提供了多种进程间通信==(Inter-Process Communication, IPC)==机制
进程间通信的核心本质:让不同的进程能够看到同一份由内核管理的 "公共资源"
一.管道
1.匿名管道
(1)单工/半双工/全双工
按照「数据传输方向」和「能否同时双向传输」,通信模式可分为三个层级,从弱到强依次是:单工 → 半双工 → 全双工
- 单工(Simplex)
数据只能沿一个固定方向传输,永远不能反向。一端是纯发送方,一端是纯接收方,角色不可互换 - 半双工(Half-Duplex)
数据可以双向传输,但同一时刻只能有一个方向在传输
通信双方分时复用同一个传输通道:A 发送时 B 只能接收,B 发送时 A 只能接收,不能同时发也不能同时收,需要切换方向 - 全双工(Full-Duplex)
数据可以在同一时刻双向同时传输
相当于在物理上或逻辑上拥有两条独立的单向通道,一条负责 A→B,一条负责 B→A,两条通道互不干扰,收发可以并行进行

(2)匿名管道
匿名管道是一种半双工的内存通信通道,仅能在具有亲缘关系的进程(父子、兄弟、祖孙进程)之间使用,同一时间数据只能单向流动。如果需要双向通信,必须创建两个独立管道,分别负责两个方向的数据传输
- "匿名":它没有文件系统路径,完全存在于内核内存中,只能通过继承文件描述符的方式被关联进程访问,外部进程无法定位和访问
- "管道":数据像水流一样单向传输,从一端写入、另一端读出,遵循 FIFO(先进先出)原则

如上图,匿名管道依赖于父子进程,前面我们讲过进程间通信的本质其实就是不同进程看到同一份"资源",那在匿名管道中,实现父子进程间通信就是两者同时看到同一个管道文件
管道文件分为两端:读端和写端
父进程首先创建一个管道文件,有两个文件描述符分别指向管道文件的读端和写端,随后fork创建的子进程的两个文件描述符也同样指向同一份管道文件的读端和写端,这样就实现了不同进程看到同一份资源,例如现在我们关闭父进程的写端,关闭子进程的读端,这样父进程就能与子进程进行通信,父进程就能读到子进程向管道文件写入的信息
(3)pipe() 系统调用
创建匿名管道使用pipe系统调用:

- 功能:在内核中创建一个匿名管道,返回两个文件描述符
- 参数 :fd为输出型参数,是大小为 2 的整型数组
pipefd0 存放读端 文件描述符
pipefd1 存放写端文件描述符 - 返回值:成功返回 0;失败返回 -1 并设置 errno
标准使用四步流程
匿名管道必须结合 fork() 使用 ------ 只有子进程会继承父进程的文件描述符,从而访问同一个内核缓冲区。标准流程:
-
父进程创建管道:调用 pipe() 获得一对读写文件描述符
-
fork 创建子进程:子进程继承父进程的两个 fd,此时父子进程都持有同一管道的读写端
-
关闭闲置文件描述符 :根据通信方向,父子进程各自关闭不需要的一端
父写子读:父进程关闭读端 pipefd0,子进程关闭写端 pipefd1
子写父读:子进程关闭读端 pipefd0,父进程关闭写端 pipefd1

-
执行读写通信:双方通过保留的 fd 进行 read/write,通信完成后关闭剩余 fd
问题:为什么必须关闭闲置文件描述符???
这是管道编程最核心的易错点,根源在于管道的内核引用计数机制:
- 只有当所有写端文件描述符都被关闭时,读端的 read() 才会返回 0(即 EOF,通信结束)
- 只有当所有读端文件描述符都被关闭时,写端的 write() 才会触发 SIGPIPE 信号,通知写进程通信异常
如果闲置的 fd 不关闭,引用计数就不会归零:
- 写进程写完数据后,读进程会永远阻塞等待,收不到 EOF
- 读进程关闭后,写进程感知不到异常,不会触发 SIGPIPE
最终导致程序死锁或逻辑异常

(4)4种通信情况
管道的四种通信场景是面试高频考点,对应读写两端不同状态的表现:
- 写慢读快
写端写入速度慢,读端读取速度快。管道为空时,读端read阻塞等待,直到写端写入数据。体现了管道自带的同步机制 - 写快读慢
写端写入速度快,读端读取速度慢。管道缓冲区被写满后,写端write阻塞等待,直到读端读走数据腾出空间 - 写端关闭,读端继续读
所有写端描述符都关闭后,读端read会返回 0,表示读到文件结尾,进程可据此退出读取循环 - 读端关闭,写端继续写
所有读端描述符都关闭后,继续写入没有意义,内核向写进程发送SIGPIPE信号,默认终止写进程。这就是 "读端关闭,写端异常退出" 的底层原因
2.进程池------基于匿名管道
上面我们学习匿名管道时,一个父进程对应一个子进程,那有没有可能一个父进程对应多个子进程,同时拥有多个管道文件呢??

如上图,这就相当于创建了一个进程池
进程池是典型的池化技术:程序启动时预先创建一批子进程常驻内存,有任务时直接分配给空闲子进程执行,避免了频繁 fork/exit 进程的开销,同时控制了并发进程总数,防止系统资源耗尽
接下来我们就使用匿名管道的知识来实现下进程池
核心通信机制:每个子进程对应一条独立的匿名管道
- 父进程持有所有管道的写端,负责向子进程发送任务码
- 每个子进程持有自己管道的读端,阻塞等待任务,收到后执行对应函数
- 通信方向:父 → 子 单向传输,子进程执行结果不回传给父进程
我们依据"先描述,再组织"的原则,我们先写一个大体框架

如上图,其中 Channel 类就是对管道的描述,而 Channel_Mannger 则是通过 vector 完成对 Channel 的组织,因为一个父进程要对应多个子进程,在类 processpool 中用一个 _num 变量记录数量
下面我们就要来实现下 Create 函数,目的是创建对应 _num 数量的子进程

如上图,这里就通过 for 循环创建对应数量的子进程以及对应的管道,其中_cm.BuildChannel() 这个函数,目的是要创建含有对应信息的 Channel 管道

BuildChannel() 函数中调用了 emplace_back() 构建了 Channel 管道
接下来我们来完成 Work() , Run() 这两个函数
其中 Work() 函数就是子进程要对应完成的操作,而 Run() 则是父进程要选择一个子进程,然后将任务码(taskcode)发送给目标子进程,子进程拿到一个taskcode,完成对应的操作


我们可以试着先运行下main.cpp

可以看到,程序却是是采用轮询策略进行接收任务码
上面的写法是传入了一个固定的任务码,我们还可以创建一个 tack.hpp

在 task.hpp 文件中,我们使用到了前面第23期学习篇C++11 的新特性------function 包装器,通过 vector 数据结构,里面存放着就是一个个函数包装器,通过下标就能访问目标函数

我们现在实现的进程池就差最后一步,实现 stop 函数
先关闭所有管道的写端,向所有子进程发送 EOF 退出信号,再逐个 waitpid 等待子进程退出,彻底回收资源,避免僵尸进程

至此,我们的进程池就实现完成了
可是真的结束了吗?????
没有!其实这份代码隐藏了一个很深的Bug!!!不知道同学们有没有发现
那是什么Bug呢?------问题出在 fork 的写时拷贝中

如上图,当我们创建第二个子进程时,写时拷贝会完整的把父进程的文件描述符表给拷贝下来,其中下标会指向此前创建的管道,这样我们关闭管道文件的时候,引用计数减不到0,所有子进程的 read() 继续永久阻塞挂起,永远不会退出循环,父进程接着执行 WaitSubProcess(),调用 waitpid() 等待子进程退出,也永久阻塞
对于这个Bug 有两种解决方法:
1.倒着关
我们可以从后往前关闭管道

2.手动关闭子进程中多余的指向
当我们创建一个新的子进程时,我们可以手动关闭父进程曾经打开的写端关闭,而父进程曾经打开的写端不就存放在 _channls 数组中Channel元素的 _wfd 私有成员吗

完整代码:
processpool.hpp
cpp
#include<iostream>
#include<string>
#include<unistd.h>
#include<vector>
#include<sys/wait.h>
#include"task.hpp"
class Channel
{
public:
Channel(int wfd,int subid)
:_wfd(wfd) , _subid(subid)
{
_name="Channel-" +std:: to_string(_wfd) + std:: to_string(-subid);
}
int wfd() { return _wfd; }
pid_t subid() { return _subid; }
std::string name() { return _name; }
void Send(int taskcode)
{
int n=write(_wfd,&taskcode,sizeof taskcode);
(void)n;
}
void Close()
{
close(_wfd);
}
void Wait()
{
pid_t wid=waitpid(_subid,nullptr,0);
(void)wid;
}
private:
int _wfd;
pid_t _subid;
std:: string _name;
};
class Channel_Mannger
{
public:
void Insert(int wfd,pid_t subid)
{
_channels.emplace_back(wfd,subid);
//Channel ch(wfd,subid);
//_channels.push_back(std::move(ch));
}
void Print_Channel()
{
for(auto& e: _channels)
{
std:: cout<< e.name() << std:: endl;
}
}
Channel& Select()
{
auto& c=_channels[_next%_channels.size()];
_next++;
return c;
}
void StopSubProcess()
{
for(auto& c: _channels)
{
c.Close();
std:: cout<< "关闭了一个子进程" << c.name()<< std:: endl;
}
// for(int i=_channels.size()-1;i>=0;i--)
// {
// _channels[i].Close();
// }
}
void WaitSubProcess()
{
for(auto& c: _channels)
{
c.Wait();
std:: cout<< "等待了一个子进程" << c.name()<< std:: endl;
}
}
private:
std::vector<Channel> _channels;
int _next=0;
};
class processpool
{
public:
processpool(int num=5)
: _num(num)
{
_tm.Register(print);
_tm.Register(upload);
_tm.Register(send);
}
void Work(int rfd)
{
//std:: cout<< rfd << std:: endl;
while(true)
{
int code=0;
ssize_t n=read(rfd,&code,sizeof code);
if(n>0)
{
if(n!=sizeof code) continue;
std:: cout<< "子进程[" << getpid() <<"]收到一个任务码:" << code << std:: endl;
_tm.Execute(code);
}
else if(n==0)
{
std:: cout<< "子进程退出" << std:: endl;
break;
}
else
{
std:: cout<< "读取错误" << std:: endl;
break;
}
}
}
bool Create()
{
for(int i=0;i<_num;i++)
{
int pipefd[2]={0};
int n=pipe(pipefd);
if(n<0)
{
perror("create pipe");
return false;
}
pid_t subid =fork();
if(subid<0)
{
perror("create fork");
return false;
}
else if(subid==0)
{
//子进程 r
for(auto& c:_cm)
{
close(c._wfd);
}
close(pipefd[1]);
Work(pipefd[0]);
close(pipefd[0]);
exit(0);
}
else
{
//父进程 w
close(pipefd[0]);
_cm.Insert(pipefd[1],subid);
}
}
return true;
}
void Run()
{
//拿到一个任务码
int taskcode=_tm.Code();
//选择一个信道,负载均衡的选择一个进程,完成任务
auto& c=_cm.Select();
std:: cout<< "选择了一个子进程:" << c.name() << std:: endl;
c.Send(taskcode);
}
void stop()
{
_cm.StopSubProcess();
_cm.WaitSubProcess();
}
void Debug()
{
_cm.Print_Channel();
}
private:
Channel_Mannger _cm;
int _num;
TaskMannger _tm;
};
task.hpp
cpp
#include<iostream>
#include<functional>
void upload()
{
std:: cout<< "我要执行下载任务" << std:: endl;
}
void print()
{
std:: cout<< "我要执行打印任务" << std:: endl;
}
void send()
{
std:: cout<< "我要执行传输任务" << std:: endl;
}
class TaskMannger
{
public:
int Code()
{
return rand()%_tm.size();
}
void Register(const std::function<void(void)>& task_func)
{
_tm.push_back(task_func);
}
void Execute(int taskcode)
{
_tm[taskcode]();
}
private:
std:: vector<std::function<void(void)>> _tm;
};
3.命名管道
命名管道(Named Pipe),又称 FIFO(First In First Out,先进先出),是匿名管道的扩展形态。上面我们学习的匿名管道依赖于父子进程的关系,而命名管道继承了管道的内核缓冲区机制,同时通过文件系统路径突破了 "仅亲缘进程通信" 的限制,支持任意进程间的单向数据传输
(1)相关属性
命名管道是一种存在于文件系统中的特殊文件类型,文件类型标记为 p(管道文件)
- 「命名」:拥有真实的文件系统路径和文件名,外部进程可以通过路径定位到它,这是和匿名管道最核心的区别
- 「FIFO」:数据严格遵循先进先出原则,写入顺序与读取顺序完全一致,和匿名管道的数据特性完全相同
核心本质:文件入口 + 内核缓冲区
命名管道的本质可以拆成完全独立的两层,这是理解它所有特性的关键:
1.文件系统层(外壳) :磁盘上仅保留一个目录项与 inode 节点,不存储任何业务数据,文件大小永远为 0。它只是一个「访问入口标识」,作用是让不同进程找到同一个内核缓冲区
2.内核层(本体):和匿名管道完全一致,是内核在内存中开辟的环形缓冲区,所有读写操作都在内存中完成,不涉及磁盘 IO,性能与匿名管道持平
一句话总结:命名管道 = 匿名管道的内核缓冲区 + 文件系统的访问入口。底层通信机制和匿名管道完全相同,只是多了一个文件系统的「名字」来让无亲缘进程找到它

半双工通信属性
和匿名管道完全一致,单个命名管道是半双工模型:
- 单个管道只能单向传输数据,一端写入、另一端读出。
- 若需要双向通信,必须创建两个方向相反的命名管道,分别负责 A→B 和 B→A 两个方向
- 不支持全双工,也不支持随机读写,只能顺序读写
命名管道 vs 匿名管道 核心对比

(2)mkfifo 创建命名管道
命名管道支持命令行与函数两种创建方式
命令行创建

函数创建

- filename:命名管道的文件路径
- mode:文件权限,与open的 mode 参数一致,例如0644,最终权限会受系统 umask 掩码影响,实际权限 = mode & ~umask
- 返回值:成功返回 0,失败返回 - 1
删除命名管道使用unlink系统调用,或命令行rm
代码展示:
server.cpp

client.cpp

commen.hpp

如上图,其实就是两个进程打开了同一个文件,看到了同一份资源,就是基本的文件操作
二.system V
System V IPC(也叫 XSI IPC)是 AT&T System V 版本 Unix 引入的经典进程间通信标准 ,也是 Linux 原生支持的内核级 IPC 机制,它包含三个独立子系统:信号量 、共享内存 、消息队列,三者拥有高度一致的编程范式、相同的内核持久化特性、统一的 Key 标识体系
System V IPC 采用「外部键值 + 内部标识符」的双层命名机制,和文件系统的「路径 + 文件描述符」设计思想高度同源:
- Key(键值) :key_t 类型的 32 位整数,是 IPC 对象的公开名字,在用户级创建。不同进程通过同一个 Key 找到同一个内核对象
- ID(标识符):非负整数,是内核对象的操作句柄,作用类似文件描述符。进程创建 / 打开 IPC 对象后获得 ID,后续所有操作都通过 ID 完成
Key 生成:ftok () 函数
Key 不能随意指定,标准做法是通过 ftok() 基于文件系统路径生成,保证不同进程可复现同一个 Key

• pathname :一个必须存在且可访问的文件 / 目录路径,ftok 基于该文件的 inode 号、设备号生成 Key
• proj_id :项目 ID,仅低 8 位有效(取值 1~255),用于同一路径下区分多个 IPC 对象(任意值)
• 返回值:成功返回 key_t 键值;失败返回 -1 并设置 errno
ipcs: 查看IPC对象
分类查看:
ipcs -m: 只查看所有共享内存段ipcs -q: 只查看所有消息队列ipcs -s: 只查看所有信号量集ipcs: 不加参数,默认显示全部三类 IPC 的摘要信息
ipcrm: 删除 IPC 对象
支持按 ID 删除(推荐,更精准)和按 Key 删除

1.共享内存
(1)介绍
System V 共享内存是所有本机进程间通信中速度最快的方案
它的本质是:内核在物理内存中开辟一块独立的内存区域,允许多个进程将这块物理内存映射到自身的虚拟地址空间中。映射完成后,进程直接读写自己的虚拟地址指针,就等同于操作这块共享的物理内存,全程不需要内核中转数据,也没有额外的内存拷贝

⚠️ 天生缺陷:共享内存本身没有任何同步与互斥机制,多个进程同时读写会出现数据竞争、内容撕裂,必须配合信号量、互斥锁等同步工具一起使用
(2)相关接口
a.shmget:创建 / 获取共享内存段
作用:创建一个新的共享内存段,或者打开一个已有的共享内存段,返回共享内存 ID

- key:ftok 生成的键值;也可以传 IPC_PRIVATE,创建私有共享内存(仅亲缘进程可用)
- size:共享内存段的大小,单位字节。内核会按 4KB 页大小向上对齐;如果是打开已有共享内存,可传 0
- shmflg :操作标志 + 权限位
IPC_CREAT:不存在------>创建 , 存在------>打开
IPC_CREAT | IPC_EXCL:不存在------>创建 ,存在------>报错 - 返回值:成功返回共享内存 ID(shmid);失败返回 -1
b. shmat:挂载(映射到进程地址空间)
作用:将共享内存段映射到当前进程的虚拟地址空间,返回的是起始虚拟地址

- shmid:shmget 返回的共享内存 ID
- shmaddr:指定映射的虚拟地址,推荐传 NULL,让内核自动选择安全可用的地址,兼容性最好
- shmflg :挂载标志
0:默认模式,映射后内存可读可写
SHM_RDONLY:以只读方式挂载
SHM_RND:配合自定义地址使用,按页边界对齐 - 返回值:成功返回映射后的起始虚拟地址指针;失败返回 (void*)-1
c.shmdt:卸载(解除映射)
作用:解除当前进程与共享内存的映射关系,之后进程不能再访问这块内存

- shmaddr:shmat 返回的虚拟地址指针
- 返回值:成功返回 0;失败返回 -1
⚠️ 关键注意:shmdt 只是断开当前进程的映射,不会删除共享内存本身,内核中的物理内存依然存在,其他挂载的进程可以正常使用
d.shmctl:控制与管理
作用:获取状态、修改属性、删除共享内存段

-
shmid:共享内存 ID
-
cmd :操作命令,常用命令:
IPC_STAT:获取共享内存的状态信息,写入struct shmid_ds* buf 指向的结构体IPC_SET:修改共享内存的权限、属主等属性IPC_RMID:标记删除共享内存 。不会立即销毁,等所有挂载进程都执行 shmdt 后 ,内核才真正释放物理内存SHM_LOCK:将共享内存锁定在物理内存,不被换出到 swap(需要 root 权限) -
buf:状态结构体指针,存放属性信息
-
返回值:成功返回 0;失败返回 -1
(3)共享内存代码封装
下面我们就基于共享内存的调用接口来实现一下对共享内存的封装

首先我们先来实现一下 Create() 函数,目的是完成对共享内存的创建工作

如上图,Create() 函数中完成了 key 值的生成,以及申请了共享内存,这里的报错是一个新的用法,不用每次都写报错信息,可以写一个总的宏函数
对于其中一个进程,例如 server端,我们可以调用 Create() ,因为 Create() 函数已经创建好了一块共享内存,那么对于 client端,只需要找到并打开就行了,所以我们再写一份 Get() 函数

如上图,我们会发现 Get() 和 Create() 两个函数就是在 shmget 函数调用的时候 flag不同,那么我们就可以做进一步的封装

如上图,在 Shm 的构造函数中,我们完成了 usertype 的匹配,不同的 usertype 完成不同的行为
现在我们申请好了一份共享内存,就需要把他挂载到进程的地址空间中,使用 shmat()

然后将 Attach() 添加到构造函数中就完成了挂载操作
与之对应的就是去关联 Detach()

为了拿到申请到的共享内存的起始虚拟地址,我们也可以提供函数接口

最后就来写一下析构函数

代码总实现:
cpp
#include<iostream>
#include<sys/types.h>
#include<sys/ipc.h>
#include<sys/shm.h>
#include<unistd.h>
const std::string pathname=".";
const int gmode=0666;
const int projid=49;
#define CREATER "CREATER"
#define USER "USER"
#define ERR_EXIT(m) \
{\
perror(m); \
exit(EXIT_FAILURE);\
}
class Shm
{
private:
void CreateHelper(int flag)
{
int k=ftok(pathname.c_str(),1);
_shmid=shmget(k,_size,flag);
if(_shmid<0)
{
ERR_EXIT("shmget");
}
std:: cout<< "shmget sucess" <<std:: endl;
}
void Create()
{
CreateHelper(IPC_CREAT | IPC_EXCL | gmode);
}
void Get()
{
CreateHelper(IPC_CREAT);
}
void Attach()
{
_start_mem=shmat(_shmid,nullptr,0);
if((long long)_start_mem < 0)
{
ERR_EXIT("shmat");
}
std:: cout<< "Attach sucess" << std:: endl;
}
void Detach()
{
int n=shmdt(_start_mem);
if(n==0)
{
printf("detach sucess\n");
}
}
public:
Shm(const std::string& pathname,const std:: string& usertype)
:_usertype(usertype)
{
int key=ftok(pathname.c_str(),projid);
if(key<0)
{
ERR_EXIT("ftok");
}
if(_usertype=="CREATER")
{
Create();
}
else if(_usertype=="USER")
{
Get();
}
else
{
ERR_EXIT("usertype");
}
Attach();
}
void* VirtualAddress()
{
printf("VirtualAddress:%p",_start_mem);
return _start_mem;
}
void Destroy()
{
if(_shmid!=-1)
{
Detach();
int n=shmctl(_shmid,IPC_RMID,0);
if(n<0)
{
ERR_EXIT("shmctl destroy err");
}
printf("destroy sucess\n");
}
}
~Shm()
{
Destroy();
}
private:
int _shmid=-1;
int _size=4096;
void* _start_mem=nullptr;
std:: string _usertype;
};

这是接口的使用
2.消息队列
(1)介绍
System V 消息队列的本质是内核维护的一条链式消息队列。发送进程向队尾追加消息,接收进程从队头读取消息,这一部分选学即可

为了区分同一个消息队列中不同消息的"来源" ,是进程 A 发的,还是进程B发的,我们会使用整型来进行区分,为了让不同进程看到同一个消息队列,依旧需要在用户态来确定一个 key 值
使用消息队列必须自定义消息结构体,且有强制格式要求:第一个字段必须是 long 类型的消息类型,后面是自定义的消息数据

• mtype:消息类型,正整数,用于分类和筛选
• mtext:消息载荷,单条消息最大长度受系统限制(默认 8192 字节)
(2)相关接口
a.msgget:创建 / 获取消息队列
作用:创建新队列或打开已有队列,返回队列 ID

- key:ftok 生成的键值,或 IPC_PRIVATE
- msgflg :操作标志 + 权限位,规则和 shmget 完全一致
IPC_CREAT:不存在------>创建 , 存在------>打开
IPC_CREAT | IPC_EXCL:不存在------>创建 ,存在------>报错 - 返回值:成功返回消息队列 ID(msqid);失败返回 -1
b.msgsnd:发送消息
作用:向消息队列尾部追加一条消息

- msqid:消息队列 ID
- msgp:指向自定义消息结构体的指针
- msgsz :消息数据部分的字节数,也就是 mtext 的长度,不包含 mtype 的大小
- msgflg :发送标志
0:默认阻塞模式,队列满时进程阻塞等待
IPC_NOWAIT:非阻塞模式,队列满时立即返回 -1,errno = EAGAIN - 返回值:成功返回 0;失败返回 -1
c.msgrcv:接收消息
作用:从消息队列中读取一条消息,核心支持按类型筛选

-
msgid:消息队列 ID
-
msgp:接收缓冲区指针
-
msgsz:缓冲区数据区的最大容量
-
msgtyp :读取类型,有三种语义:

-
msgflg :接收标志
0:默认阻塞,没有对应消息时进程阻塞等待IPC_NOWAIT:非阻塞,无对应消息立即返回 -1MSG_NOERROR:消息超长时自动截断,不报错;不设置则超长时报错返回 -
返回值:成功返回实际读取的数据字节数;失败返回 -1
d. msgctl:控制与管理
作用:获取队列状态、修改属性、删除队列

- msgid:消息队列 ID
- cmd :常用命令
IPC_STAT:获取队列状态信息
IPC_SET:修改队列权限、属主
IPC_RMID:立即删除消息队列,所有阻塞在读写上的进程会被唤醒并报错 - 返回值:成功返回 0;失败返回 -1
(3)代码展示

3.信号量
(1)介绍
在介绍信号量之前,我们要先了解几个概念:
- 共享资源:多个执行流(进程)看到的同一份资源
- 临界资源:被保护起来的共享资源
- 互斥与同步 :
互斥:保证同一时间只有一个进程进入临界区、访问临界资源
同步:多个执行流访问同一份资源时,按照一定的顺序性 - 临界区 :涉及访问互斥资源的代码部分
那信号量是什么东西???
信号量:本质上就是引用计数器,记录临界资源中子部分的总数
我们申请的资源并不是整块使用的,而是会被分成一块一块的区域

核心概念:P 操作与 V 操作
- P 操作(申请资源):信号量值减 1。如果减完后值 ≥ 0,操作成功,进程继续执行;如果值 < 0,进程阻塞挂起,等待资源释放
- V 操作(释放资源):信号量值加 1。如果有进程在等待该资源,内核会唤醒其中一个等待进程
(2)相关接口
a.semget:创建 / 获取信号量集
作用:创建一个新的信号量集,或打开已有的信号量集

- key:ftok 生成的键值,或 IPC_PRIVATE
- nsems:信号量集中信号量的个数。创建时必须指定;打开已有对象可设为 0
- semflg :操作标志 + 权限位,规则和前两者完全一致
IPC_CREAT:不存在------>创建 , 存在------>打开
IPC_CREAT | IPC_EXCL:不存在------>创建 ,存在------>报错 - 返回值:成功返回信号量集 ID(semid);失败返回 -1
⚠️ 头号新手坑:创建出来的信号量初始值是未定义的,不会默认初始化为 0 或 1,必须手动调用 semctl 进行初始化
b.semop:执行 PV 操作
作用:对信号量集中的一个或多个信号量执行原子操作

- semid:信号量集 ID
- sops:操作数组,每个元素对应一个信号量的操作
- nsops:操作数组的元素个数
- 返回值:成功返回 0;失败返回 -1
核心操作结构体 struct sembuf:

sem_op 三种取值语义:
-
0:V 操作,信号量值加上该数值,释放对应数量的资源
- < 0:P 操作,信号量值减去该数值的绝对值。资源足够则成功;不足则阻塞
- == 0:等待信号量值变为 0,常用于等待所有资源全部释放
c.semctl:控制与管理
作用:初始化、读取值、删除信号量集。第四个参数是可选联合体,必须由用户手动定义 ,glibc 不提供默认定义

- semid:信号量集 ID
- semnum:操作第几个信号量;集合级操作(如 IPC_RMID)忽略此参数
- cmd :操作命令,常用命令:

- 第四个参数:可变参数,通常为 union semun 联合体,必须自己定义:

- 返回值:成功返回对应值(如 GETVAL 返回信号量值);失败返回 -1
三.内核是如何组织管理IPC资源的

这张图就放着里,看不懂很正常,下面我具体解释下

如上图,在struct ipc_ids 中存放着指向 struct ipc_id_ary 这个数组的指针,这个数组中的 struct kern_ipc_perm* p[0] 为柔性数组
我们学的共享内存、消息队列、信号量这三者的结构的中,首个元素即为 struct kern_ipc_perm 对象,那么 struct ipc_id_ary 中的指针数组就可以通过指向共享内存、消息队列、信号量其中的首元素而找到一个个对象,进而实现了对不同 IPC 的管理,这就是C语言实现的继承
结尾
好了,今天这一期就到这里了,如果对你有帮助,谢谢你的点赞