【Linux进程】进程间的通信
目录
1. 进程间通信
1.1 进程间通信的目的
2. 管道
2.1 什么是管道
2.2. 匿名管道
匿名管道的特性
管道的4种情况
联系shell中的管道
2.3. 命名管道
代码级建立命名管道
2.4. 小结
总结
1. 进程间通信
进程间通信(Inter-Process Communication,IPC)是指在操作系统中,多个进程之间进行数据交换和信息传递的一种机制。由于进程在内存中有各自的地址空间,它们不能直接访问对方的内存,因此需要通过一些特定的方法来实现通信;
1.1 进程间通信的目的
- 数据传输:一个进程需要将它的数据发送给另一个进程
- 资源共享:多个进程之间共享同样的资源。
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
- 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变
进程间通信的本质:让不同的进程先看到同一份资源,资源通常是由操作系统提供
进程间通信的方式也有很多:匿名管道、命名管道、共享内存、消息队列...;
本文主要介绍匿名管道、命名管道这两种进程通信;
2. 管道
2.1 什么是管道
管道是Unix中最古老的进程间通信的形式;
联系一下日常生活中的管道,水在管道中流到一般都是单向的,这里的管道也是如此,进程之间通过管道通信也是单向的;
管道(Pipes)是一种进程间通信(IPC)机制,用于在一个或多个进程之间传递数据。它通过创建一个临时的通道,允许一个进程的输出直接作为另一个进程的输入。管道主要分为两种类型:匿名管道和命名管道;
管道的通信特点就是单向的,单个管道只能进行单向通信,想要双向通信怎么办?--创建两个管道;
2.2. 匿名管道
匿名管道主要用于有亲缘关系的进程(如父子进程)之间进行通信;
最常见的就是父子进程体系:
父进程创建子进程,子进程会拷贝父进程的 struct file_struct(这里可以认为是浅拷贝);files_struct 中的内容完全和父进程相同,这样父进程和子进程就同时指向了同一块资源;
files_struct:是进程用于管理打开的文件,所描述出来的数据结构;
管道可以视为一种特殊的文件,只存在于内存中的文件;
怎么理解?一个被打开的文件通常包含三个结构:inode、方法集(虚拟文件系统)、文件缓冲区;管道也是如此;只不过他不会把缓冲区的数据刷新到磁盘中;
如上图的结构,父子进程执行的是相同的文件描述对象,如果同时对文件进行写入,是无法辨识数据是谁写入的;为了避免这样的情况,管道在设计之初,它的写入就是单向的;
比如:父进程向子进程发送数据,父进程只能往管道写入数据,子进程只能从管道读取数据;数据传输的过程和管道类似,又因为它内部所使用的文件并不会在磁盘中存在,只会在内存中使用,所以也被称为匿名管道;
#include <unistd.h>
// 功能:创建一无名管道
// 原型
int pipe(int fd[2]);
// 参数
// fd:文件描述符数组,其中fd[0]表示读端, fd[1]表示写端
// 返回值:成功返回0,失败返回错误代码
示例:
#include <iostream>
#include <cassert>
#include <unistd.h>
#include <cstring>
#include <sys/wait.h>
#include <sys/types.h>
using namespace std;
int main()
{
// 第一步:建立管道
int pipefd[2] = { 0 };
int n = pipe(pipefd);
assert(n == 0);
cout << "pipefd[0]:" << pipefd[0] << ", pipefd[1]:" << pipefd[1] << endl;
// 第二步:创建子进程
pid_t id = fork();
if (id < 0)
{
perror("fork");
return 1;
}
// 父进程写入,子进程读取
// 第三步:关闭不需要的fd,形成单向通信的管道
else if (id != 0) // 父进程
{
close(pipefd[0]); // 关闭读端
int cnt = 0;
while (cnt < 10) // 发送10次
{
char msg[1024];
snprintf(msg, sizeof(msg), "hello child! I am father, pid: %d, cnt: %d", getpid(), cnt);
cnt++;
if (write(pipefd[1], msg, strlen(msg)) < 0) {
perror("write");
break; // 处理写入错误
}
sleep(1);
}
cout << "father close write point" << endl;
close(pipefd[1]); // 关闭管道写端
pid_t rid = waitpid(id, nullptr, 0);
if (rid == id)
{
cout << "wait success !" << endl;
}
}
else // 子进程
{
close(pipefd[1]); // 关闭写端
char buffer[1024];
while (true)
{
ssize_t n = read(pipefd[0], buffer, sizeof(buffer) - 1); // 预留一个位置给字符串结尾添加'\0'
if (n > 0)
{
buffer[n] = '\0'; // 添加字符串结束符
cout << getpid() << ", father says: " << buffer << " to me!" << endl;
}
else if (n == 0)
{
cout << "write quit, me too !" << endl;
break; // 读到EOF
}
else
{
perror("read");
break; // 处理读取错误
}
sleep(1);
}
cout << "read point close" << endl;
close(pipefd[0]);
sleep(5);
exit(0);
}
return 0;
}
在文件描述符的视角:
1. 父进程创建管道
开始父进程创建管道,以读方式打开一次以写方式打开一次,打开两次;
2. 父进程创建子进程
父进程创建子进程,子进程继承父进程的属性,也可以对管道也可以进行读和写;
3. 父进程关闭读端、子进程关闭写端
实现单向时,比如父进程向子进程传输数据;父进程关闭读方式打开的文件,子进程关闭写方式打开的文件这样就形成了单向信道;
实际情况:
事实上,父进程以写方式打开管道文件(写端),然后再以读方式打开同一个管道文件(读端),实际上会创建两个不同的文件描述符对象,分别指向管道的写端和读端;
在Linux环境下,两个文件描述符最终会指向同一个inode,共享相同的方法集和缓冲区;
问题来了,父进程以写的方式打开文件,又以读的方式打开文件,单向通信时,父进程需要关闭读方式打开的文件,子进程关闭以写方式打开的文件,父进程关一个,子进程关一个,这样这两个文件描述对象不就没了吗?这还怎么通信?
这样的类似的问题我们之前也遇到过,如何解决呢?--引用计数的方法所以在struct file中有一个类似于引用计数的概念;一个进程关闭一个文件时,引用计数就减一;当一个文件描述对象它的引用计数为0时,它就会自动把文件关掉;
fork创建子进程时,子进程可以读取到父进程的数据,那为什么还要大费周章的这样传输数据?
子进程确实可以通过fork进程父进程的数据,但那也只是在创建时读取到,它无法读取到变化的数据如果任意一方写入修改数据,就会发生写时拷贝;
匿名管道的特性
管道的五种特性:
- 匿名管道,可以允许具有血缘关系的的进程之间进行进程间通信,常用于父与子;
- 匿名管道默认给读写端提供同步机制;
- 面向字节流;
- 管道的生命周期是随进程的;
- 管道是单向通信的,半双工通信的一种特殊情况;
如何去理解?
比如:在以前,在shell中执行父子进程,父子进程各自执行自己的,互不干涉;而匿名管道这里,默认存在读写同步机制;
情况一:子进程写,父进程读,但子进程写的慢;会出现管道中没有数据,此时读端必须等待,直到有数据为止;
情况二:子进程写的快,父进程读的慢;子进程也会等待父进程,子进程会写一部分,然后等父进程读取,读取之后再写;
怎么写和怎么读之间没有什么关系,并不是写一条,读一条;子进程写的快,父进程读的慢,父进程可能一次就读子进程写的几十次或者上百次的数据都读出来,“可以一次只读一个字节,也可以定义一个缓冲区一次就把缓冲区全打满;
管道的4种情况
- 正常情况,如果管道没有数据了,读端必须等待,直到有数据为止
- 正常情况,如果管道被写满了(管道大小约为64kB),写端必须等待,直到有空间为止
- 写端关闭,读端一直读取,当read返回值为0,表示读到文件结尾
- 读端关闭,写端一直写入,OS会直接杀掉写端进程,通过想目标进程发送SIGPIPE(13)信号,终止目标进程;
联系shell中的管道
shell中经常使用的管道 ” | “;
比如:
sleep 1000 | sleep 2000 | sleep 3000
通过上述的测试命令,可以看到sleep之间是兄弟进程,那么它们的原理是什么?
上述测试命令:3个兄弟进程,使用两个管道;父进程先创建管道,因此两个管道对于这个三个进程都是可见的,然后再使用fork创建子进程;
对于进程1,关闭其他的文件,只保留pipefda[1](写);
进程2,只保留对pipefda[0](读)以及对pipefdb[1](写);
进程3,只保留对pipefdb[0](读);
其次就是重定向,在使用管道时,原本要输出在显示器的数据没有显示,而是作为输入,传输给了第二个进程,把进程1的 stdout 重定向为 pipefda[1];(dup2(pipefda[1],1]))
让进程2的输入从管道中读取,所以我们也需要对进程2进行重定向,后续的读写也是需要进行重定向,以此类推;
2.3. 命名管道
上述介绍的匿名管道通信,是用于亲缘关系之间的进程进行通信,如果没有任何关联的进程如何通信呢? --命名管道,都是在内存中作用;
命名管道简单示例:
两个终端实现通信:
终端一:
终端二:
命名管道可以从命令行上创建
命令:mkfifo filename
命名管道是一种特殊类型的文件,它在文件系统中有对应的文件节点,但实际上并不占用磁盘空间存储数据;
两进程之间要想实现通信,那么就需要看到同一份资源;
命名管道(Named Pipe)在文件系统中有对应的文件节点,因此它是存在磁盘中的;
路径是唯一的,所以可以使用路径+文件名访问,让不同的进程来访问相同的资源;
当进程访问命名管道时,操作系统会为该进程创建文件描述符,并为管道在内存中创建缓冲区。数据通过管道在进程间传输时,会经过内存缓冲区进行交换,而不会把数据加载到磁盘;
通信原理:
由此我们也可以发现命名管道的文件节点存放在磁盘中的意义:为了让没有血缘关系的进程能够找到命名管道,进而使得两进程可以访问同一个管道;
代码级建立命名管道
接口:
int mkfifo(const char *filename,mode_t mode);
创建成功返回0,失败返回-1;
有了这些可以模拟的写一个使用命名管道通信的服务端和客户端:
服务端:
#include <iostream>
#include <sys/types.h>
#include <sys/stat.h>
#include <cstring>
#include <cerrno>
#include <fcntl.h>
#include <unistd.h>
#include "comm.h"
// 服务端
//using namespace std;
#define FILENAME "fifo"
int Makefifo()
{
int n = mkfifo(FILENAME, 0666);
if (n < 0)
{
std::cerr << "error: " << errno << " errorstring: " << strerror(errno) << std::endl;
return false;
}
std::cout << "mkfifo success... " << std::endl;
return true;
}
int main()
{
Start:
// 打开管道文件
int rfd = open(FILENAME, O_RDONLY);
if (rfd < 0)
{
std::cerr << "error: " << errno << " errorstring: " << strerror(errno) << std::endl;
if (Makefifo()) goto Start;
else return 1;
//return 2;
}
std::cout << "open fifo success...read " << std::endl;
char buffer[1024];
while (true)
{
//读取管道数据
ssize_t s = read(rfd, buffer, sizeof(buffer) - 1); // 预留一个空间给\0
if (s > 0)
{
buffer[s] = 0; //把最后设置为\0表示字符串的结尾
std::cout << "Client say: " << buffer << std::endl;
}
else if (s == 0)
{
std::cout << "Client quit,server quit too " << std::endl;
break;
}
}
// 关闭文件
close(rfd);
std::cout << "close fifo success... " << std::endl;
return 0;
}
这里需要特别注意,创建命名管道时,最好先以读方式打开;
先让读端创建好,此时读端会阻塞等待;直到写端打开管道为止;
这种行为保证了在读端尝试读取数据之前,有写端可以向管道写入数据。读端会等待写端打开,以确保有效的通信;
如果是写端先创建好,就可能出现这种情况:
写端先创建好,向管道写数据,但此时读端还未创建好,就会导致程序被OS杀死;
客户端:
#include <iostream>
#include <iostream>
#include <sys/types.h>
#include <sys/stat.h>
#include <cstring>
#include <cerrno>
#include <fcntl.h>
#include <unistd.h>
#include "comm.h"
// 客户端
int main()
{
int wfd = open(FILENAME, O_WRONLY);
if (wfd < 0)
{
std::cerr << "error: " << errno << " errorstring: " << strerror(errno) << std::endl;
return 1;
}
std::cout << "open fifo success...write " << std::endl;
std::string msg;
while (true)
{
std::cout << "Please Enter#";
std::getline(std::cin, msg);//读取客户端一行的输入信息
ssize_t s = write(wfd, msg.c_str(), msg.size());//把数据写入到管道中
if (s < 0)
{
std::cerr << "error: " << errno << " errorstring: " << strerror(errno) << std::endl;
break;
}
}
close(wfd);
std::cout << "close fifo success... " << std::endl;
return 0;
}
2.4. 小结
命名管道与匿名管道的区别:
- 匿名管道由pipe函数创建并打开。
- 命名管道由mkfifo函数创建,打开用open
- FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在它们创建与打开的方式不同,一但这些工作完 成之后,它们具有相同的语义;
- 命名管道用于没有任何关系的进程之间进行通信;
- 匿名管道用于有亲缘关系的进程之间进行通信
- 命名管道的文件节点会存储在磁盘中,但管道数据并不会存在磁盘中;
管道的四种情况:
- 正常情况,如果管道没有数据了,读端必须等待,直到有数据为止
- 正常情况,如果管道被写满了(管道大小约为64kB),写端必须等待,直到有空间为止
- 写端关闭,读端一直读取,当read返回值为0,表示读到文件结尾
- 读端关闭,写端一直写入,OS会直接杀掉写端进程,通过想目标进程发送SIGPIPE(13)信号,终止目标进程;
总结
以上便是本文的全部内容,希望对你有所帮助或启发,后续也将会继续介绍其他进程间通信的方式,感谢阅读!