Linux进程间通信——匿名管道,实现、特性、情况
linux进程间通信——管道的特性, 情况!!!
前言:本节内容仍是管道, 上节内容我们学习了管道的原理。 这节内容将在原理的基础上, 讲解管道的编程, 特性,应用等等。 下面开始我们的学习吧。
ps:本节内容需要了解一些管道的原理, 希望友友们了解管道管理后再来学习本节内容。
目录
特性——父子进程可以进行协同,情况——读写正常,管道为空、 读端读取会造成读端阻塞
情况——读端正常读, 写端关闭,读端读到文件结尾, 不会被阻塞
管道的接口
使用man手册查看pipe函数, pipe函数就是创建一个管道文件的接口——2号手册——man 2 pipe
参数是pipefd[2], 任何一个函数在进行形参设计的时候, 形参数组的参数不需要写(直接写一个指针), 但是这里为了提醒我们, 写成了一个2个元素的数组。 这是因为这个参数是一个输出型参数。 那么什么是输出型参数——就是把进程打开文件的文件描述符带出来, 让用户使用。 其中, 我们的pipefd[0]代表的是读端的fd, pipefd[1]代表的是写端的fd。 ——也就是说,未来不管我们的pipefd里面的文件描述符是多少。我们对应的pipefd[0]一定是我们的读端, 而我们的pipefd[1]一定是我们的写端。
下面看一下返回值:
返回值就是如果成功了, 0被返回, 如果失败了, -1被返回, 错误码被设置。
管道的编码实现
1.我们要创建一个管道, 是不是要先创建一个管道文件。让进程里面的读写端指向管道文件。 而我们上面学习了pipe函数。 pipe函数可以完美的完成这个工作。 也就是下面这个图:
我们的代码如下:
#include<unistd.h> #include<iostream> using namespace std; #define N 2 int main() { int pipefd[N] = { 0 }; //定义一个pipefd, 用来当做pipe的输出型参数 int n = pipe(pipefd); //打开一个管道文件, 然后带出里面的退出码。 if (n < 0) return 1; //创建失败 cout << "pipefd[0]:" << pipefd[0] << " " << ", pipefd[1]:" << pipefd[1] << endl;//打印pipefd[0]的值和pipefd[1]的值。 return 0; }
运行结果, 就能看到当前进程的两个fd指向了这个管道, 并且3是读端, 4是写端。
2.接着我们要创建子进程, 然后关闭父进程和子进程的一端, 让他们各留一个读或者写端在这个管道上面进行通信。 如下图:(这里我们设计一个父进程读, 子进程写的管道)
int main() { int pipefd[N] = { 0 }; int n = pipe(pipefd); if (n < 0) return 1; //创建失败 //创建子进程 pid_t id = fork(); if (id < 0) return 2;//创建子进程失败 else if (id == 0) //child { //子进程写, 父进程读。 所以要关闭子进程的读入端, 关闭父进程的写入段。 close(pipefd[0]); //核心代码 //子进程结束后关闭写端 close(pipefd[1]); } //父进程关闭写入端 close(pipefd[1]); //父进程核心代码 //父进程结束后关闭读端 close(pipefd[0]); return 0; }
上面这串代码就是创建子进程, 然后子进程关闭读入端, 父进程关闭写端。 子进程和父进程分别执行各自的代码。 然后完成工作后都关闭剩下的端口。
3.然后我们定义一个写入接口。 writer, 这个写入接口的参数只有一个写入端口的文件fd。 ——也就是说, 这个writer是给我们的子进程用的, 我们的子进程可以在自己的核心代码这里调用writer, 然后传送自己拿到的pipefd[1]写入端口。就能够向管道中进行写入了。
// #define NUM 1024 //缓冲区最大大小 void Writer(int wfd) { string s = "hello, I am child"; pid_t self = getpid(); //获取自己的进程编号。 //写一些信息的时候, 带上编号 int number = 0; //先定义缓冲区 char buffer[NUM]; while (true) { //构建发送字符串 buffer[0] = 0; //字符串清空, 可以用来提醒阅读代码的人,这个是一个字符串 // snprintf(buffer, sizeof(buffer), "%s-%d-%d", s.c_str(), self, number++); cout << "child buffer: " << buffer << endl; //测试代码, 能够查看我们的buffer里面的数据 //发送/写入给父进程 write(wfd, buffer, strlen(buffer)); //wfd是管道的文件描述符, 所以我们使用write里面传参wfd, 就能向管道里面写入数据。 sleep(1); } }
这里有一个新的接口, 叫做snprintf
snprintf的用法就是将字符串, 按照固定的格式, 并且规定可以打印的最大长度, 然后 再打印到缓冲区。
3.下面实现一下reader函数。read函数同样需要接受一个文件描述符, 用来从管道文件里面读取相应的数据。注:父进程使用的是reader函数。
// //father void Reader(int rfd) { //创建buffer char buffer[NUM]; //NUM上面定义的是1024 while (true) { sleep(5); buffer[0] = 0; //告诉全世界, 这是个字符串 ssize_t n = read(rfd, buffer, sizeof(buffer)); //sizeof != strlen, 这里不能strlen(buffer), 因为buffer里面没有\0. if(n > 0) //说明读取成功 { buffer[n] = 0; //在最后一个字符的后面添加一个\0。 } cout << "father: " << getpid() << ":" << buffer << endl; } }
为什么不用静态进行进程间的通信
现在, 有一个问题, 上面的又创建管道, 又写write, read函数。 是不是很麻烦。——如果我们直接将字符串string定义成为全局, 那么是不是就可以直接在子进程里面读到了, 就不需要创建管道文件了?
那么, 这个主要是因为, 我们通信的时候, 大多数时候通信的这些数据都是发生变化的, 比如我们发送消息的时候, 每一次消息都是变化的。 而我们的子进程在创建出来之后, 和父进程就是相互独立的。 如果我们的子进程的数据或者父进程的数据发生了修改,就会发生写时拷贝, 那么另一个进程并不会接受到这个数据。 所以不能使用静态变量。
操作系统是否允许进程直接访问管道
那么, 管道的本质上是文件, 文件的本质上是内核资源, 操作系统允许进程直接访问资源吗?
不会的, 因为操作系统不相信任何人, 所以我们读写文件的时候必须使用read和write, 这两个都是系统调用。 上面的通信中, 我们的子进程的buffer, 其实就是我们的子进程的用户级缓冲区, 然后我们使用系统调用write, 将数据写到我们的wfd文件中去, 本质就是把用户级缓冲区的数据, 拷贝到文件缓冲区面。 然后我们的父进程的buffer, 其实就相当于父进程的用户级缓冲区, 而父进程就是使用read, 将我们的内核级缓冲区的内容, 加载到我们的用户级缓冲区中去。
管道的特性与情况
特性——父子进程可以进行协同,情况——读写正常,管道为空、 读端读取会造成读端阻塞
我们写下面这个程序
#include<unistd.h>
#include<iostream>
#include<cstdio>
#include<cstdlib>
#include<sys/types.h>
#include<sys/wait.h>
#include<cstring>
using namespace std;
#include<string>
#define N 2
#define NUM 1024 //缓冲区最大大小
//child
void Writer(int wfd)
{
string s = "hello, I am child";
pid_t self = getpid(); //获取自己的进程编号。
//写一些信息的时候, 带上编号
int number = 0;
//先定义缓冲区
char buffer[NUM];
while (true)
{
sleep(1);
//构建发送字符串
buffer[0] = 0; //字符串清空, 可以用来提醒阅读代码的人,这个是一个字符串
//
snprintf(buffer, sizeof(buffer), "%s-%d-%d", s.c_str(), self, number++);
//cout << buffer << endl; //测试
//发送/写入给父进程
write(wfd, buffer, strlen(buffer)); //这用不用加1, 不需要。 因为这里是管道文件。 是打印的内容。 不需要分隔符!
// cout << number << endl;
}
}
// //father
void Reader(int rfd)
{
//创建buffer
char buffer[NUM]; //NUM上面定义的是1024
while (true)
{
buffer[0] = 0; //告诉全世界, 这是个字符串
ssize_t n = read(rfd, buffer, sizeof(buffer)); //sizeof != strlen, 这里不能strlen(buffer), 因为buffer里面没有\0.
if(n > 0) //说明读取成功
{
buffer[n] = 0; //在最后一个字符的后面添加一个\0。
}
cout << "father: " << getpid() << ":" << buffer << endl;
}
}
int main()
{
int pipefd[N] = { 0 };
int n = pipe(pipefd);
if (n < 0) return 1; //创建失败
// cout << "pipefd[0]:" << pipefd[0] << " " << ", pipefd[1]:" << pipefd[1] << endl;
//child->w, father->r;
pid_t id = fork();
if (id < 0) return 2;//创建子进程失败
else if (id == 0) //child
{
//子进程写, 父进程读。 所以要关闭子进程的读入端, 关闭父进程的写入段。
close(pipefd[0]);
//IPCcode
Writer(pipefd[1]);
close(pipefd[1]);
}
//父进程关闭写入端
close(pipefd[1]);
Reader(pipefd[0]);
//父进程要等待子进程
pid_t rid = waitpid(id, nullptr, 0); //父进程读取数据后,要等待子进程, 这里的退出码并不关心。
if (rid < 0) return 3;
close(pipefd[0]);
return 0;
}
为了验证这个特性, 我们要监控一下我们上面的程序, 这里用到了监控脚本
在我们的想象之中,当父亲读取管道内数据的时候, 是不是一直向后读, 然后指针一直向后偏移。 但是如果我们没有向管道中写入过数据, 那么此时是不是管道内就是一堆乱码, 那么父进程读取的时候读取的是不是就是一堆乱码。 也就是说父进程会读取一堆垃圾上来。 而现在我们的子进程一秒写入一次, 但是父进程在疯狂的读取。那么我们的父进程根本不会等待子进程, 那么我们发现的结果就是, 子进程每隔一秒写一条消息。而父进程确实疯狂的向下读取, 然后我们打印读到的内容就是一串串乱码。——这是我们想象中的。
而事实上呢, 我们的父进程并没有打印乱码,
很显然我们的父进程是要照顾子进程的, 那么,你给我写了一条消息, 我把消息读完了。 那么没有数据的时候, 我们的父进程就必须要等待我们的buffer有数据的时候再进行读取。 ——这个过程中, 父进程在等待的时候管道内是没有数据的, 也就是说, 父进程这个时候处于一个阻塞状态!!!——这, 就是管道的情况之一。
那么我们再来看,假如我们的写端的sleep的秒数变为了5秒。 那么我们观察:
就会发现, 我们的终端上面, 每五秒就会打印一次数据。 那么这说明什么? ——当我们写端1s写一次的时候, 读端默认一秒读一次;当我们写端5s写一次的时候, 读端又默认5s读一次。 这就说明, 我们的读端和写端是可以协同的!!!
在我们的父子进程中, 父子在对同一个资源进行访问,我们知道, 父子进程的本质前提是需要让不同的进程, 看到同一份资源。 ——那么这一份资源,其实呢, 它是被多执行流共享的, 难免出现访问冲突问题。就会引发临界资源进程的问题(具体什么情况博主也不清楚), 就比如我们上面的进程, 如果我们的父进程刚刚读进来一个hello, 拷贝到应用层, 可是对应的子进程, 又把后面的数据给修改了。 此时我们的父进程一半读的是旧的, 一半读的是新的, 那么就不行了。但是常识告诉我们当我们的进程正常通信的时候不会发生这种情况, 所以, 这里就有一个结论——父子进程是会进行协同的, 同步和互斥的。——这个是为了保护管道的数据安全。
特性——管道是面向字节流的
现在我们不让写端去等待了, 改成让读端去等待。
我们就会发现, 我们运行程序后,一开始会疯狂的向buffer里面写入。 然后5秒之后就立马疯狂写入了。(注意, 下面两张图片的PID不相同, 是因为博主的操作太慢了, 来不及截图。 所以就实现了两次, 但是对于我们理解该特性影响不大)
这个现象说明了什么呢? ——首先我们来观察一下这个现象。 就是我们的写端疯狂写, 然后一瞬间打印出两千多个数字, 之后就等待了五秒, 然后开始疯狂写数据。 ——其中, 我们知道, 写端疯狂写, 所以一瞬间就能写入两千多次(number每写入一次就加1)。但是我们要知道, 管道也是有大小的。 所以一瞬间子进程就能将管道打满, 然后就不能再写了。 当5s之后管道的数据被读走了之后才能再次写入。——也就是说, 这个现象说明了我们的管道是由固定大小的!!
但是问题来了——为什么我们的父进程一下子就把所有的数据都读上来了呢?对于我们能对应的父进程来讲, 子进程曾经写了多少次, 根本不重要, 只要管道里有数据, 并且只要缓冲区不满, 我们的父进程有多少读多少。 至于这个数据变成什么格式, 由用户去做, 管道不去管!!! ——而这, 也是管道的第四个特点:管道是面向字节流的。
也就是说, 我们在管理管道的时候, 写端写了一次, 写了100次, 读端是不管的。 可能曾经写端写了100次, 但是读端读的时候, 1次就读完了。 对的拿到的数据的格式, 分隔符等等, 我们的读端是不管的, 在我们的读端看来, 全部都是一个一个的字节, 这些格式区分等等, 全部都是由上层去做。 这种特性就叫做字节流。 就像自来水, 自来水并不关心水与水之间的边界, 它是一下子浇灌下来, 对应的如何接住这些水, 这些水怎么用, 就由我们人去管理。
特性——管道是基于文件的,文件的生命周期是随进程的。
现在再来看这么一个问题, 就是如果我们的进程退出了, 这个文件会怎么办呢? ——我们的进程一旦推出, 我们的进程的文件描述符, struct_file的结构会怎么办?——答案就是会被操作系统回收。 那么管道也是一样, 当父子进程都准备退出的时候, 管道就会自动被释放掉。 ——管道是基于文件的, 而文件的生命周期是随进程的。——这也是管道的第五个特性。
情况——读写端正常, 管道如果被写满, 写端就要被阻塞
管道在进行操作的时候, 很明显有固定的大小——有多大呢?——这个就可以使用ulimit -a;
这个指令能查到操作系统对于很多重要资源的限制, 比如说openfiles, 就是单个进程能打开文件的个数。
我们的管道大小呢, 其实就是512bytes * 8 也就是4kb。 所以, 一个管道的大小就是4kb。
下面我们来进行测试, 下面是代码和运行图:
#include<unistd.h>
#include<iostream>
#include<cstdio>
#include<cstdlib>
#include<sys/types.h>
#include<sys/wait.h>
#include<cstring>
using namespace std;
#include<string>
#define N 2
#define NUM 1024 //缓冲区最大大小
//child
void Writer(int wfd)
{
string s = "hello, I am child";
pid_t self = getpid(); //获取自己的进程编号。
//写一些信息的时候, 带上编号
int number = 0;
//先定义缓冲区
char buffer[NUM];
while (true)
{
char c = 'c';
write(wfd, &c, 1);
number++; //一次打印一个字符, 打印一个字符, number加一, 然后打印number
cout << number << endl;
}
}
最后打印到是65536,使用计算器计算之后就是64kb。 那么就有一个问题, 明明我们使用ulimit -a查着是4kb啊, 怎么又变成了64kb了呢? ——这里要说的是, 管道是有大小的, 这个大小在不同的内核里面大小可能有差别, 我们验证的这个在用的系统, 管道的大小是64kb。
有一个PIPE_BUF的概念。 这个东西我们使用man手册之后会看到
我们在向管道写入的时候, 比如我们写了hello world, 我们的子进程刚写, 父进程就把数据都走了。 那么此时world就是一个旧的数据, 清空了,可以被覆盖了。 或者我们正在读hello world, 子进程正在写其他内容, 这就会发生子进程正在写hello world, 父进程就把hello的一部分读走了。 或者这么说, 我们此时有一个规定, 就是双方必须把hello world作为一个整体被上层都上去。
但是呢, 我们此时的子进程刚刚写了一个hello, 就被父进程拿走了。 父进程只拿到了一个上层的hello, 这个时候, 数据就不完整了。 所以呢, 我们必须要保证, 父进程要么不读, 要么一读就是把数据全部读完。 这个就是读取的原子性问题——所以呢, 我们的管道为了保证他读写时的原子性,他就规定了一个PIPE_BUF的大小, 只要我们的父进程或者子进程读写的单位是小于一个PIPE_BUF的, 我们读写的过程, 就是原子的, 也就是说, 如果hello world的长度小于PIPE_BUF, 那么父进程就不会来读。 这里我们的PIPE_SIZE就可以当成PIPE_BUF的大小。
而上面我们的计算出来的64kb, 其实就是我们的管道的大小, 当我们管道内的数据达到这个大小的时候, 就不能再向里面写入数据了。——这就是读写端正常, 管道如果写满, 写端就要被阻塞!!!
情况——读端正常读, 写端关闭,读端读到文件结尾, 不会被阻塞
我们的子进程每隔一秒写上一条消息, 所以, 父进程确实写上了这一个字符, 并且返回值是1!
但是呢, 之后,我们5秒之后就会疯狂的打印0:
为什么会打印0呢? 我们看一下我们的read:
就是说, 如果读取数据成功, 我们会返回我们的字节数。, 但是当遇到文件结尾的时候, 就会返回0。
所以,这里的结论就是, 如果读端正常在读, 写端直接关了。 读端就会读取到0, 表明读到了文件(pipe)结尾,不会被阻塞。——为了预防这种情况要判断一下n 是否为 0。 如果为0就停止读取即可。
情况——写端正常写入, 读端关闭,通过信号使写端进程退出
我们先来思考一个问题, 就是如果写端正常写入, 读端关闭, 这种情况下读写还有没有必要进行呢?——注意, 操作系统是不会做, 低效, 浪费等类似工作的, 如果做了, 那么就是操作系统的bug。 如果我们今天打开一个管道文件, 那么这个管道文件就一定会配套着一个缓冲区。 今天读端关闭了, 那么我们今天对应的写端还在写, 已经没有人来读了, 那么写端再写还有什么意义呢?——答案是没有意义, 所以操作系统就要退出正在写入的进程。 那么如何退出这个进程呢? ——通过信号退出写端进程。
那为什么我们这里使用子进程写入,父进程读取呢? ——如果我们先用父子通道模拟写端正常写入, 读端关闭, 模拟现象之后, 子进程是写入的, 但是读端也就是父进程关闭了。 然后呢, 父进程就要被阻塞在等待处。 然后子进程一写就会崩溃, 崩溃之后父进程就能读到子进程, 是想要利用这样的模式, 子进程确实被信号杀掉了, 第二个就是能够知道是被几号信号杀掉的。
实验如下:
首先看一下write——对于写端来说, 我们让这个写端, 一直写, 子进程也就是写端, 不断地构建大字符串把写的内容写给父进程, 每隔一秒就写一次。
//child
void Writer(int wfd)
{
string s = "hello, I am child";
pid_t self = getpid(); //获取自己的进程编号。
//写一些信息的时候, 带上编号
int number = 0;
//先定义缓冲区
char buffer[NUM];
while (true)
{
sleep(1);
//构建发送字符串
buffer[0] = 0; //字符串清空, 可以用来提醒阅读代码的人,这个是一个字符串
//
snprintf(buffer, sizeof(buffer), "%s-%d-%d", s.c_str(), self, number++);
// cout << buffer << endl; //测试
write(wfd, buffer, strlen(buffer));
}
}
读端——对于读端来说, 父进程直接读5秒, 智能接退出。 那么会造成什么结果呢? 就是子进程卡在那里, 一直在写, 而读端已经关闭了。
//father
void Reader(int rfd)
{
//创建buffer
char buffer[NUM];
int cnt = 0;
while (true)
{
buffer[0] = 0; //告诉全世界, 这是个字符串
ssize_t n = read(rfd, buffer, sizeof(buffer)); //sizeof != strlen, 这里不能strlen(buffer), 因为buffer里面没有\0.
if(n > 0) //说明读取成功
{
buffer[n] = 0; //在最后一个字符的后面添加一个\0。
cout << "father: " << getpid() << ":" << buffer << endl;
}
else if (n == 0) break;
else break;
cnt++;
if (cnt > 5) break;
}
}
主函数——主函数需要改动的有些多, 这里先放一个图片来解释:
然后代码如下:
int main()
{
int pipefd[N] = { 0 };
int n = pipe(pipefd);
if (n < 0) return 1; //创建失败
//child->w, father->r;
pid_t id = fork();
if (id < 0) return 2;//创建子进程失败
else if (id == 0) //child
{
//子进程写, 父进程读。 所以要关闭子进程的读入端, 关闭父进程的写入段。
close(pipefd[0]);
//IPCcode
Writer(pipefd[1]);
close(pipefd[1]);
}
//子进程写端完毕
//父进程关闭写入端
close(pipefd[1]);
Reader(pipefd[0]);
close(pipefd[0]);
cout << "father close read fd: " << pipefd[0] << endl; //将读端打出来。
sleep(10);
int status = 0;
//父进程要等待子进程
pid_t rid = waitpid(id, &status, 0); //父进程读取数据后,要等待子进程, 这里的退出码并不关心。
if (rid < 0) return 3;
cout << "wait child success : " << rid << "exit code: " << ((status >> 8 )&0xFF) << "exit signal: " << (status&0x7F) << endl;
sleep(5);
cout << "father quit" << endl;
return 0;
}
上面的程序运行后, 能够观察到的现象就是, 父子进程开始都在运行。 但是五秒之后, 父进程的读端关闭,此时子进程成为僵尸进程。 十秒之后, 子进程被回收, 然后打出子进程的推出信号。 五秒之后进程退出。
这个信号就是13, 我们可以看到信号就是下面的PIPE信号
我们利用监视脚本就是如下:
开始我们没有运行程序, 这个时候只会打印PID的标识符以及分隔符这些。
经过10s之后子进程僵尸进程期间:
然后这里回收子进程后又经过了5秒, 然后父进程退出。
——以上就是本篇的全部内容, 本篇内容到此就结束啦, 感谢友友的阅读, 下面是本节的笔记, 和正文几乎一样的, 觉得本节内容有用的话可以保存方便查阅哦。
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)