🔥 博客主页: 我要成为C++领域大神
🎥系列专栏:【C++核心编程】 【计算机网络】 【Linux编程】 【操作系统】
❤️感谢大家点赞👍收藏⭐评论✍️本博客致力于分享知识,欢迎大家共同学习和交流。
管道实现的原理
进程的0~3G用户空间是独占内存,此内存数据不允许多进程共享。而3~4G的内核层PCB控制块在进程之间是共享的。所以要想实现进程间的通信,需要利用进程之间共享的内核层来传输数据。
匿名管道
在接触匿名管道之前,我们需要先了解一下pipe
函数
pipe
函数
pipe()
函数用于创建一个匿名管道。允许一个进程将数据写入管道,另一个进程从管道读取数据。
函数原型
#include <unistd.h>
int pipe(int fds[2]);
参数
fds
:这是一个包含两个整数的数组,用于存储管道的文件描述符。
fds[0]
:读端,进程从这个文件描述符读取数据。
fds[1]
:写端,进程向这个文件描述符写入数据。
返回值
成功时返回
0
。失败时返回
-1
并设置errno
以指示错误类型。
工作原理
pipe()
在内核中创建一个管道,这个管道由一个内存缓冲区和两个文件描述符(读端和写端)组成。
fds[0]
用于从管道读取数据,fds[1]
用于向管道写入数据。
数据通过管道从写端流向读端,实现进程间的单向通信。
了解了pipe函数之后,我们知道,pipe实现的原理是两个进程共用读写的文件描述符。而什么样的进程共用文件描述符呢?
答:调用 fork()
创建一个子进程。子进程会继承父进程的文件描述符,包括管道的文件描述符。
pipe()
函数使用文件描述符和进程间的继承机制,所以只有具有亲缘关系的进程才能使用管道通信。
pipe函数要在fork之前调用,确保只有父进程创建了管道。
匿名管道的原理
在进程的内核块创建管道缓冲区,由循环队列(circular buffer)实现。遵循先进先出,具有一定的暂存能力。
管道缓冲区的大小是4k,老版本Ubuntu的管道大小都是64k
管道通过pipe
函数创建的读写文件描述符实现向管道写入和读取数据。
管道使用前要确定通信方式,管道要单工使用,让进程关闭无用的描述符
当一个进程向管道写数据时,内核将数据存入缓冲区;当另一个进程从管道读数据时,内核从缓冲区取出数据。
(系统会自动为文件描述符分配当前最小未被使用数字。)
管道不需要我们手动进行回收,每一个指向管道的描述符都是一个引用计数,当管道引用计数为0,系统会自行释放管道空间。
通信方式
单工(Simplex)
数据传输只能在一个方向进行,任意一个设备非读即写
[发送方] ---> [接收方]
半双工(Half-Duplex,可调节单工)
数据传输可以在两个方向进行,但同一时刻只能有一个方向的数据传输。
在一个时刻,进程A可以发送数据给进程B,而在另一个时刻,进程B可以发送数据给进程A。
时刻1: [A] ---> [B]
时刻2: [A] <--- [B]
全双工(Full-Duplex)
数据传输可以在两个方向同时进行,即双方可以同时发送和接收数据。
[A] <--> [B]
匿名管道通信的四种特殊情况
管道为空:写端未写数据,读端读取管道时,读阻塞。
管道为满:读端未读数据,写端写管道时,写阻塞。
写端关闭:如果管道有数据,读端读取完管道剩余数据后再一次读取返回0(表示EOF),管道为空,直接返回0。
管道读端关闭:写端尝试向管道写数据,系统会向写端进程发送 SIGPIPE
信号,杀死写端进程。
面试题:
在编写一个服务端-客户端模型时,客户端异常退出,服务端也异常退出。
原因
当客户端异常退出时,客户端关闭了它的读端文件描述符。此时,如果服务端尝试向管道或套接字写数据,会触发 SIGPIPE
信号,默认行为是终止进程,因此服务端也会异常退出。
解决方案
可以在 send()
函数中使用 MSG_NOSIGNAL
标志位,来忽略 SIGPIPE
信号,从而避免服务端异常退出
send(int sockfd, buffer, len, MSG_NOSIGNAL);
使用 MSG_NOSIGNAL
标志位,写操作在对端关闭连接的情况下不会触发 SIGPIPE
信号,而是会返回 EPIPE
错误,程序可以根据这个错误码进行处理而不是直接终止。
管道的特点
1、流通性(传输介质)
2、方向性
3、暂存能力
匿名管道的缺点:
1、有亲缘限制,只有亲缘进程能用其完成通信
2、默认情况下,管道使用无格式字节流传输,需要用户自行封装
管道这种进程间通信方式,效率较好。在所有unix或Linux系统,管道都是可以使用的,但是其他系统是未知的,要看具体操作系统是否支持。
下面是使用匿名管道进行通信的demo程序
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>
#include <string.h>
#include <sys/fcntl.h>
#include <sys/wait.h>
#define MSG "Can u hear Me?"
int main()
{
int fds[2];
pipe(fds);
pid_t pid;
pid=fork();
if(pid>0){/* 父进程工作区 */
close(fds[0]);//管道是单工工作的,非读即写。
/* 让父进程执行写操作,所以需要关闭读的文件描述符 */
write(fds[1],MSG,strlen(MSG));
printf("Parent Process PID:%d Send MSG:%s\n",getpid(),MSG);
pid_t zpid=wait(NULL);
printf("Zombie Process ZPID:%d 回收成功\n",zpid);
close(fds[1]);
}else if(pid==0){/* 子进程工作区 */
close(fds[1]);/* 让子进程执行读操作,所以关闭写操作的文件描述符 */
char buffer[1024];
bzero(buffer,sizeof(buffer));
read(fds[0],buffer,sizeof(buffer));
printf("Child Process PID:%d Get MSG:%s\n",getpid(),buffer);
close(fds[0]);
}else{
}
return 0;
}
运行结果
FIFO 有名管道
与匿名管道不同的是, 有名管道由 mkfifo函数
或命令创建,存在于文件系统中,可以在不相关的进程之间使用。 而匿名管道仅在具有亲缘关系的进程之间有效。
使用mkfifo
命令创建管道:
在终端下有名管道显示的是黑底,这也说明了它的特殊性。
当我们尝试使用vim
尝试编辑时:
vim中只有一个光标,无法移动,无法插入和命令。只能关闭终端。
管道文件没有存储能力,无法编辑。本质是一个指针,没有磁盘实体,指向内核层的管道缓冲区。
有名管道的原理
mkfifo创建的有名管道文件本质是一个指针,它指向管道缓冲区 。
通过管道文件的文件描述符来向管道缓冲区写入数据和读取数据。
管道文件与4k缓冲区是绑定的,管道文件被删除,立刻清理释放缓冲区。
当管道文件被删除,系统会立即释放清理管道缓冲区。
有名管道通信使用的也是单工方式,所以在需要确定读端和写端。
下面是使用有名管道实现进程间通信的demo程序:
写端程序:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>
#include <string.h>
#include <sys/fcntl.h>
#define MSG "hello process"
int main()
{
/* 打开管道文件 */
printf("******\n");
int wfd=open("test_fifo",O_WRONLY);
printf("------\n");
write(wfd,MSG,strlen(MSG));
printf("Process %d 正在向测试管道写入数据\n",getpid());
close(wfd);
return 0;
}
读端程序:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>
#include <string.h>
#include <sys/fcntl.h>
int main()
{
/* 打开管道文件 */
int rfd=open("test_fifo",O_RDONLY);
char buf[1024];
bzero(buf,sizeof(buf));
read(rfd,buf,sizeof(buf));
printf("Process %d 读端收到数据:%s\n",getpid(),buf);
return 0;
}
运行结果:
当我们仅在一个终端运行写端时,我们会发现,终端只打印出了管道文件打开前的日志,而没有打印"------"。
printf("******\n");
int wfd=open("test_fifo",O_WRONLY);
printf("------\n");
在介绍匿名管道时,我们说了匿名管道通信的四种特殊情况,而这四种特殊情况同样也适用于有名管道。其中当管道读端关闭, 写端尝试向管道写数据,系统会向写端进程发送 SIGPIPE
信号,杀死写端进程。
但是我们的demo程序不仅打开管道读端,写端也未被杀死,为什么?
答:因为没有读端打开管道,写端在open
堵塞了。
访问有名管道,必须满足两种权限,读写,才可以成功的打开和使用管道,如果只有一种访问权限,会阻塞等待另一种。 例如,如果进程 A 以 O_WRONLY 打开管道,而没有进程以 O_RDONLY 打开管道,进程 A 会阻塞,直到有进程以 O_RDONLY 打开管道。
当我们运行读端后:成功实现通信。
单进程访问:如果一个进程以 O_RDWR 打开管道,则不需要等待其他进程,可以直接打开和使用管道。 (但是单进程使用管道没有意义)
有名管道使用的特殊情况
特殊情况
- 权限要求:有名管道需要同时满足读和写两种访问权限才能成功打开并使用。否则,访问管道的进程将阻塞等待。
- 多读序列:如果在一个进程中有多个读序列,阻塞只会对第一个读序列生效,其他读序列会被设置为非阻塞。
多线程为进程读取数据,一个线程阻塞等待即可,其他线程设置非阻塞立即返回,执行其他任务,避免阻塞开销。
原子使用管道和非原子使用管道
原子使用管道
特点
传输速度较慢:每次写入的大小必须小于等于管道的原子大小。
数据完整性好:系统会确保一次完整的写入操作不会被分割,即数据不会被其他写入操作干扰。
工作原理
写操作:
如果写入的数据块大小小于等于管道的原子大小,数据将直接写入管道。
如果写入的数据块大小大于管道的原子大小,系统会将数据块分割,并逐块写入,确保每次写入操作不会被分割。
读操作:
读取操作会按照写入的顺序读取数据,确保数据的完整性。
假设管道的原子大小为 4096
字节:
写入 3000 字节
原子写操作:因为 3000 字节小于 4096 字节,系统会保证这次写入操作的原子性。数据将直接写入管道,其他进程的写入操作不会插入或干扰这次写入。
写入 6000 字节
非原子写操作:因为 6000 字节大于 4096 字节,系统不能保证这次写入操作的原子性。写入操作可能被分为多次进行,且在此过程中,其他进程的写入操作可能会插入。
可能会发生的情况:系统将 6000 字节的数据分为两部分,例如:前 4096 字节和后 1904 字节。两个部分的写入操作之间可能会有其他进程的数据写入。
非原子使用管道
特点
传输效率高:写操作不受数据块大小的限制,写入操作立即进行。
数据完整性无法保证:数据可能会被其他写入操作干扰,导致数据完整性无法保证。
工作原理
写操作:
写操作立即进行,不管写入的数据块大小。
系统不会对数据块进行分割或重组,直接写入管道。
读操作:
读取操作按照管道中数据的顺序读取,可能会读取到不完整的数据块。
需要额外的机制来检查和保证数据的完整性。
优缺点
原子使用管道:传输速度较慢,但数据完整性好,适用于需要保证数据完整性的场景。
非原子使用管道:传输效率高,但数据完整性无法保证,适用于对数据完整性要求不高的场景。
匿名管道和有名管道在默认情况下都采用原子操作来确保数据的完整性。