Pipe 管道
学习文章:https://blog.csdn.net/skyroben/article/details/71513385
进程间的通信
每个进程各自有不同的用户地址空间,任何一个进程的全局变量在另一个进程中都看不到,所以进程之间要交换数据必须通过内核,在内核中开辟一块缓冲区,进程A把数据从用户空间拷到内核缓冲区,进程B再从内核缓冲区把数据读走,内核提供的这种机制称为进程间通信。
不同进程间的通信本质:进程之间可以看到一份公共资源;而提供这份资源的形式或者提供者不同,造成了通信方式不同,而 pipe就是提供这份公共资源的形式的一种。
进程间数据交换机制(IPC).
主要区别在于可访问性模型
- 管道:“相关”进程
- FIFO(命名管道):
- 在文件系统中有名称
- 可访问性:用户/组所有权+文件权限
对于这两种机制,数据都具有进程持久性
- 当所有进程关闭指向管道/FIFO的fd时,未读数据将被丢弃
管道是常用的外壳特征;如
ls | wc -l
执行这条指令的时候,shell会有以下的步骤:
- 使用fork()创建两个执行进程ls和wc
- 将ls的标准输出和wc的标准输入连接到管道
管道中的数据保存在内核内存中
管道的特点
1.管道只允许具有血缘关系的进程间通信,如父子进程间的通信。
2.管道只允许单向通信。
3.管道内部保证同步机制,从而保证访问数据的一致性。
4.面向字节流
5.管道随进程,进程在管道在,进程消失管道对应的端口也关闭,两个进程都消失管道也消失。
:::tips
-
管道是字节流
- 数据是不受限制的字节序列
- 可以读取任意块的数据,而不管写的大小
- 数据依次通过管道(没有随机访问)
-
管道是单向的
- 管道有一个读端(fd[0])和一个写端(fd[1])
-
管道的容量有限 PIPE_BUF
- 限制因系统而异
-
Linux下的管道容量:
- Linux <= 2.6.10: 4096字节
- Linux >= 2.6.11: 65,536字节
- **fcntl(fd, F_SETPIPE_SZ, size)**可用于更改管道容量(自Linux 2.6.35起)
-
应用程序的设计不应该关心容量
- 为了防止写入阻塞,请确保始终有一个活动的读取器
:::
管道的缓存区
:::info
管道的缓存区的大小是有限的,PIPE_BUF.
- 为了防止写入阻塞,请确保始终有一个活动的读取器
-
如果写入的数据小于等于PIPE_BUF,字节保证是原子的
- 如果没有足够的空间写入所有字节,则不写入任何字节
- 调用程序阻塞,直到有空间在一个操作中写入所有字节
- 字节不会与其他进程的写入混杂在一起
- PIPE_BUF == 4096;在某些系统上低至512
-
写入> PIPE_BUF字节可能不是原子的
- 数据可以分成/传送成更小的片段
- Write()在传输完所有数据后完成
:::
实例验证计算管道的容量
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>
#include <sys/wait.h>
int main()
{
int fd[2];
int ret = pipe(fd);
if (ret == -1)
{
perror("pipe error\n");
return 1;
}
pid_t id = fork();
if (id == 0)
{//child
int i = 0;
close(fd[0]);
char *child = "I am child!";
while (++i)
{
write(fd[1], child, strlen(child) + 1);
printf("pipe capacity: %ld\n", i*(strlen(child) + 1));
}
close(fd[1]); //缓存区满了后就关闭写端
}
else if (id>0)
{//father
close(fd[1]); //关闭写端,但是不读取管道里的数据
waitpid(id, NULL, 0);
}
else
{//error
perror("fork error\n");
return 2;
}
return 0;
}
可以看到写到65520之后管道堵塞了,而65536即为64K大小即为管道的容量(由于代码问题,少统计一次数据)。
创建一个管道
管道是由调用pipe函数来创建
#include<unistd.h>
int pipe(int fd[2]);
//返回:成功返回0,出错返回-1
:::info
fd参数返回两个文件描述符,
- fd[0]指向管道的读端,
- fd[1]指向管道的写端。
fd[1]的输出是fd[0]的输入。
:::
管道如何实现进程间的通信
:::info
- 父进程创建管道,得到两个文件描述符指向管道的两端。
- 父进程fork出子进程,子进程也有两个文件描述符指向同一个管道的两端。
- 父进程关闭fd[0],子进程关闭fd[1],即 父进程关闭管道读端,子进程关闭管道写端。(因为管道只支持单向通信)。父进程可以往管道里写,子进程可以往管道里读,管道是用环形队列实现的。数据从写端流入,从读端流出,就这样形成了进程间的通信。
当前,相反也是可以的,父进程关闭写端,子进程关闭读端。要保证不能父进程和子进程同时在管道上读或写,因为管道是单向的。
:::
fork()之后,每个进程关闭未使用的文件描述符
代码实现管道通信
- 假设我们想要将数据从子进程传输到父进程,即子进程写入,父进程读取。那么就要保证父进程关闭写端,子进程关闭读端;然后子进程往管道里写入,父进程往管道里读取数据。
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<string.h>
int main(){
int fd[2];
int ret = pipe(fd);
if(ret == -1){
perror("pipe");
exit(EXIT_FAILURE);
}
pid_t childPid = fork();
if(childPid == -1){
perror("fork");
exit(EXIT_FAILURE);
}
else if(childPid == 0){
int i = 0;
close(fd[0]); //子进程关闭fd[0];关闭读端
char *child = "I am child";
while(i<5){
write(fd[1],child,strlen(child)+1);
sleep(2);
i++;
}
}
else{ //parent
close(fd[1]);
char msg[100];
int j = 0;
while(j < 5){
memset(msg,'\0',sizeof(msg));
ssize_t s = read(fd[0],msg,sizeof(msg));
if(s > 0){
msg[s-1] = '\0';
}
printf("%s\n",msg);
j++;
}
}
return 0;
}
运行结果:
每隔两秒钟,打印出一次I am child.
- 想要将数据从父进程传输到子进程. 那么过程就正好相反,父进程写入,子进程读出. 父进程关闭读端.子进程关闭写端
管道读取数据的四种的情况
:::info
只有当所有写描述符都关闭时,读取器才会看到EOF
只有当所有读描述符都关闭时,写入器才能获得EPIPE + SIGPIPE
:::
读端不读,写端一直写
如果持续这样,会导致管道里面写满数据,从而导致再次write的时候导致堵塞,直到管道里有空位置才写入数据并返回。测试PIPO_BUF的案例,就是这样
写端不写,但是读端一直读
管道里的数据被读取完之后,再次read的时候就会堵塞,直到管道里有数据写入,才能重新读取数据。
读端一直读,且fd[0]保持打开,而写端写了一部分数据不写了,并且关闭fd[1]
:::info
如果一个管道读端一直在读数据,而管道写端的引⽤计数⼤于0决定管道是否会堵塞,引用计数大于0,只读不写会导致管道堵塞。
读端一直读,但是写端突然写一半不写了,此时读端将会收到EOF的信号
:::
父节点通过管道将argv[1]字符串发送给子节点
读端读了一部分数据,不读了且关闭fd[0],写端一直在写且f[1]还保持打开状态
:::info
一旦读端的fd[0]被关闭,而子进程继续想管道里写端写入数据,那么子进程会受到信号SIGPIPE,通常会导致进程异常终止。
写端一直写,读端读一半不读了,此时写端会生成SIGPIPE信号,并出现EPIPE的错误。
:::
:::success
如果pipe没有读取器,则通知写入器
- write()导致SIGPIPE信号的生成 (Kill 13号)
- 默认动作:终止进程
- 可以使处置“忽略”,在这种情况下…
- write()失败,出现EPIPE错误
:::
实例:
#include<stdio.h>
#include<unistd.h>
#include<string.h>
#include<sys/wait.h>
int main(){
int fd[2];
int ret = pipe(fd);
if(ret == -1){
perror("pipe");
return 1;
}
pid_t childPid = fork();
if(childPid == 0){
int i = 0;
close(fd[0]);
char *child = "I am child.";
while(i < 10){
write(fd[1],child,strlen(child) + 1);
sleep(2);
i++;
}
}
else if(childPid > 0){
int j = 0;
close(fd[1]);
char msg[100];
int status = 0;
while(j < 5){
memset(msg,'\0',sizeof(msg));
ssize_t s = read(fd[0],msg,sizeof(msg));
if(s > 0){
msg[s - 1] = '\0';
}
printf("%s\n",msg);
j++;
}
//写端还在继续写,此时关闭读端
close(fd[0]);
pid_t ret = waitpid(childPid,&status,0);
printf("existingsingle(%d), exit(%d)\n",status & 0xff,(status>>8) & 0xff);
//低八位存放该子进程退出时是否收到信号
//此低八位子进程正常退出时,退出码是多少
}
else{
perror("fork");
return 2;
}
return 0;
}
使用kill -l 查看13号信号,可以知道13号信号代表SIGPIPE。
总结
:::info
- 如果一个管道的写端一直在写,而读端的引⽤计数是否⼤于0决定管道是否会堵塞,引用计数大于0,只写不读再次调用write会导致管道堵塞;
- 如果一个管道的读端一直在读,而写端的引⽤计数是否⼤于0决定管道是否会堵塞,引用计数大于0,只读不写再次调用read会导致管道堵塞;
- 当他们的引用计数等于0时,只读不写会导致读端收到EOF的错误。
- 当他们的引用计数等于0时,只写不读会导致写端的进程收到一个SIGPIPE信号,导致进程终止,只写不读会导致read返回0,就像读到⽂件末尾⼀样。
:::
将过滤器连接到管道
Filter ==从标准输入读取和/或写入标准输出的程序
假设我们希望过滤器读取或写入管道
ls | wc -l
将会出现什么问题?
:::info
- 通常,文件描述符0、1和2已经在使用中
- Pipe()将使用另外2个描述符 【STDOUT_FILENO=fd[1]】,【STDIN_FILENO=fd[0]】
- 解决方案是使用dup(fd)(或similar) [复制函数]
- 将过滤器连接到管道的写端
:::
int fd[2];
pipe(fd);
close(STDOUT_FILENO); //关闭fd[1]
dup(fd[1]);
- 由于我们不再需要fd[1],我们应该关闭它:
close(fd[1]);
但是,如果描述符0在pipe()和(第一个)close()之间……?
:::info
- 使用**dup2(oldfd, newfd)**复制函数来解决
- 关闭newfd,如果它是打开的
- 使newfd成为oldfd的副本
- 前两步是原子的;防止多线程应用程序中的FD竞争
- 如果oldfd == newfd什么都不做
- 用dup2()替换对close()和dup()的调用
:::
dup2(fd[1],STDOUT_FILENO); //关闭fd1,并重新打开绑定到管道写端的fd1
close(fd[1]);
- 但是,如果描述符0和1在pipe()之前被关闭:
pipe ( fd ); /* Uses FD 0 and FD 1 *
//假设使用FD 1的管道的写端
dup2 (fd[1], STDOUT_FILENO ); /* dup2 (1 ,1) [no -op] */
close (fd[1]); /* close (1) [!!] */
- dup2()没有做任何事情,close()关闭了我们唯一的描述符
- 解决方案:如果pipe()使用了我们想要的描述符,则不需要dup2() + close():
pipe(fd);
if (fd[1] != STDOUT_FILENO ) {
dup2(fd[1], STDOUT_FILENO );
close(fd[1]);
}
Exercise
实例: 实现 ls | wc -l (省略错误检查)
ls | wc -l
#include<unistd.h>
#include<stdio.h>
#include<string.h>
int main(){
int fd[2];
int ret = pipe(fd);
if(ret == -1){
perror("pipe");
return 1;
}
pid_t childPid = fork();
if(childPid == 0){
if(fd[1] != STDOUT_FILENO){
dup2(fd[1],STDOUT_FILENO);
close(fd[1]);
}
execlp("ls","ls",(char*)NULL);
perror("execlp ls");
}
else if(childPid > 0){
close(fd[1]);
//在管道的读端重复标准输入
if(fd[0] != STDIN_FILENO){
dup2(fd[0],STDIN_FILENO); #将fd[0]复制到STDIN_FILENO,可以标准输入
close(fd[0]);
}
execlp("wc","wc","-l",(char*)NULL);
perror("execlp wc");
}
else{
perror("fork");
return 2;
}
return 0;
}
Exercise2:实现tr指令
- 创建一个程序,它接受一个文件名参数,并使用fork(), exec(), dup2()和pipe()来实现以下管道:
$ tr 'h' 'w' < out.txt | sort -u
:::info
- tr命令将文本中的h转换成w。(输入重定向是必需的,因为tr不接受文件名参数)
- 如果要编写调试输出,请将其写入标准错误
- 要使tr从filename读取,只需open()文件并将(dup2())结果FD复制到STDIN_FILENO上
- Makefile提供了一个测试:make test_unique_tokens
:::
#include<unistd.h>
#include<stdio.h>
#include<string.h>
#include<fcntl.h>
int main()
{
int pfd[2];
int fd;
int ret = pipe(pfd);
if(ret == -1){
perror("pipe");
}
pid_t childPid = fork();
if(childPid == 0){
fd = open("./out.txt",O_RDONLY);
dup2(fd,STDIN_FILENO); #将open的内容复制到标准输入
if(pfd[1] != STDOUT_FILENO){
dup2(pfd[1],STDOUT_FILENO);
close(pfd[1]);
}
execlp("tr","tr","h","w",(char*)NULL);
perror("execlp tr");
}
else if(childPid > 0){
close(pfd[1]);
if(pfd[0] != STDIN_FILENO){
dup2(pfd[0],STDIN_FILENO);
close(pfd[0]);
}
execlp("sort","sort","-u",(char *)NULL);
perror("execlp sort");
}
else{
perror("fork");
}
return 0;
}
- 扩展前面的程序来创建一个新程序count_unique_tokens.c,它接受一个文件名参数并实现以下管道:
tr 'h' 'w' < out.txt | sort -u | wc -w
int main()
{
int pfd[2];
int pfd2[2];
int fd;
int ret = pipe(pfd);
if(ret == -1){
perror("pipe");
}
pid_t childPid = fork();
if(childPid == 0){
fd = open("./out.txt",O_RDONLY);
dup2(fd,STDIN_FILENO);
if(pfd[1] != STDOUT_FILENO){
dup2(pfd[1],STDOUT_FILENO);
close(pfd[1]);
}
execlp("tr","tr","h","w",(char*)NULL);
perror("execlp tr");
pid_t childPid2 = fork(); # 创建子进程2
if(childPid2 == 0){
if(pfd2[1] != STDOUT_FILENO){ # stdout
dup2(pfd2[1],STDOUT_FILENO);
close(pfd2[1]);
}
execlp("sort","sort","-u",(char *)NULL);
perror("execlp sort");
}
else if(childPid2 > 0){
close(pfd2[1]);
}
else{
perror("fork");
}
}
else if(childPid > 0){
close(pfd[1]);
if(pfd[0] != STDIN_FILENO){
dup2(pfd[0],STDIN_FILENO);
close(pfd[0]);
}
execlp("wc","wc","-w",(char *)NULL);
perror("execlp wc");
}
else{
perror("fork");
}
return 0;
}
- 泛化在上一个练习中创建的程序来创建一个新版本(pipeline_builder.c),该版本实现并使用以下函数:
int execlPipeline (int infd , bool makePipe , char *arg , ...)
此函数创建一个子进程,其标准输出连接到该函数创建的管道的写端。子进程执行在arg和后续参数中包含的可变长度参数列表中指定的命令。
除此之外,execlPipeline()应该做以下事情:
:::tips
- 在调用fork()之前,如果makePipe非零,则创建一个管道。
- 在子进程中
- 将文件描述符infd复制为标准输入,以便子进程将从该文件描述符读取。
- (如果makePipe为非零)复制管道的写端,使其成为子进程执行的命令的标准输出。
:::
作为它的函数结果,execlPipeline()返回它所创建的管道的读端文件描述符,如果没有创建管道则返回-1。在返回之前,execlPipeline()关闭infd。使用此函数,可以使用以下代码构建管道:
fd = open(argv[1] , O_RDONLY);
fd = execlPipeline (fd , true , "tr", "h", "w",(char *) NULL );
fd = execlPipeline (fd , true , "sort", "-u", ( char *) NULL );
(void) execlPipeline (fd , false , "wc", "-l", ( char *) NULL );
:::info
-
(在子程序中),您将需要使用stdarg(3),以便解析变长参数列表。您可能会发现,检查procexec/execlp.c源文件以获取如何从变长参数列表构建argv风格向量的示例非常有用。
-
不要忘记关闭多余的管道文件描述符。
::: -
用下面的命令行参数写一个程序:
$ ./ pipe_speed num - blocks wblock -size rblock -size
:::tips
- 创建管道
- fork()创建子进程
- 子进程从管道中读取大小为rblock-size的数据块,直到文件结束
- 父进程:
- 将大小为wblock-size的num-blocks块写入管道
- 关闭管道
- 等待子进程终止
:::
计时程序的操作为num_blocks和wblock-size的不同值
FIFOs(First-In First Out)
FIFOs的特点:
:::info
- 语义上类似于管道
- 主要区别:FIFO在文件系统中有一个名称【命名管道】
- 任何具有打开FIFO权限的进程都可以执行I/O
- 在shell中创建:mkfifo [-m permissions] pathname
:::
$ mkfifo -m u+rw ,g=,o= myfifo
$ ls -lF myfifo
prw -------. 1 mtk mtk 0 Oct 31 13:21 myfifo |
函数原型
#include<sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);
:::info
- 当不再需要时,使用**unlink()或remove()**删除
:::
打开一个fifo
- 可以使用open()
fd = open("myfifo", O_RDONLY); // 打开读端
fd = open("myfifo", O_WRONLY); // 打开写端
:::info
- 打开FIFO的一端会阻塞,直到另一个进程打开另一端
- 如果另一端打开,则open()立即成功
- 打开是同步的
- 基本原理:FIFO只有在有读取器和写入器时才有用
- 就像管道一样(read(), write())
- 注意:FIFO数据是内核中的缓冲区
- FIFO有文件系统路径名,但这只是一种允许多个进程访问相同缓冲区的机制
- 如果所有fd都关闭,未读数据将被丢弃
- (FIFO名称在文件系统中持续存在,但数据具有进程持久性)
:::
- (FIFO名称在文件系统中持续存在,但数据具有进程持久性)
Exercise
在shell会话中对你喜欢的文本文件尝试以下操作:
$ mkfifo myfifo
$ tr 'aeiou' 'aieuo' < myfifo &
$ man 2 pipe > myfifo