【Linux系统】进程间通信一
初识进程间通信
1、进程间通信目的
- 数据传输:一个进程需要将它的数据发送给另一个进程。
- 资源共享:多个进程之间共享同样的资源。
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
- 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
2、如何通信(核心)
前提: 先得让不同的进程,看到同一份资源!
同一份资源: 某种形式的内存空间
提供资源的人: 只能是操作系统!
OS 的系统调用: 因为需要先保证进程间独立性,就需要为进程间通信设计一套通信方案,使不同进程可以操作同一份资源,这个资源一定是操作系统提供 的,想要操作这个资源就免不了使用 OS 的系统调用
3、进程间通信的分类
IPC
全称是 Inter-Process Communication
,即为“进程间通信”
(若在平台上看到IPC,就表示进程间通信)
进程间通信大体分为两种:
- 本地通信: 同一台主机、同一个OS,不同进程间的通信
- 网络通信: 不同主机,同一网络中不同的两个进程之间的通信
进程间通信实现方式的具体分类如下:
- 管道
- 匿名管道pipe
- 命名管道
- System V IPC
- System V 消息队列
- System V 共享内存
- System V 信号量
- POSIX IPC
- 消息队列
- 共享内存
- 信号量
- 互斥量
- 条件变量
- 读写锁
下面文章会挑选几个相对重要的讲解:
匿名管道
1、何为管道
管道是 Unix
中最古老的进程间通信的形式。我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”。
文件描述符表被继承,共享同一 struct file
当一个进程通过 fork() 创建子进程时,子进程会获得父进程打开文件的副本,具体来说是复制了父进程的文件描述符表,而这些文件描述符指向的是内核中的同一个 struct file 结构体实例。这意味着父子进程共享同一 struct file,父子进程将通过这同一个 struct file
对他们打开的同一个文件进行操作
因此,如果父子进程同时对同一文件进行读写操作,它们会相互影响。因为 struct file
是父子共享使用的,所以该结构中的文件内核缓冲区也是共享使用的,父子进程都可以像该缓冲区读写数据,若不进行进程同步等限制,则会出现数据不一致的问题,例如同一文件数据排列顺序是乱序,例如显示器文件,若父子进程对同一显示器进行写操作,则显示器则会同时输出父子进行输入的内容,显示的数据则按照父子进行的写入顺序显示
构成进程间通信
从上面这段话可以看出,若子进程继承父进程的文件描述符表,就能实现父子共享操作某个文件的行为
这就是父子进程两个进程看到同一个资源:同一文件
这刚好构成进程间通信!
我们前面讲解过,文件 struct file
结构中,存在文件内核缓冲区,进程写入缓冲区的内容会被系统刷新到磁盘中。
问题:若该文件用于进程间通信,是否存在与磁盘进行交互的需求?
答:进程间通信是内存级的操作,通信的数据都在进程之间,在内存之中传输,并没有和磁盘交互的需求,和磁盘交互一是比较耗时,二是没必要。
因此设计师设计了一种纯内存级的文件,该文件其他属性和普通文件差不多,但是该文件不用和磁盘交互(和磁盘无关,不用刷新缓冲区到磁盘中),通过这样的文件使进程在内存中通信,拷贝传输数据!
这就是用于进程间通信的特殊文件:管道文件!
2、匿名管道的使用:概念层面
下面是对这张管道使用的步骤解释:
1、父进程创建一个这样纯内存级别的管道文件,同时以读和写的方式打开该文件(因此分配有两个文件描述符)
2、子进程继承父进程的文件描述符表时,自然也拥有对同一管道文件的读写端
3、父进程关闭写端,子进程关闭读端:子进程通过写端向该管道中写入数据,父进程通过读端向该管道中读出数据,通过这样单向的读写交互,即通过管道完成进程间通信!
问题:
问题 1:是否可以不关闭进程的读或写端,使得每个进程同时拥有管道文件的读写端呢?
答:可以,但有风险!
(1)造成 fd 泄漏:我们使用管道,一个进程只会使用读或写端的其中一个,另一个用不上,就需要关闭,若不关则占用文件描述符资源,造成fd 泄漏问题(简单来说就是 fd 的数量有限,若不关闭一端则造成浪费占用)。
(2)误操作:但不关闭读或写端,那天一个本该操作写端的进程,误操作了读端,两个进程同时读,造成出错
问题 2:能不能以读写的方式打开打开一个fd,而不是读与写打开两个fd?
答:不能。 操作系统的设计是严谨的,你该是写端操作,就不建议你拥有读端的权限,一个 fd 代表一种权限。
问题 3:能不能先创建进程,再创建管道文件呢?
答:不能!
本身就是利用了子进程会继承父进程资源的这种特性,才达到父子不同进程看到同一份资源(管道),如果先创建子进程,则父子进程其实打开管道文件,就不是同一份了(此时已经产生进程独立性的“隔离”了!)
问题 4:打开该管道文件是否需要指定磁盘路径,是否需要文件名打开?
答: 管道文件是内存级别文件,和磁盘无关了,就没有所谓路径,也没有所谓使用文件名打开
因此该管道没有名字,因此称为匿名管道!!!
2、匿名管道的理解:内核层面
所以,看待管道,就如同看待文件⼀样!管道的使用和文件⼀致,迎合了 “ Linux
⼀切皆文件思想”。
3、管道创建接口
int pipe(int fd[2]);
-
参数:
fd
: 文件描述符数组,用户传入一个两元素数组,管道创建好后会将两个文件描述符表放到该数组中给你
其,中fd[0]
表示读端,fd[1]
表示写端。
-
返回值:
- 成功返回
0
。 - 败返回错误代码。
- 成功返回
4、再谈重定向
默认 fd
重定向
我们看一个样例:使用 cout 打印正常信息,使用 cerr 打印错误信息,将打印结果重定向到一个文本文件中
注:cerr
是 C++ 中,用于指定向显示器打印错误信息。
代码如下:
#include<iostream>
#include<unistd.h>
using namespace std;
int main()
{
int fds[2] = {0};
int ret = pipe(fds);
if(ret == 0)
{
cout << "OK!" << '\n';
cout << "OK!" << '\n';
cout << "OK!" << '\n';
cout << "OK!" << '\n';
cerr << "pipe error!" << '\n';
cerr << "pipe error!" << '\n';
cerr << "pipe error!" << '\n';
cerr << "pipe error!" << '\n';
}
return 0;
}
运行结果如下:
为什么 cerr
的结果不会重定向写入文件 log.txt
中?
之前说过:
文件描述符 fd = 1
的文件为标准输出流,对应显示器文件
文件描述符 fd = 2
的文件为标准错误流,也对应显示器文件
重定向 >
:默认改变的是 fd = 1
的标准输出流文件,将该文件替换指向文件 log.txt
如果没有重定向: cout
标准输出流, cerr
标准错误流,都会向对应的显示器文件打印出来信息
如果重定向了, 默认修改的只是 fd = 1
的标准输出流文件,因此 cout
输出到 文件 log.txt
而重定向不会影响 fd = 2
的标准错误流,因此 cerr
照样向显示器打印。
重定向是默认对 fd = 1
标准输出流文件进行处理,那是否有方式可以处理指定的 fd
?
指定 fd
重定向
实际上,./mypipe > log.txt
这样重定向的写法是默认重定向 fd = 1
的文件
完整写法是:指定 fd
重定向(注意不要数字和 >
留空格:错误写法1 >
,正确写法1>
)
指定重定向 fd = 1
的文件:即重定向标准输入流文件
./mypipe 1> log.txt
cout
打印的重定向到文件 log.txt
中了,cerr
打印的在显示器上:
指定重定向 fd = 2
的文件:即重定向标准错误流文件
./mypipe 2> log.txt
这样其实就是将标准错误流重定向了,cerr
打印的结果就会放到文件 log.txt
中
cerr
打印的重定向到文件 log.txt
中了,cout
打印的在显示器上:
实现 fd=1, fd=2
同时重定向
命令 ./mypipe 1> log.txt 2>&1
先将 fd=1
重定向为 log.txt
,再 2>&1
将 fd=2
的内容重定向到 fd=1
中
相当于有两个指针同时指向同一个文件 log.txt
。
这样则两中输出都写入了文件 log.txt
5、匿名管道的使用:代码层面
声明:
1、对应 C/C++ 库函数 和 系统调用混用的场景,建议在 系统调用 前加上双冒号 ::
,没有任何“副作用”,就是为了区分一下 库函数 和 系统调用,其本身的作用是标识为全局的意思
2、管道的两个 fd 的含义: fd[0]
对应读端, fd[1]
对应写端
通过子进程向管道文件中写入数据,父进程从管道中读出数据,实现了进程间通信的效果
#include<iostream>
#include<cstdlib>
#include<cstring>
#include<unistd.h>
#include<sys/wait.h>
#include<sys/types.h>
using namespace std;
int main()
{
int fds[2] = {0};
int ret = pipe(fds);
if(ret == 0)
{
cout << "管道创建成功: \n";
cout << "fd[0] : " << fds[0] << '\n';
cout << "fd[1] : " << fds[1] << '\n';
}
// 创建子进程
pid_t pid = ::fork();
if(pid < 0) {
cerr << "pid error!" << '\n';
exit(1);
}
// 实现:子进程写,父进程读
// 子进程
if(pid == 0)
{
// 前置工作
::close(fds[0]); // 关闭读端
// 写数据
int cnt = 0;
while(1)
{
cnt++;
const char* message = "h";
// ssize_t write(int fd, const void *buf, size_t count);
ssize_t n = ::write(fds[1], message, strlen(message));
if(n < 0) {
cerr << "write error !" << '\n';
exit(1);
}
sleep(1);
}
// 结束工作
::close(fds[1]);
exit(0);
}
// 父进程
else if(pid > 0)
{
// 前置工作
::close(fds[1]); // 关闭写端
// 读数据
char buff[1024];
int cnt = 0;
while(1)
{
cnt++;
// ssize_t read(int fd, void *buf, size_t count);
ssize_t n = read(fds[0], buff, 1024);
if(n < 0) {
cerr << "read error !" << '\n';
exit(1);
}
else if(n > 0)
{
buff[n] = '\0';
cout << "父进程第 " << cnt << " 轮读取: " << buff << '\n';
}
}
// 结束工作
::close(fds[0]);
int status = 0;
int ret = ::waitpid(pid, &status, 0);
if(ret < 0){
cerr << "wait error !" << '\n';
exit(1);
}
}
return 0;
}
代码运行效果:
6、管道的四种情况
你在上面父子进程通信的代码中,给子进程的循环加上 sleep(5);
,表示让子进程慢一点输入内容
再次运行该代码,会发现,父进程迟迟不打印一次:这是因为父进程在阻塞!
首先,IPC本质是先让不同的进程,看到同一份资源!
而这份资源是共享资源,既然共享,就可能出现同时操作该共享资源的情况
当写数据的进程写到一半,读数据的进程就直接读操作,这是否合理?
不行,这造成了数据不一致问题,在线程角度就是线程安全问题。
这种共享资源,也叫做临界资源,需要保护起来,避免多方进程不按顺序操作导致数据不一致问题
我们需要保护该资源等待一个操作对象操作完成后,另一个操作对象才能操作,而这种资源由谁保护?这就是管道中保护临界资源的保护机制!
管道保护机制:当管道内有数据时,父进程(读方)直接读取数据,当管道内没有数据时,就会将父进程链入管道文件 struct file
的等待队列中,一直到有数据才可以读,没数据就阻塞等待,这样不就保证了管道数据的安全吗!
下面同时管道的 4 种情况,进一步理解管道通信的机制:
(1)管道为空&&管道正常,read 会阻塞
注:read 是一个系统调用
我们再实验一下:
子进程的写数据该成一个长一点的字符串:
const char* message = "hello_World_Linux!";
父进程一次读 3 个字符(改小一点)
ssize_t n = read(fds[0], buff, 3);
运行结果如下: 注意下面的图是动图,只不过需要等待几秒
你可以发现,程序一旦运行起来,父进程就立马读了好多次,然后需要等待几秒父进程才会再次读取
这里想说的是:
当缓冲区中有数据时,父进程会不停的将数据全部读出来
当缓冲区内没有数据时,父进程就会阻塞等待子进程写入数据,只有当缓冲区中重新有数据,父进程才会继续读取
(2)管道为满&&管道正常,write会阻塞
注:write 是一个系统调用
我们再实验一下:
子进程修改:
1、写数据改成一个字符 “h”
const char* message = "h";
2、加上写入数据的记录:统计子进程当前的写入次数
cout << "写入数据第 " << cnt << " 次" << '\n';
3、将 sleep
语句去掉:表示子进程不停的快速写入内容
父进程修改:
加上长时间 sleep(1000)
:目的是“阻塞住”父进程,先别读,等子进程写数据写一会
运行结果如下:
可以发现,程序一打开,子进程就不断写入数据,最后在写入数据第 65536 次停下了
解释原因:
因为一次写入 1 字节(一个字符 h
),因此说明本次一共写入 65536
字节数据
计算一下,65536 字节 / 1024 字节 = 64 KB
可知写入 64KB
的数据,这里证明了我们当前的 Ubuntu
系统下,管道文件缓冲区大小为 64KB
!!
表示管道是有上限的!!
面向字节流
我们再实验一下:
子进程保持数据不变,父进程修改:
1、sleep
改小点:sleep(1)
2、一次读数据的大小多点:
ssize_t n = read(fds[0], buff, 1024);
运行结果如下:
可以发现,子进程“拼命”写几万次,父进程一次就读一堆
这里可以看出:父进程读数据时,根本不用关系管道文件写入何种数据、被写入多少次,只关心我要多少,我自己进程的需求多少
这个现象叫做:面向字节流!
(3)管道写端关闭&&读端继续,读端读到0,表示读到文件结尾
如果管道的写端,它不光不写了它还退了,那么读端,它再读就没有意义了,因为管道只有一端写,一端读,写端已经关了,你还读啥呢?
如果写端关闭,读端读完管道内部的数据,在读取的时候,就会读取到返回值0,表示对端关闭,也表示读到文件结尾
因此,read
的返回值 n==0
时,表示没有数据写入,读到文件结尾了,可以直接退出管道通信
if(n==0)
{
//...
break;
}
(4)管道写端正常&&读端关闭,OS会直接杀掉写入的进程
OS
会给目标进程发信号:13 号信号 SIGPIPE
我们主动关闭父进程的 读端,并退出循环,则进入等待子进程程序中
子进程此时就会被信号 13 杀掉
#include<iostream>
#include<cstdlib>
#include<cstring>
#include<unistd.h>
#include<sys/wait.h>
#include<sys/types.h>
using namespace std;
int main()
{
int fds[2] = {0};
int ret = pipe(fds);
if(ret == 0)
{
cout << "管道创建成功: \n";
cout << "fd[0] : " << fds[0] << '\n';
cout << "fd[1] : " << fds[1] << '\n';
}
// 创建子进程
pid_t pid = ::fork();
if(pid < 0) {
cerr << "pid error!" << '\n';
exit(1);
}
// 实现:子进程写,父进程读
// 子进程
if(pid == 0)
{
// 前置工作
::close(fds[0]); // 关闭读端
// 写数据
int cnt = 0;
while(1)
{
cnt++;
const char* message = "h";
// ssize_t write(int fd, const void *buf, size_t count);
ssize_t n = ::write(fds[1], message, strlen(message));
if(n < 0) {
cerr << "write error !" << '\n';
exit(1);
}
cout << "子进程写入数据第 " << cnt << " 次" << '\n';
sleep(1);
}
// 结束工作
::close(fds[1]);
exit(0);
}
// 父进程
else if(pid > 0)
{
// 前置工作
::close(fds[1]); // 关闭写端
// 读数据
char buff[1024];
int cnt = 0;
while(1)
{
cnt++;
// ssize_t read(int fd, void *buf, size_t count);
ssize_t n = read(fds[0], buff, 1024);
if(n < 0) {
cerr << "read error !" << '\n';
exit(1);
}
else if(n > 0)
{
buff[n] = '\0';
cout << "父进程第 " << cnt << " 轮读取: " << buff << '\n';
}
else if(n == 0)
{
cout << "n: " << n << '\n';
cout << "write quit! so I need to quit, too!" << '\n';
break;
}
cout << "————————" << '\n';
cout << "关闭父进程的读端!" << '\n';
::close(fds[0]);
break;
}
// 结束工作
int status = 0;
int ret = ::waitpid(pid, &status, 0);
if(ret < 0){
cerr << "wait error !" << '\n';
exit(1);
}
cout << "father wait child success, child pid : " << pid << '\n';
cout << "退出码为: " << WEXITSTATUS(status) << '\n'; // 退出码是status的后八位,也可以位运算得出:((status<<8)&0xFF)
cout << "信号为: " << WTERMSIG(status) << '\n'; // 信号是status的前八位,也可以位运算得出:(status&0x7F)
}
return 0;
}
运行结果如下:
果然就是信号 13 干的
7、匿名管道的五大特性
这几个特性是根据上面讲解的管道的四种情况总结的来:
1、面向字节流:和管道的读写次数、读写数据类型等信息无关,我只关心我读或写的需求
2、用于具有血缘关系的进程,进行IPC,常用于父子:
只有在有血缘关系的情况下,才能继承到管道的读写端的连接,共享资源管道的读写端才能确定并运用起来
3、文件的生命周期,随进程!管道也是!
当父子进程都关闭时,管道文件也就无需存在了,即关闭
4、管道只能用于单向数据通信
因为多进程同时拥有读和写权限,则容易导致读写数据不一致问题
5、管道自带同步互斥等保护机制
当管道内没有数据时,read
阻塞,等待数据写入
当管道内数据满了,write
阻塞,等待数据被读出
这些现象的背后就证明了,管道具有同步互斥等保护管道操作顺序机制!
通俗理解互斥:去自助ATM机存取钱,就会进入一个房间,一个人进去就反锁,外面的人想要进去操作就必须等这个人结束,这就是互斥