Linux之管道,systemV共享内存和信号量
一.进程间通信
在我们之前有关Linux指令的学习时我们使用过“|”这个命令,当时我们说这是管道文件,那么什么是管道?管道的作用是什么?
管道是进程间通信的一种方式,在我们之前的学习中我们反复强调进程是具有独立性的所以不同的进程很难相互影响但是在项目的开发中我们不可能只使用一个进程或者个别进程来进行开发,在进程的数量多了起来之后数据也就多了起来那么我们就需要在进程之间传输数据或共享数据。而且我们有时候还需要利用一个进程来通知另外一个进程发生了什么事件或者控制另外一个进程进行某种操作。这些都是我们进程间通信的目的!
1.1进程间通信的目的
- 传输数据:一个进程需要将它的数据发送给另一个进程
- 共享资源:多个进程之间共享同样的资源。
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
- 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
1.2进程间通信的方式
- 管道
- 匿名管道
- 命名管道
- system V
- system V 共享内存
- system V 消息队列
- system V 信号量
二.管道
2.1管道的概念
管道是最古老的一种通信方式但是时至今日它仍然是我们需要学习的知识,古老但并不落后。
对于想要让不同的进程进行通信那么我们可以先思考一个问题:进程间通信的本质是什么?
本来我们强调进程要具有独立性所以我们无法让一个进程中含有另外一个进程那么一个进程要如何读取到另外一个进程写入的信息呢?
读取?写入?如果我们让一个进程向一个文件中写入数据然后让另外一个进程读取该文件的数据,这是不是就是完成了进程间的通信呢?那么这种方法的本质是什么?
所以进程间通信的本质是让不同的进程看见了同一份资源。
所以管道就诞生了而那个作为中转的文件就是管道而可以叫做管道文件。
那么如何理解让不同的进程看见同一份资源呢?我们先以父子进程为例并配图来展示。
同时我们要注意管道是单向的所以只能让一个文件写入一个文件读取
并且有两种管道之分:匿名管道和命名管道。这两种管道有什么区别我也可以提前告诉各位,匿名管道只能让有“亲缘关系”的进程即父子,兄弟,爷孙等等进行通信而命名管道可以让任意两个进程进行通信。
2.2匿名管道
在Linux中想要创建匿名管道需要用到系统调用pipe
这个系统调用很简单,成功就返回零,失败就返回-1同时设置错误码。而参数int pipefd[2]在我们刚刚看见时可能会有点奇怪,我们知道这是一个整型数组但是为什么会有个2呢?
我们只需要回想有关数组传参的知识就可以知道对于数组来说传参传的都是数组的首地址所以这个2是没有用的更多的是起到一种提示的作用。那么在提示什么呢?我们来深入了解一下匿名管道的原理就可以知道了,一样我们结合图来讲述。
在了解了其中的原理之后我们再回过头来看这个接口就能知道这个参数其实就是文件描述符的数组而那个2也就是提示我们只有两个文件描述符。那么问题又出现了这两个文件描述符中哪个是写端哪个是读端呢?我们从手册中就能知道。
所以文件描述符数组中第一个文件描述符是读端,第二个文件描述符是写端。在了解了哪个是读写端后我们就可以尝试使用匿名管道来传输数据了,并且通过对匿名管道的不同使用场景我们可以总结出来四种情况和五种特性。假设为子写父读的情况!
#include <iostream>
#include <unistd.h>
#include <cassert>
#include <stdio.h>
#include <cstring>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;
int main()
{
// 打开管道
int pipefd[2] = {};
int n = pipe(pipefd);
assert(n == 0);
// 使用断言来判断pipe的返回值,
// 因为pipe大概率是创建成功的所以使用断言比使用if更加的方便
(void *)n;
// 为了避免编译器因为定义了变量但是没有使用从而产生报错所以随意使用一下变量n
cout << "fd[1]:" << pipefd[0] << " " << "fd[2]:" << pipefd[1] << endl;
// 创建子进程
pid_t id = fork();
if (id < 0)
{
perror("fork");
return 1;
}
else if (id == 0)
{
// 子进程
// 子进程为写入端所以要关闭读取端即pipefd[0]
close(pipefd[0]);
// 向管道中写入
// 正常情况
// int cnt = 5;
// while (cnt--)
// {
// ssize_t n = write(pipefd[1], buffer, strlen(buffer));
// if (n < 0)
// {
// perror("write");
// return 1;
// }
// sleep(1);
// }
// 情况4
while (true)
{
char buffer[1024] = {};
snprintf(buffer, sizeof(buffer), "i am child , pid:%d , ppid:%d\n", getpid(), getppid());
ssize_t n = write(pipefd[1], buffer, strlen(buffer));
if (n < 0)
{
perror("write");
return 1;
}
sleep(1);
}
cout << "write point quit" << endl;
close(pipefd[1]);
exit(0);
}
else
{
// 父进程
// 父进程为读取端所以要关闭写入端即pipefd[1]
close(pipefd[1]);
// 从管道中读取数据
char buffer1[1024] = {};
// 正常情况
// while (true)
// {
// ssize_t n = read(0, buffer1, sizeof(buffer1));
// if (n == 0)
// {
// break;
// }
// else if (n > 0)
// {
// buffer1[n] = 0;
// cout << "child say:" << buffer1;
// }
// else
// {
// perror("read");
// return 1;
// }
// sleep(1);
// }
// 情况3
// while(true)
// {
// ssize_t n = read(0, buffer1, sizeof(buffer1)-1);
// if(n > 0)
// {
// buffer1[n] = 0;
// cout << "child say:" << buffer1;
// sleep(2);
// }
// else if(n == 0)
// {
// cout << "write point is close , i read the file's end" << endl;
// break;
// }
// else
// {
// perror("read");
// return 1;
// }
// }
// 情况4
while (true)
{
ssize_t n = read(pipefd[0], buffer1, sizeof(buffer1));
if (n > 0)
{
buffer1[n] = 0;
cout << "child say:" << buffer1;
sleep(1);
}
else if (n == 0)
{
cout << "write point is close , i read the file's end" << endl;
break;
}
else
{
perror("read");
return 1;
}
break;
}
close(pipefd[0]);
cout << "read point quit" << endl;
// 等待子进程
// 正常情况
// pid_t rid = waitpid(id,NULL,0);
// if(rid == id)
// {
// cout << "wait success" << endl;
// }
// 情况4
sleep(3);
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if (rid == id)
{
cout << "wait success " << "child quit sig:" << (status&0x7F) << endl;
}
}
return 0;
}
而从这些代码中我们可以总结出来匿名管道的四种情况和五种特性:
四种情况:
- 正常情况下,如果管道没有数据了那么读端必须等待直到管道中有数据即写端写入数据
- 正常情况下,如果管道中数据满了那么写端必须等待直到管道中有空间即读端读取数据
- 写端关闭,读端一直读取,读端会读到read返回值为0,表示读到文件结尾
- 读端关闭,写端一直写入,操作系统会通过向写端进程发送sig 13信号来终止写端进程。
五种特性:
- 匿名管道只允许有亲缘关系的进程之间进行通信,常见的为父子
- 匿名管道默认要给予读写段提供同步机制 ——了解现象即可
- 管道是面对字节流的 ——了解现象即可
- 管道的生命周期是随进程的
- 管道是单项通信的,也是半双工通信的一种
2.3命名管道
匿名管道是只能让有亲缘关系的进程通信而命名管道则可以让任意的两个进程互相通信,而命名管道的原理和匿名管道也是相似的。
那么想要创建一个命名管道有两种方法:一种是指令级的一种是代码级的。
- 指令级
- 代码级
//connect.hpp
//为了符合两个进程都能看见同一个资源的本质
//所以我们创建一个.hpp文件来存储那些公用的数据和接口
#pragma once
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <cassert>
#include <fcntl.h>
#include <cstring>
#define FILENAME "./FIFO"
#define SIZE 1024
//Makefile
##利用伪目标达成一次性生成多个可执行文件
.PHONY:all
all:launch reception
launch:launch.cc
g++ -o $@ $^ -std=c++11
reception:reception.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -f FIFO launch reception
//launch.cc
#include <iostream>
#include "connect.hpp"
using namespace std;
int main()
{
// 创建命名管道
mkfifo(FILENAME, 0666);
// 写端要以写入模式打开命名管道
int fd = open(FILENAME, O_WRONLY);
if (fd == -1)
{
perror("open");
return 1;
}
// 向命名管道中写入数据
int cnt = 10;
while (cnt--)
{
char buffer[SIZE] = {};
cout << "i am wirting message..." << endl;
snprintf(buffer, sizeof(buffer), "message is pid:%d ppid:%d\n", getpid(), getppid());
write(fd, buffer, strlen(buffer));
sleep(1);
}
return 0;
}
//reception.cc
#include <iostream>
#include "connect.hpp"
using namespace std;
int main()
{
// 读端要以读取的模式打开命名管道
int fd = open(FILENAME, O_RDONLY);
if (fd == -1)
{
perror("open");
return 1;
}
// 从命名管道中读取数据
int buffer[SIZE] = {};
while (true)
{
int n = read(fd, buffer, sizeof(buffer) - 1);
if (n > 0)
{
buffer[n] = 0;
cout << "i am receiving meesge..." << endl;
cout << "message is:" << buffer << endl;
}
else if(n == 0)
{
cout << "write point quit , me too" << endl;
break;
}
else
{
perror("read");
return 1;
}
sleep(1);
}
return 0;
}
当我们利用命名管道进行通信时我们会发现如果读写端任意一个先使用open打开了命名管道后这个进程会卡住不会向下继续运行代码直到另外一个进程也使用open打开命名管道后,这也符合了我们五种特性中的一种即管道默认要给予读写段提供同步机制。
对于命名管道而言,它也有四种情况和五种特性但是大部分都和匿名管道相同只有一个特性不同即命名管道可以让任意两个进程进行通信。
匿名管道是通过亲缘关系继承制的看见同一份资源但是命名管道可以让没有亲缘关系的两个进程看见同一份资源这是怎么做到的呢?
在我们创建命名管道时大家有没有注意到其中的参数是什么
第二个参数是创建管道的权限这个无需多谈因为管道也是个文件所以一定需要设置权限,重点在第一个参数路径加文件名,两个进程正是通过路径加文件名来看到同一个资源的。
当我们没有了继承制后我们想要让两个不同的进程看见同一份资源那么一定要让这两个进程找到这一份资源而在我们学习了文件系统和软硬连接后我们知道在一个分区下文件名是唯一的因为inode是唯一而文件名会和inode形成映射关系所以文件名也是唯一的,同时我们也知道查找一个文件的方法是通过路径那么利用路径加文件名是不是就能做到准确的找到这个文件并且还保持着唯一性。所以我们利用路径加文件名的方式让两个进程找到同一份资源!!!
三.system V
通过对管道的学习我们大致了解了进程间通信的概念和本质,所以我们现在来介绍另外一种进程间通信的方法即system V。
System V它最初由AT&T开发,曾经也被称为AT&T System V,是Unix操作系统众多版本中的一支。而在system V中存在着三种进程间通信的方式。
3.1共享内存
我们创建管道文件并且利用管道完成进程间通信,本质是向管道文件的文件缓冲区写入读取。但是使用管道我们还需要调用系统接口向缓冲区中写入,而使用接口的过程中其实是比较浪费时间的那么我们是否可以之间向内存中写入读取数据呢?
这就是system V共享内存,共享内存是物理内存中的一块内存而在虚拟地址空间中则是映射到共享区的和动静态库一样,而且共享内存是可以被开辟多块的。那么问题也就出来了对于管道而言我们是通过继承制和路径加文件名来看见同一份资源的,而共享内存要如何看见同一份资源即看见同一块共享内存呢?我们结合图来讲述。
所以我们知道了共享内存是通过创建时设立一个标识符来达到让另外一个进程找到同一个共享内存的,并且这个标识符是存储在共享内存的结构体中的。想要证明这些我们就需要使用接口了
共享内存的接口很多,其中包括了创建,挂载,卸载,控制。
- 创建:创建共享内存
//connect.hpp
#pragma once
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <unistd.h>
#include <cerrno>
#include <cstring>
#define FILENAME "/home/ly/lesson7"
#define ID 0x12345678
#define SIZE 4096
//Makefile
.PHONY:all
all:server client
server:server.cc
g++ -o $@ $^ -std=c++11
client:client.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -f client server
#include <iostream>
#include "connect.hpp"
using namespace std;
int main()
{
//获取key
key_t key = ftok(FILENAME,getpid());
if(key == -1)
{
cout << "errno:" << errno << ", errstring" << strerror(errno) << endl;
}
//创建共享内存
int n = shmget(key,SIZE,IPC_CREAT|0666);
if(n == -1)
{
cout << "errno:" << errno << ", errstring" << strerror(errno) << endl;
}
return 0;
}
如果我们想要查看创建的共享内存则可以使用ipcs -m命令
其中key是标识符,shmid就是共享内存的id,owner是创建者,perms则是权限,bytes则是共享内存的大小,nattch则是共享内存挂载到了多少个虚拟地址空间中。
2.挂载:将共享内存链接到进程的虚拟内存空间中
//connect.hpp
#pragma once
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <unistd.h>
#include <cerrno>
#include <cstring>
#include <iostream>
#define FILENAME "/home/ly/lesson7"
#define ID 0x12345678
#define SIZE 4096
using namespace std;
//获取key
key_t GetKey()
{
key_t key = ftok(FILENAME,ID);
if(key == -1)
{
cout << "errno:" << errno << ", errstring:" << strerror(errno) << endl;
exit(1);
}
return key;
}
//将key转为会16进制打出
string ToHex(key_t key)
{
char buffer[1024];
snprintf(buffer,sizeof(buffer),"0x%x",key);
return buffer;
}
//创建或者获得共享内存
int CreatShm(key_t key)
{
int id = shmget(key,SIZE,IPC_CREAT|0666);
if(id == -1)
{
cout << "errno:" << errno << ", errstring:" << strerror(errno) << endl;
exit(2);
}
return id;
}
//server.cc
#include "connect.hpp"
#include <iostream>
using namespace std;
int main()
{
// 获取key
key_t key = GetKey();
cout << "key:" << ToHex(key) << endl;
// 创建共享内存
int id = CreatShm(key);
cout << "id:" << id << endl;
// 将共享内存挂载到进程的虚拟地址空间中
char *s = (char *)shmat(id, nullptr, 0);
if (*s == -1)
{
cout << "errno:" << errno << ", errstring:" << strerror(errno) << endl;
}
else
{
cout << "server attch shm done" << endl;
}
return 0;
}
//client.cc
#include "connect.hpp"
#include <iostream>
using namespace std;
int main()
{
// 获取key
key_t key = GetKey();
cout << "key:" << ToHex(key) << endl;
// 获得共享内存
int id = CreatShm(key);
cout << "id:" << id << endl;
// 挂载
char *s = (char *)shmat(id, NULL, 0);
if (*s == -1)
{
cout << "errno:" << errno << ", errstring:" << strerror(errno) << endl;
}
else
{
cout << "server attch shm done" << endl;
}
return 0;
}
- 卸载:将共享内存和虚拟地址空间之间的链接去除
可以发现在我们卸载了之后共享内存并没有被删除说明共享内存的生命不是随进程的,而是随内核的。所以如果我们想要删除一个共享内存要怎么做呢?
4.控制:删除共享内存或者查找更改共享内存的属性
除了使用shmctl接口外我们还可以通过指令来删除共享内存即ipcrm -m +shmid
我们通过shmctl手册中还可以看见有关共享内存的结构体以及里面存储的属性那么是是否就证明了我们之前说的对共享内存的管理工作是存在并且正确的呢?
在了解了那几个接口之后我们就要尝试向共享内存中写入和读取数据了从而来观察出共享内存的通信和管道的通信有什么区别。
至于共享内存和管道这两个通信方式有什么区别,只能说共享内存是所有通信方式中最快的,而原因就是利用共享内存来通信减少了数据的迁移也可以说是数据的拷贝。
我们可以大致思考一些管道和共享内存中分别需要数据迁移多少次
- 管道:我们首先要将数据从键盘迁移到读端自己定义的缓冲区中然后使用write接口将缓冲区的数据迁移到管道文件的缓冲区中,之后我们想要读取数据还需要将数据从管道文件的缓冲区中迁移到写端自己的缓冲区中最后再将其迁移到显示屏文件中。中间甚至还省略了一些语言层面的迁移,所以使用管道来通信的话至少需要4次数据迁移。
- 共享内存:在我们创建并且分别挂载到两个进程后,我们只需要将数据从键盘中迁移到共享内存中然后再将数据从共享内存迁移到显示屏文件中,所以使用共享内存通信我们只需要2次数据迁移。
从迁移的数量上我们就可以看出来使用共享内存通信的速度是远远大于使用管道的,其主要原因就是共享内存我们只需要向内存中写入读取即可省略了那些繁琐的接口。
3.2消息队列
system V中的消息队列则是提供了一个进程向另外一个进程发送数据块的能力从而实现进程间通信。
对于消息队列同样也有系统接口,而消息队列的系统接口和共享内存的接口是相似的。
- 创建
这与共享内存的则基本相同,参数分别为标识符和权限。返回值则为msgid - 接受消息
第一个参数是消息队列的id,第二个参数是一个结构体存储了消息的类型和消息的内容,第三个参数则是发送消息的大小,第四个参数则和共享内存挂载时相同是以什么模式发送消息。
返回值则为常见的成功返回0,失败返回-1
-
接收消息
第一个参数是消息队列的id,第二个参数则和发送消息时相同也为一个结构体,第三个参数则是想要接收多少大小的消息,第四个参数为模式选择。
返回值则是成功就返回接收消息的大小即msgsz,失败返回-1。 -
控制
控制接口则与共享内存完全相同。
同时在消息队列中我们也可以找到它的结构体,而且我们发现消息队列的结构体第一个变量也是这个ipc_perm这个结构体。
3.3信号量
在理解信号量的概念之前我们需要了解一些简单的概念
为了完成进程间的通信我们需要做到让不同的进程看见同一份资源而这份资源也被叫做公共资源,并且在开发中可能会有很多的进程同时访问这份资源这也就叫做并发开发,但是在我们之前学习共享内存的时候我们发现共享内存是被暴露给所有的使用者的,那么对于共享内存这份公共资源来说是具有数据安全的问题的,所以对于公共资源我们是需要保护起来的而保护的手段主要分为互斥和同步。
而对于公共资源的保护是可以由用户来做或者由操作系统来做的,比如匿名命名管道和消息队列都是由操作系统做了保护就像管道的同步机制一样,而例如共享内存的保护就是由我们用户来做的就像我们之前利用管道完成了共享内存的同步机制。
对于互斥和同步我们现在暂时不需要深入了解只需要知道简单的概念和现象就可以,我们会在多线程的部分再次学习到。
互斥:任何一个时刻只允许一个进程访问公共资源。
同步:多个进程访问一个公共资源时是按照一定的顺序来访问的。
我们对于这些被保护起来的公共资源叫做临界资源,而对于那些访问临界资源的代码叫做临界区。
例如我们使用管道保护起来的共享内存就是一个临界资源,而在代码中我们向共享内存中写入数据读取数据的代码就是临界区。
而且我们可以很简单的发现我们想要维护临界资源其实只需要维护临界区就可以了,代码一旦被维护了那么资源就自然而然的也被维护了。
同时我们再来介绍一个概念:原子性
原子性是数据库事务的四大特性之一,它确保事务中的所有操作要么全部成功,要么全部失败回滚。这意味着事务的操作如果成功就必须完全应用到数据库,如果操作失败则不能对数据库有任何影响。
就像我们小时候吹牛说我以后只会考一百分,那么这句话带来的后果只有两个:考到一百分,没考到一百分。其中的其他情况我们都不需要考虑。
在了解了这些概念后我们就可以来谈谈信号量了
要如何理解信号量呢我们可以用一个例子来引入:看电影。
在我们去看电影的时候我我们的座位是在我们买票的时候就已经决定了还是在我们坐到座位的时候就已经决定了呢?
毫无疑问是在我们买票的时候就已经决定了,无论我们去不去看这场电影电影院都必须给我们把这个座位留着。
而信号量就是这个计数器,所以它的作用也就是保护临界资源。
信号量:表示对资源数量的计数器,每一个进程想要访问公共资源的某一份资源,就必须在访问之前申请一份信号量资源而本质就是对计数器进行–操作,只要–成功就代表该进程已经预定了资源,而如果一个进程–失败了就必须被挂起阻塞。
想要完成这种对信号量的访问我们只需要在申请资源的代码即临界区之前增加对计数器的–操作以及在代码的结尾增加对计数器的++操作即可。
那么如果信号量的资源量只有1呢?同时只能有一个进程访问这个资源,这是不是就完成了一种互斥的功能,这就是互斥锁而这种信号量就被叫做二元信号量。
在介绍了信号量的大概概念后我们可以提出两个问题:
- 在了解了信号量的作用后我们发现如果想要信号量有作用就必须让每个进程都看见同一个信号量资源,这又要如何做到呢?
对于问题我们可以采用和共享内存以及消息队列同样的操作即使用key,但是这个key就必须是操作系统提供的了。 - 信号量是为了保护公共资源所以进程在访问资源之前都会先访问信号量,那么信号量是不是就变成了一个公共资源呢?那么信号量是不是也要被保护呢?它又要怎么被保护呢?
信号量本身就是保护公共资源但是现在发现信号量本身也需要被保护这不就陷入了一种循环中,所以对于信号量的保护我们需要换一种方式,这件需要使用到我们刚刚提到的原子性,只要让进程对信号量的访问成功就完全应用,失败就完全不影响。这就完成了对信号量的保护。所以我们对访问信号量申请资源的操作叫做p操作,释放资源的操作叫做v操作,总结就是只提供pv操作来完成保护。 - 如何让进程在访问信号量申请资源失败时完成挂起阻塞操作
在信号量中不仅有计数器来完成–和++操作还会存在一个阻塞队列,只要是申请资源失败的进程就会被移入到阻塞队列中。
现在我们要来熟悉一下信号量的接口
1.创建
- 申请资源
3.控制
对于信号量我们同样可以找到它的结构体所以信号量=信号量的内容+信号量的属性。同时我们可以发现信号量的结构体也有一个ipc_serm。所以我们发现从共享内存到消息队列再到信号量都有ipc_perm这个结构体。