文章目录
- 前言
- 进程间通信的目的
- 进程间通信的发展
- 进程间通信分类
- 管道
- 什么是管道?
- 站在内核角度-管道本质
- 匿名管道pipe函数
- 管道的特点(重要)
- 用fork来共享管道原理
- 匿名管道的使用步骤
- 管道的读写规则
- 管道的四种场景
- 如何使用管道进行进程间通信?
- makefile
- Task.hpp
- ctrlPipe.cc
- 命名管道
- 创建一个命名管道
- 用命名管道实现serve&client通信
- 匿名管道和命名管道的区别
前言
之前所学的进程,一般进程和进程之间是独立的关系,最多产生一些耦合,比如父进程创建子进程,父进程等待子进程等等。但实际在很多问题上进程和进程之间是需要相互协同的,那么进程间通信就是在不同进程之间传播或交换信息,接下来让我们一起走进进程间通信的大门!
进程间通信的目的
- 数据传输:一个进程需要将它的数据发送给另一个进程。
- 资源共享:多个进程之间共享同样的资源。
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知它们发送了某种事件(如子进程终止时要通知父进程)。
- 进程控制:有些进程希望完全控制另一个进程的执行(如debug调试进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时直到它的状态改变。
可以形象理解成:两个人之间互相打电话,不管你们的目的是什么,是A打电话给B,叫他拿外套(进程控制),还是A通知B,今天晚上要开会(通知事件),本质都要“打电话”。
进程间通信的发展
- 管道
- System V进程间通信(可以实现本地通信)
- POSIX进程间通信(可以实现跨网络通信)
进程间通信分类
管道:
- 匿名管道pipe
- 命名管道
System V IPC:
- System V 消息队列
- System V 共享内存
- System V 信号量
POSIX IPC:
- 消息队列
- 共享内存
- 信号量
- 互斥量
- 条件变量
- 读写锁
任何进程通信的手段:
- 想办法,先让不同的进程,看到同一份资源(这一步至关重要)!!!
- 让一方写入,一方读取完成通信的过程,至于,通信的目的和后续的工作,要结合具体场景。
- 不能说让A进程在它的内存中开辟一块空间,让B进程可以访问,因为这样就破坏了进程之间具有独立性这一说。抽象如图:
管道
什么是管道?
- 管道是Unix中最古老的进程间通信的形式。
- 我们把一个进程连接到另一个进程的一个数据流称为一个“管道”。
- 其本质是一个伪文件(管道实为内核使用环形队列机制,借助内核缓冲区4k实现);
其中,who命令和wc命令都是两个程序,当它们运行起来后就变成了两个进程,who进程通过标准输出将数据打到“管道”当中,wc进程再通过标准输入从“管道”当中读取数据,至此便完成了数据的传输,who命令用于查看当前云服务器的登录用户(一行显示一个用户),wc -l是计算行数的一个命令,说明当前只有一个用户在线,
站在内核角度-管道本质
匿名管道pipe函数
pipe函数用于创建匿名管道,pipe函数的函数原型如下:
int pipe(int pipefd[2]);
pipe函数的参数是一个输出型参数,所以用户需要定义两个数组,并传过来,数组pipefd用于返回两个指向管道读端和写端的文件描述符:
数组元素 | 代表含义 |
---|---|
pipefd[0] | 管道读端的文件 |
pipefd[1] | 管道写端的文件 |
pipe函数调用成功时返回0,调用失败时返回-1。 |
管道的特点(重要)
- 由两个文件描述符引用,一个表示读端,一个表示写端;
- 管道提供流式服务,规定数据从管道的写端流入管道,从读端流出。
- 管道是单向通信的,如果想要使用双向通信,那就创建两个管道吧;
- 管道通信,通常用来进行具有“血缘”关系的进程,进行进程间通信。常用于父子通信,因为具有了血缘关系,fork创建子进程后才能看到同一份资源—pipe函数打开管道,并不清楚管道的名字,称之为匿名管道。
- 在管道通信中,写入的次数,和读取的次数,不是严格匹配的,读写次数的多少没有强相关。——表现:面向字节流。
- 管道具有一定的协同能力,让读端和写端能够按照一定的步骤进行通信——自带同步进制。
- 一般而言,进程退出,管道就会被释放,所以管道的生命周期随进程。
- 一般而言,内核会对管道操作进行同步与互斥;
- 管道是半双工的,数据只能向一个方向流动(ps:吵架是全双工的!哈哈哈!)
用fork来共享管道原理
图上模拟的是子进程向管道写入数据,父进程从管道读取数据。在创建匿名管道实现父子进程间通信的过程中,需要pipe函数和fork函数搭配使用,具体步骤如下:
匿名管道的使用步骤
站在文件描述符角度-深度理解管道
所以,看待管道,就如同看待文件一样!管道的使用和文件一致,迎合了“Linux一切皆文件思想”。
管道的读写规则
-
当没有数据可读时
O_NONBLOCK disable: read调用阻塞,即进程暂停执行,一直等待有数据来为止;
O_NONBLOCK enable: read调用返回-1,errno值为EAGAIN。 -
当管道满的时候
O_NONBLOCK disable: write调用阻塞,直到有进程读走数据;
O_NONBLOCK enable: 调用返回-1,errno值为EAGAIN。 -
如果所有管道写端对应的文件描述符被关闭,则read返回0,表面读到文件结尾;
-
如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能导致write进程退出。
-
当要写入的数据量不大于PIPE_BUF时,linux将保证写入的原子性(即要么全做,要么都不做)。
-
当要写入的数据量大于PIPE_BUF时,linux将不再保证写入的原子性。
管道的四种场景
- 数据一旦被读走,便不再管道中存在,不可反复读取。如果我们读取完毕了所有的管道数据,如果对方不写入,我们就只能等待。
- 如果我们写端将管道写满了,我们就不能再写了。
- 如果我关闭了写端,读取完管道数据后,再读,read就会返回0,表明读到了文件结尾。
- 如果写端一直写,读端关闭,那么写端继续写就没有意义了,OS不会维护无意义,低效率的事情,OS会杀死一直在写入的进程!通过信号来终止进程:13)SIGPIPE
下列代码演示:子进程通过写端写数据到管道,父进程读取管道数据。
实例代码:
#include<iostream>
#include<unistd.h>
#include<cerrno>
#include<string.h>
#include<string>
#include<cassert>
#include<sys/types.h>
#include<sys/wait.h>int main()
{//父进程需要以读写的方式打开管道文件,父进程和子进程之间进行相互通信,父进程打开的管道文件地址是会被子进程继承下来的//所以它们指向的是同一份管道文件//【一定要让不同的进程看到同一份资源】//1.创建管道int pipefd[2] = {0};//将其初始化为0int n = pipe(pipefd);//pipefd是一个输出型参数if(n<0){std::cout<<"pipe error, "<<errno<<":"<<strerror(errno)<<std::endl;}//pipe函数创建管道文件,将数组pipefd初始化成3和4,并返回,因为0,1,2已被占用std::cout<<"pipefd[0]: "<<pipefd[0]<<std::endl;//3->读端std::cout<<"pipefd[1]: "<<pipefd[1]<<std::endl;//4->写端//2.创建子进程pid_t id = fork();assert(id!=-1);//正常应该应用判断,我这里就断言if(id==0) //子进程{//3.关闭不需要的fd,让父进程进行读取,让子进程进行写入//我们想让子进程进行写入,所以关闭的是pipefd[0]读,保留pipefd[1]写close(pipefd[0]);//4.开始通信--结合某种场景---子进程将数据写入管道const std::string namestr = "嗨!我是子进程";int cnt = 1;char buffer[1024];while(true){//先向buffer字符缓冲区格式化写入数据snprintf(buffer,sizeof(buffer),"%s,计数器:%d,我的PID:%d\n",namestr.c_str(),cnt++,getpid());//然后再将缓冲区的数据写到管道里write(pipefd[1],buffer,strlen(buffer));sleep(1);}close(pipefd[1]);//子进程执行完退出exit(0);}//父进程//3.为实现单向通信,需要关闭不需要的文件描述符fdclose(pipefd[1]);//4.开始通信--结合某种场景---父进程从管道中读取数据char buffer[1024]={0};while(true){//父进程先把管道里的数据读出来,放到缓冲区中int n = read(pipefd[0],buffer,sizeof(buffer)-1);if(n>0){buffer[n]='\0';std::cout<<"我是父进程,子进程给我的消息是:"<<buffer<<std::endl;}else if(n==0){std::cout<<"我是父进程,我读到文件结尾了"<<std::endl;break;}else{std::cout<<"我是父进程,我读异常了"<<std::endl;}}close(pipefd[0]);//关闭管道读端//父进程等待子进程waitpid(id,&status,0);std::cout<<"sig: "<<(status & 0x7) << std::endl;//如果子进程一直写,但是父进程读取一段时间后退出//OS就会杀掉子进程return 0;
}
如何使用管道进行进程间通信?
makefile
ctrlPipe:ctrlPipe.ccg++ -o $@ $^ -std=c++11
.PHONY:clean
clean:rm -rf ctrlPipe
Task.hpp
#pragma once#include <iostream>
#include <vector>
#include <unistd.h>
#include <unordered_map>// typedef std::function<void ()> func_t;typedef void (*fun_t)(); //函数指针void PrintLog()
{std::cout << "pid: "<< getpid() << ", 打印日志任务,正在被执行..." << std::endl;
}void InsertMySQL()
{std::cout << "执行数据库任务,正在被执行..." << std::endl;
}void NetRequest()
{std::cout << "执行网络请求任务,正在被执行..." << std::endl;
}// void ExitProcess()
// {
// exit(0);
// }//约定,每一个command都必须是4字节
#define COMMAND_LOG 0
#define COMMAND_MYSQL 1
#define COMMAND_REQEUST 2class 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:std::vector<fun_t> funcs;
};
ctrlPipe.cc
#include <iostream>
#include <string>
#include <vector>
#include <cassert>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include "Task.hpp"
using namespace std;const int gnum = 3;
Task t;class EndPoint
{
private:static int number;
public:pid_t _child_id;int _write_fd;std::string processname;
public:EndPoint(int id, int fd) : _child_id(id), _write_fd(fd){//process-0[pid:fd]char namebuffer[64];snprintf(namebuffer, sizeof(namebuffer), "process-%d[%d:%d]", number++, _child_id, _write_fd);processname = namebuffer;}std::string name() const{return processname;}~EndPoint(){}
};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){std::cout << "父进程让我退出,我就退出了: " << getpid() << std::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);// std::cout << getpid() << " 子进程关闭父进程对应的写端:";// for(auto &fd : fds)// {// std::cout << fd << " ";// close(fd);// }// std::cout << std::endl;// 1.3 关闭不要的fdclose(pipefd[1]);// 我们期望,所有的子进程读取"指令"的时候,都从标准输入读取// 1.3.1 输入重定向,可以不做dup2(pipefd[0], 0);// 1.3.2 子进程开始等待获取命令WaitCommand();close(pipefd[0]);exit(0);}// 一定是父进程// 1.3 关闭不要的fdclose(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)
{// 2.1 我们可以写成自动化的,也可以搞成交互式的int num = 0;int cnt = 0;while(true){//1. 选择任务int command = ShowBoard();if(command == 3) break;if(command < 0 || command > 2) continue;//2. 选择进程int index = cnt++;cnt %= end_points.size();std::string name = end_points[index].name();std::cout << "选择了进程: " << name << " | 处理任务: " << command << std::endl;//3. 下发任务write(end_points[index]._write_fd, &command, sizeof(command));sleep(1);}
}void waitProcess(const vector<EndPoint> &end_points)
{// 1. 我们需要让子进程全部退出 --- 只需要让父进程关闭所有的write fd就可以了!// for(const auto &ep : end_points) // for(int end = end_points.size() - 1; end >= 0; end--)for(int end = 0; end < end_points.size(); end++){std::cout << "父进程让子进程退出:" << end_points[end]._child_id << std::endl;close(end_points[end]._write_fd);waitpid(end_points[end]._child_id, nullptr, 0);std::cout << "父进程回收了子进程:" << end_points[end]._child_id << std::endl;} sleep(10);// 2. 父进程要回收子进程的僵尸状态// for(const auto &ep : end_points) waitpid(ep._child_id, nullptr, 0);// std::cout << "父进程回收了所有的子进程" << std::endl;// sleep(10);
}// #define COMMAND_LOG 0
// #define COMMAND_MYSQL 1
// #define COMMAND_REQEUST 2
int main()
{vector<EndPoint> end_points;// 1. 先进行构建控制结构, 父进程写入,子进程读取 , bug?createProcesses(&end_points);// 2. 我们的得到了什么?end_pointsctrlProcess(end_points);// 3. 处理所有的退出问题waitProcess(end_points);return 0;
}
命名管道
- 管道应用的一个限制就是只能在具有共同祖先(具有亲缘关系)的进程间通信。
- 如果我们想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它们经常被称为命名管道。
- 命名管道是一种特殊类型的文件,是一种内存级别的文件,写到里面的数据不会被刷新到磁盘。
创建一个命名管道
- 命名管道可以从命令行上创建,命令如下:
mkfifo [filename文件名]
- 命名管道也可以从程序里创建,相关函数有:
int mkfifo(const char* filename, mode_t mode);int main(int argc, char* argc[])
{mkfifo("fifo",0664);return 0;
}
用命名管道实现serve&client通信
实现服务端(server)和客户端(client)之间的通信之前,我们需要先让服务端运行起来,因为我们需要让服务端运行后先创建出一个命名管道文件,然后以读的方式打开该命名管道文件,之后服务端就可以从该命名管道当中读取客户端发来的通信信息了。
服务端的代码如下:
1 #include <iostream>2 #include <cerrno>3 #include <cstring>4 #include <sys/types.h>5 #include <sys/stat.h>6 #include <fcntl.h>7 #include <unistd.h>8 #include "comm.hpp"9 10 11 12 int main()13 {14 // 1. 创建管道文件,只需要一次创建15 umask(0); //这个设置并不影响系统的默认配置,只会影响当前进程16 int n = mkfifo(fifoname.c_str(), mode);17 if(n != 0)18 {19 std::cout << errno << " : " << strerror(errno) << std::endl;20 return 1;21 } 22 std::cout << "create fifo file success" << std::endl;23 // 2. 让服务端直接开启管道文件24 int rfd = open(fifoname.c_str(), O_RDONLY);25 if(rfd < 0 )26 {27 std::cout << errno << " : " << strerror(errno) << std::endl;28 return 2;29 }30 std::cout << "open fifo success, begin ipc" << std::endl;31 32 // 3. 正常通信33 char buffer[NUM];//定义缓冲区34 while(true)35 {36 buffer[0] = 0;37 ssize_t n = read(rfd, buffer, sizeof(buffer) - 1);//先将管道中的数据读到缓冲区中38 if(n > 0)39 {40 buffer[n] = 0;//缓冲区肯定会有一个换行的,将其取消41 //std::cout << "client# " << buffer << std::endl;42 printf("%c", buffer[0]);43 fflush(stdout);44 }45 else if(n == 0)//read读到0,表示管道文件被读空了,则关闭46 {47 std::cout << "client quit, me too" << std::endl;48 break;49 }50 else 51 {52 std::cout << errno << " : " << strerror(errno) << std::endl;53 break;54 }55 }56 57 // 关闭不要的fd58 close(rfd);59 60 unlink(fifoname.c_str());//删除指定文件61 62 return 0;63 }
对于客户端来说,服务端运行起来后命名管道文件就已经被创建了,所以客户端只需以写的方式打开该命名管道文件,之后客户端就可以将通信信息写入到命名管道文件当中,进而实现和服务端的通信。
客户端的代码如下:
1 #include <iostream>2 #include <cstdio>3 #include <cerrno>4 #include <cstring>5 #include <cassert>6 #include <sys/types.h>7 #include <sys/stat.h>8 #include <fcntl.h>9 #include <unistd.h>1011 #include "comm.hpp"12 13 int main()14 {15 //1. 客户端就不需要再创建管道文件了,我只需要打开服务端已经创建好的管道文件即可16 int wfd = open(fifoname.c_str(), O_WRONLY);17 if(wfd < 0)18 {19 std::cerr << errno << ":" << strerror(errno) << std::endl;20 return 1;21 }22 23 // 可以进行常规通信了24 char buffer[NUM];25 while(true)26 {27 // std::cout << "请输入你的消息# ";28 // char *msg = fgets(buffer, sizeof(buffer), stdin);//可以从键盘格式化的输入29 // assert(msg);30 // (void)msg; 31 // int c = getch();32 // std::cout << c << std::endl;33 // if(c == -1) continue;34 35 system("stty raw");36 int c = getchar();//也可以同步消息37 system("stty -raw");38 39 //std::cout << c << std::endl;40 //sleep(1);41 42 //buffer[strlen(buffer) - 1] = 0;43 // abcde\n\044 // 01234545 //if(strcasecmp(buffer, "quit") == 0) break;46 47 ssize_t n = write(wfd, (char*)&c, sizeof(char));48 assert(n >= 0);49 (void)n;50 }51 52 close(wfd);53 54 return 0;55 }
同时我们还要让客户端和服务端使用同一个命名管道文件,这里我们可以让客户端和服务端包含同一个头文件,这样客户端和服务端就可以通过这个文件名,打开同一个命名管道文件,进而进行通信了。
头文件的代码如下:
1 #pragma once2 3 #include<iostream>4 #include<string>5 6 #define NUM 1024 7 8 //mkfifo函数——管道文件名+打开方式,写在一个头文件里,便于不同进程访问同一个文件9 const std::string fifoname = "./fifo";10 uint32_t mode = 0666;
Makefile文件:
1 .PHONY:all 2 all:server client3 4 server:server.cc5 g++ -o $@ $^ -std=c++116 client:client.cc7 g++ -o $@ $^ -std=c++118 9 .PHONY:clean10 clean:11 rm -f server client
运行结果:记得要先运行服务端,再运行客户端哦!(需要先运行服务端,进行创建管道文件)
注意通信是在内存当中进行的
通过以上程序的实验,我们不难发现每次管道文件的大小都是0kb,尽管服务端不读取管道当中的数据,但是管道当中的数据并没有被刷新到磁盘,使用ll命令看到命名管道文件的大小依旧为0,也就说明了双方进程之间的通信依旧是在内存当中进行的,和匿名管道通信是一样的。
匿名管道和命名管道的区别
- ]匿名管道由pipe函数创建并打开。
- 命名管道由mkfifo函数创建,打开用open。
- FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在它们创建与打开的方式不同,一旦这些工作完成之后,它们具有相同的语义。