Pipe

我们经常能看到如下的代码:

1
2
3
4
5
6
7
8
9
10
int pipefd[2];
int status = pipe(pipefd);
if(status==-1){
printf("create pipe erro\n");
}
......
...fork()....
//child
dup2(pipefd[0], STDIN_FILENO);
......

(这边省略了父子进程close无用fd的代码,要close这些无用fd,参看下面的讲解)

上面这段代码首先定义了一对pipe的fd,初始化这对pipe,并在fork后的子进程中将子进程的标准输入重定向至pipefd[1]

在理解上面的含义之前,其实我应当声明一些重要的概念,首先是在声明初始化一对pipe时,默认pipefd[1]是数据流入端,而pipefd[0]是数据流出端,并且,pipe是单向的,永远从pipefd[1] –> pipefd[0]

另外,以下这部分代码经常引起疑惑(大概):

1
dup2(pipefd[0], STDIN_FILENO);

因为上面说过,pipefd[0]是数据流出端,那为啥标准输入会与pipefd[0]打上交道呢?一个输入,一个流出,感觉貌似弄反了。

但实际上,并不是如此,因为如果你细细看过dup2的实现你会发现,dup2函数的原型是int dup2(int fd,int fd2),其实现的功能就是:对于fd2,可以用fd参数指定fd2的值。如果fd2已经打开,则先将其关闭。如若fd等于fd2,则dup2返回fd2,而不关闭它。否则,fd2的FD_CLOEXEC文件描述符标志就被清楚,这样fd2在进程调用exec时是打开状态。

那这里面实际上最重要的,就是这个关闭是啥意思

实际上,我们可以把fd看成一个连线图的感觉,举例来说,原先STDIN_FILENO指向的是键盘,也就是我们所谓的标准输出,但是,如果经过上面的代码,那么STDIN_FILENO将会取消指向键盘,转而指向pipefd[0]所指向的管道的0端,所以此时只要是管道的0端流出的任何东西都将会成为STDIN_FILENO所接受的输入

看到这儿,基本能解释“弄反”的疑惑了,反之同理,如下的代码将表示STDOUT_FILENO以及STDERR_FILENO所输出的任何东西将变为管道的1端(pipefd[1]指向的就是管道的1端)所流入的任何东西

(管道的0端和1端可以看下面的抽象图形)

1
2
dup2(pipefd[1],STDOUT_FILENO);
dup2(pipefd[1],STDERR_FILENO);

那管道呢,实际上我们可以将其抽象为这样一个图形:

pipe : pipefd[1]–指向–>1———数据流转———>0<–指向–pipefd[0]

而所谓的dup2(pipefd[0], STDIN_FILENO),也就是这样:

更改后: pipefd[1]–指向–>1———数据流转———>0<–指向–(pipefd[0],STDIN_FILENO)

(注意:这里写在一起只是因为不方便画图,实际上pipefd[0],STDIN_FILENO是分别指向管道的0端,并不是STDIN_FILENO先指向pipefd[0],pipefd[0]再指向管道的0端,这很关键!是分叉而不是直线!)

而一般我们在子进程中调用完dup2(pipefd[0], STDIN_FILENO);后会调用close(pipefd[0]),让pipefd[0]不再指向0,从而将管道变为这样:

pipe : pipefd[1]–指向–>1———数据流转———>0<–指向–STDIN_FILENO

这时,父进程只需要向pipefd[1]中写入数据,就会通过管道,写入至子进程的STDIN_FILENO中了

但是这里要注意了,务必要在调用完dup2(pipefd[0], STDIN_FILENO);之后再调用close(pipefd[0]),切不可倒过来,因为一旦倒过来,先取消了pipefd[0]与管道0端的绑定,那么STDIN_FILENO将无法与管道0端进行绑定!(其实就有点pipefd[0]把自己的绑定信息“拷贝”给STDIN_FILENO的感觉,如果自己绑定信息被清空,那么自然也就无法“拷贝”给STDIN_FILENO了,意思是这么个意思,不想深究其原理的话大致可以粗浅的这么认为)

另外还有一点需要提一下,那就是如果在fork之前执行了dup2函数,那么fork后的子进程依然会继承父进程dup2函数所带来的影响,换句话说,就是继承相同的fd表。那么如果上面的dup2语句放在了fork前执行,那么管道会变成这样:

pipe : pipefd[1]–指向–>1———数据流转———>0<–指向–(父-STDIN_FILENO,子-STDIN_FILENO)

那么此时可以发现,有两个标准输入都指向了同一条管道的0端,那么为了防止由1端输入的数据被错误进程的标准输入读走,我们必须在读出数据前取消其中一个STDIN_FILENO与此管道的绑定

如果说我们希望子进程来读,那么在fork之后,父进程必须记得执行close(0),取消自己的标准输入与管道的绑定,让管道变成如下这样:

pipe : pipefd[1]–指向–>1———数据流转———>0<–指向–子-STDIN_FILENO

这样的话,父进程向pipefd[1]中写入数据,就能保证一定会被子进程的标准输入所捕获了,所以由此可得,我们在使用一条管道时,务必保证指向管道1端以及指向管道0端的fd分别有且仅有一个,不然程序可能会出现预期之外的行为

最后,因为管道是单向的,所以如果我们还希望将子进程的输出由父进程捕获,那么我们就还需要另一条管道,用来使子进程向父进程传输数据

如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int pipefd[2];
int pipefd2[2];
int status = pipe(pipefd);
if(status==-1){
printf("create pipe erro\n");
}
int status2 = pipe(pipefd2);
if(status2==-1){
printf("create pipe erro\n");
}
......
...fork()....
//child
dup2(pipefd[0], STDIN_FILENO);
dup2(pipefd2[1],STDOUT_FILENO);
dup2(pipefd2[1],STDERR_FILENO);
......

(这边省略了父子进程close无用fd的代码,要close这些无用fd,参看上面的讲解,是一个意思)

这样,子进程的输出将会自动写入pipefd2[1],父进程只需要根据需要,读出pipefd2[0]的数据即可实现子—->父的通讯过程

另外,还有个小tip,就是有时候我们会无限循环地读管道地数据流出端(即0端),那么我们该如何退出这个死循环,就像go中close channel一样,让读出端能够感知管道已经被废弃,从而结束”读“任务呢?

要完成以上的目标,我们所需要做的,就是将1端,即数据输入端的引用计数变为0,也即相当于我们上面所提到的,取消所有fd与管道数据输入端的绑定,当输入端的引用计数变为0时,读取者将会读到0,此时通过判断数据长度是否为0就可以退出循环

后记

其实管道的内部实现是很复杂的,linux的管道机制要深究起来能写一本书。。。以后有空还是要多研究总结,不然老是记混 👻