赞
踩
进程间通信是两个或者多个进程实现数据层面的交换。但是由于进程间存在独立性,所以导致进程间通信的成本比较高。
那么为什么要有进程间通信呢?其中有以下几种目的:
那么怎么做到进程间通信呢?由于进程之间具有独立性,所以在不打破它们的独立性的前提下,使它们看到同一份“资源”,这就是进程间通信的本质。那么这个“资源”是什么呢?是谁提供的呢?其实就是特定形式的空间,一般由操作系统提供。必定是不能由一个进程提供,假设是由一个进程提供,这个资源就是属于这个进程独有了,破坏了进程的独立性!
所以我们进程访问这个空间,进行通信,本质就是访问操作系统!进程代表就是用户,所以这个“资源”从创建、使用、释放,都是通过系统调用接口!从底层设计,从接口设计,都要由操作系统独立设计,一般操作系统会有一个独立的通信模块,它隶属于文件系统,叫做 IPC通信模块。
首先我们知道,每一个进程都有自己的文件描述符表,文件描述符表中 0、1、2 默认已经被打开,分别指向键盘、显示器、显示器。如今我们新建一个文件,我们是否能做到该文件不在磁盘中被打开呢?也就是说,我们有该文件的 fd、inode、file_operators、缓冲区,但是该文件就不在磁盘中,也就是实现真正的内存级文件。答案是可以做到的,而且在操作系统内核中,会存在非常多的内存级文件,而这些文件不在磁盘中真正存在,只要它们能在内存里被我们用起来即可。也就是下图的结构:
当我们以只读方式打开一个文件时,同时创建一个子进程,操作系统会帮我们将父进程的 task_struct 拷贝给子进程,也就是,文件描述符表也拷贝给子进程了!那么文件描述符表中的内容也和父进程一样,我们知道,文件描述符表其实是数组指针,那么子进程中的文件描述表中的指针也指向了父进程的表中的指向!也就是说父进程和子进程都能看见一样的文件资源!如下图:
那么如果父进程想向缓冲区里面写入,子进程是不是就可以从缓冲区中读取呢?是的,这样就能实现进程间通信了!所以管道的本质,也叫做文件!因为管道就是文件,只是不是我们理解的磁盘文件,只是内存级文件。
但是,我们上面打开文件的时候是以只读方式打开的,创建子进程时子进程也是只有只读的权限,那么它们之间怎么通信呢,没有办法一个读一个写。所以父进程在打开文件的时候就不能这么随意地打开啦,接下来我们继续理解管道的原理。
所以在系统当中,父进程在打开一个文件的时候,并不是单方面的以读、写方式或者读写方式打开的。它在创建管道时,把同一个文件既以读方式打开,又以写方式打开! 如下图:
接下来父进程在 fork 创建子进程,子进程它会拷贝父进程的文件描述符表,所以它们都会有对应的读写端指向管道,如下:
紧接着需要结合具体场景,我们是想让父进程读,子进程写,还是子进程读、父进程写。此时我们就要求父进程或子进程它们各自要关闭对应的读写端,来形成一个单向通信的管道!如下图:
那么有了上面的初步理解后,我们接下来站在内核的角度再次理解管道的本质。我们继续画图理解,如下,首先我们把同一个文件在同一个进程中再打开一次,在操作系统层面上还是要给它创建一个 struct_file,因为这两个文件的读写方式不一样!其中我们知道,每一个文件里都有自己当前的读写位置,比如我们读写到哪个偏移量,如果我们读写混合用的话,会很容易出问题的。但是这两个文件是指向同一个 inode、方法集以及缓冲区!
接下来父进程创建子进程,子进程中的文件描述表也就指向了对应的 struct_file;所以这时候就要实现父子进程的单向通信了,所以此时就需要我们用户决定到底是父进程写还是子进程写,那么我们在这就让子进程写入,父进程读取。所以父进程就要根据文件描述符关闭对应的 struct_file 了,子进程也如此,如下图:
至此,这就是管道通信的原理!正是因为它只能进行单向通信,所以给它命名为管道。
那么我们上面讲解的原理都是通过父进程创建子进程实现的,如果没有任何关系,可以用上面的原理进行通信吗?不可以!必须是父子关系、兄弟关系、爷孙关系…所以管道通信必须具有血缘关系才可以,常用于父子关系。
我们上面讲的原理中,我们打开的文件有名字吗?有 inode 吗?有路径吗?都没有!因为这个文件不需要有名字,更不需要怎么去标定它,所以我们把这种管道叫做匿名管道,我们把红色框中的整体成为匿名管道。
至此我们还没有进行通信,我们一直都在建立通信信道!我们这么费劲建立就是因为进程具有独立性,通信是有成本的!
接下来我们认识一下管道的系统接口:pipe
那么 pipe
中的参数是什么呢?为什么是一个只有两个变量的数组呢?其实它就是输出型参数,它会将文件的文件描述符数字带出来,让用户使用!
那么规定,pipefd[0]
是读端,pipefd[1]
是写端。
我们可以验证一下该结论,如下代码:
#include <iostream>
#include <unistd.h>
using namespace std;
int pipefd[2] = {0};
int main()
{
int n = pipe(pipefd);
cout << "pipefd[0]: " << pipefd[0] << endl << "pipefd[1]: " << pipefd[1] << endl;
return 0;
}
结果如下,被打开的文件果然是 3 号和 4 号,而3号fd就是读端,4号fd就是写端:
其中返回值,如果成功返回0,否则返回-1,错误码被设置:
接下来我们按照上面的原理建立一个管道。首先我们把整体架构搭建出来,代码如下:
#include <iostream> #include <string> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> #include <cstring> #include <cstdlib> #include <cstdio> #define NUM 1024 using namespace std; int pipefd[2] = {0}; int main() { int n = pipe(pipefd); if(n < 0) return -1; // 创建子进程 pid_t id = fork(); if(id < 0) return -1; // 子进程写入 if(id == 0) { close(pipefd[0]); Write(pipefd[1]); close(pipefd[1]); exit(0); } // 父进程读取 close(pipefd[1]); Read(pipefd[0]); pid_t wid = waitpid(id, nullptr, 0); if(wid < 0) return -1; close(pipefd[0]); return 0; }
接下来子进程进行写入,其中 snprintf
是以字符串格式向 buffer 写入,我们先打印出来观察结果:
// 子进程写入 void Write(int wfd) { string str = "hello, world"; pid_t myid = getpid(); int number = 0; char buffer[NUM]; while(true) { buffer[0] = 0; snprintf(buffer, sizeof(buffer), "%s - %d - %d", str.c_str(), myid, number++); write(wfd, buffer, strlen(buffer)); // strlen(buffer) 只需要写入长度个字节,如果用sizeof(buffer)就相当于是buffer指针的大小了 cout << buffer << endl; sleep(1); } }
接下来父进程向管道中读取数据,我们也先打印出来观察结果,我们关闭子进程的打印结果,观察父进程的打印结果:
// 父进程读取 void Read(int rfd) { char buffer[NUM]; while(true) { buffer[0] = 0; ssize_t n = read(rfd, buffer, sizeof(buffer)); if(n > 0) { buffer[n] = 0; cout << "father get message[" << getpid() << "]# " << buffer << endl; } else if(n == 0) { cout << "read done!" << endl; break; } else break; } }
如上图,父进程确实向管道中读取到了子进程写入的结果。接下来我们验证一些问题。
假设我们让子进程在写入前,休眠一段时间,而现在的管道是空的,我们观察父进程会如何:
// 子进程写入 void Write(int wfd) { string str = "hello, world"; pid_t myid = getpid(); int number = 0; char buffer[NUM]; sleep(10); while(true) { buffer[0] = 0; snprintf(buffer, sizeof(buffer), "%s - %d - %d", str.c_str(), myid, number++); write(wfd, buffer, strlen(buffer)); // strlen(buffer) 只需要写入长度个字节,如果用sizeof(buffer)就相当于是buffer指针的大小了 } }
我们观察到,父进程阻塞了,所以我们得出一个结论,读写端正常,管道如果为空,读端就要阻塞。
所以我们也就知道,父子进程是会进程协同的,同步和互斥的,这是为了保护管道文件的数据安全,这个我们后面再讨论。
所以我们在父进程中先让父进程休眠一段时间,并在子进程中打印 number,观察写端写满管道后会发生什么情况:
// 父进程读取 void Read(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; cout << "father get message[" << getpid() << "]# " << buffer << endl; } else if(n == 0) { cout << "read done!" << endl; break; } else break; } }
如上,我们发现写端也阻塞了!但是五秒后父进程休眠完毕就开始读取数据了:
所以我们得出结论,读写端正常,管道如果被写满,写端就会阻塞!
但是根据以上现象,我们延申出另外一个问题,对于父进程来说,子进程写了多少次根本不重要,只要管道里有数据,有多少就会读多少,前提条件是我们缓冲区足够大。也就是说,当子进程向管道写满了,当父进程在读的时候,就会把多次写的信息一次读了出来,在父进程看来,它读到的就是一个一个的字符,对于我们用户用什么存取,如何区分,这是我们用户的事。所以我们得出一个管道的特点,管道是面向字节流的!
当两个进程退出时,文件会被操作系统自动退出,所以管道资源会被自动释放,就像我们的0、1、2号fd文件,我们也从来没有打开和关闭过,这就是操作系统帮我们做的。所以我们又得出管道的一个特点,管道是基于文件的,而文件的生命周期是随进程的!
我们上面也看到管道会被写满,那么管道的大小是多少呢?我们可以在系统中查看一下,指令为 ulimit -a
,ulimit 是一条命令,用来查看操作系统对于很多重要资源的限制,如下:
我们可以看到,8指的是单个进程可以打开文件的个数,大小是512字节,所以管道的大小也就是4KB,我们可以验证一下。我们现在只需要子进程写入,父进程休眠即可,我们让子进程一次写入一个字节,也就是一个字符,写入一次number++一次,所以代码如下:
void Write(int wfd)
{
string str = "hello, world";
pid_t myid = getpid();
int number = 0;
char buffer[NUM];
while(true)
{
char c = 'c';
write(wfd, &c, 1);
number++;
cout << number << endl;
}
}
结果如下:
我们可以看到,管道写入了 65536 个字节,也就是 64KB,也就是说在我们的机器下管道的大小是 64KB. 那么我们要说一下了,在不同的内核里,管道的大小是有差异的。我们也可以读一下管道的手册:
如上,也就是说从 Linux 2.6.11 内核之后管道的大小就变成了 64KB;我们接着看:
我们可以看到有一个 PIPE_BUF 的东西,其实它就是单次向管道中写入的大小,我们可以看到它的大小是 4KB;上面的手册中提到了原子性的问题,例如,当子进程往管道中写数据时,父进程读数据,当子进程只写了一部分数据,还没有写完,就被父进程读走了,这就导致读取到的数据不完整。那么所以我们要保证,父进程在读的时候要么不读,要么就把完整的数据读取,这就叫做读取的原子性问题。所以管道在保证读取的原子性,它规定 PIPE_BUF 的大小,只要是父进程或子进程读写的单位是小于 PIPE_BUF 的,它们读写的过程就是原子的,也就是说当子进程写入的数据小于 PIPE_BUF,父进程也不会来读取的,这就是 PIPE_BUF 的本质,所以我们在 ulimit 中查到的管道大小我们可以理解成 PIPE_BUF 的大小。
接下来我们验证另一个问题,当读端正常,写端关闭会出现什么情况。接下来我们让子进程在写的时候,写入10个字节就退出,如下代码:
// 子进程写入 void Write(int wfd) { string str = "hello, world"; pid_t myid = getpid(); int number = 0; char buffer[NUM]; while(true) { sleep(1); char c = 'c'; write(wfd, &c, 1); // strlen(buffer) + 1??? number++; cout << number << endl; if(number >= 10) break; } }
结果如下:
我们观察到当写端还在写入的时候,读端在正常读,而写端退出后呢?如下:
而 read 的返回值返回的是读到的数据大小,以字节为单位,当返回值为0时,父进程也就退出了循环,所以我们得出结论,读端正常读,写端关闭,读端就会读到0,表明读到了文件(pipe)结尾,不会被阻塞!
首先我们要知道,操作系统是不会做低效、浪费资源和时间等类似的工作的,如果做了,操作系统就是bug;所以我们想,写端正常,读端关闭后,还有实际意义吗?没有了!因为写满了又怎样呢,又没有进程去读,所以当写端正常,读端关闭了,操作系统就要 kill 掉正在写入的进程。如何 kill 呢?通过信号,其实操作系统会使用13号信号 SIGPIPE kill 掉正在写入的进程:
所以对上面的现象总结,我们可以分别得到管道的四种情况和物种特性。
那么我们上面学的管道,和我们以前学过的哪些有关系呢?首先我们以前接触过 |
这个符号,其实这个就是管道,例如我们在多条指令中使用 |
:
cat test.c | head -10 | tail -5
那么它和我们上面学的管道又有什么关系呢?我们知道,每一条指令在命令行运行时都是会创建进程去执行的。下面我们观察现象得出结论:
如上图,我们使用管道运行 sleep 指令,而在右端终端我们看到,它们最终都会变成进程,它们三个的 pid 是不一样的,而 ppid 是同一个!我们查看一下该相同的 ppid 究竟是什么:
如图,它就是 bash!所以它们的父进程都是 bash,都是一样的,所以它们是具有血缘关系的进程。所以在我们使用 |
的时候,在上面的语句中,操作系统为我们创建两个管道,因为有两个 |
,然后再连续创建三个进程,然后每个进程程序替换执行不同的命令。所以我们使用 |
的原理就是我们上面所说的 pipe!
通过上面的学习,我们可以使用管道实现一个简易的进程池。什么是进程池呢?例如内存池,内存池是提前向操作系统一次性申请一大片内存,供我们用户使用,这就可以有效减少我们调用系统接口的次数,因为系统调用是有成本的!因为涉及到操作系统帮我们申请空间、做空间内部的调整等等。
那么进程的本质其实就是帮助我们执行代码,让操作系统去调度的,如果我们执行任务的时候总是来一个任务才创建进程,然后去执行,这样是可以的,但是这样会非常慢。那么我们就可以提前将一些进程创建好,当有任务到来时,我们只派给其中一个进程。其中一次性把一批进程创建好,这个工作就叫做进程池的储备,提前储备好,当需要的时候再派任务给它们。
那么如下图,当父进程接收任务之前,它先一次性同时创建出若干个子进程。然后为了更好地控制这些子进程,父进程和每一个子进程都建立一条管道的信道,然后让每一个子进程只从管道中进行读取,而父进程每次想往哪个管道里写内容,就往哪个管道写内容。当父进程没有向管道里写内容时,对应的子进程就会阻塞等待父进程派任务,一旦父进程向管道中写了,子进程会读取对应的数据然后继续向后执行,结合读取的数据就可以执行对应的任务了。接下来我们规定,父进程向子进程管道里写的,都叫做任务码,也就是,我们规定好父子通信时,父进程每次写入时,只能写入4字节,子进程在读取时,也只能读取4字节。所以,我们让父进程向管道里写入4字节 数据,数据不同值代表不同任务,我们就可以想让哪个子进程执行什么任务,就让哪个子进程执行什么任务。
所以,当父进程想布置任务的时候,无非就是做两件事,一就是选择任务,二是选择进程。接下来我们就可以实现这样的代码,参考代码链接为:进程池.
上面我们学到的匿名管道是没有名字的,因为打开那个文件的时候并没有告诉我们文件名,也就是管道并没有命名。我们直接以读方式写方式打开父子进程,各自拿一个读写端就可以通行。正是因为它没有名字,那么所以它必须得让我们对应的父子进程看到通信资源,它采用的是让父子继承的方案看到的。
那么有没有一种其他的方案呢?因为我们发现匿名管道只能用来进行具有血缘。如果毫不相关的进程进行进程间通信呢。所以我们需要有下一个方案叫做命名管道。接下来我们先使用一下命名管道,先看现象再解释。其中建立命名管道的接口为 mkfifo
.
我们先看手册的介绍,我们可以在当前的工作目录下建立命名管道:
例如我们在当前目录下创建一个命名管道,名字为 pipefifo
:
如上,我们就创建了以 p 属性开头的管道文件,该管道就是命名管道。该管道看起来是在磁盘中存在,但是它实际数据并不会刷新到磁盘上。
那么如何让两个进程进行通信呢?我们创建两个终端,两个终端都在当前目录下,一个写,另一个读。观察现象:
如上图,当写端进行写入的时候,命令行会变成一个进程,向管道里写入,此时读端没有读取,所以写端正在阻塞。当读端进行读取后:
此时左侧的字符串会到了右侧。如果我们一直往管道里写,该管道的大小也不会有变化,这就是命名管道:
那么关于命名管道的理解,首先,如果两个不同的进程打开同一个文件的时候,在内核中,操作系统会打开几个文件呢?因为我们要打开的时候是以不同的读写方式打开,那么对应的这个被打开的文件,它的内核里的属性和它所谓的那些操作方法,还有缓冲区了这些东西,其实操作系统只会给我们维持一份。为什么呢?因为对于操作系统而言,没必要给你把属性相同的类似的属性写两份,写两份还不方便进行维护,那方法也只有一套就行了,属性也只有一套。更重要的是缓冲区我要给你留一个就行了,那为什么留一个呢?它不怕我们文件读写的时候出现错乱吗?我们都用两个进程打开同一个文件了,它在读写时不加保护的情况下,它在读写是注定会错乱的,你都不怕我怕什么?所以对我们来讲,你会发现我们如果两个不同的进程,打开同一个文件时,实际上在内核里它还是这张图:
当两个进程打开同一个文件时,在操作系统层面上还是这种结构。所以,可能有不同的读取文件对象,但是文件还是同一个。所以我们就理解我们把它叫做命名管道的原因了。
因为它也是基于文件,因为我们正常进程通信我们只想用它的内存及缓冲区,不想让我们对应的这个数据再进行刷盘。我们是想让一个进程将数据交给另一个进程,它只需要放到缓冲区里,然后不需要进行刷盘,另一个进程读取就可以了。如果打开普通文件,它就必须得刷盘了。所以我们就有了一个文件类型叫做管道文件,属性以 p 开头的。所以当看到这个管道文件时,原来系统里单独创建一个叫做管道文件,那么这个文件当我们的进程在不同地方在打开的时候就知道了,这个文件不需要刷盘,也就是说它只是一个内存及文件!
那么问题又来了,两个不同的进程打开同一个文件时,它们是怎么知道打开的是同一个文件?比如说我们上面讲的匿名管道,父子进程怎么知道打开的是同一个管道文件?因为可以通过继承的方式来进行。能按通过继承的方式让父子看到不同对应的文件。可是命名管道不一样,怎么知道我们两个进程打开的是同一个文件的呢?
很简单,两个进程只要看到同一个文件名,那么此时这两个进程就可以打开同一个文件了。可是其实可不仅仅只有文件名,还有一个前提条件叫做 pwd ;因为我们在上面使用的命名管道,都是在同一路径下的文件名,所以怎么知道两个进程打开的是同一个文件呢?就是用路径+文件名确定的,而路径+文件名具有唯一性,而且该文件是 p,是管道文件,所以就进行内存级通信就可以了,这就是命名管道。
接下来我们使用两个毫不相干的进程进行,建立命名管道,并且进行命名管道间的通信,形成两个可执行程序分别是 server 和 client. 其中 server 是管理管道文件的,也就是说创建、删除等工作。
其中我们使用到的系统接口是 mkfifo,参考手册:
其中参数 pathname
是哪一个路径下的文件名,也就是保证唯一性的;第二个 mode
,因为管道也是文件,所以这个文件的权限也要有。
其中返回值,成功返回0,失败返回-1,errno 被设置。
参考代码链接为:命名管道使用.
关于日志,实际上我们程序在运行期间需要不断向显示器或者文件进行信息输出的,我们在运行代码时,想产生各种各样的日志数据,这些日志数据方便我们记录程序运行的痕迹,方便后期进行排查。
而日志的格式并没有严格的要求,我们可以按照自己的要求定制,但是一般都会有日志时间,日志等级,日志内容等,可能还会有文件的名称和行号。
那么什么是日志等级呢?对于我们写的软件还是服务器,在运行过程中避免不了各种各样的问题,所以根据严重程度的不同,我们的处理方法是不一样的。常见的日志等级有:
那么我们上面实现的使用命名管道中,我们看见有许多的 perror 的信息,还可以加上一些常规消息,比如创建文件成功后打印一些数据,而这些信息我们都可以用一个日志函数处理,我们可以让这些信息直接打印在显示屏上,也可以让这些信息写到文件中,这个看我们的需求。所以我们下面实现一个简单的日志函数,引入到命名管道的代码中。
参考代码链接为:日志.
其中补充知识如下。
C语言 当中获取时间的方式非常多,接下来介绍一种,localtime
那么它的参数是 time_t
类型,那么就是 time()
接口的返回值,如下:
那么 localtime
就是我们传入一个 time_t
类型,它会帮我们转化为 struct tm
这样的结构,我们可以看一下这个结构:
所以这个 localtime
就可以让我们控制自己打印的时间。
注意,tm_year
是从 1900 年开始算的,tm_mon
是从 0 开始的。
我们都见过可可变参数,但是还没有使用过,接下来介绍一下如何使用可变参数。假设我们有一个 sum 函数,是求任意个数个元素的求和,如下:
int sum(int n, ...);
使用可变参数之前,必须要有一个 va_list
结构,其实它就是一个 char*
类型的结构。
那么我们在调函数的时候,函数创建栈帧结构,无论是可变参数还是确定参数,在使用的时候,参数是要压栈的,在形成栈帧结构之后,要将传入的参数从右向左依次入栈。
那么上面说 va_list
其实就是一个指针,所以我们创建一个 va_list
的对象,就是一个 char*
指针,它可以根据 va_list
帮我们提取可变参数一个一个的参数。我们初始化一个 va_list
的对象如下:
int sum(int n, ...)
{
va_list s;
va_start(s, n);
// ...
}
因为参数在压栈的时候是从右往左压的,所以参数 n 是最后一个入栈的,所以 va_start
就是使用 s 指向 n;也就是说,我们想让 s 指向可变部分的开头,其实只要让 s 指针指向 n 的地址,然后加上 n 的大小个字节,就可以让 s 指向可变部分的开头,如下图:
也就是说, va_start
我们可以把它看作是 s = &n + 1
,其中 +1 就是让指针向后移动 n 自身大小个字节。
当 s 指向了可变部分的开头处,我们只需要知道开头处的类型,就可以把这个元素访问出来,然后再让 s 加上类型大小,就可以继续指向下一个可变参数,依次解析。
其实 va_start
就是一个宏,还有其他的 va_list
、va_end()
等都是宏,它在实现的时候会自己取地址的。
所以在可变参数中,必须要有至少一个具体的参数!因为它要找可变部分的起始地址!那么如果我们想用上面的 sum 函数,就可以像如下代码使用:
int sum(int n, ...)
{
va_list s;
va_start(s, n);
int _sum = 0;
while(n)
{
va_arg(s, int);
n--;
}
va_end(s); // s = NULL;
return _sum;
}
那么 va_arg()
就是用 s 根据类型来提取参数,第二个参数就是根据的类型,如今我们的代码是写固定了 int
类型,实际上需要像 printf 函数那样进行格式控制,进行字符串分析。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。