前提引入:进程创建子进程后文件资源共享机制
父进程创建子进程时,内容都是写时拷贝,而对于文件,父进程创建子进程时同样要拷贝一份files_struct给子进程,这就意味着父进程和子进程的文件指针所指向的内容是一样的,因此他们可以同时对一个文件进行读写。而有一种文件时内存文件,也就是该文件是在内存中的,内容写入或者读的时候不会讲数据从缓冲区刷出至硬盘,而是只将其放入缓冲区,这类文件同样可以有自己的inode存放自己的文件属性,也有file_operator存放各种方法,但正因它不需要繁琐地将数据从磁盘刷新,故它时进程通信用于两个进程共享同一份资源的最佳媒介。
那么,管道通信为什么需要通过内存级文件实现资源共享呢?
性能:内存的访问速度远高于磁盘。使用内存级文件(如匿名管道或命名管道)可以避免磁盘I/O,极大地提升数据传输的速度,减少延迟。
临时性:管道通常用于进程间的短暂通信,数据是临时的,不需要持久化存储。使用内存级文件可以在进程完成通信后自动释放资源,而使用磁盘文件则需要显式清理。
同步和并发性(最重要):管道是一种同步通信机制,进程可以通过管道实现数据流的同步传递。使用内存级文件可以更好地管理并发和同步,而磁盘文件的使用可能导致额外的锁机制,增加复杂性和延迟。
资源效率:管道的设计是为了实现轻量级的进程间通信,使用内存可以更有效地管理资源,而磁盘文件通常需要更多的系统资源(如磁盘空间和I/O操作)。
接下来我们谈谈管道通信的原理:
父进程在创建管道的时候,为了在父子进程对管道进行管制时不出现冲突,会创建两个文件指针指向同一个管道(内存级文件),分别进行读文件和写文件,也就是会创建两个fd,这两个fd会创建出两个struct file对象,若不这样做,就会导致读和写在操控文件时用的是同一个指针,就会导致两者发生冲突,当我们创建两个文件指针分别用于读和写后,父进程fork出一个子进程,根据上面的引入可知,子进程的文件指针和父进程是一样的拷贝的一份,因此子进程和父进程都可以对该文件进行读和写操作,若子进程选择写文件,那么子进程就会关闭读文件的fd文件指针,而父进程选择读文件,父进程就会关闭写文件的文件指针,这样就实现了父子进程在对文件分别进行读写时互不干扰,而因为父子进程分别关闭了写和读的端口,因此读和写的struct file里面读和写的引用计数都会变成一,也就是说只有父进程读文件,只有子进程写文件,双方互相就不会有任何干扰。
正因其单向通信的特点,故我们将控制读写的两个struct file和对应内存级文件的集合命名为管道,又因为这个文件仅仅用来通信,不需要自己的名字,同时因为只是作为通信介质,因此不需要inode映射到硬盘,也不需要文件路径,故我们又将其称为匿名管道。
管道通信不仅仅只局限于父子进程,只要是由血缘关系的进程都可以进行管道通信,例如,父进程创建两个子进程,随后父进程将读写端都关闭了,这时两个兄弟进程的读写段都和父进程一样,故他们直接也可以构成管道通信,父进程创建的子进程,子进程再创建一个子进程,那么由于父进程和它孙子进程的读写段相同,故也可以构成管道通信。
接下来,我们就开始学习创建管道通信
管道通信的函数在manual手册中如下:
SYNOPSIS
#include <unistd.h>int pipe(int fildes[2]);
DESCRIPTION
The pipe() function shall create a pipe and place two file descriptors, one each into the arguments fildes[0] and
fildes[1], that refer to the open file descriptions for the read and write ends of the pipe. Their integer values shall
be the two lowest available at the time of the pipe() call. The O_NONBLOCK and FD_CLOEXEC flags shall be clear on both
file descriptors. (The fcntl() function can be used to set both these flags.)Data can be written to the file descriptor fildes[1] and read from the file descriptor fildes[0]. A read on the file
descriptor fildes[0] shall access data written to the file descriptor fildes[1] on a first-in-first-out basis. It is
unspecified whether fildes[0] is also open for writing and whether fildes[1] is also open for reading.A process has the pipe open for reading (correspondingly writing) if it has a file descriptor open that refers to the
read end, fildes[0] (write end, fildes[1]).Upon successful completion, pipe() shall mark for update the st_atime, st_ctime, and st_mtime fields of the pipe.
ERRORS
The pipe() function shall fail if:EMFILE More than {OPEN_MAX} minus two file descriptors are already in use by this process.
ENFILE The number of simultaneously open files in the system would exceed a system-imposed limit.
The following sections are informative.
简单来说,pipe可用于创建管道通信,传入的参数int fildes[2]是一个存放两个整形变量的整形数组,这个数组是一个典型的输出型参数,将这个变量传入函数后函数会创建管道并将管道用于读和写的两个文件描述符写入这个数组。
下面是创建管道的代码:
#include <iostream>
#include <unistd.h>
#define N 2
using namespace std;
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;
return 0;
}
结果如下:
显然,这两个文件描述符是3和4,其中一二三分别指的是输入输出以及错误流文件描述符。
接下来我们就要开始通信了,首先要创建一个子进程,这里我们要让父进程执行读取,子进程执行写入,故我们的父进程在创建子进程后需要关闭自己的写入端口(文件描述符pipefd[1]),而我们的子进程需要关闭自己的读取端口(文件描述符pipefd[0]):
#include <iostream>
#include <unistd.h>
#include <cstdlib>
#define N 2
using namespace std;
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;
pid_t id = fork();
//子进程写入,父进程读取
if(id == 0)
{
//child
close(pipefd[0]);//子进程关闭读取
//IPC code
close(pipefd[1]);//退出前关闭所有文件(非必要)
exit(0);
}
//parent
close(pipefd[1]);//父进程关闭写入
//IPC code
pid_t rid = waitpid(id, nullptr, 0);
if(rid < 0) return 3;
close(pipefd[0]);//退出前关闭所有文件(非必要)
return 0;
}
接下来,我们就让父进程和子进程各自执行各自的任务,子进程负责写入Writer(),而父进程负责读取Reader():
#include <iostream>
#include <unistd.h>
#include <cstdlib>
#include <cstring>
#define N 2
#define NUM 1024
using namespace std;
//child
void Writer(int wfd)
{
string s = "hello, I am child";
pid_t self = getpid();
int time = 0;
char buffer[NUM];
while(true)
{
//写入内容处理
buffer[0] = 0;//字符串清空(非必要,但是一个好习惯)
snprintf(buffer, sizeof(buffer), "%s-%d-%d", s.c_str(), self, time);
//写入文件
write(wfd, buffer, strlen(buffer));
sleep(1);
++time;
}
}
//father
void Reader(int rfd)
{
char buffer[NUM];
while(true)
{
buffer[0] = 0;
ssize_t n = read(rfd, buffer, sizeof(buffer));
if(n > 0)
{
buffer[n] = 0;//字符串结尾加入'\0'
cout << "father get a message[" << 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;
pid_t id = fork();
//子进程写入,父进程读取
if(id == 0)
{
//child
close(pipefd[0]);//子进程关闭读取
//IPC code
Writer(pipefd[1]);
close(pipefd[1]);//退出前关闭所有文件(非必要)
exit(0);
}
//parent
close(pipefd[1]);//父进程关闭写入
//IPC code
Reader(pipefd[0]);
pid_t rid = waitpid(id, nullptr, 0);
if(rid < 0) return 3;
close(pipefd[0]);//退出前关闭所有文件(非必要)
return 0;
}
运行结果如下:
依规律运行下去。
在这个代码中我们会发现,子进程在写数据时是每隔一秒写一次,但父进程在读数据时却也是每隔一秒读一次,我们在代码中看到只有子进程写了sleep(1),也就是我们只要求子进程每写一次文件休息一秒,那父进程为什么要凑热闹?其实这时操作系统对通信时资源的一种保护,假若父进程不管子进程,一直读取,那么就可能会出现子进程正在写的时候父进程读取到了乱码,这样是不安全的,也就是说,父子进程是会进程协同的,同步与互斥的,从而保护管道文件的数据安全。
而如果我们让父进程sleep(5),而子进程不做休息,将读写代码改成如下会发生什么呢?
//child
void Writer(int wfd)
{
string s = "hello, I am child";
pid_t self = getpid();
int time = 0;
char buffer[NUM];
while(true)
{
//写入内容处理
buffer[0] = 0;//字符串清空(非必要,但是一个好习惯)
snprintf(buffer, sizeof(buffer), "%s-%d-%d", s.c_str(), self, time);
//写入文件
write(wfd, buffer, strlen(buffer));
cout<<time<<endl;
++time;
}
}
//father
void Reader(int rfd)
{
char buffer[NUM];
while(true)
{
sleep(5);
buffer[0] = 0;
ssize_t n = read(rfd, buffer, sizeof(buffer));
if(n > 0)
{
buffer[n] = 0;//字符串结尾加入'\0'
cout << "father get a message[" << getpid() << "]# " << buffer <<endl;
}
}
}
运行结果如下:
第一次5s:
第二次5s:
第三次5s:
第四次5s:
由现象可以看出,每隔5s屏幕打印一次信息,而第一次子进程写入了2457条信息到管道中,我们会发现子进程后面再不写入了,而是父进程一直读取缓冲区数据,这时因为子进程吧缓冲区写满了,只有等父进程读取一些数据后缓冲区有空间了子进程才可以继续写入,接下来详细解释一下这个现象:
子进程持续写入数据:
- 子进程不停地向管道写入数据。管道的内核缓冲区会不断填充,直到达到其容量上限。
- Linux 中,管道的默认缓冲区大小通常是 4096 字节。当缓冲区被填满后,子进程的写操作将会被阻塞,直到缓冲区中有足够的空间来存放新数据。
父进程每5秒读取数据:
- 父进程每隔5秒调用一次
read
,每次读取1024字节的数据。- 当父进程读取数据时,缓冲区中的数据量会减少,释放出相应的空间。
阻塞与释放:
- 由于子进程持续写入数据,而父进程的读取频率较低,管道缓冲区会迅速被填满。
- 一旦缓冲区满了,子进程的写操作就会被阻塞,直到父进程读取并释放了一部分缓冲区空间。
- 每次父进程读取1024字节的数据后,缓冲区会释放相应的空间,子进程的写操作得以继续进行,直到缓冲区再次被填满。
长时间运行的结果:
- 如果这种状态持续下去,子进程将频繁地被阻塞和解锁,导致写入操作的效率降低。
- 同时,父进程的低频率读取会成为整个系统性能的瓶颈,因为子进程的写入速度远高于父进程的读取速度。
因此,管道通信是有一定协议的,不然这种现象会导致子进程被频繁地阻塞,协议在这里暂不做说明,我们只需要知道没有协议的话进程通信就很容易出现诸如上述现象,由此可以看出,管道是面向字节流的。
在代码中最后close那里我标记了此代码非必要,是因为管道是基于文件的,而文件是有生命周期的,因此只要我们推出进程,管道自然会销毁。
故而我们可以总结出管道通信的如下特点:
1、具有血缘关系的进程间可进行管道通信。
2、管道只能单向通信。
3、管道通信的双方进程是进程协同的,同步与互斥的。
4、管道是面向字节流的。
5、管道是基于文件的,而文件时有生命周期的,随进程结束而释放。
接下来我们再研究第三种情况,先将结论放出来:如果读端正常读,写端关闭,读端就会读到0,表示读到了文件(管道)结尾,并且不会阻塞。我们让子进程每隔一秒写入一个字符,写五次后停止写入,而父进程一直读数据,当读到数据为0(read返回值为0)时退出读取,并休眠5秒,我们预期会看到子进程由僵尸进程被回收,代码如下:
#include <iostream>
#include <unistd.h>
#include <cstdlib>
#include <cstring>
#include <sys/types.h>
#include <sys/wait.h>
#define N 2
#define NUM 1024
using namespace std;
//child
void Writer(int wfd)
{
string s = "hello, I am child";
pid_t self = getpid();
int time = 0;
char buffer[NUM];
while(true)
{
sleep(1);
//写入内容处理
// buffer[0] = 0;//字符串清空(非必要,但是一个好习惯)
// snprintf(buffer, sizeof(buffer), "%s-%d-%d", s.c_str(), self, time);
char c = 'c';
//写入文件
write(wfd, &c, 1);
cout<<time<<endl;
++time;
if(time >= 5)
break;
}
}
//father
void Reader(int rfd)
{
char buffer[NUM];
while(true)
{
// sleep(5);
buffer[0] = 0;
ssize_t n = read(rfd, buffer, sizeof(buffer));
if(n > 0)
{
buffer[n] = 0;//字符串结尾加入'\0'
cout << "father get a message[" << getpid() << "]# " << buffer <<endl;
}
else if(n == 0)
{
cout<<"father read file done"<<endl;
break;
}
else break;
}
}
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;
pid_t id = fork();
//子进程写入,父进程读取
if(id == 0)
{
//child
close(pipefd[0]);//子进程关闭读取
//IPC code
Writer(pipefd[1]);
close(pipefd[1]);//退出前关闭所有文件(非必要)
exit(0);
}
//parent
close(pipefd[1]);//父进程关闭写入
//IPC code
Reader(pipefd[0]);
sleep(1)//查看僵尸进程
pid_t rid = waitpid(id, nullptr, 0);
if(rid < 0) return 3;
close(pipefd[0]);//退出前关闭所有文件(非必要)
sleep(5);
return 0;
}
代码结果如下:
最后一种情况,如果写段正在写入,而读端关闭了,那么操作系统就会杀掉该写段进程,因为操作系统不会做任何低效,浪费时间的事情,如果有的话,那就一定是操作系统的bug,因此结果必然是写段被系统通过信号杀掉,而我们要研究的就是写段被系统用几号信号杀掉的。
接下来我们改变一下代码,让子进程一直写入文件,而父进程读取5秒后停止读取,并关闭读取文件,此时我们通过waitpid中的输出型参数statu读取异常信号,并在读取之前休眠3秒观察僵尸进程,代码如下:
#include <iostream>
#include <unistd.h>
#include <cstdlib>
#include <cstring>
#include <sys/types.h>
#include <sys/wait.h>
#define N 2
#define NUM 1024
using namespace std;
//child
void Writer(int wfd)
{
string s = "hello, I am child";
pid_t self = getpid();
int time = 0;
char buffer[NUM];
while(true)
{
sleep(1);
//写入内容处理
// buffer[0] = 0;//字符串清空(非必要,但是一个好习惯)
// snprintf(buffer, sizeof(buffer), "%s-%d-%d", s.c_str(), self, time);
char c = 'c';
//写入文件
write(wfd, &c, 1);
cout<<time<<endl;
++time;
// if(time >= 5)
// break;
}
}
//father
void Reader(int rfd)
{
char buffer[NUM];
int time = 0;
while(true)
{
// sleep(5);
buffer[0] = 0;
ssize_t n = read(rfd, buffer, sizeof(buffer));
if(n > 0)
{
if(time == 5)
{
cout<<"father read file done"<<endl;
break;
}
else
{
buffer[n] = 0;//字符串结尾加入'\0'
cout << "father get a message[" << getpid() << "]# " << buffer <<endl;
}
}
else if(n == 0)
{
cout<<"father read file done"<<endl;
break;
}
else break;
++time;
}
}
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;
pid_t id = fork();
//子进程写入,父进程读取
if(id == 0)
{
//child
close(pipefd[0]);//子进程关闭读取
//IPC code
Writer(pipefd[1]);
close(pipefd[1]);//退出前关闭所有文件(非必要)
exit(0);
}
//parent
close(pipefd[1]);//父进程关闭写入
//IPC code
Reader(pipefd[0]);
close(pipefd[0]);//退出前关闭所有文件(必要)
sleep(3);
int statu = 0;
pid_t rid = waitpid(id, &statu, 0);
if(rid < 0) return 3;
cout<< "exit signal" << (statu&0x7f) <<endl;//获取子进程退出信号
sleep(5);
return 0;
}
运行结果如下:
那么13号信号是什么呢:
可以看出,13号信号正是管道信号,由此验证了我们的猜想。