【Linux】进程间通信1——管道概念,匿名管道

掘根 2024-07-18 12:07:02 阅读 53

1.进程间通信介绍

        进程是计算机系统分配资源的最小单位(严格说来是线程)。每个进程都有自己的一部分独立的系统资源,彼此是隔离的。为了能使不同的进程互相访问资源并进行协调工作,才有了进程间通信。

      进程间通信,简称为 <code>IPC(Interprocess communication),顾名思义,就是进程与进程之间互通信交流,OS保证了各进程之间相互独立,但这不意味着进程与进程之间就必须完全隔离开,在不少的情况下,进程之间需要相互配合共同完成某项任务,这就要求各进程之间能够互相交流。

1.1、进程间通信的概念

        每个进程各自有不同的用户地址空间,任何一个进程的全局变量在另一个进程中都看不到,所以进程之间要交换数据必须通过内核,在内核中开辟一块缓冲区,进程1把数据从用户空间拷到内核缓冲区,进程2再从内核缓冲区把数据读走,内核提供的这种机制称为进程间通信(IPC,InterProcess Communication)

1.2.进程间通信的目的

进程间通信的目的和原因,有如下几个点

数据传输:一个进程需要将它的数据发送给另一个进程资源共享:多个进程之间共享同样的资源通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变

总得来说,实现进程间通信就是为了进程之间能够协同完成某项任务

1.3..进程间通信的本质

进程间通信的本质是让 不同的进程看到同一份资源(内存 , 文件,内核缓冲等)

资源由谁(OS的哪些模块)提供 , 就有了不同的进程间通信方式!

这里的模块可以是: (文件–管道) , (OS内核IPC提供- SystemV IPC) , (网络–套接字)

                                

进程运行的时候是具有独立性的!(数据层面) , 因此进程之间要实现通信是非常困难的。

各个进程之间若想实现通信,一定要借助第三方资源,这些进程就可以通过向这个第三方资源写入或是读取数据,进而实现进程之间的通信,这个第三方资源实际上就是操作系统提供的一段内存区域。

通信的本质就是”数据的拷贝“

1.4.进程间通信分类

管道

匿名管道命名管道

System V IPC

System V 消息队列System V 共享内存System V 信号量

POSIX IPC

消息队列共享内存信号量互斥量条件变量读写锁

        进程间通信起初有很多不同的的相关协议,随着不断的实践发展,目前主要有两个主流的通信规则,一个是System V,另一个是POSIX

POSIX:让通信过程可以跨主机,System V 标准如今比较少用了,但其通信速度极快的共享内存还是值得深入学习的System V:聚焦在本地通信,POSIX 是 Unix 系统的一个设计标准,很多类 Unix 系统也在支持兼容这个标准,如 Linux , POSIX 标准具有跨平台性,就连 Windows 也对其进行了支持,后续学习 同步与互斥 时,所使用的信号量等都是出自 POSIX 标准,这是进程间通信的学习重点,POSIX 标准支持网络中通信,比如 套接字(socket) 就在此标准中

        由于System V由于制定的比较早,不支持跨主机间的通信,在今天属于比较陈旧的标准了,因此我们会将更多精力放在POSIX上,不过POSIX通信并不是这篇文章的内容,因此不会提及,而System V我们关注比较重要的共享内存的概念,通信规则并非只局限于POSIX和System V,我们先介绍比较简单易接受的管道通信

管道可以说是十分古老且简单了,适合深入学习,探究进程间通信时的原理及执行流程 

2.管道通信

1.什么是管道?

管道是Unix中最古老的进程间通信的形式。我们把从一个进程连接到另一个进程的一个数据流称为一个"管道"管道是一种最基本的进程间通信机制。 把一个进程连接到另一个进程的一个数据流称为一个“管道”,通常是用作<code>把一个进程的输出通过管道连接到另一个进程的输入

管道的实质是一个内核缓冲区,进程以先进先出的方式从缓冲区存取数据,管道一端的进程顺序的将数据写入缓冲区,另一端的进程则顺序的读出数据。 该缓冲区可以看做是一个循环队列,读和写的位置都是自动增长的,不能随意改变,一个数据只能被读一次,读出来以后在缓冲区就不复存在了。 

2.管道, 其实是 <code>一个打开的文件 . 但是这个文件很特殊, 向这个文件中写入数据实际上并不会真正写入磁盘中.

Linux 中一切皆文件,所以管道本质上就是一个文件  

在介绍Linux系统的文件描述符时, 简单介绍了Linux系统中 描述已打开文件的结构体files_struct, 其中存储着 指向打开文件的数组fd_array, 此数组的类型是 struct files*.

而这个 files结构体中, 直接或间接描述了文件的所有属性, 以及 此文件的缓冲区相关信息:

缓冲区信息中, 包含着描述文件的inode结构体, 而inode结构体中其实描述着一个联合体:

 这个处于inode结构体中的联合体, 其实就是为了标识这个文件的类型, 其中pipe 就表示此文件的类型是管道文件.

通过文件的inode, 系统可以辨别出打开的文件是管道文件.

而**<code>向管道文件中写入数据实际上并不会写入到磁盘上, 而是只写入到文件的缓冲区中** , 因为管道文件主要是用来进程间通信的, 如果先写入磁盘另一个进程再读取, 整个过程就太慢了

这种不实际存储数据的行为特点, 其实也符合生活中管道的特点, 管道不能用来存储资源, 只能用来传输资源

并且, 除了管道不实际存储资源以外, 管道还有一个特点:管道是单向传输的

这是管道的特点, Linux的管道也是遵循这个特点的, 也就是说, 两个进程间使用管道通信时, 其中一个进程若以只写方式打开管道, 那么另一个进程就只能以只读方式打开文件.

3.管道通信主要是借助文件系统来实现的,怎么理解呢?

        我们假设现在系统上的进程A和进程B要互相通信,A不能直接去B里面读数据,因为进程具有独立性,那该怎么办呢?这就需要找一块空间C,空间C用来存放通信双方通信的数据,现在进程A要给B发送数据,那么A和B要向系统声明建立连接,申请一块空间C,然后A往空间C里发送数据,B从空间C里读取数据,这样A就实现了和B的通信这块空间C就像一根管道一样,连接着A与B,整个管道通信的基本原理就是如此,当然这只解释了管道名称的由来,并没有解释管道通信是借助文件系统来实现的

        我们要理清楚如何在Linux系统中让两个进程读取到同一块内存空间,如果看过IO篇的同学应该会想到,那就是通过文件,进程从磁盘中或除自身以外的其他可读写的内存区域中读取或写入数据主要是通过文件系统来解决的只要系统在内存中创建一个文件,A进程打开这个文件,B进程也打开这个文件,那么A与B就通过这个文件连接起来进行通信了,这就是管道通信是借助文件系统来实现的原因

我们举个例子

        在<code>shell中执行命令,经常会将上一个命令的输出作为下一个命令的输入,由多个命令配合完成一件事情。而这就是通过管道来实现的。|这个竖线就是管道符号

ls -l | grep string //grep是抓取指令

ls命令(其实也是一个进程)会把当前目录中的文件都列出来但它不会直接输出,而是把要输出到屏幕上的数据通过管道输出到grep这个进程中,作为grep这个进程的输入;然后这个进程对输入的信息进行筛选(grep的作用),把存在string的信息的字符串(以行为单位)打印在屏幕上。

3.匿名管道

经过上述的说明,我们已经明白了管道通信就是用来实现进程与进程之间的通信,但是进程与进程之间的通信也分为两种

一种是父子进程或兄弟进程之间的通信另一种则是没有亲属关系的进程间的通信

匿名管道的创建, <code>不会指定打开文件的文件名、文件路径等, 即不会有目标的打开文件

只是在内存中打开一个文件, 用于进程间的通信

而由于匿名管道是非明确目标的文件, 也就意味着两个完全不相关的进程是无法一起访问这个管道的, 因为完全不相关的进程无法找到这个管道文件.

这也就意味着, 匿名管道其实只能用于具有血缘关系的进程间通信.

父子进程之间是共享代码和数据的,但这个数据共享只能用来读,一旦一方试图使数据发生变化会触发写时拷贝,父进程与子进程的数据就存放到了不同的地址,这时父子双方该如何通知对方数据发生了变化呢?

        这就是匿名管道通信要研究的东西

        进程间通信的本质就是,让不同的进程看到同一份资源,使用匿名管道实现父子进程间通信的原理就是,让两个父子进程先看到同一份被打开的文件资源,然后父子进程就可以对该文件进行读写操作,进而实现父子进程间通信

        子进程拷贝父进程的fd_array,父子进程看到同一份文件 , 这里父子进程看到的同一份文件资源是由操作系统来维护的,所以当父子进程对该文件进行写入操作时,该文件缓冲区当中的数据并不会进行写时拷贝(文件并不存在磁盘,只在内存中存在)。

        管道虽然用的是文件的方案,但操作系统一定不会把进程进行通信的数据刷新到磁盘当中,因为这样做有IO参与会降低效率。也就是说,这种文件是一批不会把数据写到磁盘当中的文件,换句话说,磁盘文件和内存文件不一定是一 一对应的,有些文件只会在内存当中存在,而不会在磁盘当中存在

3.1.创建匿名管道的原理

        创建一个管道也就是系统在内存中创建一个文件,进程A与进程B通过这个文件互相读取数据,这就涉及到另一个问题,是否进程A和进程B可以双向通信,即都可向管道文件中读写数据,若可以,则进程A与B又该如何分辨自己该读取哪部分数据呢?

        可能你会说等A写完,B赶紧读,然后B再写,A再读,这样会有潜在的隐患,因为A写的时候你要阻止B写入,如果这个时候B有很重要的数据不能及时写入,就造成数据丢失

        因此我们规定管道通信都是单向通信,创建一个管道时,只能由一方负责写,一方负责读,这是在创建管道时就要决定好的,如果要实现双方都可以读写,那就创建两个管道,创建两个管道无非是创建两个文件罢了,开销并不大

        管道的通信是单向的,也就是A进程在管道通信时,既可以做写方,又可以做读方,系统如何区分此时A是写还是读呢?

        解决办法就是让父进程以读和写两种形式分别打开管道文件,也就是我们需要一个数组,这个数组只有两个元素,用来记录以读的形式打开管道文件的fd以及以写的形式打开管道文件的fd,然后根据相关情况,选择关闭其中一个,这也是为什么在声明管道通信前要先声明

我们从文件描述符视角来看 这个过程

也就是说, 匿名管道的创建应该是 <code>由父进程创建, 然后创建子进程继承父进程的管道, 然后再关闭管道的写入端或读取端

这样就创建了一个管道通信

1.为什么父子进程要分别以只读和只写方式打开两次文件, 然后再创建子进程呢?

为什么不是父进程以一个方式打开, 子进程再以另一个方式打开呢?

因为子进程会以继承父进程的方式打开同一个文件, 即子进程打开文件的方式与父进程是相同的

那这样的话, 父子进程通过想要通过管道实现进程通信, 子进程就需要先关闭已打开的文件, 再以某种方式打开同一个文件

这样比较麻烦, 如果在创建子进程之前, 父进程就已经以两种方式打开同一个文件, 那么再子进程创建之后, 只需要父进程关闭一个端口, 子进程关闭另一个端口就可以了

2.必须父进程关闭读取端, 子进程关闭写入端吗?

并不是的, 父子进程关闭哪个端口, 其实是 根据需求 关闭的.

如果子进程要向父进程传输数据, 那么关闭读取端的就应该是子进程

3.进程是如何知道管道被打开了什么端口的?或者说 进程是如何知道管道被打开了几次的?

其实在file结构体中, 存在一个计数变量 f_count:

不过, 这个变量实际上还是一个结构体, 用于计数

很好,到这里原理结束

3.2.pipe函数

Linux操作系统提供了一个接口来进行匿名管道的创建与使用 

<code>#include <unistd.h>

int pipe(int fd[2]);

//功能:创建一个无名管道

fd:文件描述符数组,其中fd[0]表示读端, fd[1]表示写端返回值:成功返回0,失败返回错误代码

 注意:该函数的参数是输出型参数,在传参fd时要先创建fd,也就是事先声明 int fd[2];

且, pipe(), 如果 创建管道成功, 则返回0, 否则返回-1, 并设置errno

pipe系统调用的作用是, 打开一个管道文件. 其参数是一个 输出型参数

在pipe系统调用 执行成功之后, 参数数组内会存储两个元素 :

pipe[0], 存储以 只读方式 打开管道时获得的fdpipe[1], 存储以 只写方式 打开管道时获得的fd

之后就可以根据需求, 选择父子进程的端口关闭

3.3.创建匿名管道

我们将按照原理一步一步来讲解

int fd[2];//这个语句就是用来记录进程分别以读写端打开管道文件的fd

int main() {

int fd[2];

int check = pipe(fd);

if (check != 0) {

printf("create pipe error\n");

return 0;

}

}

进程在创建子进程时,子进程会拷贝一份父进程的进程地址空间,同样的,子进程也会拷贝父进程的文件描述符表

<code>int main() {

int fd[2];

int check = pipe(fd);

if (check != 0) {

printf("create pipe error\n");

return 0;

}

pid_t id = fork();

if (id > 0) { /*执行父进程代码*/ };

if (id = 0) { /*执行子进程代码*/ };

return 0;

}

接下来,我们明确父子进程谁是读端,谁是写端,就可以进行通信了,这里我们让父进程写数据给子进程,那么父进程就要关闭自己的读端,子进程就要关闭自己的写端

fd[0]是读端,fd[1]是写端

巧记:按照读音的顺序,读写,01,正好对应。还有1像一支笔,所以是写端,0像张开的嘴,所以是读端

<code>int main() {

int fd[2];

int check = pipe(fd);

if (check != 0) {

printf("create pipe error\n");

return 0;

}

pid_t id = fork();

if (id > 0) {

close(fd[0]);

/*关闭父进程的读端,接着执行父进程代码*/

};

if (id = 0) {

close(fd[1]);

/*关闭子进程的写端,接着执行子进程代码*/

};

return 0;

}

现在读写双方都确定了,那写方如何给读方发数据,读方又如何读取写方的数据呢?

既然管道通信是借助文件系统实现的,那么是不是......没错,就是使用read和write函数,接下来通过一个demo来示例这个通信过程

#include<stdio.h>

#include<string.h>

#include<unistd.h>

#include<stdlib.h>

#include<sys/stat.h>

#include<sys/types.h>

#include<fcntl.h>

#include<sys/wait.h>

int main()

{

int fd[2];

int check = pipe(fd);//声明创建管道

if (check != 0) { printf("create pipe error"); return 0; }

char test_buff[64] = "this is a communication test";

pid_t id = fork();

if (id > 0) {

close(fd[0]);

write(fd[1], test_buff, sizeof(test_buff));

wait();

}

if (id == 0) {

close(fd[1]);

memset(test_buff, 0, sizeof(test_buff));

read(fd[0], test_buff, sizeof(test_buff));

printf("测试结果:%s\n", test_buff);

}

return 0;

}

我们成功实现了父子进程之间的通信,接下来我们修改部分代码,然后刨析一下通信的过程

如下是修改后的代码以及运行结果

<code>#include<iostream>

#include <string.h>

#include <unistd.h>

#include <stdlib.h>

#include <sys/stat.h>

#include <sys/types.h>

#include <fcntl.h>

#include<sys/wait.h>

using namespace std;

int main()

{

int fd[2];

int check = pipe(fd);

if (check != 0) { std::cout << "create pipe error" <<endl; return 0; }

char test_buff[64] = "this is a communication test";

pid_t id = fork();

if (id > 0)

{

close(fd[0]);

write(fd[1], test_buff, sizeof(test_buff));

std::cout << "我是父进程,我的pid是"<<getpid() << endl;

}

if (id == 0)

{

close(fd[1]);

while (true)

{

sleep(1);

memset(test_buff, 0, sizeof(test_buff));

read(fd[0], test_buff, sizeof(test_buff)-1);

std::cout << "我是子进程,我的pid是"<<getpid() << endl;

std::cout << test_buff << endl;

}

}

wait(nullptr);

return 0;

}

1.观察运行结果可以发现,子进程循环两次后就卡在了那里不动了,这是什么原因呢?

        这是因为read函数是一个阻塞式函数,在上述程序中,父进程往管道中写入一次数据后,就进入了wait阻塞,等待回收子进程,子进程则是循环从管道中读取数据,等到把管道中的数据读完的时候,而父进程又没有关闭它的写端,此时子进程的read函数就会进入读堵塞状态,等待父进程继续向管道中写入数据,父进程已经进入了进程阻塞等待,自然不会再向管道中写入数据,因此就进入了卡死状态

     2.   细心的小伙伴可能会有疑惑,父进程只向管道中写入一次数据,子进程读取一次就应该将数据读取完了呀,子进程循环一次就该进入堵塞,而运行结果显示子进程的循环进行了两次呢?

这是因为,我们使用write函数进行写入时,写入的大小是sizeof(test_buff)也就是64个字节,而我们用read函数读取数据的时候,读取的是sizeof(test_buff)-1,也就是63个字节,此时管道中还剩一个字节,管道并不为空,因此read函数还可以读取一次,所以循环就进行了两次

3.可能你会想为什么要读取sizeof(test_buff)-1个字节呢?一次读完不好吗?

这是因为在C语言中有些函数会自动给字符数组的末尾添加/0,而有的函数又不会自动添加,如果一次读完,遇到了末尾自动添加/0的函数,就会将末尾的数据给覆盖掉,导致数据丢失,因此在不能分辨某个字符处理函数是否会自动在字符末尾添加/0的时候,为了安全,我们统一把字符数组的最后一位给留出来,也就是数据只读取字符数组大小-1个

上述的情况是父进程进入阻塞等待时,并没有关闭写端,导致子进程的read函数误认为父进程还会向管道中写入数据,于是就进入阻塞状态一直等待。

4.如果父进程写完了,并且关闭了自己的写端呢?

如果管道中还有数据,那么子进程的read函数会继续读取数据,如果管道中没有数据了,那么read函数就会返回0,不会进入阻塞等待状态,因此在循环读取的场景下,一定要注意接收read函数的返回值,不然会进入死循环的状态的

5.如果父进程往管道中写入的数据很快,而子进程读取的速度比较慢的话,会出现什么情况呢?

我们前面说过管道通信文件是借助文件系统实现的,但是管道通信文件跟一般的文件还不太一样,管道通信文件不像普通文件一样可以存放到磁盘中,管道通信文件不存放到磁盘上,和磁盘没有关系且没有inode,是操作系统临时分配的一块固定大小的内存,我们也称其为管道缓冲区,所以当写的速度太快,读的速度太慢,管道缓冲区被写满的时候,此时写方就会进入写入阻塞状态,直到缓冲区足够再次容纳写入的数据时,才会再次允许写入

6.还有一种场景,如果写方还在继续向管道缓冲区写入数据时,而读方却关闭了读端,那么此时系统就会终止并杀死写端,因为读方都已经关闭读端了,再写也没有意义了

这样子就引出了四大特殊情况,我们下面再说

7.说了这么多,貌似并没有解释为什么会叫匿名管道?

父子进程之间进行通信时,临时创建的这个管道文件并没有对应的文件名和inode,只是系统分配的一块内存空间,可以以文件的形式被父子进程打开或关闭,这一切工作都在不知不觉中由OS全部完成了,所以称为匿名管道,等命名管道文件看完,也可以回头对比着理解

8.现在我们回过头来理解命令ps ajx | grep pid

管道符|用于将一个命令的输出作为另一个命令的输入。在这个命令中,ps ajx命令的输出将作为grep pid命令的输入。 当这个命令在shell中执行时,shell会创建一个匿名管道。ps ajx命令形成的进程作为管道的写端,将其输出写入管道;而grep pid命令形成的进程作为管道的读端,从管道中读取输入。 因此,ps ajx和grep pid都作为shell的子进程,通过匿名管道进行通信。ps ajx将其输出写入管道,而grep pid从管道中读取数据,实现了两个命令之间的通信

4.管道的4中特殊情况 

我们看段代码,先确定几个事项,父子进程读写问题,这里我以父进程为写端,子进程为读端(相反也行)。

<code>#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)进行读取,我们修改写端接口如下:

<code>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 大小! 注意管道文件的大小依据平台的不同也各不相同

所以我们得到的 结论 是:当管道内部被写满,且读端不关闭自己的文件描述符,写端写满之后,就要进行阻塞等待

②读端进程不读,写端进程一直写,那么当管道被写满后,对应的写端进程会被阻塞挂起,直到管道当中的数据被读端进程读取后,写端进程才会被唤醒。

        前面的①②两种情况就能够很好的说明,管道是自带同步与互斥机制的读端进程和写端进程是有一个步调协调的过程的,不会说当管道没有数据了读端还在读取,而当管道已经满了写端还在写入。

        读端进程读取数据的条件是管道里面有数据,写端进程写入数据的条件是管道当中还有空间,若是条件不满足,则相应的进程就会被挂起,直到条件满足后才会被再次唤醒。

如何理解阻塞挂起 ?唤醒?       

         进程先立即停止执行,然后将PCB的状态改为阻塞状态,并将PCB插入相应的阻塞队列。

        当被阻塞进程所期待的事情发生,将阻塞进程从阻塞队列中移出,将其PCB的状态改为就绪状态(R),然后将PCB插入到就绪队列中.       

③写端进程将数据写完后将写端关闭

当写端对管道文件缓冲区进行了有限次的写入,并且把写端的文件描述符关闭,而读端我们保持正常读取内容,读端多的仅仅把读端的返回值打印出来。

我们发现当10读取执行完成之后,就一直在执行读取操作,而我们读取使用的 read 接口的返回值却从0变为了1。我们接着用监视窗口来监视一下:

当写端写了10个数据之后将文件描述符关闭,那么读端进程就会变为僵尸状态。由此我们可以得出,read接口返回值的含义 是,当写端停止写入并关闭了文件描述符,read的返回值为0,正常读取的返回值 >0

所以我们可以这样修改读端的代码:

<code>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,表示读取结束,类似于读到了文件的结尾

③写端进程将数据写完后将写端关闭,那么读端进程将管道当中的数据读完后,就会继续执行该进程之后的代码逻辑,而不会被挂起。读端进程已经将管道当中的所有数据都读取出来了(读端就会read返回值0,代表文件结束),而且此后也不会有写端再进行写入了,那么此时读端进程也就可以执行该进程的其他逻辑了,而不会被挂起。     

④读端进程将读端关闭,而写端进程还在一直向管道写入数据

我们把情况三最后的代码变换一下,读端读取改为有次数限制,并且读取一定次数之后关闭读的文件描述符,而写端无限制对管道文件写入,那么我们会看到什么现象呢?

而我们发现似乎也没什么不对啊?读取完之后不就直接退出了吗?

你应该仔细想想,我们仅仅是关闭了读的文件描述符,但是没有关闭写的文件描述符啊。

这就是最后一个 结论当读端不再进行读取操作,并且关闭自己的文件描述符fd,而写端依旧在写。那么OS就会通过信号(SIGPIPE)的方式直接终止写端的进程

④读端进程将读端关闭,而写端进程还在一直向管道写入数据,没有进程读取,那么写入的数据就没有意义,那么操作系统会将写端进程杀掉。既然管道当中的数据已经没有进程会读取了,那么写端进程的写入将没有意义,因此操作系统直接将写端进程杀掉。而此时子进程代码都还没跑完就被终止了,属于异常退出,那么子进程必然收到了某种信号。 管道是单向通信,如果读端不读数据且把文件描述符关闭,那么写端做的就没有意义了。写端相当于废弃的动作,浪费资源,所以OS直接将子进程干掉。为什么?OS不做不做任何浪费空间或者低效的事情,只要发现OS一定要把这个事情修正了。 

如何证明读端是被13号信号杀死的?

        我们采用的是父进程读子进程写的方式,也就是说将来子进程被杀死而父进程则可以通过wait的方式来获取子进程退出时的异常!

<code>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);

}

7)使用命令查看信号 kill - l

 5.验证管道的大小

管道的容量是有限的,如果管道已满,那么写端将阻塞或失败,需要了解一下管道的大小             

①方法一:使用man手册

根据man手册,在2.6.11之前的Linux版本中,管道的最大容量与系统页面大小相同,从Linux 2.6.11往后,管道的最大容量是65536字节。

查看Linux系统版本

这里使用的是Linux 2.6.11之后的版本,因此管道的最大容量是65536字节。  

②方法二:使用ulimit命令

可以使用ulimit -a 命令,查看当前资源限制的设定, 管道的最大容量是 512 × 8 = 4096 字节

③写代码验证管道容量

根据man手册得到的管道容量与使用ulimit命令得到的管道容量不同,测试验证代码概述: 读进程一直不读,写进程一直写,直到管道被写满 

<code>#include <unistd.h>

#include <stdio.h>

#include <stdlib.h>

#include <sys/wait.h>

int main()

{

int fd[2] = { 0 };

if (pipe(fd) < 0){ //使用pipe创建匿名管道

perror("pipe");

return 1;

}

pid_t id = fork(); //使用fork创建子进程

if (id == 0){ //child

close(fd[0]); //子进程关闭读端

char c = '.';

int count = 0;

while (1){

write(fd[1], &c, 1);

count++;

printf("%d\n", count); //打印当前写入的字节数

}

close(fd[1]);

exit(0);

}

//father

close(fd[1]); //父进程关闭写端

//父进程不读取数据

waitpid(id, NULL, 0);

close(fd[0]);

return 0;

}

在读端进程不进行读取的情况下,写端进程最多写65536字节的数据就被操作系统挂起了,也就是说,我当前Linux版本中管道的最大容量是65536字节 

6.管道的特点

①管道内部自带同步与互斥机制。

我们将一次只允许一个进程使用的资源,称为临界资源。管道在同一时刻只允许一个进程对其进行写入或是读取操作,因此管道也就是一种临界资源。

临界资源是需要被保护的,若是我们不对管道这种临界资源进行任何保护机制,那么就可能出现同一时刻有多个进程对同一管道进行操作的情况,进而导致同时读写、交叉读写以及读取到的数据不一致等问题。

为了避免这些问题,内核会对管道操作进行同步与互斥

同步: 两个或两个以上的进程在运行过程中协同步调,按预定的先后次序运行。比如,A任务的运行依赖于B任务产生的数据。互斥: 一个公共资源同一时刻只能被一个进程使用,多个进程不能同时使用公共资源。

实际上,同步是一种更为复杂的互斥,而互斥是一种特殊的同步。

对于管道的场景来说,

互斥就是两个进程不可以同时对管道进行操作,它们会相互排斥,必须等一个进程操作完毕,另一个才能操作而同步也是指这两个不能同时对管道进行操作,但这两个进程必须要按照某种次序来对管道进行操作。

        也就是说,互斥具有唯一性和排它性,但互斥并不限制任务的运行顺序,而同步的任务之间则有明确的顺序关系。

        子进程往管道里面写入,子进程去读取的时候,有数据就拿上来,没数据就不在读取而是阻塞式的等待管道数据写入,并非父进程sleep了,而是因为子进程写的慢,父进程必须等,而引起好像父进程sleep了,这种—个等另一个的现象叫做同步。

                                      

②管道的生命周期随进程。

管道本质上是通过文件进行通信的,也就是说管道依赖于文件系统,那么当所有打开该文件的进程都退出后,该文件也就会被释放掉,所以说管道的生命周期随进程。

③管道提供的是流式服务。

我们一般所谓的流式概念就是,给你提供一个通信的信道,你的写端就直接写,读端直接读,但是具体写多少,读多少完全有上层决定。底层就只是提供一个数据通信的信道就完了,它不关心数据本身的一些细节格式,这叫做面向字节流。

流式服务: 数据没有明确的分割,一次拿多少数据都行。数据报服务: 数据有明确的分割,拿数据按报文段拿。

④管道是半双工通信的。

在数据通信中,数据在线路上的传送方式可以分为以下三种:

单工通信:单工模式的数据传输是单向的。通信双方中,一方固定为发送端,另一方固定为接收端。半双工通信:半双工数据传输指数据可以在一个信号载体的两个方向上传输,但是不能同时传输。全双工通信:全双工通信允许数据在两个方向上同时传输,它的能力相当于两个单工通信方式的结合。全双工可以同时(瞬时)进行信号的双向传输。

管道是半双工的,数据只能向一个方向流动,需要双方通信时,需要建立起两个管道。

7.管道的读写规则 



声明

本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。