【Linux】系统部分——进程间通信1(管道)
20.进程间通信——管道
文章目录
- 20.进程间通信——管道
- 在内核角度管道的本质
- 匿名管道
- 管道的系统调用
- 创建一个管道
- 利用管道实现父子进程之间的通信
- 管道的四大现象
- 匿名管道的特性
- 管道通信的场景
- 穿插知识点:make 与Makfile的使用补充
- 命名管道
- 特点
- 使用举例
进程间的通信可以实现数据传输,资源共享、通知事件、进程控制等功能。但要实现进程间的通信,一定要先让不同的进程看到同一份资源。这个资源应该是某种形式的内存空间,提供资源的只能是操作系统(因为要满足进程之间的独立性)。
在内核角度管道的本质
在之前我们学习过进程与文件IO相关的内容,我们知道每一个打开的文件在OS中都有一个对应的struct file
结构体,这个结构体包含被打开文件的属性以及内核级缓冲区,这个内核级缓冲区同样也是由操作系统提供的。而一个进程的PCB中有一个**struct files_struct
结构,是进程的私有财产**,是进程控制块(PCB, task_struct
)的一部分。它管理了该进程所有已打开的文件描述符(File Descriptors),就像一个文件描述符的数组或表格。files_struct
内部通过一个指针数组(fd_array
)指向一个个 struct file
,从而将进程与其所打开的文件连接起来。
当父进程创建子进程时,子进程会复制父进程的整个 files_struct
结构。这意味着子进程会获得一个相同的文件描述符表,其中每个描述符都指向父进程打开的同一个 struct file
对象。这里的“复制”是立即复制了指针数组,但共享了指针所指向的 struct file
对象。struct file
对象的引用计数 f_count
会增加。这不是传统的写时拷贝,而是一种资源共享。
这样父子进程就共同指向同一块操作系统提供的文件资源,满足了进程间通信的要求:让不同的进程看到同一份资源,但是如果我们用文件进行进程间通信是不需要向磁盘中进行读写操作的,在struct file
结构体中内核级缓冲区即可完成通信任务。这也是管道的原理:管道的本质就是一个被两个进程共享的、内核管理的缓冲区,以及对这两个进程分别暴露的一个只读端和一个只写端的文件抽象
但需要注意:
管道不是一个,而是一对 struct file
当调用 pipe(int pipefd[2])
系统调用时,内核并不是创建一个“管道文件”,而是同时创建两个 struct file
对象,一个用于读,应用于写。
先创建管道,父进程再创建子进程,子进程继承父进程资源从而父子进程能看到同一份资源。并且父进程必须要以读写的形式打开管道。并且最后只能单向通信
匿名管道
管道是一种进程间通信(IPC)机制,允许一个进程的输出作为另一个进程的输入,实现数据流传输
管道的系统调用
在man
中查找手册2的pipe
系统调用
#include <unistd.h>
int pipe(int pipefd[2]);
函数形参是一个输出类型的参数,返回值表示执行是否成功,具体使用示例:
创建一个管道
#include <iostream>
#include <unistd.h>int main()
{int fds[2] = {0};int n = pipe(fds);if(n != 0){std::cerr << "pipe error" << std::endl;std::cerr << "pipe error" << std::endl;std::cerr << "pipe error" << std::endl;}std::cout << "pipe ok" << std::endl;std::cout << "pipe ok" << std::endl;std::cout << "pipe ok" << std::endl;std::cout << "pipe ok" << std::endl;return 0;
}
补充:在这里有一个不经常使用的
std::cerr
这个对应三个输入输出流中的标准错误流,与标准输出流std::cout
区分#include <iostream>int main() {std::cout << "std::cout" << std::endl;std::cout << "std::cout" << std::endl;std::cout << "std::cout" << std::endl;std::cout << "std::cout" << std::endl;std::cerr << "std::cerr" << std::endl;std::cerr << "std::cerr" << std::endl;std::cerr << "std::cerr" << std::endl;std::cerr << "std::cerr" << std::endl;return 0; }
使用:
user@iZ7xvdsb1wn2io90klvtwlZ:~/lession23$ ./cerr std::cout std::cout std::cout std::cout std::cerr std::cerr std::cerr std::cerr user@iZ7xvdsb1wn2io90klvtwlZ:~/lession23$ ./cerr > log.txt std::cerr std::cerr std::cerr std::cerr user@iZ7xvdsb1wn2io90klvtwlZ:~/lession23$ cat log.txt std::cout std::cout std::cout std::cout user@iZ7xvdsb1wn2io90klvtwlZ:~/lession23$ > log.txt user@iZ7xvdsb1wn2io90klvtwlZ:~/lession23$ ./cerr 1>log.txt std::cerr std::cerr std::cerr std::cerr user@iZ7xvdsb1wn2io90klvtwlZ:~/lession23$ > log.txt user@iZ7xvdsb1wn2io90klvtwlZ:~/lession23$ ./cerr 2>log.txt std::cout std::cout std::cout std::cout user@iZ7xvdsb1wn2io90klvtwlZ:~/lession23$ > log.txt user@iZ7xvdsb1wn2io90klvtwlZ:~/lession23$ ./cerr 1>log.txt 2>log.txt user@iZ7xvdsb1wn2io90klvtwlZ:~/lession23$ cat log.txt std::cerr std::cerr std::cerr std::cerr user@iZ7xvdsb1wn2io90klvtwlZ:~/lession23$ > log.txt user@iZ7xvdsb1wn2io90klvtwlZ:~/lession23$ ./cerr 1>log.txt 2>&1 user@iZ7xvdsb1wn2io90klvtwlZ:~/lession23$ cat log.txt std::cout std::cout std::cout std::cout std::cerr std::cerr std::cerr std::cerr
- 在之前学习文件重定向的时候可以在命令行使用
>
将前一个的输出交给下一个文件,但是这个>
完整的使用方式需要在>
的前面加上1或者2,1表示标准输出流中的内容重定向到后面的文件中,2表是标准错误流的内容重定向到后面的文件中- 每次重定向到后面的文件,这个文件都会清理掉之前的内容
./cerr 1>log.txt 2>&1
表示**运行程序./cerr
,并将其标准输出(stdout)和标准错误(stderr)全部重定向到同一个文件log.txt
中;**将文件描述符1(标准输出)的输出目标,从默认的屏幕,改变为文件log.txt
,让文件描述符2(标准错误)去重复文件描述符1(标准输出)当前所指向的地方。要注意的是:shell处理命令的顺序是从左向右的,当它处理到2>&1
时,文件描述符1已经被重定向到了log.txt
。std::cout
和printf
对应标准输出,std::cerr
和perror
对应标准错误- 一定要注意:完整表示的时候>和1、2中间不能有空格
利用管道实现父子进程之间的通信
#include <iostream>
#include <unistd.h>
#include <string>
#include <sys/wait.h>int main()
{int fds[2] = {0};int n = pipe(fds);if (n != 0){std::cerr << "pipe error" << std::endl;return 1;}std::cout << "pipe ok" << std::endl;pid_t id = fork();if (id < 0){std::cerr << "fork error" << std::endl;return 2;}else if (id == 0){// 子进程close(fds[0]);int cnt = 0;while (true){std::string message = "child process:";message += std::to_string(getpid());message += ",";message += std::to_string(cnt);::write(fds[1], message.c_str(), message.size());sleep(1);cnt++;}exit(0);}else{// 父进程close(fds[1]);char str[1024] = {0};while (true){ssize_t n = ::read(fds[0], str, 1024);if (n > 0){str[n] = 0;std::cout << str << std::endl;}}pid_t rid = waitpid(id, nullptr, 0);std::cout << "father wait child success: " << rid << std::endl;}return 0;
}
运行结果:
user@iZ7xvdsb1wn2io90klvtwlZ:~/lession23$ ./test
pipe ok
child process:4900,0
child process:4900,1
child process:4900,2
child process:4900,3
child process:4900,4
child process:4900,5
child process:4900,6
child process:4900,7
^C
- 通过系统调用
pipe
创建的管道有读写两种方式,形参作为输出类型的参数在调用pipe
之后记录了管道的文件描述符,其中数组的第一个元素fds[0]
为管道的读文件描述符,数组的第二个元素fds[1]
为管道的写文件描述符,又因为管道是单向通信的,所以在正式收发消息之前父子进程需要关闭一个文件(管道入口)。 - 由代码可知:子进程向管道中刷新数据有1秒的间隔
sleep(1)
,而父进程并没有休眠。但是在程序运行结果中可以看到父进程读取的消息与子进程发送的消息速度一致,内容一致,没有出现父进程重复读取同一次数据或少读的情况(数据不一致)。由此可知:父进程再子进程没有刷新数据的时候会阻塞,以保护管道数据的安全
管道的四大现象
-
管道为空&&管道正常,read(系统调用)会阻塞
-
管道为满&&管道正常,write会阻塞[write也是一个系统调用]
-
管道写端关闭&&读端继续,读端读到0,表示读到文件结尾
-
管道写端正常&&读端关闭,OS会直接杀掉写入的进程!(OS会给目标进程发送信号)
匿名管道的特性
- 面向字节流
- 用来进行具有"血缘关系"的进程,进行IPC,常用于父子
- 文件的生命周期,随进程!管道也是!
- 单向数据通信
- 管道自带同步互斥等保护机制!
管道通信的场景
进程池(Process Pool)是一种并发编程技术,它预先创建一组进程( worker processes),这些进程处于等待状态,随时准备处理任务。当有新的任务到达时,主进程将任务分配给池中的空闲进程,而不是为每个任务都创建新的进程。而管道特别适合实现进程池中的任务分配和结果收集。
穿插知识点:make 与Makfile的使用补充
makefile中可以定义变量,将我们编译程序的编译器,选项,文件这些用变量定义,之后再修改的时候就方便得多。具体代码如下:
BIN=processpool #定义最终生成的可执行文件名称为 processpool
cc=g++ #指定使用 g++ 作为 C++ 编译器
FLAGS=-c -Wall -std=c++11 #-c:只编译不链接,生成目标文件 (.o)-Wall:启用所有警告-std=c++11:使用 C++11 标准
LDFLAGS=-o #设置链接标志,-o 用于指定输出文件名
# SRC=$(shell ls *.cc) #获取所有 .cc 源文件,下面也是一样
SRC=$(wildcard *.cc)
OBJ=$(SRC:.cc=.o) #通过替换后缀,生成对应的目标文件列表,注意是列表,没有直接生成文件$(BIN):$(OBJ)$(cc) $(LDFLAGS) $@ $^
%.o:%.cc$(cc) $(FLAGS) $< #将所有的.cc文件逐个编译为同名.o文件.PHONY:clean #清理
clean:rm -f $(OBJ) $(BIN)
要注意:这个Makefile
文件可以识别所在文件夹的所有.cc
文件,只在该文件夹起作用
命名管道
如果我们想在不相关的进程之间交换数据,可以使⽤FIFO⽂件来做这项⼯作,它经常被称为命名管道。命名管道是⼀种特殊类型的⽂件
命名管道可以从命令⾏上创建:
mkfifo filename
命名管道也可以从程序⾥创建
int mkfifo(const char *filename,mode_t mode); //第一个参数是名称,第二个参数是创建文件的权限(在之前讲过)
特点
-
我们可以把命名管道看做一个文件,但是它与普通文件不同的是:命名管道不会将数据写入到磁盘中
-
在文件系统中有一个路径名,像普通文件一样存在
-
即使没有进程使用,命名管道也会保留在文件系统中直到被删除
-
允许没有任何亲缘关系的进程之间进行通信
-
数据只能单向流动(但可以创建两个管道实现双向通信)
-
补充:在创建文件(不管是什么文件)的时候文件会记录当前创建文件的用户UID,而进程的
task_struct
中同样存在UID记录这个进程是由哪个用户创建的。因此在进程访问文件的时候会通过对比进程和文件的UID以及权限和其他属性来判断当前进程是否能够对这个文件进行某些操作。 -
user@iZ7xvdsb1wn2io90klvtwlZ:~/lesson25$ mkfifo pipe user@iZ7xvdsb1wn2io90klvtwlZ:~/lesson25$ ll total 16 drwxrwxr-x 2 user user 4096 Sep 5 14:02 ./ drwxr-x--- 10 user user 4096 Sep 3 20:15 ../ prw-rw-r-- 1 user user 0 Sep 5 14:02 pipe|
-
补充:一个目录本质上是存放文件名和
inode
的映射关系,删除文件的本质就是减少文件的硬链接数,当一个文件的硬链接数较少到0的时候文件自然就被删除了,在程序的代码中,我们可以使用unlink
删除文件
使用举例
准备两个可执行程序公用一个管道进行通信,sever
读管道,client
写管道
Sever.hpp /.cc
#pragma once
#include <iostream>
#include "Comm.hpp"class Init
{
public:Init(){umask(0);int n = ::mkfifo(gpipeFile.c_str(), gmode);if (n < 0){std::cerr << "mkfifo error" << std::endl;return;}std::cout << "mkfifo success" << std::endl;// sleep(10);}~Init(){int n = ::unlink(gpipeFile.c_str());if (n < 0){std::cerr << "unlink error" << std::endl;return;}std::cout << "unlink success" << std::endl;}
};Init init;class Server
{
public:Server():_fd(gdefultfd){}bool OpenPipeForRead(){_fd = OpenPipe(gForRead);if(_fd < 0) return false;return true;}// std::string *: 输出型参数// const std::string &: 输入型参数// std::string &: 输入输出型参数int RecvPipe(std::string *out){char buffer[gsize];ssize_t n = ::read(_fd, buffer, sizeof(buffer)-1);if(n > 0){buffer[n] = 0;*out = buffer;}return n;}void ClosePipe(){ClosePipeHelper(_fd);}~Server(){}
private:int _fd;
};//.cc
#include "Server.hpp"
#include <iostream>int main()
{Server server;std::cout << "pos 1" << std::endl;server.OpenPipeForRead();std::cout << "pos 2" << std::endl;std::string message;while (true){int n = server.RecvPipe(&message);if (n > 0){std::cout << "client Say# " << message << std::endl;std::cout << "read size: " << n << std::endl;}else{break;}std::cout << "pos 3" << std::endl;}std::cout << "client quit, me too!" << std::endl;server.ClosePipe();return 0;
}
client.cc / .hpp
//.hpp
#pragma once
#include <iostream>
#include "Comm.hpp"class Client
{
public:Client():_fd(gdefultfd){}bool OpenPipeForWrite(){_fd = OpenPipe(gForWrite);if(_fd < 0) return false;return true;}// std::string *: 输出型参数// const std::string &: 输入型参数// std::string &: 输入输出型参数int SendPipe(const std::string &in){return ::write(_fd, in.c_str(), in.size());}void ClosePipe(){ClosePipeHelper(_fd);}~Client(){}
private:int _fd;
};//.cc
#include "Client.hpp"
#include <iostream>int main()
{Client client;client.OpenPipeForWrite();std::string message;while(true){std::cout << "Please Enter# ";std::getline(std::cin, message);client.SendPipe(message);}client.ClosePipe();return 0;
}
//两个进程公用代码 “comm.hpp”#pragma once#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>const std::string gpipeFile = "./fifo";
const mode_t gmode = 0600;
const int gdefultfd = -1;
const int gsize = 1024;
const int gForRead = O_RDONLY;
const int gForWrite = O_WRONLY;int OpenPipe(int flag)
{// 如果读端打开文件时,写端还没打开,读端对用的open就会阻塞int fd = ::open(gpipeFile.c_str(), flag);if (fd < 0){std::cerr << "open error" << std::endl;}return fd;
}void ClosePipeHelper(int fd)
{if (fd >= 0)::close(fd);
}
重点:
-
默认情况下,读写操作会阻塞直到另一端有进程准备好,这也就解释了为什么:如果一个进程先创建并打开了管道的读端,但是另一个进程还没来得及打开写端,但是管道并没有被释放(按理来说管道通信时出现这种情况会直接释放)
-
写端不退出读端是不会读到0的,如果写端不写,读端会一直阻塞在读的系统调用,这也是管道与一般文件的区别