进程间通信介绍
进程通信(IPC,Inter-Process Communication)是指不同进程之间传递数据或信号的机制。这是一种能在运行中的程序之间协调或者交互信息的技术。尤其是在操作系统中,不同的进程可能通过某种方式来共享数据。
每个进程都是相互独立的,一般要通过第三方操作系统(OS)提供通信的方式才能实现两个进程通信。
进程通信的本质其实就是让不同的进程看到同一份资源(文件、内存、内核级缓冲区等)。不同的通信方式具体的实现原理也会有不同。
进程之间为什么要通信?
进程通信的目的就是为了使得运行在计算机上的进程能够交换信息和协调动作,以实现复杂的任务和资源共享。
举个例子:在一个多人游戏中,每一个玩家的动作都会被其它玩家观察到,并能对其它玩家产生影响,比如攻击。我们把攻击看成是一次数据发送,被攻击的玩家收到”数据后“就做出”扣血‘的反应。这其实也是进程之间的通信。
有哪些通信方式?
管道(Pipes)
- 匿名管道:通常用于有血缘关系的进程之间(如父子进程),只支持单向数据流
- 命名管道(FIFOs):允许无血缘之间的进程通信,支持文件系统中的命名访问
System V
System V IPC是类UNIX系统中进程通信的一套标准,主要用于本地通信,主要包括下面三种通信机制:
- 消息队列(Message Queues)进程可以将消息发送到队列中,其他进程可以读取这些消息。消息队列通过关键字识别,不同进程也可以通过它进行数据交换。
- 共享内存(Shared Memory):多个进程直接访问同一个内存区域,因为避免了数据在进程中的拷贝,是最快的IPC方式之一。
- 信号量(Semaphores):主要用于进程之间的同步,确保多个进程可以有序安全的共享资源。信号量类似一个计数器,控制对共享资源的访问。当一个进程访问完共享资源时,会释放一个信号量,这时候才允许其它进程访问资源。
POSIX
POSIX是继System V 之后引入的一套IPC机制,尽管它们在许多方面有一些相似之处,但是在API设计、功能和使用方式上存在一些关键的区别。
- 信号量:支持命名信号量和无名信号量
- 消息队列:允许数据在进程间异步传输,支持更多的特性,如消息优先级。
- 内存共享:支持通过文件映射方式实现内存共享。
- 其它还有互斥量、条件变量、读写锁等通信方式。由于不是本章重点,略过。
本章节重点介绍管道中的匿名管道。
管道
管道是早期Unix操作系统引入IPC机制。它允许一个进程的输出成为一个另进程的输入,这种机制促进了linux命令行工具的灵活性。在linux中,我们常常使用管道符|来串联多个命令,使得数据以一种我们想要的方式流动。具体一点,命令其实也是一个程序,也就有着自己数据输出的需求,以及数据输入的需求。使用|可以使数据从左边进程的输出流入到右边进程的读入端,以此来实现不同进程之间的信息共享。其中间的原理就是我们下面探讨的重点。
管道的实现方式
简单来说,管道是通过创建一个简单的内存缓冲区来实现的。这个缓冲区由两个文件描述符访问访问:一个用于写入,一个用于读取。如何理解这个缓冲区呢?
同一进程中,不同的方式打开同一个文件,其描述符是不一样的。同样的道理,不同的进程能不能打开同一个文件呢?答案是肯定可以的。
如果两个进程打开了同一个文件,且这个文件有对应的内核级缓冲区,相当于两个进程控制了同一个缓冲区 。这样一来,该缓冲区的数据就可以被认为是进程间的共享数据了。 这个缓冲区其实就是管道实现进程通信的方式 。我们可以将这种用来实现进程通信的文件称为 管道文件 。
当两个进程都对同一个内存缓冲区都拥有读写的权利,那就相当于能够控制数据从管道种流动的方向,即一个进程输出数据到缓冲区,另一个进程从缓冲区度数据。这也是为什么我们可以让管道符|
左边的输出数据流入到右边的读入端。
半双工
此外,管道只允许单向通信(半双工),通信时,只能由一端写入另一端读。不能两端同时读或者写。这也就意味着,两个进程在通信时要有一个关闭读端,另一个关闭写端。这样才能保证通信稳定且有序的进行。
什么叫全双工呢?
简单来说,全双工就是通信时允许通信双方同时读或者同时写。
知道了管道的大致原理之后,我们来谈谈管道中的匿名管道。
匿名管道
用于有血缘关系的进程之间通信(如父子进程)。
模型如下:
为什么匿名管道常用于父子进程的通信呢?
这是因为在父进程创建出子进程之后会天然的继承父进程的所有打开文件。这样一旦创建出了子进程,父子进程就看到了同一份管道文件的缓冲区,也同时能通过一端关闭读,另一端关闭写的方式来控制数据在管道缓冲区的流动方向。
pipe函数创建管道
在linux中,pipe()是一个用于创建匿名管道的系统调用。在man手册中我们可以查看到:
int pipe(int pipefd[2]) • 1
其中pipefd是一个输出型参数,当我们传给pipe之后,会通过参数pipefd返回两个文件描述符,pipefd[0]表示读数据端,pipefd[1]表示写数据端。创建成功函数返回0,失败则返回-1.
为什么不用文件路径也能打开一个管道文件并返回其文件描述符呢?
这是因为我们说的管道文件是内存级的,并不是在磁盘上找一个文件打开,而是由操作系统在内存给找的一片空间。只不过我们可以将其视作为普通文件。
使用管道
下面给出代码样例来帮助我们理解如何使用管道:
#include <iostream> #include <unistd.h> #include <cerrno> #include <cstring> #include <sys/types.h> #include <sys/wait.h> #define _size 1024 using namespace std; string GetOtherMessage() { static int cnt = 1; string messageid = to_string(cnt); cnt++; pid_t pid = getpid(); string messagepid = to_string(pid); string message = "massageid: "; message += messageid; message += "massagepid: "; message += messagepid; return message; } void SubProcessWrite(int wfd) { // 子进程写入端 int pipesize = 0; string message = "father, i am your son"; char c = 'A'; while (true) { //cerr << "+++++++++++++++++++++++++" << endl; string info = message + GetOtherMessage(); write(wfd, info.c_str(), info.size()); sleep(1); // write(wfd,&c,1); } } void FatherProcessRead(int rfd) { // 父进程读入端 char inbuff[_size]; while (true) { cout << "-------------------------" << endl; ssize_t n = read(rfd, inbuff, sizeof(inbuff) - 1); if (n > 0) { inbuff[n] = '\0'; cout << "getmessage: " << inbuff << endl; } else if (n == 0) { // 如果read的返回值为0,表示写入端关闭,我们读到了文件的末尾 cout << "写入端关闭,父进程放弃读数据" << endl; } else { cerr << "read error" << endl; } } } int main() { // 1.创建管道 int pipefd[2]; // 输出型参数,用来获得rfd、wfd. int n = pipe(pipefd); if (n != 0) { cerr << "errno: " << errno << ":" << "errstring:" << strerror(errno) << endl; return 1; } cout << "pipefd[0]: " << pipefd[0] << " pipefd[1]: " << pipefd[1] << endl; sleep(1); // 创建子进程,子进程发送数据,父进程读取数据 pid_t id = fork(); if (id < 0) cout << "fork" << endl; if (id == 0) { // child cout << "子进程关闭不需要的fd了,准备发消息了" << endl; close(pipefd[0]); // 子进程关闭读端 // 发送消息 SubProcessWrite(pipefd[1]); close(pipefd[0]); // 发送结束,关闭写端 exit(0); } cout << "父进程关闭不需要的fd了,准备接收消息了" << endl; sleep(1); close(pipefd[1]); FatherProcessRead(pipefd[0]); close(pipefd[0]); return 0; }
对以上代码做出简要的解释:
- 首先我们利用pipe函数创建一个管道用于父子进程的通信,并且得到两个文件描述符,pipefd[0]是读端的文件描述符,pipefd[1]是写的文件描述符。
- 我们的目的是子进程向管道里写入数据,父进程向管道里读取数据并打印到屏幕上。所以在创建子进程之后,父子进程需要关闭不需要的文件描述符。
- 为了防止写入数据过快,子进程每次写一次数据之后就会等待上一秒。
观察代码现象:
管道的5种情况
通过上面的学习我们已经学会使用系统调用来创建一个管道了,接下来谈谈管道在数据流动的过程中有情况需要我们注意。
- 如果当前管道里没有数据且写端没有被关闭。表示读取条件不具备,读端进程就会被阻塞,直到写进程写入数据。
- 如果当前管道被写满且读端没有被关闭。表示写条件不具备,写端进程就会被阻塞,直到读进程读入数据。 (管道的空间是有限的,不同系统下的空间大小可能会有所区别。)
- 管道一直在读,但是写端已经被关闭。读端的read会返回0,表示读到了文件的末尾。
- 管道一直在写,但是读端已经被关闭。操作系统会像写端进程发送一个SIGPIPE(13)信号,强行关闭写端进程。(可以通过waitpid获取退出信号)
- 管道也有大小,如果一次传输的数据太大且大于管道容量,linux将不保证写入的原子性。否则保证写入的原子性。
管道的5种特征
结合管道的4种情况,我们能总结出关于管道的5种特征:
- 匿名管道只能用于具有血缘关系的进程之间的通信,通常使用于父子进程之间
- 管道内部自带同步机制(读写操作的原子性)。这也是为什么我们的读端会在写端写入数据之后才会读取数据。这保证了数据在管道中流通的有序性。
- 管道文件的生命周期随着进程的终止而结束。
- 管道文件在通信时,是以字节流的方式读写数据。
- 管道的通信模式是一种半双工模式。