【Linux-进程通信1】管道
🌈进程间通信介绍
🍄进程间通信目的
在操作系统中,每个进程都是独立运行的,它们有自己的地址空间和资源,它们不能直接访问其他进程的资源。然而,在现代计算机系统中,很少有一个进程能够独立完成所有任务,因此需要不同的进程之间进行通信和协作。
进程间通信的目的包括但不限于以下几点:
- 数据传输:一个进程需要将它的数据发送给另一个进程
- 资源共享:多个进程之间共享同样的资源。
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
- 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
🐖说到底:进程间通信的本质就是让不同进程看到同一份资源。
🍄进程间通信的分类
管道
- 匿名管道
- 命名管道
System V IPC
- System V 消息队列
- System V 共享内存
- System V 信号量
POSIX IPC
- 消息队列
- 共享内存
- 信号量
- 互斥量
- 条件变量
- 读写锁
posix这个是关于多线程相关的,后面再讲
🌈管道
👿前言
提起管道,大家都知道输油管道,天然气管道,排污管道等等。但是这些管道是不是都是单向的。而以前的大佬设计管道进行进程交流时也是设计的单向流通。至于双向管道后来才有。这里我们介绍的管道也是单向的。
🍄 什么是管道
- 管道是Unix中最古老的进程间通信的形式。
- 我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”
🍄管道通信的原理:
进程间通信的本质就是,让不同的进程看到同一份资源,使用匿名管道实现父子进程间通信的原理就是,让两个父子进程先看到同一份被打开的文件资源,然后父子进程就可以对该文件进行写入或是读取操作,进而实现父子进程间通信。
当父子进程进行管道交流时:双方关闭各自不需要的文件描述符,父进程关闭读,保留只写,而子进程关闭写,保留只读。
- 这里父子进程看到的同一份文件资源是由操作系统来维护的,所以当父子进程对该文件进行写入操作时,该文件缓冲区当中的数据并不会进行写时拷贝。
- 管道虽然用的是文件的方案,但操作系统一定不会把进程进行通信的数据刷新到磁盘当中,因为这样做有IO参与会降低效率,而且也没有必要。也就是说,这种文件是一批不会把数据写到磁盘当中的文件,换句话说,磁盘文件和内存文件不一定是一一对应的,有些文件只会在内存当中存在,而不会在磁盘当中存在。
🍄匿名管道
- 匿名管道(Anonymous Pipes)是一种进程间通信的机制,它可以在同一台计算机上的两个进程之间传递数据,且不需要借助于文件系统。匿名管道是一种半双工通信方式,只能在具有父子关系的进程之间使用。
- 在使用匿名管道时,创建一个管道,然后创建两个进程。其中一个进程称为管道的“写端”,另一个进程称为管道的“读端”。写端进程将数据写入管道,而读端进程则从管道中读取数据。管道是一个字节流,没有记录分隔符或消息边界,因此读取端需要自行解析数据。
- 匿名管道是一种简单、快速的进程间通信方式,适用于进程间需要高效、低延迟数据交换的场景。
👿pipe函数
pipe函数用于创建匿名管道,pip函数的函数原型如下:
int pipe(int pipefd[2]);
pipe函数的参数是一个输出型参数,数组pipefd用于返回两个指向管道读端和写端的文件描述符:
数组元素 | 含义 |
---|---|
pipefd[0] | 管道读端的文件描述符 |
pipefd[1] | 管道写端的文件描述符 |
pipe函数调用成功时返回0,调用失败时返回-1。
👿匿名管道的使用步骤
我们写一个代码试一试:Makefile
mypipe:mypipe.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -f mypipe
#include<iostream>
#include<unistd.h>
#include<assert.h>
using namespace std;
int main()
{
int pipefd[2]={0};
int n= pipe(pipefd);
assert(n=-1); //debug下有效
(void)n; //这个就是代表n被使用过,没其他意思
cout<<"pipefd[0]: "<<pipefd[0]<<endl; //3
cout<<"pipefd[1]: "<<pipefd[1]<<endl; //4
return 0;
}
因为管道需要一个读取端和一个写入端,所以创建一个管道时需要使用长度为2的整型数组来存储管道的读取端和写入端。在这个数组中,第一个元素 pipefd[0]
表示管道的读取端,第二个元素 pipefd[1]
表示管道的写入端。所以是pipefd[2]
🌰例子: 子进程写入数据,父进程读出数据
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
int fd[2] = { 0 };
if (pipe(fd) < 0){ //使用pipe创建匿名管道
perror("pipe");
return 1;
}
pid_t id = fork(); //使用fork创建子进程
if (id == 0){
//child
close(fd[0]); //子进程关闭读端
//子进程向管道写入数据
const char* msg = "hello father, I am child...";
int count = 20;
while (count--){
write(fd[1], msg, strlen(msg));
sleep(1);
}
close(fd[1]); //子进程写入完毕,关闭文件
exit(0);
}
//father
close(fd[1]); //父进程关闭写端
//父进程从管道读取数据
char buff[64];
while (1){
ssize_t s = read(fd[0], buff, sizeof(buff));
if (s > 0){
buff[s] = '\0';
printf("child send to father:%s\n", buff);
}
else if (s == 0){
printf("read file end\n");
break;
}
else{
printf("read error\n");
break;
}
}
close(fd[0]); //父进程读取完毕,关闭文件
waitpid(id, NULL, 0);
return 0;
}
🍄管道读写规则
pipe2函数与pipe函数类似,也是用于创建匿名管道,其函数原型如下:
int pipe2(int pipefd[2], int flags);
该函数创建一个管道,并将其两端文件描述符分别存储在
pipefd[0]
和pipefd[1]
中。flags
参数可以用来设置管道的行为。
pipe2()
函数与pipe()
函数的区别在于,pipe2()
函数增加了flags
参数,可以用于设置管道的行为。flags
可以取以下值:
O_CLOEXEC
:将管道的文件描述符设置为close-on-exec
,即当调用exec()
函数执行一个新程序时,该管道将被关闭。O_DIRECT
:启用直接IO模式,让数据在传输时避免从内核缓存区复制,直接在应用层和硬件之间进行数据传输,可以提高效率,但只能传输块大小为512字节的倍数的数据。O_NONBLOCK
:启用非阻塞IO模式,当读取管道中没有数据时,read()
函数将立即返回0而不是阻塞等待数据。
- 当没有数据可读时
- 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将不再保证写入的原子性。
🍄管道的特点:
1.只能用于具有共同祖先的进程(具有亲缘关系的进程)之间进行通信;通常,一个管道由一个进程创建,然后该进程调用fork,此后父、子进程之间就可应用该管道。
2.管道内部自带同步与互斥机制。
- 问题思考:
- 如果一个进程向管道写入数据时,另一个进程正在从管道中读取数据,那么这两个操作可能会发生冲突,导致数据读写错误或数据丢失。
- 当出现同一时刻有多个进程对同一管道进行操作的情况,会导致同时读写、交叉读写以及读取到的数据不一致等问题。
- 解决方法:
- Linux中的管道内部采用了同步与互斥机制来保证数据传输的正确性。具体来说,管道内部使用了一个缓冲区,当一个进程写入数据时,它会先将数据存储到缓冲区中,等待另一个进程从缓冲区中读取数据。
- 在这个过程中,管道内部使用了同步机制来确保缓冲区中的数据不会被同时读写,从而避免了数据的读写冲突。同时,管道内部使用了互斥机制来确保只有一个进程可以访问缓冲区中的数据,从而避免了数据的竞争和混乱。
临界资源:一次只允许一个进程使用的资源,称为临界资源。管道在同一时刻只允许一个进程对其进行写入或是读取操作,因此管道也就是一种临界资源。
临界资源是需要被保护的,若是我们不对管道这种临界资源进行任何保护机制,那么就可能出现同一时刻有多个进程对同一管道进行操作的情况,进而导致同时读写、交叉读写以及读取到的数据不一致等问题。
为了避免这些问题,内核会对管道操作进行同步与互斥:
- 同步是指协调多个进程的执行顺序,以便它们按照一定的顺序或时序进行访问和操作。同步可以通过信号量、事件等方式来实现,使得多个进程之间按照一定的顺序进行访问和操作,从而避免数据竞争、死锁等问题的出现。
- 互斥是指在同一时刻只允许一个进程或线程访问共享资源。多个进程不能同时共享资源。
3.管道提供流式服务。
对于进程A写入管道当中的数据,进程B每次从管道读取的数据的多少是任意的,这种被称为流式服务,与之相对应的是数据报服务:
4.一般而言,进程退出,管道释放,所以管道的生命周期随进程。
管道本质上是通过文件进行通信的,也就是说管道依赖于文件系统,那么当所有打开该文件的进程都退出后,该文件也就会被释放掉,所以说管道的生命周期随进程。
5.管道是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道。
🍎在数据通信中,数据在线路上的传送方式可以分为以下三种:
- 单工通信(Simplex Communication):单工模式的数据传输是单向的。通信双方中,一方固定为发送端,另一方固定为接收端。
- 半双工通信(Half Duplex):半双工数据传输指数据可以在一个信号载体的两个方向上传输,但是不能同时传输。
- 全双工通信(Full Duplex):全双工通信允许数据在两个方向上同时传输,它的能力相当于两个单工通信方式的结合。全双工可以同时(瞬时)进行信号的双向传输。
管道是半双工的,数据只能向一个方向流动,需要双方通信时,需要建立起两个管道。
🍄管道的四种特殊情况
在使用管道时,可能出现以下四种特殊情况:
- 写端进程不写,读端进程一直读,那么此时会因为管道里面没有数据可读,对应的读端进程会被挂起,直到管道里面有数据后,读端进程才会被唤醒。
- 读端进程不读,写端进程一直写,那么当管道被写满后,对应的写端进程会被挂起,直到管道当中的数据被读端进程读取后,写端进程才会被唤醒。
- 写端进程将数据写完后将写端关闭,那么读端进程将管道当中的数据读完后,就会继续执行该进程之后的代码逻辑,而不会被挂起。
- 读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么操作系统会将写端进程杀掉。
其中前面两种情况就能够很好的说明,管道是自带同步与互斥机制的,读端进程和写端进程是有一个步调协调的过程的,不会说当管道没有数据了读端还在读取,而当管道已经满了写端还在写入。读端进程读取数据的条件是管道里面有数据,写端进程写入数据的条件是管道当中还有空间,若是条件不满足,则相应的进程就会被挂起,直到条件满足后才会被再次唤醒。
第三种情况也很好理解,读端进程已经将管道当中的所有数据都读取出来了,而且此后也不会有写端再进行写入了,那么此时读端进程也就可以执行该进程的其他逻辑了,而不会被挂起。
第四种情况,既然管道当中的数据已经没有进程会读取了,那么写端进程的写入将没有意义,因此操作系统直接将写端进程杀掉。而此时子进程代码都还没跑完就被终止了,属于异常退出,那么子进程必然收到了某种信号。
那我们就来验证一下他到底收到了几号信号。
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
#include <cstring>
#include <cstdio>
int main() {
int fd[2];
pipe(fd);
pid_t id = fork();
if (id == 0){
//child
close(fd[0]); //子进程关闭读端
//子进程向管道写入数据
const char* msg = "hello father, I am child...";
int count = 10;
while (count--){
write(fd[1], msg, strlen(msg));
sleep(1);
}
close(fd[1]); //子进程写入完毕,关闭文件
exit(0);
}
//father
close(fd[1]); //父进程关闭写端
close(fd[0]); //父进程直接关闭读端(导致子进程被操作系统杀掉)
int status = 0;
waitpid(id, &status, 0);
// 判断子进程是否收到了信号,并打印出来
if (WIFSIGNALED(status)) {
printf("Child process exited due to signal %d\n", WTERMSIG(status));
}
return 0;
}
在这段代码中,我们使用WIFSIGNALED宏判断子进程是否因为收到了信号而终止。如果是的话,使用WTERMSIG宏获取信号的编号,并打印出来。在这个例子中,由于子进程直接关闭了写端,因此操作系统会向子进程发送SIGPIPE信号,导致子进程异常退出。
🍄匿名管道的缺陷
匿名管道是一种用于实现进程间通信的管道,但它也存在一些不足之处:
只能用于相关进程间通信:匿名管道只能用于直接相关的进程之间的通信,即父进程和子进程之间的通信。如果需要在不相关的进程之间进行通信,就需要使用其他的进程间通信方式,如命名管道、套接字等。
只能传递一次性数据:匿名管道只能传递一次性数据,即一旦一个进程读取了管道中的数据,这些数据就会被删除,不能被其他进程再次读取。如果需要传递多次数据,就需要重新创建一个新的匿名管道。
无法在文件系统中查找:匿名管道不像命名管道那样在文件系统中有一个唯一的名称,因此无法在文件系统中查找。这也使得在使用匿名管道时需要注意,以免在不同的进程之间出现管道名称冲突的情况。
🌈命名管道
为了改善上面匿名管道的缺陷之处,出现了命名管道。
- 命名管道是一种在文件系统中创建的特殊文件,用于实现进程间通信。它也被称为FIFO(First-In-First-Out),因为它遵循先进先出的原则,类似于一个队列。
- 与匿名管道不同,命名管道具有一个独特的名字,它们可以通过这个名字被多个进程共享和访问。这使得命名管道更加灵活,可以被用于在不同的进程之间传输数据,即使这些进程不是直接相关的。
- 在Linux和Unix系统中,使用mkfifo命令可以创建一个命名管道。一旦创建,进程可以像读写普通文件一样,使用打开和关闭文件的方式来访问命名管道。当进程向管道写入数据时,该数据将被缓存到管道中,并可以被其他进程读取。这种方式使得命名管道成为一种非常常见的进程间通信方式之一。
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int mkfifo(const char* pathname, mode_t mode);
创建出来的文件的类型是p
,代表该文件是命名管道文件。
🍄创建命名管道
在程序中创建命名管道使用mkfifo函数,mkfifo函数的函数原型如下:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int mkfifo(const char* pathname, mode_t mode);
参数说明:
pathname
:命名管道的路径,通常是一个文件路径,例如/tmp/my_pipe
。mode
:命名管道的访问权限,例如 0666 表示所有用户都有读写权限。
mkfifo函数的返回值。
- 命名管道创建成功,返回0。
- 命名管道创建失败,返回-1。
#include<iostream>
using namespace std;
#include<sys/types.h>
#include<sys/stat.h>
#define FILE_NAME "myfifo"
int main()
{
if (mkfifo(FILE_NAME, 0666) < 0)
{
cerr << "mkfifo error" << endl;
return 1;
}
//create success
cout << "hello world" << endl;
}
实际创建出来文件的权限为:mode&(~umask)。umask的默认值一般为0002,当我们设置mode值为0666时实际创建出来文件的权限为0664。若想创建出来命名管道文件的权限值不受umask的影响,则需要在创建文件前使用umask
函数将文件默认掩码设置为0。
umask(0); //将文件默认掩码设置为0
🍄命名管道的打开规则
- 如果当前打开操作是为读而打开FIFO时
- O_NONBLOCK disable:阻塞直到有相应进程为写而打开该FIFO。
- O_NONBLOCK enable:立刻返回成功。
- 如果当前打开操作是为写而打开FIFO时
- O_NONBLOCK disable:阻塞直到有相应进程为读而打开该FIFO。
- O_NONBLOCK enable:立刻返回失败,错误码为ENXIO。
🍄用命名管道实现server & client间的通信
实现服务端(server)和客户端(client)之间的通信之前,我们需要先让服务端运行起来,让服务端运行后创建一个命名管道文件,然后再以读的方式打开该命名管道文件,之后服务端就可以从该命名管道当中读取客户端发来的通信信息了。
- 注意:如果一个进程已经把文件创建好了,那么另一个进程不需要创建这个文件了,直接用就可以了。
server客户端代码如下:
//读取 #include"comm.h" using namespace std; int main() { umask(0);//将文件默认掩码设置为0 if (mkfifo(IPC_PATH, 0600) != 0) { cerr << "mkfifo error" << endl; return 1; } int pipefd = open(IPC_PATH, O_RDONLY);//以读的方式打开命名管道文件 if (pipefd < 0) { cerr << "open fifo error" << endl; return 2; } //正常的通信过程 #define NUM 1024 char buffer[NUM]; while (true) { ssize_t s = read(pipefd, buffer, sizeof(buffer) - 1); if (s > 0) { buffer[s] = '\0';//手动设置'\0',便于输出 cout << "客户端->服务器# " << buffer << endl;//输出客户端发来的信息 } else if (s == 0) { cout << "客户退出啦,我也退出啦" << endl; break; } else { //do nothing cout << "read: " << strerror(errno) << endl; break; } } close(pipefd); cout << "服务端退出啦" << endl; unlink(IPC_PATH);//通信完毕后,自动帮我们删除管道文件 return 0; }
client服务端代码如下:
//写入 #include"comm.h" using namespace std; int main() { int pipefd = open(IPC_PATH, O_WRONLY);//以写的方式打开命名管道文件 if (pipefd < 0) { cerr << "open: " << strerror(errno) << endl; return 1; } #define NUM 1024 char line[NUM]; while (true) { printf("请输入你的消息# "); fflush(stdout); memset(line, 0, sizeof(line));//每次读取之前将line清空 //fgets -》C语言接口 -》line结尾自动添加\0 if (fgets(line, sizeof(line), stdin) != nullptr) { //abcd\n\0 line[strlen(line) - 1] = '\0';//除去回车后多余的\0 write(pipefd, line, strlen(line)); } else { break; } } close(pipefd);//通信完毕,关闭命名管道文件 cout << "客户端退出啦" << endl; }
makefile代码如下:
.PHONY:all all: clientfifo serverfifo clientfifo:clientfifo.cpp g++ -Wall -o $@ $^ -std=c++11 serverfifo:serverfifo.cpp g++ -Wall -o $@ $^ -std=c++11 .PHONY:clean clean: rm -f clientfifo serverfifo .fifo
头文件comm.h代码如下:
#pragma once #include<iostream> #include<string> #include<sys/types.h> #include<sys/stat.h> #include<fcntl.h> #include<unistd.h> #include<cstring> #include<cerrno> #include<cstdio> #define IPC_PATH "./.fifo"
我们操作一下让客户端和服务器进行交流:
🍄命名管道和匿名管道的区别
命名管道和匿名管道是两种不同类型的管道,用于进程间通信。
- 匿名管道:
- 匿名管道是一种无名的管道,只能用于具有亲缘关系的进程间通信(父子进程或兄弟进程)。
- 它只能单向传输数据,也就是只能从一个进程的输出流传输到另一个进程的输入流。
- 匿名管道在创建时需要调用
pipe()
系统调用,并通过文件描述符进行访问。
2.命名管道:
- 命名管道是一种有名字的管道,可以被不相关的进程之间使用。
- 它可以双向传输数据,允许多个进程同时读写管道。
- 命名管道在创建时需要调用
mkfifo()
系统调用,并在文件系统中创建一个特殊的文件,用于标识这个管道。
总的来说,匿名管道主要用于单向、有亲缘关系的进程间通信,而命名管道则可以实现双向、不相关进程之间的通信。