目录
进程间通信介绍
进程间通信(IPC:Inter-Process Communication)是指在不同的进程之间传播或交换信息。由于进程的用户空间是互相独立的,不能互相访问,因此需要借助一些特定的机制来实现进程间的通信,根据不同的使用场景选择不同的通信解决方案,本文主要介绍的通信解决方案为:匿名管道和命名管道
进程间通信目的
数据传输:⼀个进程需要将它的数据发送给另⼀个进程资源共享:多个进程之间共享同样的资源。通知事件:⼀个进程需要向另⼀个或⼀组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)进程控制:有些进程希望完全控制另⼀个进程的执行(如Debug进程),此时控制进程希望能够拦截另⼀个进程的所有陷入和异常,并能够及时知道它的状态改变
本质:
进程间通信的本质就是:让不同的进程看到同一份资源
由于各个进程之间具有独立性,所以要做到进程间通信也不是一件容易的事,需要操作系统做出对应支持。
进程间通信分类
管道: 进程间通信的一种古老方式,它本质上是内核的一块缓冲区。管道分为匿名管道和命名管道两种。
- 匿名管道pipe
- 命名管道
System V IPC: System V进程通信(System V IPC)是一组在Unix和类Unix操作系统中用于进程间通信的机制。这些机制在System V Release 2中首次引入,并在POSIX标准中得到部分采纳。System V IPC主要包括消息队列、信号量、共享内存三种通信方式。
- System V 消息队列
- System V 共享内存
- System V 信号量
POSIX IPC: POSIX进程间通信是System V进程间通信的变体,它在Solaris 7发行版中引入。POSIX IPC对象包括消息队列、信号量、共享内存等,与System V类似,但接口和某些特性有所不同
- 消息队列
- 共享内存
- 信号量
- 互斥量
- 条件变量
- 读写锁
管道
进程间通信方式有很多,本篇我们主要介绍匿名管道和命名管道这两个相对简单简单的通信方式,其原理和特性也十分相似。所以也是值得作为进程间通信入门学习第一课的。
什么是管道?
管道是Unix中最古老的进程间通信的形式。我们把从⼀个进程连接到另⼀个进程的⼀个数据流称为⼀个“管道”

如下图:使用ps查询进程信息,并由管道交给grep过滤,查找指定的进程

匿名管道
匿名管道是一种用于进程间通信的机制,尤其适用于本地父子进程之间的数据传递。在上述例子中的|实际上就是一个匿名管道。

匿名管道一个比较显著的特点就是要求通信双方具有血缘关系,如父子关系;为何呢?这就需要从匿名管道的原理谈起了。
匿名管道原理
进程之间具有独立性,这是原则;进程间进行通信,这是目的。进程间通信的本质就是:让不同的进程看到同一份资源。使用匿名管道实现父子进程间通信的原理就是,让两个父子进程先看到同一份被打开的文件资源,然后父子进程就可以对该文件进行写入或是读写操作,进而实现父子进程间通信。

注意:此时的文件内核缓冲区由OS提供,作为父子进程共有的同一份资源,可以使用pipe函数申请,在此进行读写是不会导致写时拷贝的。
pipe
父子进程间通信创建匿名管道需要借助pipe函数
NAME
pipe - create pipe
SYNOPSIS
#include <unistd.h>
int pipe(int pipefd[2]);
RETURN VALUE
On success, zero is returned. On error, -1 is returned, errno is set appropriately, and pipefd is left unchanged.
参数:
- 个数为2的
int文件描述符数组;其中fd[0]表示读端,fd[1]表示写端
返回值:
- 成功返回0,失败返回错误代码
该函数调用成功会初始化传入的文件描述符数组,其中fd[0]表示读端,fd[1]表示写端。此后,再通过fork创建子进程,这样父子进程就能分别通过读端和写端进行通信了。这就是为什么它是匿名管道了,仅作用于父子之间,这份由pipe申请的资源由OS维护,不会发生写时拷贝。
匿名管道使用步骤
在创建匿名管道实现父子进程间通信的过程中,需要pipe函数和fork函数搭配使用,具体步骤如下:
一:父进程调用pipe函数创建管道

二:创建子进程

三:根据父子的想要的通信方向,关闭另一端:构建单向通道。如:子进程向父进程发信息

以文件描述符的方式来看待,如下图:

所以,看待管道,就如同看待文件⼀样!管道的使用和文件⼀致,迎合了“Linux⼀切皆文件思想”
使用示例:
int main()
{
int fds[2];
int fd=::pipe(fds);
if(fd<0)
{
perror("pipe\n");
return -1;
}
int pid=::fork();
if(pid<0)
{
perror("fork fail\n");
return -2;
}
else if(pid==0)
{
//子
close(fds[0]);//负责写
int cnt=10;
while(cnt)
{
string msg;
msg += "I am child pid: ";
msg += to_string(getpid());
msg += " cnt:";
msg += to_string(cnt--);
msg+='\n';//'\n'是认识的
::write(fds[1], msg.c_str(), msg.size());//一般不带'\0'
sleep(1);//注意写入速度
}
exit(0);
}
//父进程
close(fds[1]);//只负责读
char buffer[1024];
while(true)
{
ssize_t ssz=read(fds[0],buffer,sizeof(buffer));
if(ssz>0)
{
buffer[ssz]=0;//系统调用是不认识'\0'的,这只是C语言的规定,从write读取后记得加上'\0'
cout<<"child ->father: "<<buffer;
//sleep(1);
}
if(ssz==0)//可以理解为遇到文件结尾了
{
break;
}
else
{
cerr<<"read fail"<<endl;
break;
}
}
close(fds[0]);
int status=0;
pid_t rid=waitpid(pid,&status,0);//阻塞等待
cout<<"wait success exit_code:"<<WEXITSTATUS(status)<<endl;
return 0;
}

匿名管道特点
- 只能用于具有共同祖先的进程(具有亲缘关系的进程)之间进行通信;通常,⼀个管道由⼀个进程创建,然后该进程调用fork,此后父、子进程之间就可应用该管道。
- 管道提供流式服务
- ⼀般而言,进程退出,管道释放,所以管道的生命周期随进程
- ⼀般而言,内核会对管道操作进行同步与互斥
- 管道单项的,数据只能向⼀个方向流动;需要双方通信时,需要建立起两个管道
管道通信的4种情况
以下通信方向:为子进程(写端)向父进程(读端)发送信息
1;读写端正常打开;管道为空,则读端等待。
建立管道后,写端休眠5秒后(使管道为空) 再向管道写内容。

读端会一直阻塞,直到写端写完内容再进行读取。

2:读写端均正常;管道满了,写端阻塞

- 让读端先休眠五秒观看效果更加。

可以看到此时写入65536次后就停止向管道继续写内容了。

3:写端关闭,读端会读到0,表示读到文件尾

读端以此来作为通信结束的标志。

4:读端关闭,OS直接连写端也终止
建立管道的目的就是为了通信,如果没人读取,那么管道将毫无意义,所以如果读端关闭,OS会直接把该进程直杀掉。

可以看到,如果读端关闭,OS会直接向该进程发送13号信号终止该进程。

管道的大小
在情况二验证写满管道的实验中,测试多次会发现,写满管道都是65536次,这实际上就是管道的大小。
通过ulimit -a可以查看相关信息。其中管道的大小为
512
∗
8
=
4096
b
y
t
e
s
512*8=4096bytes
512∗8=4096bytes,而这只是其中一条

通过指令man 7 pipe再搜索关键pipe capacity可以查看到以下内容

从Linux 2.6.11开始,管道容量为16页(即在页大小为4096字节的系统中为65536字节)。从Linux 2.6.35开始,默认的管道容量是16页,但是可以使用fcntl(2) F_GETPIPE_SZ和F_SETPIPE_SZ操作。
匿名管道还是命名管道的大小都是如此
总结: 以上情况与内核会对管道操作进行同步与互斥有关,也就是同一时间只允许一端进行读或写操作。下面介绍的命名管道以上的四种情形基本一致。
匿名管道实践——进程池
进程池(Process Pool)是一种用于并行计算的编程技术,它允许开发者在程序中创建并管理一组预先定义好的进程。这些进程在需要执行并行任务时可以被高效地调度和利用,从而显著提高程序的执行效率和性能。于是,我们可以利用匿名管道来实现一个简易的进程池,感受一下匿名管道的使用。

演示

设计:
进程池是一个预先创建好的一组空闲进程的容器,这些进程在应用程序的生命周期内保持活动状态,随时准备处理任务。进程池主要由资源进程和管理进程组成。资源进程负责执行任务(Worker),而管理进程(Master)则负责创建资源进程、分配任务给空闲资源进程以及回收已经处理完工作的资源进程。所以实现一个简易的进程池主要涉及对进程管理和管道的使用。
再将代码进行一下封装;使用三个类进行实现:
ProcessPool:ProcessPool类为管理进程,负责管理创建出来的进程
#pragma once
//.hpp文件
#include<iostream>
#include<vector>
#include <sys/types.h>
#include <sys/wait.h>
#include"channel.hpp"
#include"taskmanger.hpp"
using namespace std;
//介绍:ProcessPool类为管理进程,负责管理创建出来的进程。
typedef void (*work_t)();//函数类型
class ProcessPool
{
public:
ProcessPool(int num,work_t worker):_processnums(num),_worker(worker)
{}
void InitPool()//初始化进程池
{
// 创建管道
for (int i = 0; i < _processnums; i++)
{
int fds[2];
int n = pipe(fds);
if (n < 0)
{
perror("pipe fail\n");
return;
}
pid_t pid = fork();
if (pid == 0)
{
// 子进程
::close(fds[1]); // 读
dup2(fds[0], 0); // 子进程从标准输入读取
Work();//子进程创建好后,进入执行方法等待父进程分派任务
::exit(0);
}
// 父
::close(fds[0]);//写
channels.emplace_back(fds[1],pid);
//printf("pipe suceess\n");
}
}
void DispatcTasks()//派发任务
{
int who=0;//谁去执行
int num=10;//任务个数
while(num--)
{
//派发任务
//选择一个任务
int cmd=tm.SelectTask();
//选择一个进程
Channel& cur=channels[who++];
who%=channels.size();
std::cout << "######################" << std::endl;
std::cout << "send " << cmd << " to " << cur.Name() << ", 任务还剩: " << num << std::endl;
//执行
cur.SendTask(cmd);
sleep(1);
}
}
void WaitProcess()//回收进程池中的进程
{
for (auto &c : channels)
{
c.CloseWfd();
pid_t rid = ::waitpid(c.Pid(), nullptr, 0);
if (rid > 0)
{
std::cout << "child " << rid << " wait ... success" << std::endl;
}
}
}
private:
vector<Channel> channels;//管理子进程
int _processnums;//任务个数
work_t _worker;//方法,执行任务
};
Channel:父进程如何管理子进程;在程序中pid只是进程的标识,无法作为父进程找到子进程的方法;在匿名管道中,管道则成为父子联系的唯一渠道;所以processpool类中需要将每条管道管理起来。
#pragma once
//.hpp文件
#include<iostream>
#include<string>
#include <unistd.h>
using namespace std;
//父进程如何管理子进程;在程序中pid只是进程的标识,无法作为父进程找到子进程的方法;在匿名管道中,管道则成为父子联系的唯一渠道
//所以processpool类中需要将每条管道管理起来。
class Channel
{
public:
Channel(int wfd,pid_t pid):_wfd(wfd),_pid(pid)
{
//Channel-3-5678
_name="Channel-"+to_string(wfd)+"-"+to_string(pid);
}
const int Pid()
{
return _pid;
}
const int Wfd()
{
return _wfd;
}
void SendTask(int cmd)
{
::write(_wfd,&cmd,sizeof(cmd));//通过管道向子进程派发任务
}
void CloseWfd()
{
::close(_wfd);
}
const string Name()
{
return _name;
}
private:
pid_t _pid;
string _name;
int _wfd;//写端;进程池中父子联系的唯一标识
};
TaskManger:任务管理集,负责任务的派发
#pragma once
//.hpp文件
#include<iostream>
#include<unordered_map>
#include <unistd.h>
using namespace std;
//任务管理集,负责任务的派发
typedef void (*task_t)();//函数类型
void Log()
{
cout<<"我是日志任务。。。"<<endl;
}
void Net()
{
cout<<"我是网络请求任务。。。"<<endl;
}
void Sql()
{
cout<<"我是数据库同步任务。。。"<<endl;
}
class TaskManger
{
public:
TaskManger():_tasksnum(0)
{
srand(time(nullptr));//生成随机数
Insert(Log);//0
Insert(Net);//1
Insert(Sql);//2
}
void Insert(task_t task)//插入任务
{
_tasks[_tasksnum++]=task;
}
int SelectTask()//挑选任务
{
int select=rand()%_tasks.size();//<=2
return select;
}
void ExecTask(int cmd)//执行任务
{
if(cmd>=0&&cmd<_tasks.size())
{
_tasks[cmd]();
}
else
{
cout<<"没有"<<cmd<<"号"<<"任务"<<endl;
return ;
}
}
private:
unordered_map<int,task_t> _tasks;//KV模型,将任务管理起来
int _tasksnum;//任务编号
};
TaskManger tm;//放全局供使用
void Work()//资源进程,负责执行任务
{
while(true)
{
int cmd = 0;
//父进程不向子进程发送任务,子进程会在read处阻塞等待,即:管道为空,则读端等待。
int n = ::read(0, &cmd, sizeof(cmd));//接收任务
if (n == sizeof(cmd))
{
tm.ExecTask(cmd);
}
else if(n==0)
{
break;
}
else
{
cout<<"通信失败"<<endl;
}
}
}
main:使用
#include<iostream>
#include <sys/types.h>
#include <unistd.h>
#include<string>
#include"processpool.hpp"
#include"channel.hpp"
//.cpp文件
using namespace std;
//逻辑程序
void Usage(const char*str)//使用提示
{
std::cout << "Usage: " << str << " processnum" << std::endl;
}
int main(int argc,char* argv[])//使用命令行参数:./exe 10(子进程个数)
{
if(argc!=2)
{
Usage(argv[0]);
}
int num=stoi(argv[1]);
ProcessPool*pp=new ProcessPool(num,Work);
//1:初始化进程池
pp->InitPool();
//2:派发任务
pp->DispatcTasks();
//3:清空进程池
pp->WaitProcess();
delete pp;
return 0;
}
该写法还有一个藏得比较深的bug;

完成任务后,父进程本应该回收子进程,但是此时却一直在等待,无法回收。

此时看一下回收的策略

我们本意是关掉写端,这样读端就会读取到0而直接退出Work方法,此时父进程能进行回收;但实际上父进程还在等待子进程,这是怎么回事呢?
实际上我们以为关闭了对应的写端,但实际上并没有,如下图:

由于fork的子进程的PCB会直接从父进程中拷贝,再加上文件描述符fd的分配规则(从小开始分配);所以导致从第二个子进程开始;会把上一个子进程的写端(4)也拷贝下来,此时再由pipe分配读写端的描述符即为:3,5;此时只关闭了写端(fd[1]=5),而4并没有被关闭,所以子进程中也有指向写端;这就是为什么回收时关闭了父进程对应的写端子进程还能没有退出的原因。
所以我们应该在创建子进程后将历史拷贝下来的写端关闭;

可以看到,当管道数为3时(也就是上图的情况),关闭的历史写端确实是(4)(4,5)。

此时就能正常回收了。

命名管道
匿名管道只能在有血缘关系的进程间通信;如果我们想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它经常被称为命名管道
命名管道的原理
命名管道就是一种特殊类型的文件,两个进程通过命名管道的文件名打开同一个管道文件,此时这两个进程也就看到了同一份资源,进而就可以进行通信了。所以还是抓住进程间通信的本质:让不同的进程看到同一份资源。这与匿名管道是类似的,只不过现在是在两个不相关进程间通信,采用命名的方式,让不同进程看到这一份资源。
命名管道的使用
1:命名管道可以从命令行上创建,命令行方法是使用下面这个命令:
mkfifo filename

- 可以看到此时创建的fifo的文件类型为p,也就是管道文件。
- fifo的文件大小为0是因为fifo虽然是文件,但是它是由OS维护的基于内核缓冲区文件,不会将文件刷盘到磁盘中
- 有inode编号只是命名管道在磁盘有一个简单的映像。
这也是为什么不直接让不同进程直接打开同一个普通文件进行通信的原因的其中之一;命名管道在通信的针对性、效率、灵活性、双向性、可靠性和同步性等方面具有显著优势。
简单使用:
此时的两个bash为不同的进程,可使用echo $$查看当前bash的pid

2:命名管道也可以从程序⾥创建,相关函数有:
NAME
mkfifo, mkfifoat - make a FIFO special file (a named pipe)
SYNOPSIS
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);
参数:
pathname:指向一个字符串,表示要创建的命名管道文件的路径mode:指定命名管道的权限。它是一个位掩码,可用来指定文件的读、写、执行权限
返回值:
- 如果成功创建命名管道,mkfifo函数返回0
- 如果创建失败,函数返回-1,并设置errno为相应的错误代码
int main()
{
int n=mkfifo("fifo",0664);
if(n<0)
{
cerr<<"mkfifo fail"<<endl;
}
return 0;
}
这样也能在程序中创建命名管道,之后便可进行通信

命名管道特点
- 支持非亲缘关系进程通信:与匿名管道不同,命名管道可以用于在不同进程间进行通信,这些进程不必具有亲缘关系(如父子进程)。这意味着,即使进程是由不同的用户或在不同的时间点启动的,它们仍然可以通过命名管道进行通信。
- 路径表示通道:使用命名管道时,需要用一个路径名来标识管道。这个路径名在文件系统中是唯一的,因此不同的进程可以通过这个路径名来打开同一个管道进行通信。
- FIFO文件形式:命名管道在文件系统中以FIFO(先进先出)文件的形式存在。这意味着数据将按照写入管道的顺序被读出,保证了数据的有序性,即存在同步与互斥。
使用命名管道实现server&client通信
演示

实现server&client通信的原理也很简单,借助一个命名管道由client端向server端发送信息。使用两个类进行封装
client
client端负责信息的发送。
client的实现:client.hpp
#pragma once
#include<iostream>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include<string>
#include <unistd.h>
#include<string.h>
#include <stdlib.h>
using namespace std;
class Client
{
public:
Client():_wfd(-1)
{
cout<<"check whether the server had started"<<endl;
_wfd = open("myfifo", O_WRONLY | O_TRUNC);
if (_wfd < 0)
{
exit(-1);
}
system("clear");
}
void SendMessage()
{
string msg;
while (true)
{
cout << "please enter# ";
getline(cin, msg);//直接按下回车默认为空串。'\0'
const char *dem = "\0";
if (strcmp(msg.c_str(), dem) == 0) // 退出
{
break;
}
::write(_wfd, msg.c_str(), msg.size());
}
}
~Client()
{
cout << "client quit" << endl;
::close(_wfd);
}
private:
int _wfd;
};
利用C++构造和析构函数的特性,直接在构造以写的方式打开命名管道,在析构中关闭管道;如此一来只需要实现一个消息发送的功能即可:使用getline进行输入;并存储到string中;此外如果不想继续通信了,则直接按下回车结束对话。
使用:client.cpp
#include"client.hpp"
using namespace std;
int main()
{
Client cl;
cl.SendMessage();
return 0;
}
服务端负责接收信息;命名管道如果不存在则由服务端创建。
server
实现:server.hpp
#pragma cnce
#include<iostream>
#include<stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include<string>
#include <unistd.h>
#include<string.h>
using namespace std;
class Server
{
public:
//输出
Server():_rfd(-1)
{
_rfd = open("myfifo", O_RDONLY);
if (_rfd < 0)//若失败则有可能为不存在
{
int n = mkfifo("myfifo", 0600);
if (n < 0)
{
cerr << "mkfifo fail" << endl;
}
_rfd = open("myfifo", O_RDONLY);
}
system("clear");//清屏
}
void ReceMessage()
{
char buffer[1024];
while (true)
{
ssize_t ssz = ::read(_rfd, buffer, sizeof(buffer));
if (ssz == 0)//写端关闭
{
break;
}
else if(ssz<0)
{
cerr<<"read fail"<<endl;
break;
}
cout << "client say# ";
buffer[ssz] = 0;
cout << buffer << endl;
}
}
~Server()
{
cout << "client quit... server end" << endl;
::close(_rfd);
}
private:
int _rfd;
};
使用read获取信息即可。
使用:server.cpp
#include"server.hpp"
int main()
{
Server sv;
sv.ReceMessage();
return 0;
}
在命名管道中,如果读端先打开,则会在open处等待写端打开后再打开读端。

如果读端关闭了,就会收到操作系统发来的13号信号(SIGPIPE),此时客户端就被操作系统强制杀掉了。就和匿名管道一样。
7462

被折叠的 条评论
为什么被折叠?



