🌎进程间通信
文章目录:
进程间通信
进程间通信简介
进程间通信目的
初识进程间通信
进程间通信的分类
匿名管道通信
认识管道
匿名管道
匿名管道测试
管道的四种情况
管道的五种特性
管道的读写规则
命名管道
命名管道通信
命名管道打开规则
System V 共享内存
工作原理
共享内存接口
shmget接口
ftok接口
共享内存编码模拟
编码初步构建
删除共享内存
共享内存各个属性
共享内存正式代码
System V 消息队列
System V 信号量
信号量相关概念铺垫
信号量
信号量相关接口
System V 共享内存、消息队列、信号量的共性
🚀进程间通信简介
✈️进程间通信目的
- 数据传输:一个进程需要将它的数据发送给另一个进程
- 资源共享:多个进程之间共享同样的资源。
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
- 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
通过之前的学习,我们知道进程之间具有独立性,为了保持这个特性,所以进程之间不存在数据直接传递的情况。在许多场景下,需要进程之间相互配合,所以需要进程间通信。
✈️初识进程间通信
进程间通信最朴素的说法是,一个进程把数据交给另一个进程即可。而想要进程之间进行通信,必须保证每个进程的独立性。所以,在进程之间就需要一个交换数据的空间,并且该 空间(内存)不能由通信双方任何一个提供。
由此可知,进程间通信的本质是 先让不同的进程看到同一份资源(通常为操作系统提供)。而具体的做法如下几种。
✈️进程间通信的分类
操作系统提供的“空间” 有不同样式,就决定了有不同的通信方式,分为以下几种:
管道通信:
- 匿名管道pipe
- 命名管道
System V IPC:
- System V 消息队列
- System V 共享内存
- System V 信号量
POSIX IPC:
- 消息队列
- 共享内存
- 信号量
- 互斥量
- 条件变量
- 读写锁
🚀匿名管道通信
✈️认识管道
管道是Unix中最古老的进程间通信方式,我们把一个进程连接到另外一个数据流称为一个 “管道”。比如我们层学过的管道符号:‘|’。
在详细谈论管道的概念之前,先来回顾一下文件描述符与缓冲区:文件描述符表的前三位分别指向标注输入、标准输出、标准错误。进程自己创建的文件则从3号下标为初始点位。
如今我们使用open()接口分别以 ‘r’ 和 ‘w’ 的方式打开同一个文件,虽然是同一个文件,但是 操作系统会分配两个文件描述符分别指向同一个文件。
每个文件都有自己的缓冲区,每个文件在读写之前,都需要把数据从磁盘先加载到内存当中,再有内核加载到缓冲区中,而log.txt文件只有一份,所以,两个文件指向同一个缓冲区。
接着,父进程进行fork创建子进程,我们知道,子进程创建时会对父进程页表、文件描述符表等数据进行 浅拷贝,而他们指向的内存空间还是同一个。
有人会问,这跟进程间通信有什么关系,别忘了进程间通信的本质是 让不同进程看到同一份资源!而上述这种方式就做到了双方看到同一份资源,所以 管道 就是:基于文件的,让不同进程看到同一份资源方式 就是管道。
管道在设计时,为了让管道更简单,所以管道被设计为只能单向通信!所以我们可以把两个进程一个负责读数据,一个负责写数据,也就是设置读写端。假设父进程为reader,子进程为writer:
而为什么我们两个文件,一个为读端一个为写端这样设计,因为当父进程fork出子进程的时候,同时把文件描述符表也拷贝下来,这样父子进程的两个文件描述符都分别是读端和写端,这时候只需要父子进程禁用掉不同的一个端就可以构建管道通信了!
✈️匿名管道
操作系统不让用户直接操作管道文件,因为用户可能会造成权限问题、文件覆盖数据泄露等问题。所以给我们提供了一个用于管道通信的接口:
int pipe(int pipefd[2]);
- pipefd[2]:输出型参数,文件描述符数组,其中pipefd[0]表示读端, pipefd[1]表示写端。
- 返回值:成功返回0,失败返回错误代码。
pipe接口不需要向磁盘中刷新,且磁盘中并不存在的文件。通过调用pipe接口系统会 生成一个内存级的文件。这种文件没有文件名,所以也叫匿名文件、而这种使用方式则被称为 匿名管道!
那么匿名管道如何让不同进程看到同一份资源呢?原理就是有父进程创建子进程,子进程继承父进程的相关属性信息。通过相同的文件描述符表从而将两个进程联系起来。
- 匿名管道特点:只能与有血缘关系的进程来进行进程间通信。常常用于父子进程。
为了更加深刻理解匿名管道通信,我们站在文件描述符的角度来理解管道通信。因为管道通信需要有血缘关系的进程之间通信,所以无法避免的我们需要使用fork创建子进程来通信:
1.父进程创建管道文件
2.父进程fork出子进程
3.父进程关闭pipefd[0],子进程关闭pipefd[1]
✈️匿名管道测试
管道究竟该怎么使用,我们不妨编写一段代码熟悉一下,在编写之前,先确定几个事项,父子进程读写问题,这里我以父进程为w端,子进程为r端(相反也行)。
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/stat.h>
#include<sys/wait.h>
#include<sys/types.h>
void writer(int wfd)//写端调用
{
const char* str = "hello father, I am child";
char buffer[128];
int cnt = 0;
pid_t pid = getpid();
while(1)
{
snprintf(buffer, sizeof(buffer), "messge:%s, pid: %d, count: %d\n", str, pid, cnt);//向buffer内写入str
write(wfd, buffer, sizeof(buffer));//通过系统调用对管道文件进行写入
cnt++;
sleep(1);
}
}
void reader(int rfd)//读端调用
{
char buffer[1024];
while(1)
{
ssize_t n = read(rfd, buffer, sizeof(buffer) - 1);//系统文件与C语言没关系所以不算 '\0'
(void)n;//返回值用不到,避免警告,制造的假应用场景
printf("father get a message: %s", buffer);
}
}
int main()
{
// 创建管道
int pipefd[2];
int n = pipe(pipefd);
if(n < 0) return 1;
printf("pipefd[0]: %d, pipefd[1]: %d\n", pipefd[0]/*reader*/, pipefd[1]/*writer*/);
// fork子进程
pid_t id = fork();
if(id == 0)
{
// child w端
close(pipefd[0]);
writer(pipefd[1]);
exit(0);
}
//father r端
close(pipefd[1]);
reader(pipefd[0]);//通过系统调用 对管道文件读取
wait(NULL);
return 0;
}
整体的代码结构还是比较简单易懂的,我们通过循环脚本来监视代码,观察是否按预期运行:
✈️管道的四种情况
管道作为最古老的一种进程间通信方式,其优点与弊端也早就被程序员们挖掘出来了,我们来看看管道通信有哪些特性吧。
情况一:
还是上述匿名管道测试代码,子进程一直在写,父进程一直在读子进程写的数据,现在我们让子进程等待五秒之后再对管道文件进行写入:
那么问题就来了,在子进程休眠的这五秒期间,父进程在干吗?实际上,在子进程休眠的这5秒,父进程在等待子进程休眠结束,直到子进程再次写入数据时,父进程才会读取。
所以我们的 结论 就是:管道内部没有数据的时候,并且其中的写端不关闭自己的文件描述符时,读端就要进行阻塞等待,直到管道文件有数据。
情况二:
第二中情况,当写端一直在对管道文件进行写入,而读端却不再对管道文件(一直执行sleep)进行读取,我们修改写端接口如下:
void writer(int wfd)
{
const char* str = "hello father, I am child";
char buffer[128];
int cnt = 0;
pid_t pid = getpid();
while(1)
{
// snprintf(buffer, sizeof(buffer), "messge:%s, pid: %d, count: %d\n", str, pid, cnt);//向buffer内写入str
// write(wfd, buffer, sizeof(buffer));//通过系统调用对管道文件进行写入
char* ch = "X";
write(wfd, ch, 1);
cnt++;
printf("cnt: %d\n", cnt);
}
}
如果我们编译运行程序我们会发现,写端对管道文件一直写入一个字符,但是到了第65536个字符时却卡在这里了。
其实这个时候 写端在阻塞,这是因为我们写入的对象,也就是 管道文件 被写满了!从计数器我们可以看出一个管道文件的大小为 65536 个字节(ubuntu20.04)!也就是 64KB 大小!
注意:管道文件的大小依据平台的不同也各不相同。
所以我们得到的 结论 是:当管道内部被写满,且读端不关闭自己的文件描述符,写端写满之后,就要进行阻塞等待!
情况三:
当写端对管道文件缓冲区进行了有限次的写入,并且把写端的文件描述符关闭,而读端我们保持正常读取内容,读端多的仅仅把读端的返回值打印出来。
我们发现当10读取执行完成之后,就一直在执行读取操作,而我们读取使用的 read 接口的返回值却从0变为了1。我们接着用监视窗口来监视一下:
当写端写了10个数据之后将文件描述符关闭,那么读端进程就会变为僵尸状态。由此我们可以得出,read接口返回值的含义 是,当写端停止写入并关闭了文件描述符,read的返回值为0,正常读取的返回值 >0。
所以我们可以这样修改读端的代码:
void reader(int rfd)
{
char buffer[1024];
while (1)
{
ssize_t n = read(rfd, buffer, sizeof(buffer) - 1);
if (n > 0)
printf("father get a message: %s, ret: %ld\n", buffer, n);
else if (n == 0)
{
printf("read pipe done, read file done!\n");
break;
}
else
break;
}
}
所以我们就能得出 结论:
对于读端而言,当读端不再写入,并且关闭了pipe,那么读端将会把管道内的内容读完,最后就会读到返回值为0,表示读取结束,类似于读到了文件的结尾。
情况四:
我们把情况三最后的代码变换一下,读端读取改为有次数限制,并且读取一定次数之后关闭读的文件描述符,而写端无限制对管道文件写入,那么我们会看到什么现象呢?
而我们发现似乎也没什么不对啊?读取完之后不就直接退出了吗?你应该仔细想想,我们仅仅是关闭了读的文件描述符,但是没有关闭写的文件描述符啊。
这就是最后一个 结论:当读端不再进行读取操作,并且关闭自己的文件描述符fd,而写端依旧在写。那么OS就会通过信号(SIGPIPE)的方式直接终止写端的进程。
如何证明读端是被13号信号杀死的?我们采用的是父进程读子进程写的方式,也就是说将来子进程被杀死而父进程则可以通过wait的方式来获取子进程退出时的异常!
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if(rid == id)
{
printf("exit code : %d, exit signal : %d\n", WEXITSTATUS(status), status & 0x7F);
}
✈️管道的五种特性
根据管道的4种特殊情况,也就间接的创造了管道的5个特性,分别来认识管道的5种特性。
第一、二种:
根据情况一和情况二,两者结合来看,当管道文件有数据时读端就读,有空间写端就进行写入。而当管道缓冲区没有空间时,写端停止写入,当管道没有数据时,读端就不读了。
换句话说,父子进程(w 和 r)之间是具有明显的执行顺序的。父子进程之间会协调他们之间的步调。这样我们的第一个特性也就出来了:
-
特性一:父子进程(读写端)自带同步机制。
-
特性二:管道是以具有血缘关系的进程通信的,常见于父子关系。
第三种:
我们让写端一直向管道内写,而读端控制在特定时间内进行读取。也就是让写端一直写,读端间断读。
我们可以发现,写端在写满了之后就等待读端读取,当读取一部分之后写端就又会 从刚才停止的地方继续对管道内进行写入!
虽然写端写满了,但是为何读端一次性会读取那么多的数据呢?其实这个情况现在并不好解释,以后在学习网络时会有详细解读,这里我们只需要知道:
- 特性三:管道是面向字节流的。
第四种:
普通文件退出时,操作系统会自动释放掉这个文件,而我们管道文件也是文件,所以我们第四种特性就是:
- 特性四:父子进程退出,管道将会自动释放,这也就说明文件的声明周期是跟随进程的。
第五种:
其实最后一种我们潜移默化的已经知道了,从我们写的第一份管道代码起,管道的通信都是一个进程读一个进程写,所以我们的最后一种特性就是:
- 特性五:管道只能单向通信,并且管道通信是一种半双工的特殊情况。
全双工:数据可以在两个方向上同时传输,允许通信双方同时发送和接收数据。比如网络中 tcp 协议就是采用 全双工通信方式。
半双工:数据只可以在两个方向的其中一个方向上传输,但是不能两个方向都传输。比如我们日常对话就是半双工模式。
✈️管道的读写规则
当没有数据可读时:
- O_NONBLOCK disable:read调用阻塞,即进程暂停执行,一直等到有数据来到为止。
- O_NONBLOCK enable:read调用返回-1,errno值为EAGAIN。
当管道满的时候:
- O_NONBLOCK disable: write调用阻塞,直到有进程读走数据
- O_NONBLOCK enable:调用返回-1,errno值为EAGAIN
- 如果所有管道写端对应的文件描述符被关闭,则read返回0
- 如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能导致write进程退出。
- 当要写入的数据量不大于 PIPE_BUF 时,linux将保证写入的 原子性。
- 当要写入的数据量大于 PIPE_BUF 时,linux将不再保证写入的 原子性(原子性将在线程篇作详细解释)。
🚀命名管道
✈️命名管道通信
命名管道与匿名管道有什么区别,其实在名字上就可以看出来。命名管道的管道文件是有名字的,而不同的是,命名管道可以让不同的进程之间可以通信,让不同的进程看到同一份资源。
这里不同的进程不仅仅指有血缘关系的进程,没有血缘关系的进程依旧适用。要让两个进程之间进行通信,那么就必定需要让两个进程看到同一份资源!
而要打开管道文件,那么每个进程就必定要有对应的struct file结构体对象,但是OS不会让一个文件存在两个属性和两个重复的缓冲区,所以实际上 两个file的inode是同一个文件的inode,而它们的缓冲区也指向同一个缓冲区!
但是这样的话,怎么能保证两个不同的进程打开的是同一个文件呢?在平常我们是通过 文件路径 + 文件名 来找到文件的。而命名管道文件也是如此!
我们使用如下命令创建命名管道文件:
mkfifo pipe_name #创建命名管道文件
FIFO表示先进先出,而管道其实就是一种队列,它的字节流就是先进先出。管道文件在创建完成之后,我们在Shell中可以发现:
管道文件创建出来之后,OS甚至会在文件名后面加上 ‘|’ 来表示这是一个管道文件,并且在文件权限那里我们能够看到开头为 ‘p’,也表示pipe文件。
那么如何使用代码创建管道文件呢?我们来认识一个接口:
int mkfifo(const char*pathname, mode_t mode);
- pathname参数:需要生成管道文件的路径信息。
- mode参数:生成管道文件的权限位,受权限掩码的影响。
- 返回值:成功创建管道返回0,创建失败返回-1,并且设置错误码。
基于此,我们来写一个不同进程之间使用命名管道的简单通信:
Comm.hpp:
#ifndef __COM_HPP__
#define __COM_HPP__
#include <iostream>
#include <sys/types.h>
#include <cerrno>
#include <cstring>
#include <sys/stat.h>
#include <unistd.h>
#include <string>
#define Mode 0666 // 设置权限位
// 把管道通信封装为一个类
class Fifo
{
public:
Fifo(const std::string& path):_path(path)// 构造函数创建管道文件
{
umask(0);// 消除权限掩码的影响
int n = mkfifo(_path.c_str(), Mode);// 调用接口创建管道文件
if(n == 0)// 根据返回值做判断
{
std::cout << "mkfifo sucess" << std::endl;
}
else
{
// 创建失败则打印出错误信息并且导出错误码
std::cerr << "mkfifo failed, errno: " << errno << ", errstring: " << strerror(errno) << std::endl;
}
}
~Fifo()
{}
private:
std::string _path;
};
#endif
pipe_client.cpp:
#include "Comm.hpp"
int main()
{
std::cout << "hello client" << std::endl;
return 0;
}
pipe_server:
#include "Comm.hpp"
int main()
{
// 创建文件
Fifo fifo("./fifo");
sleep(1);
return 0;
}
makefile:
.PHONY:all #依次生成多个可执行程序,将all的依赖方法置空即可
all:pipe_client pipe_server
pipe_server:PipeServer.cc
g++ -o $@ $^ -std=c++11
pipe_client:PipeClient.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -f pipe_client pipe_server
我们执行了两次可执行程序,第二次就报错了,报错信息也打印出来了,报错原因是文件已经存在。如果我们想在代码里让创建的管道析构,那么可以调用下面接口:
int unlink(const char* pathname);
- pathname:需要删除的文件名+文件路径。
- 返回值:与mkfifo返回值含义相同。
~Fifo()
{
sleep(10);// 等待10s 再析构
int n = unlink(_path.c_str());// 删除管道文件
if(n == 0)
{
std::cout << "remove fifo file " << _path << " sucess" << std::endl;
}
else
{
std::cerr << "remove failed, errno: " << errno << ", errstring: " << strerror(errno) << std::endl;
}
}
这样文件就可以删除了,至此,我们就初步搭建好管道文件了,接下来就可以写通信的代码了。
这里我以 客户端为写端(writer),服务器端为读端(reader),并且由服务端创建好管道文件,那么代码编写如下:
pipe_client:
#include "Comm.hpp"
int main()
{
int wfd = open(PATH, O_WRONLY);// 客户端为writer,以只写的方式打开文件
if(wfd < 0)// 当wfd<0时打印错误信息
{
std::cerr << "open failed, errno: " << errno << ", errstring: " << strerror(errno) << std::endl;
return 1;
}
std::string inbuffer;
while(true)
{
std::cout << "Please enter your message# ";
std::getline(std::cin, inbuffer);// 从标准输入里获取信息到inbuffer里
// 消息为quit则退出
if(inbuffer == "quit") break;
// 发消息
ssize_t n = write(wfd, inbuffer.c_str(), inbuffer.size());// 对inbuffer数组进行写入操作
if(n < 0)// 当n < 0 时,我们需要将对应的报错信息打印出来
{
std::cerr << "write failed, errno: " << errno << ", errstring: " << strerror(errno) << std::endl;
break;
}
}
close(wfd);// 执行完毕,关闭文件描述符
return 0;
}
pipe_server:
#include "Comm.hpp"
int main()
{
Fifo fifo(PATH);// 创建管道文件
int rfd = open(PATH, O_RDONLY); // 服务端为读端以只读的方式打开文件
if(rfd < 0)// 文件打开失败,打印错误信息以及错误码
{
std::cerr << "open failed, errno: " << errno << ", errstring: " << strerror(errno) << std::endl;
return 1;
}
char buffer[1024];
while(true)// 一直对客户端进行读取
{
ssize_t n = read(rfd, buffer, sizeof(buffer) - 1);
if(n > 0)
{
buffer[n] = 0;
std::cout << "client say : " << buffer << std::endl;
}
else if(n == 0)
{
std::cout << "client quit, me too!" << std::endl;
break;
}
else// 读取文件失败时,打印错误信息
{
std::cerr << "read failed, errno: " << errno << ", errstring: " << strerror(errno) << std::endl;
break;
}
}
close(rfd);// 关闭文件fd
return 0;
}
完整的源代码戳这里:命名管道通信。
这里还有一个点需要注意,当仅仅运行服务器端时会卡在那里,这是因为 调用open接口的时候就会阻塞等待,直到写端对管道文件进行写入时 open 才会返回。
✈️命名管道打开规则
如果当前打开操作是为读(reader)而打开FIFO时:
- O_NONBLOCK disable:阻塞直到有相应进程为写而打开该FIFO
- O_NONBLOCK enable:立刻返回成功
如果当前打开操作是为写(writer)而打开FIFO时:
- O_NONBLOCK disable:阻塞直到有相应进程为读而打开该FIFO
- O_NONBLOCK enable:立刻返回失败,错误码为ENXIO
🚀System V 共享内存
✈️工作原理
首先我们要明白,共享内存是为了让进程之间进行通信,所以共享内存一定也遵守着 让不同进程看到同一份资源 的原则,而共享内存可以让毫不相干的进程之间进行通信。
当两个进程之间使用共享内存进行通信的时。首先,操作系统在内存中开辟一段物理空间作为 共享内存,然后在通过页表建立映射关系,将共享内存映射到进程地址空间的共享区。最后将 地址空间共享区映射位置的起始地址返回给用户。
于是用户就可以拿到虚拟地址,在经由页表映射到共享内存的起始地址。而不论是mm_struct(进程地址空间)还是页表,都属于内核数据结构,所以构建映射以及返回虚拟地址等操作都是由操作系统来完成的。
当两个进程都对同一块共享内存建立了映射关系,那么它们就可以 通过共享内存块来看到同一份资源,于是就满足进程间通信的条件。以上就是共享内存的工作原理。
✈️共享内存接口
🚩shmget接口
多说无益,码上见真章,在实现System V 共享内存的代码之前,我们需要先认识一个接口 shmget 用来 申请共享内存:
int shmget(key_t key, size_t size, int shmflag);
参数及返回值含义:
参数/返回值 | 含义 |
---|---|
key | 共享内存段的标识符,与进程id类似 |
size | 共享内存大小 |
shmflag | 由九个权限标志构成,它们的用法和创建文件时使用的mode模式标志是一样的 |
返回值 | 成功返回一个非负整数,即该共享内存段的标识符,失败返回 -1,同时错误码被设置。 |
参数key和参数shmflag需要单独来解释一下。首先,我们要明白,共享内存进程间通信并不仅仅局限于一对进程,未来我们可以在 内存中创建多个共享内存 从而支持多对进程都可以进行通信。
所以说 共享内存再内存中可以存在很多个,那么 多个共享内存是一定要被操作系统管理的。操作系统如何管理共享内存?先描述,再组织!
将每一个共享内存的属性抽离,用结构体将属性组织,于是对共享内存属性的管理就变为了对共享内存结构体的管理。而有那么多的进程,操作系统怎么知道那两个进程是在使用同一个共享内存的呢?
所以,OS为了识别不同进程进行通信的共享内存,于是也给共享内存添加了一个 标识符:key,其与进程的标识符类似,不同的共享内存key值具有唯一性。
shmflag 参数是 用来指定创建共享内存的的权限,其存在多个参数,这些参数都是由宏构成,而我们最常用的不过一下两个参数:
- IPC_CREAT选项:如果共享内存不存在,则创建。如果共享内存已经存在,则获取这个共享内存。
- IPC_EXCL选项:此选项不能单独使用,无意义。
- IPC_CREAT | IPC_EXCL:如果共享内存不存在,则创建共享内存。如果已经存在,则报错。
而我们使用这两个选项尽量两个选项一起使用,也就是第三种形式,这样的好处就是,只要我们共享内存创建成功了,就一定是最新创建的!
🚩ftok接口
可是为什么共享内存标识符需要我们手动的去设置呢?为何不能像进程那样分配一个标识符呢?其实,如果让操作系统来给我们传key这个参数是做不到的,如果操作系统能将同一个key值传递给两个不同的进程 那还需要共享内存来做通信吗?
基于此,所以我们需要手动传参key值,但是key值我们传什么呢?其实key这个参数有专门的接口提供给用户使用:
key_t ftok(const char* pathname, int proj_id);
- pathname:路径名。
- proj_id:传入任意一个整数。
ftok的返回值就是key的类型,而ftok接口其实是一个算法,由我们传入的文件名和一个整数进行算法,返回一个数字,这个数字就是key值。至于这个值是多少并不重要,只要能够标识唯一性即可。我们进程想要找到对应的共享内存,拿上这个key值就可以找到对应的共享内存了。
✈️共享内存编码模拟
🚩编码初步构建
要想进行共享内存方式的进程间通信,首先需要获取共享内存,并且需要两个测试进程来获取共享内存,Comm.hpp用来编写接口供客户端和服务端直接来调用。
Comm.hpp:
#pragma once
#include <iostream>
#include <cerrno>
#include <sys/shm.h>
#include <string>
#include <sys/types.h>
#include <sys/ipc.h>
#include <cstdlib>
#include <cstring>
const char* pathname = "/home/xzy/work/name_pipe/shm_ipc";// 创建路径
const int proj_id = 0x100;// 任意整数
const int defaultsize = 4096; // 字节为单位
std::string ToHex(key_t k)//转换16进制
{
char buffer[1024];
snprintf(buffer, sizeof(buffer), "%x", k);
return buffer;
}
key_t GetShmKeyOrDie()// 获取共享内存key值
{
key_t key = ftok(pathname, proj_id);
if(key < 0)
{
std::cerr << "ftok error, errno: " << errno << ", errno string: " << strerror(errno) << std::endl;
exit(1);
}
return key;
}
int CreateShmOrDie(key_t key, int size, int flag)// 共享内存创建方式,用于二级调用
{
int shmid = shmget(key, size, flag);
if(shmid < 0)
{
std::cerr << "shmget error, errno: " << errno << ", error string: " << strerror(errno) << std::endl;
exit(2);
}
return shmid;
}
int CreateShm(key_t key, int size)// 仅创建共享内存
{
return CreateShmOrDie(key, size, IPC_CREAT | IPC_EXCL);
}
int GetShm(key_t key, int size)// 仅获取共享内存(可能会创建)
{
return CreateShmOrDie(key, size, IPC_CREAT);
}
ShmClient.cpp:
#include "Comm.hpp"
int main()
{
key_t key = GetShmKeyOrDie();// 获取key值
std::cout << "key: " << ToHex(key) << std::endl;
int shmid = GetShm(key, defaultsize);// 获取共享内存key值,客户端并不需要创建
std::cout << "shmid: " << shmid << std::endl;
return 0;
}
ShmServer.cpp:
#include "Comm.hpp"
int main()
{
key_t key = GetShmKeyOrDie();
std::cout << "key: " << ToHex(key) << std::endl;// 将数字转换为16进制更美观
int shmid = CreateShm(key, defaultsize);// 创建共享内存
std::cout << "shmid: " << shmid << std::endl;
return 0;
}
Makefile:
.PHONY:all
all:shm_client shm_server
shm_server:ShmServer.cc
g++ -o $@ $^ -std=c++11
shm_client:ShmClient.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -f shm_client shm_server
上述共享内存的代码还是很简单的,但是我们再来看下面这个现象:
为什么我们再次运行服务端想要创建一个共享内存却不行呢?而报错信息显示的是文件已经存在,说到底是共享内存已经存在。
🚩删除共享内存
一个文件,当我们对一个文件进行操作时,一个进程打开一个文件,进程退出的时候这个被打开的文件就会被系统自动释放掉,也就是说 文件的生命周期随进程。
而我们在上述代码运行了共享内存,运行的两个进程(客户端、服务端)都已经退出了,当我们想再次创建共享内存时就被告知共享内存已存在。其实,当我们 创建了共享内存,如果 没有主动释放它,则一直存在。 也就是说,共享内存的生命周期随内核。除非重启系统。
虽然系统不能帮助我们自动释放共享内存,但是系统给我们提供了删除共享内存的命令,而在删除共享内存之前,我们需要先查看系统中的共享内存:
ipcs -m #查看系统中指定用户创建的共享内存
删除共享内存,在Linux中也有相对的指令,只不过删除共享内存是通过shmid来删除的并不是通过key值来删除的,原因我们稍后会提:
ipcrm -m shmid #删除指定的共享内存
删除共享内存并不仅仅只有指令级操作,也有代码级操作,我们同样可以调用删除接口shmctl:
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
- shmid:由shmget返回的共享内存标识码。
- cmd:将要采取的动作(三个可取值)。
- buf:指向一个保存着共享内存的模式状态和访问权限的数据结构。
- 返回值:成功返回0,失败返回-1。
共享内存在内核中的数据结构:
struct shmid_ds {
struct ipc_perm shm_perm; /* operation perms */
int shm_segsz; /* size of segment (bytes) */
__kernel_time_t shm_atime; /* last attach time */
__kernel_time_t shm_dtime; /* last detach time */
__kernel_time_t shm_ctime; /* last change time */
__kernel_ipc_pid_t shm_cpid; /* pid of creator */
__kernel_ipc_pid_t shm_lpid; /* pid of last operator */
unsigned short shm_nattch; /* no. of current attaches */
unsigned short shm_unused; /* compatibility */
void *shm_unused2; /* ditto - used by DIPC */
void *shm_unused3; /* unused */
};
// 可通过shmid_ds结构体对象调用ipc_perm
struct ipc_perm {
key_t __key; /* Key supplied to shmget(2) */
uid_t uid; /* Effective UID of owner */
gid_t gid; /* Effective GID of owner */
uid_t cuid; /* Effective UID of creator */
gid_t cgid; /* Effective GID of creator */
unsigned short mode; /* Permissions + SHM_DEST and
SHM_LOCKED flags */
unsigned short __seq; /* Sequence number */
};
cmd参数的三个动作:
当然cmd参数的选项并不只有这三项,但是最常用的就是这三个选项,我们也可以看看man手册里对cmd这个参数的介绍:
还记得我们使用指令删除共享内存吗,为什么我们指定key来删除呢?其实 不论是指令级还是代码级别,最后对共享内存进行控制,使用的都是shmid,而key值站在内核的角度是 仅仅 用来区分shm的唯一性。而key值和shmid之间的关系就类似于打开文件的struct file* 和 文件fd。
🚩共享内存各个属性
我们可以使用ipcs -m来查看共享内存,但是我们在查看时,会发现共享内存有一些我们并不认识的选项:
- key:共享内存段的键值,它是一个标识符,进程通过key值来访问共享内存段,key值常常使用ftok接口生成。
- shmid:共享内存段的标识符,系统分配给共享内存的唯一标识。
- owner:指定共享内存创建的用户名。
- perms:共享内存段的权限位(8进制),在创建共享内存时,shmflag参数可以添加共享内存权限。
- bytes:共享内存段大小,字节为单位,在Ubuntu20.04下最小单位为4096字节,也就是4kb。
- nattch:共享内存进程使用数量,表示有多少个进程正在使用该共享内存。
- status:共享内存段的状态。
为什么字节数和我上面给出的并不一致呢?不是说好以4kb为单位的吗?其实虽然在这里写的是4097但是内核会给我们开辟8kb的空间,并且我们仅仅使用4097字节。而剩下的字节就会被浪费掉,所以我们尽量将字节数写为4kb的整数倍。
🚩 共享内存正式代码
在写代码之前还需要认识两个接口shmat(shm attach):
int shmat(int shmid, const void *shmaddr, int shmflg);
- 功能:将共享内存段连接到进程地址空间。
- shmid:共享内存标识符。
- shmaddr:指定连接的地址,即用户指定将shm挂接到哪里。
- shmflag:其两个可能取值是 SHM_RND 和 SHM_RDONLY。
- 返回值:成功返回一个指针(地址空间的虚拟地址),指向共享内存的首地址;失败返回-1,并且设置错误码。
shmaddr说明:
shmaddr为NULL,核心自动选择一个地址
shmaddr不为NULL且shmflg无SHM_RND标记,则以shmaddr为连接地址。
shmaddr不为NULL且shmflg设置了SHM_RND标记,则连接的地址会自动向下调整为SHMLBA的整数倍。公式:shmaddr - (shmaddr % SHMLBA)
shmflg=SHM_RDONLY,表示连接操作用来只读共享内存
以及另外一个接口shmdt(shm detach):
int shmdt(const void *shmaddr);
- 功能:将共享内存段与当前进程脱离(联系切断)
- shmaddr: 由shmat所返回的指针(虚拟地址)
- 返回值:成功返回0;失败返回-1,并设置错误码
注意:将共享内存段与当前进程脱离不等于删除共享内存段。
共享内存同样分为三个文件,客户端、服务器端、头文件。头文件提供客户端和服务器端所需要的接口。
Comm.hpp:
#pragma once
#include <iostream>
#include <cerrno>
#include <sys/shm.h>
#include <unistd.h>
#include <string>
#include <sys/types.h>
#include <sys/ipc.h>
#include <cstdlib>
#include <cstring>
const char* pathname = "/home/xzy/work/shm_ipc";// 创建路径
const int proj_id = 0x100;// 任意整数
const int defaultsize = 4096; // 字节为单位
std::string ToHex(key_t k)//转换16进制
{
char buffer[1024];
snprintf(buffer, sizeof(buffer), "%x", k);
return buffer;
}
key_t GetShmKeyOrDie()// 获取共享内存key值
{
key_t key = ftok(pathname, proj_id);
if(key < 0)
{
std::cerr << "ftok error, errno: " << errno << ", errno string: " << strerror(errno) << std::endl;
exit(1);
}
return key;
}
int CreateShmOrDie(key_t key, int size, int flag)// 共享内存创建方式,用于二级调用
{
int shmid = shmget(key, size, flag);
if(shmid < 0)
{
std::cerr << "shmget error, errno: " << errno << ", error string: " << strerror(errno) << std::endl;
exit(2);
}
return shmid;
}
int CreateShm(key_t key, int size)// 仅创建共享内存
{
return CreateShmOrDie(key, size, IPC_CREAT | IPC_EXCL | 0666);// 权限设置为0666
}
int GetShm(key_t key, int size)// 仅获取共享内存(可能会创建)
{
return CreateShmOrDie(key, size, IPC_CREAT);
}
void DeleteShm(int shmid)// 删除共享内存
{
int n = shmctl(shmid, IPC_RMID, nullptr);
if(n < 0)
{
std::cerr << "shmctl error" << std::endl;
}
else// 成功删除
{
std::cout << "shmctl delete shm sucess, shmid: " << shmid << std::endl;
}
}
void ShmDebug(int shmid)
{
struct shmid_ds shmds;
int n = shmctl(shmid, IPC_STAT, &shmds);
if(n < 0)
{
std::cerr << "shmctl error" << std::endl;
return;
}
//Debug 日志
std::cout << "shmds.shm_segsz: " << shmds.shm_segsz << std::endl;
std::cout << "shmds.shm_nattch: " << shmds.shm_nattch << std::endl;
std::cout << "shmds.shm_ctime: " << shmds.shm_ctime << std::endl;
std::cout << "shmds.shm_perm.__key" << ToHex(shmds.shm_perm.__key) << std::endl;
}
void* ShmAttach(int shmid)
{
void* addr = shmat(shmid, nullptr, 0);// 连接进程 返回虚拟地址
if((long long)addr == -1)// 连接失败打印错误信息
{
std::cerr << "shmat error" << std::endl;
return nullptr;
}
return addr;
}
void ShmDetach(void *addr)// 解除关联
{
int n = shmdt(addr);
if(n < 0)
{
std::cerr << "shmdt error" << std::endl;
return;
}
}
ShmServer:
#include "Comm.hpp"
int CreateShm()
{
// 获取key
key_t key = GetShmKeyOrDie();
std::cout << "key: " << ToHex(key) << std::endl;// 将数字转换为16进制更美观
// 创建共享内存
int shmid = CreateShm(key, defaultsize);
std::cout << "shmid: " << shmid << std::endl;
return shmid;
}
int main()
{
// 创建共享内存
int shmid = CreateShm();
// 挂接共享内存
char* addr = (char*)ShmAttach(shmid);
std::cout << "Attach shm sucess, addr: " << ToHex((uint64_t)addr) << std::endl;
// Server 通信
for(;;)
{
std::cout << "shm content: " << addr << std::endl;
sleep(1);
}
ShmDetach(addr);
std::cout << "Detach shm sucess, addr: " << ToHex((uint64_t)addr) << std::endl;
sleep(5);
// 删除共享内促
DeleteShm(shmid);
return 0;
}
ShmClient:
#include "Comm.hpp"
int main()
{
key_t key = GetShmKeyOrDie();// 获取key值
std::cout << "key: " << ToHex(key) << std::endl;
int shmid = GetShm(key, defaultsize);// 获取共享内存
std::cout << "shmid: " << shmid << std::endl;
// 将客户端挂接到共享内存
char* addr = (char*)ShmAttach(shmid);
std::cout << "Attach shm sucess, addr: " << ToHex((uint64_t)addr) << std::endl;
memset(addr, 0, defaultsize);
// 通信开始
for(char ch = 'A'; ch <= 'Z'; ++ch)
{
addr[ch-'A'] = ch;
sleep(1);
}
// 将与共享内存的挂接取消
ShmDetach(addr);
std::cout << "Detach shm sucess, addr: " << ToHex((uint64_t)addr) << std::endl;
sleep(5);
return 0;
}
首先,在运行之前将监控脚本打起来,一直检测是否连接成功,然后运行服务器端(读端),再运行客户端(写端):
我们可以看到,当我们仅仅运行服务器端的时候,服务器端一直在进行读取,并没有进行写入,这个现象就很奇怪,我们前面在运行管道文件的时候,当管道内没有数据时,读端是会阻塞等待的,会与写端做一个协同。
其实,这就是共享内存的一个 缺点:共享内存不提供进程间通信协同的任何机制,导致数据不一致!但是它也有自己的 优点:共享内存是所有进程间通信最快的!
为什么说共享内存是进程间通信最快的一种通信方式呢?其实,如果你仔细品共享内存和用户之间是如何传递信息的就可以知道为什么共享内存会这么快了:
共享内存是在内存中开辟的,而我们前面说过,共享内存会将数据从内存中加载到进程地址空间的共享区中,这个过程只需要拷贝一次,而用户则会通过页表获取加载进共享区的共享内存的起始地址,整个过程并不需要过多的拷贝!
而管道在运行时,写端会先将数据从用户端拷贝(写入)到内核的管道文件中,而读端读取数据时,需要将数据从管道文件在拷贝到本地,这样拷贝次数增多,开销成本就变大,自然比不过共享内存了。
为了保证数据的一致性,只能由我们用户自己来实现,我们可以 使用 信号量 的方式来实现共享内存,但是我们还没有接触到。还有一种方式就是 使用管道来同步我们的共享内存,因为 管道自带同步机制!
而恰好我们前面也学习了管道文件,我们可以复用上面写的命名管道,并且添加一些同步机制,让共享内存可以同步起来:
#pragma once
#include <iostream>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <cstring>
#include <fcntl.h>
#include <assert.h>
#include <cerrno>
#include <string>
#define Path "fifo"
#define Mode 0666
// 创建管道文件
class Fifo
{
public:
Fifo(const std::string &path = Path) :_path(path)
{
umask(0);
int n = mkfifo(_path.c_str(), Mode);
if(n <= 0)
{
std::cerr << "mkfifo failed, errno: " << errno << " failed result: " << strerror(errno) << std::endl;
}
std::cout << "mkfifo sucess, fifo pipe be created..." << std::endl;
}
~Fifo()
{
int n = unlink(_path.c_str());
if(n < 0)
{
std::cerr << "unlink fifo failed, errno: " << errno << " failed result: " << strerror(errno) << std::endl;
}
std::cout << "unlink fifo sucess..." << std::endl;
}
private:
std::string _path;
};
// 同步机制
class Sync
{
public:
Sync() :_wfd(-1), _rfd(-1)
{}
void OpenRead()// 以读的方式打开文件
{
_rfd = open(Path, O_RDONLY);
if(_rfd < 0)
{
std::cerr << "open read failed, errno: " << errno << " failed result: " << strerror(errno) << std::endl;
exit(1);
}
}
void OpenWrite()// 以写的方式打开文件
{
_wfd = open(Path, O_WRONLY);
if(_wfd < 0)
{
std::cerr << "open write failed, errno: " << errno << " failed result: " << strerror(errno) << std::endl;
exit(1);
}
}
bool Wait()// 等待
{
bool ret = true;
uint32_t c;
ssize_t n = read(_rfd, &c, sizeof(uint32_t));// 根据管道文件的特性,读端在没有写端写入之前会一直处于等待状态
if(n == sizeof(uint32_t))
{
std::cout << "wakeup the process" << std::endl;
return ret;
}
else if(n <= 0)
{
std::cerr << "Wait failed, errno: " << errno << " failed result: " << strerror(errno) << std::endl;
ret = false;
}
return ret;
}
void wakeup()// 唤醒
{
uint32_t c;
ssize_t n = write(_wfd, &c, sizeof(uint32_t));// 同样,根据管道的特性,当写端对管道文件进行写入的时候,我们的读端才能解除等待状态,开始对管道文件内容进行读取
if(n <= 0)
{
std::cerr << "wakeup failed, errno: " << errno << " failed result: " << strerror(errno) << std::endl;
}
std::cout << "wakeup server..." << std::endl;
}
~Sync()
{}
private:
int _wfd;// 写端fd
int _rfd;// 读端fd
};
将读写端设置完成之后,我们就可以在客户端和服务器端对其进行调用了:
这样再次运行其客户端和服务器端,效果如下:
对于共享内存内存以管道的方式实现同步的完整源代码点击以下链接:共享内存通信(管道实现同步机制)
🚀System V 消息队列
随着时代的进步,System V 的本地通信逐渐被淘汰,除了共享内存现在依旧存有不少应用场景,其他类似消息队列这种技术已经被逐渐淘汰,我们在这里只需要简单了解即可。
消息队列属于内核数据结构,用户层不可对其随意修改,只能通过系统提供的接口对消息队列的内容进行写入和读取。
用户层的 每个进程都可以是读写端,每个既可以向消息队列中写入数据,也可以从消息队列中读取数据。
系统中的消息队列那么多,我怎么知道你给我发送数据是在哪一个块上呢?我怎么能保证自己不会读取到自己在消息队列中写的信息呢?
其实,消息队列的内核数据结构就说明了一些,因为和共享内存都属于System V类型的通信,所以他们的内核数据结构会有很强的相似性:
通过消息队列的数据结构我们可以看到,消息队列也有 ipc_perm 这个结构体,其也有自己的key值,而这个 key值就是消息队列的唯一标识符。
和共享内存一样,消息队列有自己的获取、发送、以及销毁接口:
获取消息队列:
msgctl,cmd参数与共享内存相同:
发送数据到消息队列:
查看系统中的消息队列也很简单使用 ipcs -q
即可查询系统中的消息队列的情况了:
🚀System V 信号量
✈️信号量相关概念铺垫
前面我们介绍了共享内存,我们直到共享内存不具有同步机制,所以后面我们使用管道来为共享内存构建的进程间通信读写端做同步工作。如果我们没有对共享内存使用管道做一个同步机制,那么可能会出现下面这样的问题:
我们使用管道,让两个进程分别处于读写端,如果不加任何同步,我们可以让不同的进程同时访问同一块内存资源,如果两个进程对该资源为只读,那么就不会有任何影响。但是如果 不同进程对同一块内存资源进行修改,这样就会造成 数据不一致的问题。
对于公共资源进行保护,是一个多执行流场景下,一个比较常见和重要的话题。而 对公共资源进行保护有两种方法: 同步 和 互斥。
- 同步:访问公共资源安全的前提下,具有一定的顺序性。
- 互斥:访问公共资源的时候,在任何时刻只有一方对公共资源进行访问。
资源在操作系统并发编程中是很重要的概念,而有些公共资源又被称为临界资源:
- 临界资源:被保护起来的,任何时刻只允许一个执行流访问的公共资源。
共享内存、管道,都是被多个进程看到同一份资源,而这份公共资源就属于一种临界资源。常见还有打印机、文件等。
- 临界区:访问临界资源的代码叫做临界区。
同一个程序中,临界区是需要进行同步的部分,确保同一时间只有一个 进程/线程 可以进入临界区访问临界资源。比如在共享内存中,Client端调用Wakeup就属于临街区,而其他未访问到临界资源的代码就是 非临界区。
由此可以看出,保护公共资源的本质:程序员保护临界区。
- 原子性:操作对象的时候,不会中中断。只有两种状态,要么完全执行,要么完全不执行。
✈️信号量
信号量(Semaphore) 是用于 进程/线程 间的 同步机制。信号量可以控制多个进程对共享资源的访问。
通俗来说,我们日常在预定火车票,在火车真正开来之前,这个票会一直给你留着,也就是说资源不一定是我持有才是我的,我预定了,那么这个资源在将来也是我的。而我多少资源,我就卖多少票,并保证每一份资源都不会被并发访问。
那么我们可以把整个火车看作一份资源,一份资源只能有一个人抢票,这个人哪里都可以坐,但是这样效率很低。而我们把火车切割为无数个小资源,这样每个小资源都可以对应一个人抢票把所有座位的票卖出去,这样资源利用率就会比前者高。
操作系统也是如此,对临界资源的分配有自己的规则,而这种规则就叫做 信号量。
- 信号量:本质是一个计数器,描述临界资源数量的计数器。
也就是说,我们进程之间的通信可以采用信号量的方式来时间对资源的同步访问,如何访问呢?实际上,如果我们使用信号量的方式来获取资源,进程就需要先申请信号量,信号量申请成功,就一定会有该 进程/线程 的资源(和预定和车票类似)。
申请完成信号量,等待资源的分配。接着找到对应访问资源进行访问,访问完成最后一步释放信号量。就比如阿熊坐火车到站了,出站的那一刻火车票就算是失效了,不然难不成这趟火车的这个座位一直是阿熊的专座?显然不合常理。释放完的信号量后面就可以再次被别人申请了。
而信号量的使用非常简单,其实就是一个计数器,开始有一个可分配数值。遇到 进程/线程 申请信号量则计数器 -1,遇到信号量被释放则计数器 +1,如果信号量 <= 0 则之后的 进程/线程 则需要进行等待。
话虽如此,但是我们使用一个整数作为信号量,对其进行增加删除来对资源计数,这样的方式对于多进程的场景真的可行吗?
实际上,这种场景是没办法使用一个整数来当做计数器的,就拿父子进程来说,我们都知道,子进程被fork出来之后,任何一个进程对自己的数据进行增删改的时候,就会发生写时拷贝,其中一个进程保留原始数据,另外一个进程保留改动后的数据,这样就造成了数据不一致的问题。
而今天我们想要使用一个整数作为信号量不也是如此吗?如何才能保证进程之间数据一致性的问题呢?所以解决方法一定是,让不同的进程看到同一份计数器资源!
综上所述,我们可以得出,信号量也是一种进程间通信!因为它 保证了不同进程看到同一份资源!而这就是进程间通信的前提。
只不过我们并不是通过信号量来传递消息,而是 使用信号量来实现不同进程之间的协同操作!
其实为什么使用整数不能作为信号量还有一个原因:
假设信号量计数器为一个变量 int count; 那么对于 count++、count- -,这样的操作也是不能使用整数的一个原因,因为其不能保证原子性!
在这里,我写了一份简单的代码,对于第一条语句,对count进行赋值操作,在汇编层面只有一条语句,第一句就是原子性的。
但是第二句和第三局就不同了,因为都是后置++,- -,而这样的操作转换成汇编层面实际上是由六条汇编语句来完成的,所以操作上并非是原子性的。这样就可能会导致,有一方执行流正在做++,但是另一方执行流在++期间还没进行++时已经做了- -了,这样就会产生数据不一致的问题。
上面的部分会详细在线程篇讲述。
程序员既然要实现多进程并发的场景,所有的进程需要访问临界资源,在申请 Sem(信号量) 和释放 Sem 的时候,都必须要保证 申请(++) 和 释放(- -) 操作是原子的!而对信号量++和- - 的操作我们就叫做PV操作:
信号量PV操作:
- P操作(wait操作):将信号量的值减一,信号量的值大于0时,进程继续执行,信号量小于等于0时,进入阻塞状态进入等待队列,等待信号量的值再次大于0。
- V操作(signal操作):将信号量的值加一,当信号量的值小于等于0时,则会唤醒一个阻塞中的进程,移除阻塞队列并 开始/继续 执行。
信号量的P操作用于请求资源,资源无可分配时进程则被阻塞。V操作用于释放资源,唤醒阻塞的进程。但是今天,如果我们信号量的初始值是1呢?也就是说开始就只有一份资源的情况下,会有什么不同吗?其实如果 信号量只有1的话,一定是互斥的,我们称其为 二元信号量:
- 二元信号量(Binary Semaphore):也被称为 互斥量(Mutex),也是一种控制对共享资源访问的同步机制。二元信号量的取值只有0和1。主要用于实现互斥访问,防止 多线程 同时访问临界资源,从而导致数据不一致的问题。
但是在这里我们并不对二元信号量做深入了解,因为其也是在线程篇很重要,所以在线程篇我们会详细谈论。
✈️信号量相关接口
一个临界资源可以申请一个信号量,而在多数并发场景中,临界资源不止一个,所以 申请信号量资源定然一次性申请多个信号量,这与信号量是几 定要做区分。
理论知识我们说的也差不多了,那么我们在程序中如何申请信号量呢?如何对信号量进行操作呢?我们一般使用 semget 接口:
int semget(key_t key, int nsem, int semflg);
- key参数:指定信号量集的键值,该键值用于标识唯一的信号量集,同样,使用ftok函数生成key值。
- nsems参数:表示指定信号量集信号量的数量,如果需要获取信号量集,该参数设置为0,如果要创建信号量集需要设置对应的参数。
- semflg参数:与共享内存的flag标志位相同,有IPC_CREAT、IPC_EXEC等选项,以及权限位。
- 返回值:成功返回信号量集的一个标识符,失败返回-1,并设置错误码。
概念中我们不止一次的提到了信号量集,其实就可以把信号量集看作为一个数组,数组里可以有多个信号量。而删除信号量接口 semctl:
int semctl(int semid, int semnum, int cmd, ...);
- semid参数:信号量集的标识符。
- semnum参数:信号量集中信号量编号从0开始,类似数组下标。
- cmd参数:与共享内存cmd些许选项一致,使用 IPC_RMID 选项可删除共享内存。
- 第四个参数:信号量集的属性,可传入semid_ds的结构体,与共享内存和管道类似。
- 返回值:与cmd选项相关,大部分选项成功则返回0,失败返回-1,并设置错误码。
而我们能创建和删除信号量之后,我们还需要对信号量进行增删控制,也就是需要 对信号量进行 PV操作,我们可以使用 semop 接口:
int semop(int semid, struct sembuf* sops, size_t nsops);
- semid参数:与前面两个接口一致。
- sops参数:表示指向 struct sembuf 数组指针。其为操作数组,每个数组元素定义了对信号量的一个操作。
struct sembuf {
unsigned short sem_num; // 信号量集中的信号量编号,指定信号量集中的哪个信号量进行操作(从 0 开始计数)
short sem_op;// 操作类型指定要执行的操作类型。常见的操作类型包括:
/*正数:将信号量的值增加sem_op 的值。
负数:将信号量的值减少 -sem_op 的值。
如果减少后的值小于 0,则调用进程将被阻塞,直到信号量的值为非负数。
0:等待信号量的值变为 0*/
short sem_flg;// 操作标志
/*sem_flg:操作标志,可以是以下值的组合:
IPC_NOWAIT:如果操作不能立即完成,则 semop 调用会立即返回错误,而不是阻塞。
SEM_UNDO:操作会被记录下来,以便在进程终止时自动撤销。*/
};
-
nsops参数:操作数组中的操作数目,表示 sops 数组中包含的 struct sembuf 结构体数量。
-
返回值:0表示返回成功,-1为失败,并设置错误码。
在系统中查看信号量使用 ipcs -s
即可查看系统中信号量情况:
🚀System V 共享内存、消息队列、信号量的共性
我们学完了共享内存、消息队列以及信号量就不难发现他们有非常多的相似之处,首先是在系统中分别查看他们三个的状态用到的命令都是 ipcs
并且他们的程序调用接口都有cmd参数,并且都可调用 xxxid_ds 结构体 和 ipc_perm 结构体。也就是说他们三个是操作系统特意设计的!
而它们都是可以对进程之间进行通信的方法,而操作系统注定要对 IPC(Inter-Process Communication,进程间通信) 资源做管理!如何管理?先描述,再组织!
接下来我们就看一看进程间通信在 内核中 的表示形式:
实际上,在操作系统中,共享内存、消息队列、信号量被视为同一种资源,可以被看成一个整体,而我们内核中的共享内存、消息队列、信号量都存在一个内核结构体:kern_ipc_perm 。而实际在内核当中,所有管理IPC资源的结构体,第一个成员都一样,他们三个都 是由其进行强制类型转换所得到的 三个不同类型的 ipc_perm(sem_perm、shm_perm、q_perm)。
而 kern_ipc_perm 是 ipc_id_ary 结构体中的一个指针数组,指针数组的每一个元素都是指针,每个指针指向你所创建的 共享内存/消息队列/信号量 的 ipc_perm(sem_perm/shm_perm/q_perm)结构体 ,而我们学过C语言的都知道,结构体中数组指针的地址,是该数组指针指向数组首元素的地址。所以,我们就可以拿到不同类型 ipc_perm 的地址,那么就可以 通过 起始地址+偏移量 的方式访问内核数据结构成员!
那么从此以后,操作系统对IPC资源的管理就转化为了对数组的增删查改!但是问题来了,我们IPC有多种方式进行通信,而且IPC不同它们的 ipc_perm 的类型就不同,那么操作系统如何转换 kern_ipc_perm* 指针数组的每一个元素让其与IPC的类型对应呢?
很简单,我们使用强制类型转换,将对应IPC 类型的 ipc_perm 强制类型转换即可:
// 例子,以下全是假设
kern_id_perm* ipc[n];
(sem_array*)ipc[0]->sem_base[0].semval--;// 强制类型转换为信号量ipc_perm,再基于此对信号量数目做--
(msg_queue*)ipc[1]->q_time;// 强转为消息队列的ipc_perm,访问其成员
(shmid_kernel)ipc[2]->id;// 强转为共享内存...
现在我们知道了如何对不同类型IPC的ipc_perm进行类型转换,但是有个更重要的问题,我们怎么确定你是谁呢?怎么知道你是IPC的哪个类型呢?不知道哪个类型我们也没办法做强制类型转换啊??
其实这个问题也非常简单,内核中的IPC类型无非就 共享内存、信号量、消息队列 这三个类型,而我们写三个接口,每个接口的作用就是强转为它们三个的类型,一一进行匹配,成功则返回强转后的结果,失败则返回nullptr,接着继续强转试错,终是可以找到对应的类型的。
可是计算机怎么知道你需要强转为什么类型呢?不用担心,在kern_ipc_perm中有一个叫做mode的属性成员,其记录着你需要转换结构体的类型,所以我们就可以通过上述方式对不同IPC类型进行识别并强转了,例如:
#define IPC_TYPE_SHM 0x1
#define IPC_TYPE_SEM (0x1 << 1)
#define IPC_TYPE_MSG (0x1 << 2)
shmid_kernel* (kern_ipc_perm *p)
{
if(p->mode & IPC_TYPE_SHM)
return (shmid_kernel*)p;// 是则强转
else
return nullptr;
}
...
如果你学习过像java、C++、python、rust…具有面向对象的高级语言,那么你一定对上面那张图有疑问:这张图怎么这么像我学过的 多态 呢??但是它是C语言啊,并没有多态啊?没错,这就是 使用 C语言实现的多态。
每个结构体的第一个成员就是基类指针,而基类就可以通过指针对子类进行访问,所以就间接形成了我们今天的多态,但是注意,操作系统是要比C++、Java、Python这些具有面向对象特性语言要出来的早!所以多态其实就是在我们日常的工程开发当中总结出来的规律。
以上就是全部内容啦,文章创作不易,如果对您有帮助的话,还望给作者一个小小的三连吧~~