深入了解linux系统—— 进程间通信之管道
前言
本篇博客所涉及到的代码一同步到本人gitee
:testfifo · 迟来的grown/linux - 码云 - 开源中国
一、进程间通信
什么是进程间通信
在之前的学习中,我们了解到了进程具有独立性,就算是父子进程,在修改数据时也会进行写时拷贝;
而进程间通信(IPC
)就是指在同一个计算机或者不同的计算机上的不同进程之间进行数据交换和通信的计数。
为什么要进程间通信
每一个进程都有自己的进程地址空间,它们无法访问彼此的数据,也就是进程具有独立性;那为什么要存在通信呢?
因为我们进程之间要进行数据传输、资源共享、通知事件等一系列操作。
- 数据传输:一个进程要将它的数据发送到另一个进程。
- 资源共享:多个进程之间共享同样的资源。
- 通知事件:一个进程需要向另一个/一组进程法信息,通知它们发生了某种事情(例如:进制终止时要通知父进程)
- 进程控制:一些进程希望可以控制另一个进程的执行(
Debug
控制进程),此时控制进程就希望它可以拦截另一个进程的所有陷入和异常,并且能及时知道它的改变。
在现实生活中,我们在使用微信进行聊天时,微信在我们的系统中运行就是一个进程,我们发送信息给对方本质上就是我们的微信进程将信息发送到对方系统中的微信进程。这不就是两个进程之间的数据传输吗。
如何通信
我们知道进程是具有独立性的,就算父子进程,在对数据做修改时也会进行写时拷贝;
那进程之间如何进行通信呢?
这里进程之间要进行通信,那前提是这些进程要看到同一块资源啊,总不能这个进程在这一块内存中度写数据,另一个进程在另一块内存读写数据啊。
所以我们进程之间要像进行通信,就先有一块所有进程都能看到的资源,而这一块资源从哪来呢?
只能由
OS
提供
- 管道: 建立一个单向或双向的通道来传递信息。
- 共享内存: 开辟一块双方都能访问的内存区域,直接在上面读写。
- 消息队列: 把消息放到一个队列里,对方按需取走。
- 信号/信号量: 发送一个简单的通知或控制指令(如“开始”、“停止”、“资源已释放”)。
- 套接字: 即使在不同机器上也能通信(虽然通常 IPC 指同一台机器,但套接字也可用于本机通信)。
二、管道
这里我们来了解管道,对于管道,之前在第一次迈入Linux
学习的大门——学习指令时,我们曾用过管道;
但是管道它究竟是什么呢?
如上图所示
ps -axj | head -1
,ps -axj
指令(进程)通过管道将自己的执行结果输出给head -1
指令(进程)。这里的
|
就是管道(匿名管道)。
管道又分为匿名管道和命名管道。
管道原理
那管道可以用来进行进程间通信,那又是如何支持的呢?
要实现进程间通信,就要让不同的进程看到同一份资源;
我们知道,文件是可以被多个进程打开的,那也就是说:一个文件可以被多个进程打开,这样不同的进程都能看到同一个文件
所以可以说,管道是基于文件实现的
在进程打开文件的角度理解:
我们知道,每一个进程都有自己的
task_struct
,都有一份自己的文件描述符表struct file_struct
;
那我们不同的进程打开同一个文件时,在每一个进程中都会存在一个文件描述符,那我们的进程不就看到了同一份资源吗。
所以,管道就是基于文件实现的进程间通信
不同的进程看到的同一份资源,就是文件;至于这个文件从哪里来,肯定是通过操作系统提供的系统调用接口创建的文件,或者现有的文件。
而管道又分为匿名管道和命名管道
匿名管道
管道本质上呢就是文件,只不过这个文件是OS
提供的;
现在来了解通过管道来实现进程间通信的操作。
匿名管道,从这个名字上我们可以想象到:不同进程看到的同一份资源(同一个文件),这个文件是没有名字的。
在之前对于文件的操作中,都是使用文件名来对文件进行相关操作,那匿名管道文件没有文件名,如何创建这个匿名管道文件呢?
系统调用
很显然,匿名管道这个文件只能由OS
来提供,所以OS
也提供了相关的系统调用。
int pipe(int pipefd[2]);
pipe
系统调用的作用就是:创建一个用于进程间通信的单向数据通道。
返回值:
当调用
pipe
创建管道文件成功时,返回值为0
;创建失败时,返回值为-1
。
参数:
先来看
pipe
系统调用的参数pipefd[2]
,这是一个输出型参数;在调用
pipe
创建管道文件成功时。fd[0]
表示r
(以读方式打开)该文件的文件描述符、fd[1]
表示w
(以写方式打开)该文件的文件描述符。
#include <stdio.h>
#include <unistd.h>
int main()
{int fd[2];int n = pipe(fd);if(n<0){perror("pipe");exit(1);}printf("fd[0] : %d\n",fd[0]);printf("fd[1] : %d\n",fd[1]);return 0;
}
匿名管道实现原理
- 当父进程创建子进程时,操作系统会将父进程的
task_struct
、mm_struct
、file_struct
等的数据拷贝给子进程,然后在子进程中修改部分数据。- 那当我们父进程调用
pipe
创建了管道文件,此时父进程的文件描述符表中就存在了w
和r
方式打开管道文件的文件描述符;而这时再创建子进程,OS
就会将父进程的文件描述符表拷贝给子进程,那这样在子进程的文件描述符表中,不也就存在了以w
和r
方式打开的管道文件的文件描述符了吗。- 那这样父子进程不就可以看到同一个资源(文件)了吗,那父进程写(或者子进程写),子进程读(或者父进程读);这样不就可以实现父子进程之间的通信了吗。
所以,在父进程创建子进程时,子进程的文件描述符表等信息都来源于父进程,
OS
也会为子进程创建新的struct file
;在子进程中,可以通过文件描述符表找到对应的
struct file
,然后根据struct file
就可以找到该文件。从上图中我们也可以看出,匿名管道它和文件系统没有关系。
当父进程创建子进程时,子进程就会继承(拷贝)父进程的文件描述符表;这样子进程和父进程就看到了同一份资源(文件),就可以进程进程间的通信
这样在创建子进程之后,再关闭父子进程不用的文件描述符,不就可以实现进程之间的单向通信了吗。
父子进程间通信
了解了匿名管道的实现原理,那我们就应该明白匿名管道只能实现具有血缘关系的进程间的通信(父子进程),因为是通过文件描述符让不同进程看到同一份资源的。
了解了匿名管道实现通信的原理,现在来通过代码实现父子进程的通信。
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <string.h>
#include <stdlib.h>
void Write(int fd)
{char buff[1024];snprintf(buff, sizeof(buff), "I am chile, pid : %d, ppid : %d", getpid(), getppid());write(fd, buff, strlen(buff));
}
void Read(int fd)
{char buff[1024];int n = read(fd, buff, sizeof(buff) - 1);if (n < 0) // 读取失败{perror(read);exir(1);}buff[n] = 0;printf("read : %s\n", buff);
}
int main()
{int fd[2];int n = pipe(fd);//创建管道文件if(n<0) exit(1);int id = fork();if(id < 0) eixt(2);else if(id == 0){//child -> wclose(fd[0]);//关闭不用的文件描述符Write(fd[1]);//向fd[1]文件中写入close(fd[1]);//写入完成后关闭fd[1]exit(1);//子进程退出}//parent -> rclose(fd[1]);//关闭不用的文件描述符Read(fd[0]);//在文件fd[0]中读数据close(fd[0]);//读完成后关闭fd[0]wait(NULL);//父进程等待子进程退出return 0;
}
如上述代码,父进程创建匿名管道文件后,创建子进程;
这里实现子进程写,父进程读;关闭多余的文件描述符,子进程关闭
fd[0]
、父进程关闭fd[1]
。然后子进程向
fd[1]
中写入,父进程在fd[0]
中读取。子进程在写入完成后关闭
fd[1]
然后退出;父进程读取完成后关闭fd[0]
然后等待子进程退出。
可以看到,父进程读取到了子进程向匿名管道中写入的数据,并输出到显示器上。
管道通信的四种情况
在上述实现父子进程通信中,子进程只写入了一条信息就退出了,而父进程也只读了一条信息就退出了。
那如果这里的子进程一直中写入信息,父进程也在一直读信息。
管道通信就有四种情况:
- 数据读的快,写入的慢:读端就会在
read
阻塞,等待写端写入。- 数据读的慢,写入的快:写端一直在写,直到匿名管道被写满;写端等读端读取数据后再写入。
- 读端正常,写端退出了:读端
read
就会返回0
,表示写端已经退出了。- 读端退出了,写端正常:
OS
就会通过信号杀掉正在写入的进程。
1. 读的快,写的慢
当读取管道文件中数据比写入的快时,读端就会阻塞在read
处。
void Write(int fd)
{sleep(3);char buff[1024];snprintf(buff, sizeof(buff), "I am chile, pid : %d, ppid : %d", getpid(), getppid());write(fd, buff, strlen(buff));
}
void Read(int fd)
{printf("read begin:\n");char buff[1024];int n = read(fd, buff, sizeof(buff) - 1);if (n < 0) // 读取失败{perror("read");exit(1);}buff[n] = 0;printf("read end\n");printf("read : %s\n", buff);
}
2. 读的慢,写的快
当我们写端写入的非常快,读端读取数据很慢,写端就会一直写入,直到将匿名管道写满,然后等待读端读取,然后继续进行写入。
这里就对Write
和Read
函数进行修改,让读取每读取一次就sleep
一秒钟;写端就一直写入。
void Write(int fd)
{int cnt = 0;while (1){char buff[1024];snprintf(buff, sizeof(buff), "I am chile, pid : %d, ppid : %d", getpid(), getppid());printf("cnt = %d\n",cnt++);write(fd, buff, strlen(buff));}
}
void Read(int fd)
{// printf("read begin:\n");while (1){sleep(1);char buff[1024];int n = read(fd, buff, sizeof(buff) - 1);if (n < 0) // 读取失败{perror("read");exit(1);}buff[n] = 0;// printf("read end\n");printf("read : %s\n", buff);printf("----------------------------------------------------------\n");//分割每一次读取的数据}
}
3. 写端退出,读端正常
但写端退出时,读端的read
的返回值就为0
,表示写端已经退出了。
void Write(int fd)
{int cnt = 0;while (1){char buff[1024];snprintf(buff, sizeof(buff), "I am chile, pid : %d, ppid : %d", getpid(), getppid());printf("cnt = %d\n", cnt++);write(fd, buff, strlen(buff));break;}
}
void Read(int fd)
{while (1){sleep(1);char buff[1024];int n = read(fd, buff, sizeof(buff) - 1);if (n < 0) // 读取失败{perror("read");exit(1);}else if (n == 0){printf("read return value : %d\n", n);continue;}buff[n] = 0;printf("read : %s\n",buff);}
}
这里写端写入一次之后就退出,读端每一秒读取一次。
一般情况下,当写端退出时,读端也应该要退出了(read
返回值为0
)
4. 写端正常,读端退出
当我们读端退出,而写端正常时,操作系统就会通过信号杀掉写端进程。
也就是说,当一个管道文件没有读端时(读端全部关闭),操作系统就会杀掉所有的写端。
这就好比,你和别人在聊天,别人都不看你发的信息了,你再发就没有任何意义了。
当读端退出时,写端再进行写入就没有任何意义了,并且还会浪费内存空间;操作系统不允许这种情况出现,所以就会通过信号杀掉写端进程。
测试代码:
void Write(int fd)
{int cnt = 0;while (1){char buff[1024];snprintf(buff, sizeof(buff), "I am chile, pid : %d, ppid : %d", getpid(), getppid());printf("cnt = %d\n", cnt++);write(fd, buff, strlen(buff));sleep(1);}
}
void Read(int fd)
{while (1){char buff[1024];int n = read(fd, buff, sizeof(buff) - 1);if (n < 0) // 读取失败{perror("read");exit(1);}else if (n == 0){printf("read return value : %d\n", n);continue;}buff[n] = 0;printf("read : %s\n",buff);sleep(2);break;}
}int main()
{int fd[2];int n = pipe(fd); // 创建管道文件if (n < 0){perror("pipe");exit(1);}int id = fork();if (id < 0){perror("fork");exit(2);}else if (id == 0){// child -> wclose(fd[0]); // 关闭不用的文件描述符Write(fd[1]); // 向fd[1]文件中写入close(fd[1]); // 写入完成后关闭fd[1]exit(1); // 子进程退出}// parent -> rclose(fd[1]); // 关闭不用的文件描述符Read(fd[0]); // 在文件fd[0]中读数据close(fd[0]); // 读完成后关闭fd[0]sleep(1); //父进程sleep两秒,子进程处于僵尸状态int status;waitpid(id, &status, 0);printf("child exit : %d\n",status & 0x7F);//输出子进程退出时的退出信号sleep(2);return 0;
}
命名管道
匿名管道是通过文件描述符表让父子进程看到同一个文件,所以匿名管道就只能用来完成父子进程(存在血缘关系的进程)之间的通信;那如果两个毫不相干的进程想要进行通信呢?
而两个进程想要通信就要先看到同一份资源,那我们让不同的进程打开同一个文件不就行了;但是如果进程打开的是普通文件,在进程退出时操作系统就会将文件缓冲区的内容刷新到磁盘中,而进程间通信这里是不需要将内容刷新到缓冲区的。
所以,两个进程就不能打开普通文件,而是管道文件来进行通信;那这个管道文件也是一个文件啊,进程要打开这个文件就要有文件名啊,那这个文件就是一个命名文件也就是命名管道。
创建/删除管道文件
我们可以使用mkfifo
命令来创建一个管道文件:
mkfifo filename
创建成功管道文件之后,我们就可以在程序中打开进程文件然后进行写入和读取操作。
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
int main()
{//打开命名管道文件printf("open fifo\n");open("fifo",O_RDONLY);//read or writeprintf("open fifo success\n");return 0;
}
我们可以发现,当我们在程序中试图打开管道文件时,它阻塞在了open
函数中;
这是因为:管道文件是用来实现进程间通信的,这里我们只有一个进程要打开这个管道文件,
OS
就会让进程在open
函数中阻塞住,直到有其他进程也打开这个管道文件。
当然可以创建管道文件也可以删除:
删除一个管道文件可以使用命令rm
也可以使用命令unlink
。
进程自己创建管道文件
我们可以使用mkfifo
指令来创建管道文件,这样在程序还没有运行时,就要先创建好管道文件;那程序在运行时可不可以自己创建管道文件实现通信呢?
当然是可以的:
程序可以通过调用
mkfifo
函数,在程序运行时创建管道文件。参数:
- 第一个参数
pathname
表示要创建管道文件的文件路径(包含文件名)- 第二个参数
mode
,表示权限;指创建管道文件的默认权限(文件权限 = 默认权限 & (~umask))。我们可以使用
umask
函数设置局部的umask
码。返回值
- 如果创建管道文件成功就返回
0
- 如果失败就返回
-1
,并且错误码被设置
删除管道文件
程序在运行时可以创建管道文件,当然也可以删除管道文件。
程序可以通过调用unlink
函数来删除一个管道文件。
利用命名管道实现文件的拷贝
这里呢简单使用一下管道文件,实现文件的拷贝;
原理呢非常简单,本质上就是进程读取一个文件的内容,然后通过管道传给另一个文件,然后该进程再将接受到的数据写入到另一个文件当中。
- 进程1(
process1
)以读方式打开文件src.txt
;进程2(process2
)以写发生打开文件dest.txt
- 进程2创建管道文件,并以读方式打开管道文件;进程1以写方式打开管道文件
- 进程1将读取文件
src.txt
的内容,写入到管道文件;进程2读取管道文件的内容,然后将内容写入到文件dest.txt
中
至此,就完成了文件的拷贝。
//process1.c
#include <stdio.h>
#include <fcntl.h>
#include <string.h>
#include <stdlib.h>
#define _FIFO_FILE_ "fifo"
#define _SRC_FILE_ "src.txt"int main()
{//以w方式打开管道文件int wf = open(_FIFO_FILE_, O_WRONLY);if(wf < 0) exit(1);//以r方式打开src.txtint rs = open(_SRC_FILE_, O_RDONLY);if(rs < 0) exit(2);//读取src文件内容,写入到管道文件中char buff[1024];int n = 0;while(n = read(rs,buff,sizeof(buff)-1))//当n等于0时表示读到文件末尾{//将文件内容写入到管道文件中buff[n] = 0;write(wf, buff,strlen(buff));}printf("read src.txt and write fifo success\n");return 0;
}
//process2.c
#include <stdio.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include <stdlib.h>#define _FIFO_FILE_ "fifo"
#define _DEST_FILE_ "dest.txt"int main()
{//创建管道文件int mf = mkfifo(_FIFO_FILE_, 0666);if(mf < 0) exit(1);//以读方式打开管道文件int rf = open(_FIFO_FILE_, O_RDONLY);if(rf < 0) exit(2);//以写方式打开dest.txt文件int wd = open(_DEST_FILE_, O_WRONLY | O_TRUNC);if(wd < 0) exit(3);//从管道文件中读取char buff[1024];int n = 0;while(n = read(rf, buff,sizeof(buff)-1)){buff[n] = 0;//将读取到的内容写入到dest.txt文件中write(wd, buff,strlen(buff));}printf("read fifo and write dest.txt success\n");return 0;
}
通过命名管道实现进程间通信
在上述过程中,利用管道文件实现了文件的拷贝,这是非常简单的,使用一个进程以r
方式打开src.txt
文件,以w
方式打开dest.txt
文件也是可以完成文件的拷贝的。
那我们想要两个进程之间进行通信,如何完成呢?
现在模拟一个场景,客户端进程
client
向服务端进程server
发送信息。
- 服务端进程
server
:创建管道文件,并以读r
方式打开管道文件。- 客户端进程
client
: 以w
方式打开管道文件,向管道文件中发送信息。
1. 服务端server
这里服务端进程server
要接受客户端进程client
发送的信息;管道文件也就由服务端进程创建。
- 创建管道文件
- 以读方式打开管道文件,读取管道文件内容
- 数据传输结束,关闭并删除管道文件
#include <iostream>
#include <cstdio>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#define _FIFO_FILE_ "fifo"int main()
{//创建管道文件int mf = mkfifo(_FIFO_FILE_, 0666);if(mf < 0){perror("mkfifo");exit(1);}int rf = open(_FIFO_FILE_, O_RDONLY);if(rf < 0){perror("open");exit(2);} //等待客户端发送信息while(1){char buff[1024];int n = read(rf, buff, sizeof(buff));if(n < 0){perror("read");exit(3);}else if(n == 0){printf("client exit, server exit too\n");break;}buff[n] = 0;printf("Client : %s\n",buff);}//关闭并删除管道文件close(rf);unlink(_FIFO_FILE_);return 0;
}
2. 客户端client
服务端进程server
创建管道文件成功,客户端就只需以w
方式打开管道文件,然后向管道文件写入内容即可。
- 以
w
方式打开管道文件,将传输内容写入到管道文件- 数据传输结束,关闭管道文件
#include <iostream>
#include <string>
#include <cstdio>
#include <fcntl.h>
#include <unistd.h>
#define _FIFO_FILE_ "fifo"int main()
{//w打开管道文件int wf = open(_FIFO_FILE_, O_WRONLY);if(wf < 0){perror("open");exit(1);}while(1){std::string massage;std::cout<<"Enter : ";std::getline(std::cin, massage);write(wf,massage.c_str(),massage.size());}close(wf);return 0;
}
3. 效果演示
4. 代码优化
在上述代码中,虽然说使用了C++
语法,但是整个代码的过程还是面向过程化的,现在我们对其进行修改;
将整个代码面向对象化。
对于服务端进程
server
和客户端进程client
,通过管道进行通信的本质就是文件操作。这里就设计出一个文件类,而在服务端进程中要进行管道文件的创建和删除,这里就再设计一个管道文件类。
管道文件fifofile
我们要创建一个管道文件,就要有路径和文件名,所以在fifofile
类中就要有成员变量_path
路径和_name
文件名。
为了方便操作,再增加一个_filename
成员变量,表示管道文件的路径+文件名。
在server
进程中,要创建管道文件,也要删除文件;而有时异常退出就可能没有删除管道文件;
所以这里在fifofile
的构造函数中创建管道文件,在析构函数中删除这个管道文件。
成员变量:
_path
,表示路径_name
:表示管道文件的文件名_fifoname
表示管道文件的(路径+文件名)成员方法:
- 构造函数:在构造函数中创建管道文件。
- 析构函数:在析构函数中删除管道文件。
class fifofile
{
public:fifofile(const std::string &path, const std::string &name): _path(path), _name(name){// 创建管道文件_fifoname = path + '/' + name;int n = mkfifo(_fifoname.c_str(), 0666);if (n < 0){std::cerr << "mkfifo failed" << std::endl;exit(1);}//std::cout << "mkfifo success" << std::endl;}~fifofile(){unlink(_fifoname.c_str());}private:std::string _path;std::string _name;std::string _fifoname;
};
文件操作file
打开管道文件之后,进程直接的通信就变成了对文件的写入和读取,这里设计一个file
类,实现对文件的一系列操作。
成员变量:
_path
:文件路径_name
:文件名filename
:文件路径 + 文件名成员函数:
- 构造函数、析构函数
_readopen
和_writeopen
:以读/写方式打开文件_read
和_write
:文件的读取/写入_close
:关闭文件
class file
{
public:file(const std::string &path, const std::string &name): _path(path), _name(name), _fd(-1){_filename = path + '/' + name;}~file(){_close();}bool _readopen(){_fd = open(_filename.c_str(), O_RDONLY);if (_fd < 0){std::cerr << "open failed" << std::endl;return false;}std::cout << "readopen succcess" << std::endl;return true;}bool _writeopen(){_fd = open(_filename.c_str(), O_WRONLY);if (_fd < 0){std::cerr << "open failed" << std::endl;return false;}std::cout << "write open success" << std::endl;return true;}int _read(char *buff, int n){if (_fd < 0)-1;int x = read(_fd, buff, sizeof(buff) - 1);if (x < 0){std::cerr << "read failed" << std::endl;exit(1);}else if (x == 0)return 0;buff[x] = 0;return x;}int _write(std::string &str){if (_fd < 0)return -1;int n = write(_fd, str.c_str(), str.size());if (n < 0){std::cerr << "read failed" << std::endl;exit(1);}return n;}void _close(){if (_fd >= 0)close(_fd);}
private:std::string _path;std::string _name;std::string _filename;int _fd;
};
这里实现的
_read
和_write
都是一次读取/写入;如果读取失败就直接终止,写端退出就返回
0
,成功读取就返回读取到的字节数。如果写入是吧就直接终止,写入成功就返回实际写入的字节数。
针对上述实现的fifofile
和file
类,这里也写出了测试方法,实现的效果和面向过程实现进程间通信是一样的,这里就演示了
fifo.hpp:
//fifo.hpp
#pragma once
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#define _FIFO_PATH_ "."
#define _FIFO_NAME_ "fifo"
//fifofile类
//fife类
server.cc:
#include "fifo.hpp"int main()
{// 创建管道文件fifofile ff(_FIFO_PATH_, _FIFO_NAME_);// 打开管道文件file rf(_FIFO_PATH_, _FIFO_NAME_);rf._readopen();char buff[1024];while (1){int n = rf._read(buff, sizeof(buff));if (n == 0){std::cout << "client exit, me too" << std::endl;break;}buff[n] = 0;std::cout << "client : " << buff << std::endl;}// 自动调用析构函数,删除管道文件return 0;
}
client.cc:
#include "fifo.hpp"int main()
{file wf(_FIFO_PATH_, _FIFO_NAME_);wf._writeopen();while(1){std::string str;std::getline(std::cin, str);wf._write(str);}return 0;
}
到这里本篇文件内容就结束了,感谢各位的支持!!!
到这里本篇文件内容就结束了,感谢各位的支持!!!
简单总结:
- 进程间通信
IPC
- 管道的原理:管道是基于文件系统实现的进程间通信
- 匿名管道:父子进程通过文件描述符表看到同一个文件
- 命名管道:通过文件名看到同一个文件
pipe
创建匿名管道mkfifo
命名先创建命名管道再运行程序;先运行程序,在运行时通过mkfifo
函数创建命名管道