目录
前言
1.进程间通信理论
2.使用管道进行通信
3.管道的一些信息
4.应用场景:进程池(基于匿名管道)
5.命名管道
6.总结
前言
本篇我们开始学习linux进程间通信相关的内容,第一篇我们要介绍的是进程间通信的理论概念以及匿名、命名管道通信的详细操作,并且我们还会基于匿名管道实现一个简单的线程池,加油!!
1.进程间通信理论
1.什么是通信
进程间通信也叫做IPC技术
发展:从单机通信到网络通信
如何理解通信的本质问题:
1.操作系统需要直接或者间接给通信双方的进程提供 ”内存空间“
2.要通信的进程,必须看到一份公共的资源
通信前提:要先让不同的进程看到同一份资源【某一种“内存”】(我们学通信实际上是学这个)
不同的通信种类:
本质就是:上面所说的资源,是操作系统的哪一个模块提供的
2.为什么要有通信?
有时候我们是需要多进程协同,然后去完成某种业务内容比如: cat file | grep 'hello'
3.怎么办?
进程看到的同一份“资源”是由操作系统提供,操作系统需要设计统一的通信接口,这个接口要如何被调用就是我们的进程间通信的方案,原则就是先统一标准,后使用
进程间通信的方案
采用标准做法:
POSIX —— 让通信过程可以跨主机
System V —— 聚焦在本地通信
采用文件做法让不同的进程看到同一份资源:
1.管道——基于文件系统
a.匿名管道
b.命名管道
什么是管道
匿名管道,不需要IO,不需要刷新到磁盘上,和磁盘甚至没关系,为内存级文件,没有所谓的名称
两进程分别以读和写方式打开同一个文件,为了让子进程也能看到读写端
一般而言,我们的管道只能用来进行单向数据通信,所以需要分别把父子进程不需要的fd关闭
匿名管道:目前能用来进行父子进程之间进程间通信
2.使用管道进行通信
管道是被操作系统单独设计的,需要配上单独的系统调用
管道函数:pipe
其中的pipefd为输出型参数,用来存储打开文件后返回的读写两个文件描述符
-
成功时:返回
0
,同时会通过传入的整数数组参数(如int pipefd[2]
)返回两个文件描述符,pipefd[0]
用于从管道读取数据(读端),pipefd[1]
用于向管道写入数据(写端) -
失败时:返回
-1
,并设置errno
变量以标识具体的错误原因(如文件描述符用尽、参数地址不合法等)
[^] pipefd0是读取(想象成嘴巴用来读),pipefd1是写入(想象成笔用来写)
我们可以看到创建pipe管道这个文件时,pipe函数是不需要传递文件路径的,因为它是内存级文件,压根不需要文件路径,也就是说也不需要文件名,所以叫做匿名管道
那没有文件名,要怎么去保证两个进程打开的是同一个管道文件呢?答:子进程继承父进程文件描述符表
各种 ‘printf’
前面几种的区别是把内容格式化到特定的文件或是字符串内,第一种printf就是格式化显示到显示器上
3.管道的一些信息
管道的5种特性:
-
匿名管道只能用来进行具有”血缘关系“的进程进行进程间通信(通常是父子)
-
管道文件,自带同步机制(后面多线程详谈)
-
管道是面向字节流的
-
管道是单向通信的——属于半双工的一种特殊情况
a. 任何一个时刻,一个发,一个收——半双工
b. 任何一个时刻,可以同时发收——全双工
-
(管道)文件的生命周期是随进程的,进程一旦结束,所打开的文件就被操作系统关闭
管道的4种通信情况:
-
写慢,读快——读端就要阻塞(进程),要等写端
-
写快,读慢——写端就要阻塞(进程),要等读端
以上两种情况是因为管道的同步机制这一特性形成的
-
写关,继续读——read就会读到返回值(\0也就是0),表示文件结尾,如果让n=read(),那么n的值此时就为0
-
读关,继续写——写端在写入,没有任何意义(os不会做没有意义的事情),所以这种情况发生之后,操作系统会杀掉写端进程,然后发送异常信号:13->SIGPIPE
管道的容量
[^] 也就是65536/1024=64kb
管道的写入原子性
管道写入的原子性是指:当进程向管道写入数据时,若数据量不超过 PIPE_BUF
(如 Linux 中为 4096 字节,POSIX 规定至少 512 字节),该写入操作要么完全成功(数据全部写入),要么完全失败(数据未写入),不会出现部分写入的情况
4.应用场景:进程池(基于匿名管道)
池化技术
可以减少我们创建对应某种资源的成本,提高访问时的效率
如果父进程把所有任务都分配给一个子进程——负载不均衡
我们要雨露均沾地分配任务——负载均衡
几种方式实现均衡
-
轮询
-
随机
-
channel添加负载指标
进程池信道的建立
#pragma once
#include <iostream>
#include <cstdlib> //cstdlib->stdlib.h
#include <vector>
#include <unistd.h>
using namespace std;
//.hpp后缀可以使得方法实现和声明在一个文件,这样Main.cc在调用时只需要包含.hpp头文件
// 进程池
// 对(信道)管道进行管理
// 先描述:描述管道信息
class channel
{
public:channel(int fd, pid_t id): _wfd(fd),_subid(id){// 使用to_string将fd和id都转成字符加到后面作为名字一部分_name = "channel-" + to_string(_wfd) + "-" + to_string(_subid);}
~channel() {}
// 外部需要拿到channel内部的信息,我们需要一些get方法int Fd() { return _wfd; }pid_t SubId() { return _subid; }string Name() { return _name; }
private:// 每个管道都需要有对应的文件描述符int _wfd;// 还需要知道对应的子进程的idpid_t _subid;// 以及为了之后方便打印提示消息对应管道的名字string _name;
};
// 要创建多少个进程池
const int gdefaultnum = 5;
// 再组织;管理信道(管道)的接口类,我们这里通过vector数据结构来组织
class ChannelManager
{
public:ChannelManager() {}~ChannelManager() {}
void Insert(int wfd, pid_t subid){// 构建一个channel然后push到组织的vector数组中(先描述,再组织)// channel c(wfd, subid);//_channels.push_back(move(c)); //c为临时对象,应该使用右值引用减少拷贝// 或者也可以使用vector容器中提供的emplace_back方法// 直接调用内部构造函数帮我们创建一个对象在插入vector中_channels.emplace_back(wfd, subid);}
// 打印_channels中各channel的信息的方法void PrintChannels(){for (auto &channel : _channels){cout << channel.Name() << endl;}}
private:// 通过vector来组织vector<channel> _channels;
};
// 进程池类
class ProcessPool
{
public:ProcessPool(int num): _process_num(num){}
~ProcessPool() {}
// 子进程要做的工作void Work(int rfd){// 伪工作while (true){cout << "我的rfd是: " << rfd << endl;sleep(5);}}
// 创建进程池方法bool Create(){// 要创建process_num个进程for (int i = 0; i < _process_num; i++){// 1. 创建管道int pipefd[2] = {0};int n = pipe(pipefd);if (n < 0){return false;}
// 2. 创建子进程// 子进程读,父进程写int subid = fork();if (subid < 0)return false;else if (subid == 0){// 子进程// 3. 关闭写端close(pipefd[1]);Work(pipefd[0]); // 子进程要做的读端相关工作close(pipefd[0]);
// 执行完直接退出// 不会干扰for循环,只有父进程会一直循环创建执行循环内代码exit(0);}else{// 父进程// 3. 关闭读端close(pipefd[0]);// 需要在ChannelManager内部把我们的fd和subid信息传入创建的channel中// 再由ChannelManager把创建好的channel插入到数组中// 所以在ChannelManager类中需要Insert方法来完成上述操作// 其中我们父进程对应的fd文件描述符是写端:pipefd[1]_cn.Insert(pipefd[1], subid);}}// 创建成功return true;}
// Debug方法中查看每个channel信道(管道)的信息void Debug(){// 调用ChannelManager中的PrintChannels方法_cn.PrintChannels();}
private:// 进程池类内部需要包含我们的通信信道// 所以我们通过管理通信信道的类创建一个对象在进程池内部ChannelManager _cn;// 要创建的进程池对应的进程的个数,这样就能确定管道的个数int _process_num;
};
采用轮询的方式使得负载平衡
进程池相关完整源代码可以看:ProcessPool
5.命名管道
匿名管道只能用来进行具有血缘关系的进程进行进程间通信(常用于父子进程)
如果两个进程不相关,该如何进行通信呢?
通过上图其实我们就已经通过打开同一路径下的同一个文件来做到让不同的进程看到了同一份资源(进程间通信的前提),而因为文件有路径(使得这个路径下文件具有唯一性,通过路径来区分)、有名字——所以这种通信的方式叫做——命名管道!
我们一般文件是要刷新到磁盘的,我们的命名管道这种作为通信的文件是不能被刷新的,所以命名管道需要一种特殊的文件——管道文件(只会被打开,不需要刷新)
我们通过mkfifo命令来创建管道文件,也就是命名管道
echo和cat在执行时就是进程(shell运行原理,不管是不是内建命令,它都是进程)
所以上图中我们就利用命名管道完成了一次进程间通信,echo将"hello fifo"重定向输入,然后cat重定向输出从fifo中显示内容到终端上
我们可以通过unlink命令来删除管道文件
我们在代码上可以通过mkfifo接口来创建管道文件
记住这里的第一个参数是c语言格式的字符,如果用string对象,得调用c_str()
路径加名字就构成我们要构建的命名管道了
string fifoname = _path + "/" + _name;
int n = mkfifo(fifoname.c_str(), 0666);
[^] 上图内容不理解没关系,我们在网络部分才会进一步去理解
同样的,我们在代码上可以用unlink函数来关闭管道文件
[^] 关闭成功返回0,失败返回-1,和mkfifo创建返回逻辑是一样的
简单用命名管道通信代码:
comm.hpp
#pragma once
#define FIFO_FILE "fifo"
server.cc
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <iostream>
#include <string>
#include <fcntl.h>
#include "comm.hpp"
using namespace std;
int main()
{umask(0);// 新建管道int n = mkfifo(FIFO_FILE, 0666);if (n != 0){cerr << "mkdir fifo error" << endl;return 1;}cout << "mkfifo success" << endl;
// 以读打开管道文件// write方没有执行open时,read方就要在open内部阻塞// 直到有人把管道文件打开了,open才会返回int fd = open(FIFO_FILE, O_RDONLY);if (fd < 0){cerr << "open fifo error" << endl;return 2;}cout << "open fifo success" << endl;
// 正常读取char buffer[1024];while (true){int number = read(fd, buffer, sizeof(buffer) - 1);if (number > 0){// 我们把读到内容当成字符串,所以得在n位置加上\0buffer[number] = '\0';cout << "client say: " << buffer << endl;}else if (number == 0){cout << "client quit! me too " << number << endl;break;}else{cerr << "read error" << endl;break;}}
// 关闭管道文件close(fd);
// 删除管道文件n = unlink(FIFO_FILE);if (n == 0){cout << "remove fifo success" << endl;}else{cout << "remove fifo failed" << endl;}
return 0;
}
client.cc
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <iostream>
#include <string>
#include <fcntl.h>
#include "comm.hpp"
using namespace std;
int main()
{// client端不需要创建管道文件了,因为我们在server端已经创建好了int fd = open(FIFO_FILE, O_WRONLY);// 剩下的都是文件操作了if (fd < 0){cerr << "open fifo error" << endl;return 2;}
// 写入操作string message;int cnt = 1;pid_t id = getpid();while (true){cout << "Please Enter# ";getline(cin, message);message += (",message number: " + to_string(cnt++) + ",[" + to_string(id) + "]");
// c_str()用于将string对象转换为 C 风格的字符串(即以空字符\0结尾的字符数组)int n = write(fd, message.c_str(), message.size());if (n > 0){}}
close(fd);
return 0;
}
我们需要先执行server编译后的可执行程序,来创建命名管道,而后它会阻塞在open那块,因为命名管道需要两个进程来同时打开(我们这里write方没有执行open时,read方就要在open内部阻塞,直到有人把管道文件打开了,open才会返回),所以接下来我们需要在另一个终端上执行client编译后的可执行程序,就达到了用命名管道实现进程间通信(从客户端进程向服务端进程发送消息)的简单效果
当我们关闭写端client时,读端的number就为0了,然后也跟着退出后删除管道文件
6.总结
命名管道和匿名管道的区别
其他的特性是完全和匿名管道一样的,只有一点不同,那就是命名管道可以用来进行让不相关进程进行进程间通信,命名管道也可以用来实现文件的拷贝