一、进程间通信
进程间通信(Interprocess Communication)
就是两个进程之间进行通信。进程是具有独立性(虚拟地址空间 + 页表保证进程运行的独立性),所以进程间通信成本会比较高!进程间通信的前提条件是先让不同的进程看到同一份资源(内存空间),该资源不能隶属于任何一个进程,应该属于操作系统,被进行通信的进程所共享。
进程间通信的目的
- 数据传输:一个进程需要将它的数据发送给另一个进程
- 资源共享:多个进程之间共享同样的资源。
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
- 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
进程间通信的发展和分类
进程间通信的发展和分类如下:
- Linux 原生能提供的管道,管道主要包括匿名管道 pipe 和命名管道。
- SystemV 进程间通信,System V IPC 主要包括 System V 消息队列、System V 共享内存和 System V 信号量。System V 只能本地通信。
- POSIX 进程间通信,POSIX IPC 主要包括消息队列、共享内存、信号量、互斥量、条件变量和读写锁。POSIX 进程通信既能进行本地通信,又能进行网络远程通信,具有高扩展和高可用性。
二、匿名管道
管道介绍
日常生活中,有非常多的管道,如:天然气管道、石油管道和自来水管道等。管道是 Unix 中最古老的进程间通信的形式,我们把从一个进程连接到另一个进程的一个数据流称为管道。管道传输的都是资源,并且只能单向通信。
管道的原理
每个进程都有对应的文件描述符表,文件描述符表中有相应的数组,数组中存放了标准输入0,标准输出1,标准错误2,而每个进程描述符都会存放相应struct file的地址,在进程间通信的时候系统会提供一个内存文件,这个内存文件不会在磁盘刷新,这个文件被称为匿名文件,当我们以读和写方式打开一个文件,然后我们fork创建一个子进程,子进程也具有task_struct,并且子进程会继承父进程的文件描述符表(但是不会复制父进程打开的文件对象),而文件描述符表中存放文件的地址都是相同的,所以子进程的文件描述符表也指向父进程的文件,正是因为这样,在父进程以读和写打开一份文件,而子进程也同样读和写打开和父进程打开的一样的一份文件,这就让两个进程看到了同一份资源。但是这种管道只能实现单向通信,比如我们关闭父进程的写端,关闭子进程的读端让子进程去写这两个进程就实现单向通信了。管道只能单向通信的原因是文件只有一个缓冲区,一个写入位置一个读取位置所以只能单向通信,要是想双向通信那就打开两个管道!而上面所讲的管道就是匿名管道。
管道的实现
💕 Makefile
mypipe:mypipe.cc
g++ -o {
mathJaxContainer[0]}^ -std=c++11
.PHONY:clean
clean:
rm -rf mypipe
💕 代码实现
#include <iostream>
#include <cassert>
#include <string>
#include <unistd.h>
#include <cstring>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;
int main()
{
// 让不同的进程看到同一份资源
int pipefd[2] = {
0};
// 1.创建管道
int n = pipe(pipefd);
if(n < 0)
{
std::cout << "pipe error, " << errno << ": " << strerror(errno) << std::endl;
return 1;
}
// printf("pipefd[0]:%d\n",pipefd[0]);
// printf("pipefd[1]:%d\n",pipefd[1]);
// 创建子进程
pid_t id = fork();
assert(id != -1);
if(id == 0) //子进程 —— 往管道中写入数据
{
close(pipefd[0]);
//开始通信
const string namestr = "hello,我是子进程";
int cnt = 1;
char buffer[1024];
while(true)
{
snprintf(buffer, sizeof buffer, "%s, 计数器: %d, 我的PID: %d", namestr.c_str(), cnt++, getpid());
write(pipefd[1], buffer, strlen(buffer));
sleep(1);
}
close(pipefd[1]);
exit(0);
}
//父进程 —— 从管道中读取数据
close(pipefd[1]);
char buffer[1024];
//int cnt = 0;
while(true)
{
int n = read(pipefd[0], buffer, sizeof(buffer) - 1);
if(n > 0)
{
buffer[n] = '\0';
cout << "我是父进程,子进程给我的message是:" << buffer << endl;
}
else if(n == 0)
{
cout << "我是父进程,读到了文件的结尾" << endl;
break;
}
else
{
cout << "我是父进程,读取异常了" << endl;
break;
}
}
close(pipefd[0]);
return 0;
}
运行结果如图:
这里确实完成了进程间的单向通信,我们可以清晰地看到有两个进程,并且子进程将自己的数据给了父进程。
管道的特点
- 单向通信
- 管道的本质是文件,因为fd的生命周期随进程,管道的生命周期也是随进程的。
- 管道通信,通常是用来进行 “血缘关系” 的进程,进行进程间通信,常用于父子进程间通信——pipe打开管道,并不清楚管道的名字,所以是匿名管道。
- 在管道通信中,写入的次数,和读取的次数,不是严格匹配的 读写次数的多少没有强相关 --- 表现 ----字节流。
- 具有一定的协同能力,让reader和writer能够按照一定的步骤进行通信 --- 自带同步机制。
- 管道是基于文件的,文件的生命周期是随进程的,那么管道的生命周期也是随进程的。
- 一般而言,内核会对管道操作进行同步与互斥
- 管道是单向通信的,就是半双工通信的一种特殊情况,数据只能向一个方向流动。需要双方通信时,需要建立起两个管道。半双工通信就是要么在收数据,要么在发数据,不能同时在收数据和发数据(比如两个人在交流时,一个人在说,另一个人在听);而全双工通信是同时进行收数据和发数据(比如两个人吵架的时候,相互问候对方,一个人既在问候对方又在听对方的问候)。
- 当要写入的数据量不大于 PIPE_BUF 时,Linux 将保证写入的原子性。
- 当要写入的数据量大于 PIPE_BUF 时,Linux 将不再保证写入的原子性。
- 指事务的不可分割性,一个事务的所有操作要么不间断地全部被执行,要么一个也没有执行。
管道的四种场景
- ==如果我们read读取完毕了所有的管道数据,如果对方不发,我就只能等待==
这里我们让父进程的读取速度不变,让子进程写的慢一些。
这里我们可以看到,光标卡在这儿不动了,这是因为子进程每隔5秒向管道写入一次数据,因此,如果管道中没有数据,读端在读,此时默认会直接阻塞当前正在读取的进程
- ==如果我们writer端将管道写满了,我们将不能继续往管道中写入数据。==
管道是固定大小的缓冲区,当管道被写满,就不能再写了。此时写端会阻塞。因此管道具有一定的协同能力,能让reader和writer按照一定的步骤进行通信。
- ==如果关闭了写端,读取完毕管道数据,在读,就会read返回0,表明读到了文件结尾。==
- ==写端一直写,读端关闭,操作系统不会维护无意义,低效率,或者浪费资源的事情。因此OS会杀死一直在写入的进程!==
这里我们看到确实如此,OS不会维护无意义,低效率,或者浪费资源的事情,因此操作系统通过13号信号
来杀死了子进程。
mini进程池的实现
// Task.hpp的实现
#pragma once
#include <iostream>
#include <vector>
#include <unistd.h>
using namespace std;
//定义函数指针
typedef void (*func_t) ();
void PrintLog()
{
std::cout << "pid: "<< getpid() << ", 打印日志任务,正在被执行..." << std::endl;
}
void InsertMySQL()
{
std::cout << "执行数据库任务,正在被执行..." << std::endl;
}
void NetRequest()
{
std::cout << "执行网络请求任务,正在被执行..." << std::endl;
}
//这里我们规定,每一个command都必须是四字节
#define COMMAND_LOG 0
#define COMMAND_MYSQL 1
#define COMMAND_REQEUST 2
class Task
{
public:
Task()
{
funcs.push_back(PrintLog);
funcs.push_back(InsertMySQL);
funcs.push_back(NetRequest);
}
void Execute(int command)
{
if(command >=0 && command < funcs.size())
funcs[command]();
}
~Task()
{
};
public:
vector<func_t> funcs;
};
// ctrlProcess的实现
#include <iostream>
#include <vector>
#include <cassert>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;
#include "Task.hpp"
const int gnum = 3;
Task t;
class Endpoint
{
private:
static int number;
public:
Endpoint(pid_t id, int write_fd)
:_child_id(id)
,_write_fd(write_fd)
{
char namebuffer[64];
snprintf(namebuffer, sizeof namebuffer, "process-%d[%d:%d]", number++, _child_id, _write_fd);
processname = namebuffer;
}
string name() const
{
return processname;
}
~Endpoint()
{
};
public:
pid_t _child_id;
int _write_fd;
string processname;
};
int Endpoint::number = 0;
//子进程要执行的方法
void WaitCommand()
{
while(true)
{
int command = 0;
int n = read(0, &command, sizeof(int));
if(n == sizeof(int))
{
t.Execute(command);
}
else if(n == 0)
{
cout << "父进程让我退出,我就退出了: " << getpid() << endl;
break;
}
else
{
break;
}
}
}
void createProcesses(vector<Endpoint>* end_points)
{
vector<int> fds;
for(int i = 0; i < gnum; i++)
{
// 1.1 创建管道
int pipefd[2] = {
0};
int n = pipe(pipefd);
assert(n == 0);
(void)n;
// 1.2 创建进程
pid_t id = fork();
assert(id != -1);
//子进程 —— 从管道中读取数据
if(id == 0)
{
for(auto& fd : fds) close(fd);
close(pipefd[1]);
dup2(pipefd[0], 0); // 子进程读取指令的时候,从标准输入读取 == 输入重定向
WaitCommand(); // 子进程等待获取命令
close(pipefd[0]);
exit(0);
}
//1.3 父进程——关闭不需要的fd
close(pipefd[0]);
// 1.4 将新的子进程和他的管道写端构建对象
end_points->push_back(Endpoint(id, pipefd[1]));
fds.push_back(pipefd[1]);
}
}
int ShowBoard()
{
std::cout << "------------------------------------------" << std::endl;
std::cout << "| 0. 执行日志任务 1. 执行数据库任务 |" << std::endl;
std::cout << "| 2. 执行请求任务 3. 退出 |" << std::endl;
std::cout << "------------------------------------------" << std::endl;
std::cout << "请选择$ ";
int command = 0;
std::cin >> command;
return command;
}
void ctrlProcess(const vector<Endpoint>& end_points)
{
int num = 0;
int cnt = 0;
while(true)
{
// 1. 选择任务
int command = ShowBoard();
if(command == 3) break;
if(command < 0 || command > 3) continue;
// 2.选择进程
int index = cnt++;
cnt %= end_points.size();
cout << "选择了进程: " << end_points[index].name() << " | 处理任务: " << command << endl;
// 3. 下发任务
write(end_points[index]._write_fd, &command, sizeof(command));
sleep(1);
}
}
void waitProcess(const vector<Endpoint>& end_points)
{
for(int end = 0; end < end_points.size(); end++)
{
cout << "父进程让子进程退出:" << end_points[end]._child_id << endl;
close(end_points[end]._write_fd);
waitpid(end_points[end]._child_id, nullptr, 0);
cout << "父进程回收了子进程:" << end_points[end]._child_id << endl;
}
sleep(5);
}
int main()
{
// 1. 构建控制结构,父进程写入,子进程读取。
vector<Endpoint> end_points;
createProcesses(&end_points);
// 2. 进程控制
ctrlProcess(end_points);
// 3. 处理所有的退出问题
waitProcess(end_points);
return 0;
}
随机派发任务:
用户派发指定任务
三、命名管道
匿名管道有一个 缺陷
就是:只能在具有共同祖先(具有亲缘关系)的进程间通信。如果我们想要让两个毫不相干的进程进行通信,可以使用FIFO文件来实现,他就是 命名管道
。
命名管道的创建:
mkfifo named_pipe
往管道里面写入数据 / 从管道中读取数据
原理如下:
两个进程打开同一个文件,站在内核的角度,第二个文件不需要再被创建struct file对象,因为OS会识别到打开的文件被打开了。在内核中,此时就看到了同一份资源,有着操作方法和缓冲区,不需要把数据刷新到磁盘上去,所以无论是匿名还是命名管道,本质上都是管道。
匿名管道
:通过继承的方式看到同一份资源。命名管道
:通过让不同的进程打开指定名称(路径+文件名,具备唯一性)的同一个文件看到同一份资源,所以命名管道是通过文件名来标定唯一性的。而匿名管道是通过继承的方式来标定的。
命名管道模拟客户端和服务端
创建一个管道文件,让读写端进程分别按照自己的需求打开文件,然后进行通信。
makefile
.PHONY:all
all:server client
server:server.cc
g++ -o {
mathJaxContainer[1]}^ -std=c++11
client:client.cc
g++ -o {
mathJaxContainer[2]}^ -std=c++11
.PHONY:clean
clean:
rm -f client server
comm.hpp
#pragma once
#include <iostream>
#include <string>
#define NUM 1024
const std::string fifoname = "./fifo";
uint32_t mode = 0666;
server.cc
client.cc
运行结果:
匿名管道和命名管道的区别
- 匿名管道由pipe函数创建并打开
- 命名管道由mkfifo函数创建,打开用open
- FIFO(命名管道)和 pipe(匿名管道)之间唯一的区别在于它们创建和打开的方式不同,一旦这些工作完成之后,他们具有相同的语义。