管道

管道的是进程间通信(IPC, InterProcess Communication)的一种方式,管道其实就是内核中的一块内存(内核缓冲区),这块缓冲区中的数据存储在一个环形队列中,因为管道在内核里边,因此不能直接对其进行操作。

循环队列

因为管道数据是通过队列来维护的,分析一个管道中数据的特点:
(1)管道对应的内核缓冲区大小是固定的,默认为 4k(队列最大能存储 4k 数据)。
(2)管道分为两部分:读端和写端(队列的两端),数据从写端进入管道,从读端流出管道。
(3)管道中的数据只能读一次,做一次读操作之后数据就没有了(读数据相当于出队列)。
(4)管道是单工的:数据只能单向流动,数据从写端流向读端。
(5)对管道的操作(读、写)默认是阻塞的。
读管道:管道中没有数据,读操作被阻塞,当管道中有数据之后才解除阻塞。
写管道:管道被写满了,写数据的操作被阻塞,当管道变为不满的状态,写阻塞解除。

管道在内核中,不能直接对其进行操作,通过什么方式去读写管道呢?
管道操作就是文件 IO 操作,内核中管道的两端分别对应两个文件描述符,通过写端的文件描述符把数据写入到管道中,通过读端的文件描述符将数据从管道中读出来。读写管道的函数就是 Linux 中的文件 IO 函数,函数原型如下:

1
2
3
4
// 读管道
ssize_t read(int fd, void *buf, size_t count);
// 写管道的函数
ssize_t write(int fd, const void *buf, size_t count);

为什么可以使用管道进行进程间通信?
假设父进通过一系列操作可以通过文件描述符表中的文件描述符 fd3 写管道,通过 fd4 读管道,然后通过 fork() 创建出子进程,那么在父进程中被分配的文件描述符 fd3、fd4 被拷贝到子进程中,子进程通过 fd3 可以将数据写入到内核的管道中,通过 fd4 将数据从管道中读出来。
也就是说管道是独立于任何进程的,并且充当了两个进程用于数据通信的载体,只要两个进程能够得到同一个管道的入口和出口(读端和写端的文件描述符),那么它们之间就可以通过管道进行数据的交互。

匿名管道

匿名管道是管道的一种,这个管道没有名字,但其本质是不变的,就是位于内核中的一块内存,匿名管道拥有管道的所有特性。此外,匿名管道只能实现有血缘关系的进程间通信。什么叫有血缘的进程关系呢?比如:父子进程、兄弟进程、爷孙进程、叔侄进程。

创建匿名管道的函数,函数原型如下:

1
2
3
4
5
6
7
8
9
10
11
12
#include <unistd.h>
// 创建一个匿名的管道,得到两个可用的文件描述符
int pipe(int pipefd[2]);
/*
参数:
传出参数,一个整形数组的地址,数组大小为 2
pipefd[0]:管道读端的文件描述符,通过它可以将数据从管道中读出
pipefd[1]:管道写端的文件描述符,通过它可以将数据写入到管道中
返回值:
成功返回 0
失败返回 -1
*/

使用匿名管道只能实现有血缘关系的进程间通信,要求写一段程序完成下边的功能:
在父进程中创建一个子进程,父子进程分别执行不同的操作:子进程执行一个shell命令 “ps aux”,将命令的结果传递给父进程;父进程将子进程命令的结果输出到终端。
分析:
(1)子进程中执行 shell 命令相当于启动一个磁盘程序,因此需要使用 execl()/execlp() 函数 execlp("ps", "ps", "aux", NULL)
(2)子进程中执行完 shell 命令直接就可以在终端输出结果,如果将这些信息传递给父进程呢?
数据传递需要使用管道,子进程需要将数据写入到管道中
将默认输出到终端的数据写入到管道就需要进行输出的重定向,使用 dup2()
dup2(fd[1], STDOUT_FILENO);
(3)父进程需要读管道,将从管道中读出的数据打印到终端。
(4)父进程最后需要释放子进程资源,防止出现僵尸进程。

使用管道进行进程间通信必须保证数据在管道中的单向流动,如何理解这句话?
(1)在父进程中创建了匿名管道,得到了两个分配的文件描述符(fd3 操作管道的读端,fd4 操作管道的写端)
(2)父进程创建子进程,父进程的文件描述符被拷贝,在子进程的文件描述符表中也得到了两个被分配的可以使用的文件描述符。
此时管道中数据的流动不是单向的,有以下 4 种情况:
(3)为了避免两个进程都读管道,可以关闭进程中用不到的那一端的文件描述符,这样数据就只能单向的从一端流向另外一端。

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
/*
管道的数据是单向流动的:
进程 A 读管道,需要关闭管道的写端;进程 B 写管道,需要关闭管道的读端。
如果不做上述的操作,会对程序的结果造成一些影响,对管道的操作无法结束。
*/
#include <fcntl.h>
#include <sys/wait.h>

int main()
{
// 1.创建匿名管道,得到两个文件描述符
int fd[2];
int ret = pipe(fd);
if (ret == -1)
{
perror("pipe error");
exit(0);
}

// 2.创建子进程 -> 能够操作管道的文件描述符被复制到子进程中
pid_t pid = fork();
if (pid == 0)
{
// 关闭读端
close(fd[0]);
// 3. 在子进程中执行 execlp("ps", "ps", "aux", NULL);
// 在子进程中完成输出的重定向,原来输出到终端,现在要写管道
// 终端对应的文件描述符: stdout_fileno
// 标准输出重定向到管道的写端
dup2(fd[1], STDOUT_FILENO);
execlp("ps", "ps", "aux", NULL);
perror("execlp error");
}
else if (pid > 0) // 4.父进程读管道
{
// 关闭s写端
close(fd[1]);
// 5.父进程打印读到的数据信息
char buf[4096];
// 读管道
// 如果管道中没有数据,read会阻塞;有数据之后,read解除阻塞,直接读数据。
// 循环读数据:管道是有容量的,写满之后就不写了;数据被读走后,可以继续写管道,需要继续读数据。
while (1)
{
memset(buf, 0, sizeof(buf));
int len = read(fd[0], buf, sizeof(buf));
if (len == 0)
{
// 管道的写端关闭了,如果管道中没有数据,管道读端不会阻塞
// 没数据直接返回 0;如果有数据,将数据读出,数据读完之后返回 0
break;
}
printf("%s, len = %d\n", buf, len);
}
close(fd[0]);

// 回收子进程资源
wait(NULL);
}

return 0;
}
// gcc pipe.c -o app

有名管道

有名管道拥有管道的所有特性,管道在磁盘上有实体文件,文件类型为 p ,有名管道文件大小永远为 0,因为有名管道也是将数据存储到内存的缓冲区中,打开这个磁盘上的管道文件可以得到文件描述符,通过文件描述符读写管道存储在内核中的数据。

使用有名管道可以进行有血缘关系的进程间通信,也可以进行没有血缘关系的进程间通信。
创建有名管道的方式有两种,一种是通过命令,一种是通过函数。
(1)通过命令

1
mkfifo 有名管道的名字

(2)通过函数

1
2
3
4
5
6
7
8
9
10
11
12
#include <sys/types.h>
#include <sys/stat.h>
// int open(const char *pathname, int flags, mode_t mode);
int mkfifo(const char *pathname, mode_t mode);
/*
参数:
pathname: 创建的有名管道的名字
mode: 文件的操作权限,和 open() 的第三个参数作用相同,最终权限: (mode & ~umask)
返回值:
成功返回 0
失败返回 -1
*/

不论有血缘关系还是没有血缘关系,使用有名管道实现进程间通信的方式是相同的,就是在两个进程中分别以读、写的方式打开磁盘上的管道文件,得到用于读管道、写管道的文件描述符,调用对应的 read()/write() 函数进行读写操作。
有名管道操作需要通过 open() 操作得到读写管道的文件描述符,如果只是读端打开或者只是写端打开,进程会阻塞在这里不会向下执行,直到在另一个进程中将管道的对端打开,当前进程的阻塞也就解除了。

写管道的进程,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/*
1.创建有名管道文件
mkfifo();
2.打开有名管道文件,打开方式是 o_wronly
int wfd = open("xx", O_WRONLY);
3.调用write函数写文件 -> 数据被写入管道中
write(wfd, data, strlen(data));
4.写完之后关闭文件描述符
close(wfd);
*/
#include <fcntl.h>
#include <sys/stat.h>

int main()
{
// 1.创建有名管道文件
int ret = mkfifo("./testfifo", 0664);
if (ret == -1)
{
perror("mkfifo error");
exit(0);
}
printf("管道文件创建成功...\n");

// 2.打开管道文件
// 因为要写管道,打开方式应该指定为 O_WRONLY
// 如果先打开写端,读端还没有打开,open 函数会阻塞;当读端也打开之后,open 解除阻塞
int wfd = open("./testfifo", O_WRONLY);
if (wfd == -1)
{
perror("open error");
exit(0);
}
printf("以只写的方式打开文件成功...\n");

// 3.循环写管道
int i = 0;
while (i < 100)
{
char buf[1024];
sprintf(buf, "fifo, 写管道...%d\n", i);
write(wfd, buf, strlen(buf));
i++;
sleep(1);
}
close(wfd);

return 0;
}

读管道的进程,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/*
1.这两个进程需要操作相同的管道文件
2.打开有名管道文件, 打开方式是 O_RDONLY
int rfd = open("xx", O_RDONLY);
3.调用read函数读文件 -> 读管道中的数据
char buf[4096];
read(rfd, buf, sizeof(buf));
4.读完之后关闭文件描述符
close(rfd);
*/
#include <fcntl.h>
#include <sys/stat.h>

int main()
{
// 1.打开管道文件
// 因为要读管道,打开方式应该指定为 O_RDONLY
// 如果只打开了读端,写端还没有打开,open 阻塞;当写端被打开,阻塞就解除了
int rfd = open("./testfifo", O_RDONLY);
if(rfd == -1)
{
perror("open");
exit(0);
}
printf("以只读的方式打开文件成功...\n");

// 2.循环读管道
while(1)
{
char buf[1024];
memset(buf, 0, sizeof(buf));
// 读是阻塞的,如果管道中没有数据,read自动阻塞;如果有数据,解除阻塞继续读数据。
int len = read(rfd, buf, sizeof(buf));
printf("读出的数据: %s\n", buf);
if(len == 0)
{
// 写端关闭了,read 解除阻塞返回 0
printf("管道的写端已经关闭, 退出...\n");
break;
}

}
close(rfd);

return 0;
}

管道的读写行为

管道不论是有名的还是匿名的,在进行读写的时候,它们表现出的行为是一致的,总结如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
读管道,根据写端的状态进行分析
1)写端没有关闭(操作管道写端的文件描述符没有被关闭)
如果管道中没有数据,读阻塞;如果管道中被写入了数据,阻塞解除。
如果管道中有数据,读不阻塞;如果管道中的数据被读完了,继续读管道会阻塞。
2)写端已经关闭(没有可用的文件描述符可以写管道)
如果管道中没有数据,读端解除阻塞,read 函数返回 0
如果管道中有数据,read 先将数据读出,数据读完之后返回 0,不会阻塞。

写管道,根据读端的状态进行分析
1)读端没有关闭
如果管道有存储的空间,一直写数据。
如果管道写满了,写操作就阻塞,当读端将管道数据读走了,解除阻塞继续写。
2)读端关闭
管道破裂(异常),进程直接退出。

管道的两端默认是阻塞的,如何将管道设置为非阻塞?
管道的读写两端的非阻塞操作是相同的,将匿名的读端设置为非阻塞,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
// 通过 fcntl 修改就可以,一般情况下不建议修改
// 管道操作对应两个文件描述符,分别是管道的读端和写端

// 1.获取读端的文件描述符的 flag 属性
int flag = fcntl(fd[0], F_GETFL);
// 2.添加非阻塞属性到 flag
flag |= O_NONBLOCK;
// 3.将新的 flag 属性设置给读端的文件描述符
fcntl(fd[0], F_SETFL, flag);
// 4.非阻塞读管道
char buf[4096];
read(fd[0], buf, sizeof(buf));

参考资料

https://subingwen.cn/linux/pipe/


管道
https://lcf163.github.io/2021/08/05/管道/
作者
乘风的小站
发布于
2021年8月5日
许可协议