Linux进程通信——匿名管道
目录
1、进程间通信基础概念
2、管道的工作原理
2.1 什么是管道文件
3、匿名管道的创建与使用
3.1、pipe 系统调用
3.2 父进程调用 fork() 创建子进程
3.3. 父子进程的文件描述符共享
3.4. 关闭不必要的文件描述符
3.5 父子进程通过管道进行通信
父子进程通信的具体例子
4.管道的四种场景
4.1 场景一:父进程不写,子进程尝试读取
4.2 场景二:父进程不断写入,直到管道写满,子进程不读取
4.3 场景三:关闭写端,子进程读取数据
4.4 场景四:关闭读端,父进程写入数据
5、匿名管道实操-进程控制
5.1 逻辑设计
🌇前言
在操作系统中,进程间通信(Interprocess Communication,简称IPC)是两个不同进程之间进行协同工作和信息交换的基础。IPC 允许不同的进程相互协调,协作完成任务。进程间通信的方式有很多种,而管道则是一种非常经典且常用的方式。本文将详细探讨 匿名管道,它在进程间通信中扮演着重要的角色。
🏙️正文
1、进程间通信基础概念
在深入探讨匿名管道之前,我们先来了解一些基本概念。进程间通信的目的是为了使多个独立的进程能够协同工作,进行信息交换。主要有四个目的:
-
数据传输:不同进程之间需要传输数据。例如,将数据从客户端传送到服务器。
-
资源共享:多个进程共享系统资源,保证高效使用。
-
事件通知:某个进程需要通知其他进程某个事件的发生,例如进程的终止。
-
进程控制:用于进程管理,协调进程的执行和资源的分配。
这些目的的核心是打破进程的独立性,让它们能够共享资源和信息,协同完成任务。
2、管道的工作原理
管道是一种用于进程间通信的方式,它本质上是一个文件。无论是匿名管道还是命名管道,它们的原理都是通过文件描述符来共享数据。每个管道都有两个端口:一个是写端,另一个是读端。
管道最初是由 Unix 系统引入的,它允许具有“血缘关系”的进程(如父子进程)通过管道进行通信。管道的实现通常会涉及到内核为进程分配文件描述符。父进程在创建管道后,会为其子进程继承文件描述符,并通过关闭不需要的端口,确保通信的流向。
2.1 什么是管道文件
管道文件是操作系统中用于实现进程间通信的特殊文件,具有以下几个显著特点:
-
单向通信:管道是 单向 的通信方式,意味着数据只能从一个端流向另一个端。通常情况下,一个进程写数据到管道,而另一个进程从管道中读取数据。这种方式被称为“半双工通信”,如果需要实现双向通信,需要两个管道。
-
基于文件的设计,管道本质上是内存中的文件; 管道文件并不是磁盘级别的文件,而是内存级别文件,管道文件没有自己的inode,也没有名字。过内存中的缓冲区进行存储,操作系统会将管道作为文件来处理。
-
管道分为 匿名管道 和 命名管道。匿名管道没有名字,是由操作系统在内存中创建的,仅限于有血缘关系(如父子进程或兄弟进程)的进程间通信。由于没有名字,匿名管道无法在进程间直接共享。
与此不同,命名管道(FIFO)则拥有一个系统中的路径名,因此它可以被不具备血缘关系的进程之间共享。这使得命名管道的通信更加灵活。
-
生命周期与进程绑定,管道的生命周期与创建它的进程生命周期紧密相关。当进程结束时,管道也会被操作系统回收。管道文件的生命周期由打开它的进程的生命周期决定,在进程终止时,管道的资源会被释放。
-
内存缓冲区,管道的一个重要特点是,它在内存中创建一个缓冲区,用于存储待传输的数据。由于是内存中的缓冲区,管道中的数据并不会被持久化到磁盘中。这使得管道比磁盘文件更为高效,但数据在管道中的存储是临时的,不会在系统重启后保留
-
阻塞行为与同步机制,
管道的通信遵循 阻塞 和 同步 机制。当读端尝试读取数据时,如果管道为空,进程会阻塞,直到写端写入数据。同样,写端如果尝试写入数据时,如果管道已满,进程也会阻塞,直到读端读取部分数据。
这种阻塞行为本身提供了一定的同步机制。管道会保证写入数据的顺序,并且数据在被读取之前不会丢失。这使得进程间的通信是同步的,确保数据完整传输。
-
管道大小限制,管道的大小在不同的操作系统和系统配置中可能有所不同。通常,管道大小会受到系统配置的限制。在 Linux 中,管道大小的默认值通常为 64KB(从 Linux 2.6.11 版本开始),不过在不同的系统或不同的内核版本中,管道的大小也可能有所变化
- 在管道中,写入 与 读取 的次数并不是严格匹配的,此时读写次数没有强相关关系,管道是面向字节流读写的面向字节流读写又称为 流式服务:数据没有明确的分割,不分一定的报文段;与之相对应的是 数据报服务:数据有明确的分割,拿数据按报文段拿不论写端写入了多少数据,只要写端停止写入,读端都可以将数据读取。
- 具有一定的协同能力,让 读端 和 写端 能够按照一定的步骤进行通信(自带同步机制)当读端进行从管道中读取数据时,如果没有数据,则会阻塞,等待写端写入数据;如果读端正在读取,那么写端将会阻塞等待读端,因此 管道自带 同步与互斥 机制。
3、匿名管道的创建与使用
具体流程:
父进程创建匿名管道,同时以读、写的方式打开匿名管道,此时会分配两个 fd
fork 创建子进程,子进程拥有自己的进程系统信息,同时会继承原父进程中的文件系统信息,此时子进程和父进程可以看到同一份资源:匿名管道 pipe
因为子进程继承了原有关系,因此此时父子进程对于 pipe 都有读写权限,需要确定数据流向,关闭不必要的 fd,比如父进程写、子进程读,或者父进程读、子进程写都可以。
3.1、pipe
系统调用
匿名管道的创建通过 pipe()
系统调用来实现。该函数会创建一个管道,并返回两个文件描述符:一个用于读,另一个用于写。函数原型如下:
#include <unistd.h>int pipe(int pipefd[2]);
传入一个大小为2的整型数组作为输出型参数,操作系统就会生成一个管道文件,并且让进程以读写的方式分别打开进程,并且将进程的读管道文件的文件标识符写道pipe[1],写管到文件描述符写道pipe[1]之中。
int pipefd[2];
pipe(pipefd); // 创建管道
3.2 父进程调用 fork()
创建子进程
当父进程调用 fork()
时,操作系统会创建一个新的子进程。子进程会继承父进程的文件描述符表,因此,父子进程可以共享父进程所创建的管道文件描述符。也就是说,父进程和子进程都会拥有相同的管道读端和写端。
pid_t pid = fork();
3.3. 父子进程的文件描述符共享
父进程和子进程共享管道的读写端口意味着:
-
父进程 和 子进程 都可以操作
pipefd[0]
和pipefd[1]
,但它们之间的角色(读或写)通常是根据进程的需求来确定的。 -
父进程和子进程在创建时各自拥有自己的 进程资源,但文件描述符表会被子进程继承,指向相同的管道内存资源。
3.4. 关闭不必要的文件描述符
由于管道是单向通信的,所以为了避免数据混乱,父进程和子进程通常会关闭不必要的文件描述符。例如,如果父进程要写数据到管道而子进程读取数据,父进程应该关闭管道的读端,子进程应该关闭管道的写端。
// 父进程关闭管道的读端,子进程关闭管道的写端
close(pipefd[0]); // 父进程关闭读端
close(pipefd[1]); // 子进程关闭写端
3.5 父子进程通过管道进行通信
-
父进程写数据:父进程通过管道的写端
pipefd[1]
向管道中写入数据。write(pipefd[1], "Hello from parent", 17);
子进程读数据:子进程通过管道的读端
pipefd[0]
从管道中读取数据。char buf[128]; read(pipefd[0], buf, sizeof(buf));
-
父进程写入的数据会通过管道传递给子进程。
-
子进程从管道中读取数据,通常会按顺序接收父进程写入的数据。
父子进程通信的具体例子
下面是一个完整的示例代码,展示了父子进程如何通过管道进行通信:
#include <iostream>
#include <cassert>
#include <cstdlib>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>using namespace std;int main()
{// 1、创建匿名管道int pipefd[2]; // 数组int ret = pipe(pipefd);assert(ret == 0);(void)ret; // 防止 release 模式中报警告// 2、创建子进程pid_t id = fork();if (id == 0){// 子进程内close(pipefd[1]); // 3、子进程关闭写端// 4、开始通信char buff[64]; // 缓冲区while (true){int n = read(pipefd[0], buff, sizeof(buff) - 1); //注意预留一个位置存储 '\0'buff[n] = '\0';if (n >= 5 && n < 64){// 读取到了信息cout << "子进程成功读取到信息:" << buff << endl;}else{// 未读取到信息if (n == 0)cout << "子进程没有读取到信息,通信结束!" << endl;// 读取异常(消息过短)elsecout << "子进程读取数据量为:" << n << " 消息过短,通信结束!" << endl;break;}}close(pipefd[0]); // 关闭剩下的读端exit(0); // 子进程退出}// 父进程内close(pipefd[0]); // 3、父进程关闭读端char buff[64];// 4、开始通信srand((size_t)time(NULL)); // 随机数种子while (true){int n = rand() % 26;for (int i = 0; i < n; i++)buff[i] = (rand() % 26) + 'A'; // 形成随机消息buff[n] = '\0'; // 结束标志cout << "=============================" << endl;cout << "父进程想对子进程说: " << buff << endl;write(pipefd[1], buff, strlen(buff)); // 写入数据if (n < 5)break; // 消息过短时,不写入sleep(1);}close(pipefd[1]); // 关闭剩下的写端// 父进程等待子进程结束int status = 0;waitpid(id, &status, 0);// 通过 status 判断子进程运行情况if ((status & 0x7F)){printf("子进程异常退出,core dump: %d 退出信号:%d\n", (status >> 7) & 1, (status & 0x7F));}else{printf("子进程正常退出,退出码:%d\n", (status >> 8) & 0xFF);}return 0;
}
站在 文件描述符 的角度理解上述代码:
所以,看待 管道
,就如同看待 文件
一样!管道
的使用和 文件
一致,迎合 Linux一切皆文件思想。
4.管道的四种场景
4.1 场景一:父进程不写,子进程尝试读取
情况描述:
-
父进程没有写入数据到管道。
-
子进程尝试从管道中读取数据。
结果:
-
由于管道为空,子进程在尝试读取时会进入阻塞状态。
-
只有当父进程开始向管道中写入数据后,子进程才会成功读取数据。
形象化理解:
-
这就像一个垃圾桶,子进程是倒垃圾的工作人员,而父进程是往垃圾桶里扔垃圾。如果垃圾桶为空,子进程(倒垃圾的人)就无法工作,必须等待父进程(扔垃圾的人)开始丢垃圾,才能开始工作。
4.2 场景二:父进程不断写入,直到管道写满,子进程不读取
情况描述:
-
父进程持续向管道写入数据,直到管道被写满。
-
子进程不进行读取操作。
结果:
-
当管道的缓冲区满了,父进程会被阻塞,无法继续写入数据,直到子进程读取数据。
-
这是因为管道有大小限制,管道满时,写端无法继续写入,必须等待管道中有空间才能继续写入。
形象化理解:
-
就像垃圾桶满了后,不能继续往里面丢垃圾,必须等到垃圾桶被清空(子进程读取数据)之后,才能继续丢垃圾。
4.3 场景三:关闭写端,子进程读取数据
情况描述:
-
父进程写入数据到管道,并关闭写端。
-
子进程从管道中读取数据,并在读取到末尾时判断写端是否关闭。
结果:
-
当父进程关闭写端后,子进程可以继续读取管道中的数据,直到数据读取完。
-
子进程在读取到数据末尾时会收到 read的,表示已经没有更多数据可读取,且写端已关闭。
形象化理解:
-
这类似于垃圾桶的垃圾已经被倒空,子进程(倒垃圾的人)会看到垃圾桶已经没有垃圾了。即使它继续尝试“倒垃圾”,也不会有新的垃圾,显示读取到了文件末尾。
4.4 场景四:关闭读端,父进程写入数据
情况描述:
-
父进程是写端,子进程是读端。父进程写入数据。
-
父进程在读取五次后关闭读端。
结果:
-
当关闭读端后,写端(父进程)会收到
SIGPIPE
信号,通常导致进程终止。 -
因为操作系统会发现,写端已没有可用的读取端(读端关闭了),它会强制终止写端进程以防止资源浪费。
形象化理解:
这就像垃圾桶的“倒垃圾的人”(写端)发现没有“垃圾桶”(读端)可以丢垃圾,因此操作系统会终止写端,避免无意义的行为继续发生。
5、匿名管道实操-进程控制
匿名管道作为 IPC 的其中一种解决方案,那么肯定有它的实战价值
场景:父进程创建了一批子进程,并通过多条匿名管道与它们链接,父进程选择某个子进程,并通过匿名管道与子进程通信,并下达指定的任务让其执行
5.1 逻辑设计
首先创建一批子进程及匿名管道 -> 子进程(读端)阻塞,等待写端写入数据 -> 选择相应的进程,并对其写入任务编号(数据)-> 子进程拿到数据后,执行相应任务
1.创建一批进程及管道
首先需要先创建一个包含进程信息的类,最主要的就是子进程的写端 fd,这样父进程才能通过此 fd 进行数据写入
循环创建管道、子进程,进行相应的管道链接操作,然后子进程进入任务等待状态,父进程将创建好的子进程信息注册
假设子进程获取了任务代号,那么应该根据任务代号,去执行相应的任务,否则阻塞等待
注意: 因为是创建子进程,所以存在关系重复继承的情况,此时应该统计当前子进程的写端 fd,在创建下一个进程时,关闭无关的 fd
具体体现为:每次都把 写端 fd 存储起来,在确定关系前 “清理” 干净
关于上述操作的危害,需要在编写完进程等待函数后,才能演示其作用 。
完整代码如下:
Task.hpp
#pragma once
#include<iostream>
#include<vector>typedef void (*task_t)();void task1()
{std::cout << "lol 刷新日志" << std::endl;
}
void task2()
{std::cout << "lol 更新野区,刷新出来野怪" << std::endl;
}
void task3()
{std::cout << "lol 检测软件是否更新,如果需要,就提示用户" << std::endl;
}
void task4()
{std::cout << "lol 用户释放技能,更新用的血量和蓝量" << std::endl;
}
void LoadTask(std::vector<task_t>& task)
{task.push_back(task1);task.push_back(task2);task.push_back(task3);task.push_back(task4);
}
processpool.cc
#include "Task.hpp"
#include <string>
#include <vector>
#include <cstdlib>
#include <ctime>
#include <cassert>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/wait.h>using namespace std;const int processnum =4;
vector<task_t> tasks; //把所有的任务,装进去class channel
{public:channel(int &cmdfd ,int &mypid,string &name):_name(name),_cmdfd(cmdfd),_mypid(mypid){}public:string _name;//子进程的名字int _cmdfd;//发信号的文件描述符 int _mypid;//我的PID
};void Menu()
{std::cout << "################################################" << std::endl;std::cout << "# 1. 刷新日志 2. 刷新出来野怪 #" << std::endl;std::cout << "# 3. 检测软件是否更新 4. 更新用的血量和蓝量 #" << std::endl;std::cout << "# 0. 退出 #" << std::endl;std::cout << "#################################################" << std::endl;
}void slaver(int pool)
{while(true){int cmdcode=0;//通过调用码,去领任务int n=read(pool,&cmdcode,sizeof(int));cout <<"slaver say@ get a command: "<< getpid() << " : cmdcode: " << cmdcode << endl;if(cmdcode>0&&cmdcode<=tasks.size()) tasks[cmdcode-1]();if(n==0) break;}
}void InitProcesspool(vector<channel>&channels)
{vector<int> d;//把父进程的所有打开的写管道存储到里面for(int i=1;i<=processnum;i++){int pipeid[2];int n=pipe(pipeid);assert(!n);int pid=fork();if(pid==0){cout<<"创建的"<< i<<"号子进程"<<endl;for(auto &t:d){close(t);//关掉所有不相关的管道}close(pipeid[1]);//关闭写管道slaver(pipeid[0]);//读操作// close(pipeid[0]);//多此一举cout<<"关闭的"<< i<<"号子进程"<<endl;exit(0);}close(pipeid[0]);string name="创建的子进程"+to_string(i);channels.push_back({pipeid[1],pid,name});//那个管道发数据记录下来,d.push_back(pipeid[1]);//把一会发数据的管道号记下来sleep(1);}
}void setslaver(vector<channel>&channels)
{int which=0;int cnt=4;while(cnt--){int slect=0;Menu();cin>>slect; if(!slect) break; // rand((void)time(nullptr));// int i=srand()%5;cout<<"farher message"<<channels[which]._name<<endl;write(channels[which]._cmdfd,&slect,sizeof(int)); which++;which%=channels.size();sleep(1);}
}void Quitpool(vector<channel>&channels)
{for(auto& t:channels)//关闭所有的写管道{close(t._cmdfd);waitpid(t._mypid,nullptr,0);}
}int main()
{vector<channel> channels;//把打开的子进程装进来LoadTask(tasks);InitProcesspool(channels);//创建子进程setslaver(channels);//发配任务,采用轮询Quitpool(channels);//关闭写管道,等到read=0子进程退出,全部关闭return 0;
}
总体来说,在使用这个小程序时,以下关键点还是值得多注意的
注册子进程信息时,存储的是 写端 fd,目的是为了通过此 fd 向对应的子进程写数据,即使用不同的匿名管道
创建管道后,需要关闭父、子进程中不必要的 fd
需要特别注意父进程写端 fd 被多次继承的问题,避免因写端没有关干净,而导致读端持续阻塞关闭读端对应的写端后,读端会读到 0,可以借助此特性结束子进程的运行
在选择进程 / 任务 时,要做好越界检查
等待子进程退出时,需要先关闭写端,子进程才会退出,然后才能正常等待。