管道¶
约 3163 个字 218 行代码 1 张图片 预计阅读时间 13 分钟
管道是 UNIX 系统上最古老的 IPC 方法。
管道的重要特征:
- 一个管道是一个字节流:当讲到管道是一个字节流时意味着在使用管道时是不存在消息或消息边界的概念的。从管道中读取数据的进程可以读取任意大小的数据块,而不管写入进程写入管道的数据块的大小是什么。此外,通过管道传递的数据是顺序的——从管道中读取出来的字节的顺序与它们被写入管道的顺序是完全一样的。在管道中无法使用 lseek()来随机地访问数据。
- 从管道中读取数据:试图从一个当前为空的管道中读取数据将会被阻塞直到至少有一个字节被写入到管道中为止。如果管道的写入端被关闭了,那么从管道中读取数据的进程在读完管道中剩余的所有数据之后将会看到文件结束(即 read()返回 0)。
- 管道是单向的:在管道中数据的传递方向是单向的。管道的一段用于写入,另一端则用于读取。
- 可以确保写入不超过 PIPE_BUF 字节的操作是原子的:如果多个进程写入同一个管道,那么如果它们在一个时刻写入的数据量不超过 PIPE_BUF字节,那么就可以确保写入的数据不会发生相互混合的情况。
- 管道的容量是有限的:管道其实是一个在内核内存中维护的缓冲器,这个缓冲器的存储能力是有限的。一旦管道被填满之后,后续向该管道的写入操作就会被阻塞直到读者从管道中移除了一些数据为止。
FIFO 是管道概念的一个变体,它们之间的一个重要差别在于 FIFO 可以用于任意进程间的通信。
为什么必须要关闭未使用的管道文件描述符?
关闭未使用管道文件描述符不仅仅是为了确保进程不会耗尽其文件描述符的限制——这对于正确使用管道是非常重要的。下面介绍为何必须要关闭管道的读取端和写入端的未使用文件描述符。
从管道中读取数据的进程会关闭其持有的管道的写入描述符,这样当其他进程完成输出并关闭其写入描述符之后,读者就能够看到文件结束(在读完管道中的数据之后)。如果读取进程没有关闭管道的写入端,那么在其他进程关闭了写入描述符之后,读者也不会看到文件结束,即使它读完了管道中的所有数据。相反,read()将会阻塞以等待数据,这是因为内核知道至少还存在一个管道的写入描述符打开着,即读取进程自己打开了这个描述符。从理论上来讲,这个进程仍然可以向管道写入数据,即使它已经被读取操作阻塞了。如read()可能会被一个向管道写入数据的信号处理器中断。
写入进程关闭其持有的管道的读取描述符是出于不同的原因。当一个进程试图向一个管道中写入数据但没有任何进程拥有该管道的打开着的读取描述符时,内核会向写入进程发送一个 SIGPIPE 信号。在默认情况下,这个信号会杀死一个进程。但进程可以捕获或忽略该信号,这样就会导致管道上的 write()操作因 EPIPE 错误(已损坏的管道)而失败。收到 SIGPIPE信号或得到 EPIPE 错误对于标示出管道的状态是有用的,这就是为何需要关闭管道的未使用读取描述符的原因。
注意:对被 SIGPIPE 处理器中断的 write()的处理是特殊的。通常,当 write()(或其他“慢”系统调用)被一个信号处理器中断时,这个调用会根据是否使用 sigaction() SA_RESTART 标记安装了处理器而自动重启或因 EINTR 错误而失败。对SIGPIPE 的处理不同是因为自动重启 write()或简单标示出 write()被一个处理器中断了是毫无意义的(意味着需要手工重启 write())。不管是何种处理方式,后续的 write()都不会成功,因为管道仍然处于被损坏的状态。
如果写入进程没有关闭管道的读取端,那么即使在其他进程已经关闭了管道的读取端之后写入进程仍然能够向管道写入数据,最后写入进程会将数据充满整个管道,后续的写入请求会被永远阻塞。关闭未使用文件描述符的最后一个原因是只有当所有进程中所有引用一个管道的文件描述符被关闭之后才会销毁该管道以及释放该管道占用的资源以供其他进程复用。此时,管道中所有未读取的数据都会丢失。
匿名管道¶
pipe()系统调用创建一个新匿名管道。**主要用于**有亲缘关系的进程(如父子进程)之间的通信。
/* Create a one-way communication channel (pipe).
If successful, two file descriptors are stored in PIPEDES;
bytes written on PIPEDES[1] can be read from PIPEDES[0].
Returns 0 if successful, -1 if not. */
extern int pipe (int __pipedes[2]) __THROW __wur;
核心特性¶
一、核心特性
- 半双工:数据只能单向流动,需要双向通信时需创建两个管道。
- 匿名性:没有文件系统中的实体,仅在进程间通过文件描述符传递。
- 生命周期:随进程结束而销毁,无需手动清理。
二、创建与使用流程
-
创建管道:通过
pipe()系统调用,返回两个文件描述符:-
fd[0]:读端(固定) -
fd[1]:写端(固定)
-
-
创建子进程:通过
fork()创建子进程,子进程会继承管道的文件描述符。 - 关闭无用端:父子进程分别关闭不需要的读 / 写端。
- 通信:父进程写、子进程读(或相反)

四、注意事项
- 管道的**缓冲区大小有限**(通常几 KB),若写满会导致写进程阻塞。
- 若读端关闭,写进程写入时会触发
SIGPIPE信号,默认导致进程终止。 - 匿名管道仅适用于**有亲缘关系的进程**,无亲缘关系的进程需用命名管道(FIFO)。
让父进程和子进程都能够从同一个管道中读取和写入数据这种做法并不常见的一个原因是如果两个进程同时试图从管道中读取数据,那么就无法确定哪个进程会首先读取成功 — 两个进程竞争数据了。要防止这种竞争情况的出现就需要使用某种同步机制。但如果需要双向通信则可以使用一种更加简单的方法:创建两个管道,在两个进程之间发送数据的两个方向上各使用一个。(如果使用这种技术,那么就需要考虑死锁的问题了,因为如果两个进程都试图从空管道中读取数据或尝试向已满的管道中写入数据就可能会发生死锁。)
管道通常用于两个兄弟进程之间的通信——它们的父进程创建了管道,然后创建两个子进程。这就是在构建管道线时 shell所做的工作。
示例代码¶
- case1: 匿名管道,fd[0]固定是读端,fd[1]固定是写端
- case2: 匿名管道,将管道作为一种进程同步的方法,利用空管道阻塞read + fd全部close后会返回文件结束接触阻塞的特性
- case3: 匿名管道,利用dup2的fd复制重定向能力,将标准输入输出通过管道对接
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <signal.h>
#include "lib.h"
/**
* case1: 匿名管道,fd[0]固定是读端,fd[1]固定是写端
*/
void pipe_case1()
{
int fds[2];
if (pipe(fds) == -1) perror_exit("pipe");
printf("create pipe, fd[0]=%d, fd[1]=%d\n", fds[0], fds[1]);
if (fork() == 0) {
// 关闭读端
close(fds[0]);
// 即便write比read晚3秒,read也会在空管道阻塞到至少一个字节被写入
sleep(3);
char buf[10] = {0};
for (int i = 0; i < 3; i++) {
buf[0] = 'a' + i;
printf("pid=%d, write is %s\n", getpid(), buf);
// PIPEDES[1]写操作
write(fds[1], buf, 10);
}
exit(EXIT_SUCCESS);
}
// 关闭写端
close(fds[1]);
char buf[10] = {0};
/**
* PIPEDES[0]读操作
* 试图从一个当前为空的管道中读取数据将会被阻塞直到至少有一个字节被写入到管道中为止
*/
while (read(fds[0], buf, 10) != 0) {
printf("pid=%d, read is %s\n", getpid(), buf);
}
PRINT_SUCCESSFUL;
}
/**
* case2: 匿名管道,将管道作为一种进程同步的方法
* 利用空管道阻塞read + fd全部close后会返回文件结束接触阻塞的特性
*/
void pipe_case2()
{
int fds[2];
if (pipe(fds) == -1) perror_exit("pipe");
printf("create pipe, fd[0]=%d, fd[1]=%d\n", fds[0], fds[1]);
for (int i = 0; i < 3; i++) {
if (fork() != 0) {
continue;
}
// 关闭读端
close(fds[0]);
// 子进程完成工作后关闭写端
printf("i=%d, pid=%d, child process do something firstly\n", i, getpid());
sleep(i);
close(fds[1]);
exit(EXIT_SUCCESS);
}
// 关闭写端
close(fds[1]);
/**
* 当所有子进程都关闭了管道的写入端的文件描述符之后,父进程在管道上的 read()就会结束并返回文件结束
* 在此之前,因为是空管道,父进程会阻塞在read操作上
*/
char buf[1] = {0};
if (read(fds[0], buf, 1) != 0) {
printf("pid=%d, did not get EOF\n", getpid());
}
printf("pid=%d, parent process do something after all child ok\n", getpid());
PRINT_SUCCESSFUL;
}
/**
* case3: 匿名管道,利用dup2的fd复制重定向能力,将标准输入输出通过管道对接
*/
void pipe_case3()
{
int fds[2];
if (pipe(fds) == -1) perror_exit("pipe");
printf("create pipe, fd[0]=%d, fd[1]=%d\n", fds[0], fds[1]);
for (int i = 0; i < 2; i++) {
if (fork() != 0) {
continue;
}
if (i == 0) {
// 关闭读端
close(fds[0]);
// 将管道写端重定向到标准输出
if (dup2(fds[1], STDOUT_FILENO) == -1) perror_exit("dup2");
execlp("ls", "ls", (char *)NULL);
}
if (i == 1) {
// 关闭写端
close(fds[1]);
// 将管道读端重定向到标准输入
if (dup2(fds[0], STDIN_FILENO) == -1) perror_exit("dup2");
/**
* execlp 会加载指定的可执行程序,替换当前进程的代码段、数据段和堆栈,原进程的代码会被完全覆盖,
* 仅保留进程 ID、文件描述符等系统资源。
*/
execlp("wc", "wc", "-l", (char *)NULL);
}
exit(EXIT_SUCCESS);
}
close(fds[0]);
close(fds[1]);
while (wait(NULL) != -1) {}
PRINT_SUCCESSFUL;
}
int main()
{
pipe_case1();
pipe_case2();
pipe_case3();
}
结果输出:
create pipe, fd[0]=3, fd[1]=4
pid=19732, write is a
pid=19732, write is b
pid=19732, write is c
pid=19728, read is a
pid=19728, read is b
pid=19728, read is c
[pipe_case1] successful...
--------------------------------
create pipe, fd[0]=4, fd[1]=5
i=0, pid=19761, child process do something firstly
i=1, pid=19762, child process do something firstly
i=2, pid=19763, child process do something firstly
pid=19728, parent process do something after all child ok
[pipe_case2] successful...
--------------------------------
create pipe, fd[0]=5, fd[1]=6
13
[pipe_case3] successful...
--------------------------------
命名管道¶
FIFO(命名管道)是 Linux/Unix 中一种 无亲缘关系进程间通信(IPC) 的机制,它和匿名管道的核心区别是:FIFO 在文件系统中有一个可见的路径,可被任意进程打开使用。
一旦打开了 FIFO,就能在它上面使用与操作管道和其他文件的系统调用一样的 I/O 系统调用了(如 read()、write()和 close())。与管道一样,FIFO 也有一个写入端和读取端,并且从管道中读取数据的顺序与写入的顺序是一样的。FIFO 的名称也由此而来:先入先出。
当在 FIFO(或管道)上调用 fstat()和 stat()函数时它们会在 stat 结构的 st_mode 字段中返回一个类型为 S_IFIFO 的文件(参见 15.1 节)。当使用 ls –l 列出文件时,FIFO 文件在第一列的类型为 p,ls –F 会在 FIFO 路径名后面附加上一个管道符(|)。
打开一个 FIFO 具备一些不寻常的语义。一般来讲,使用 FIFO 时唯一明智的做法是在两端分别设置一个读取进程和一个写入进程。这样在默认情况下,打开一个 FIFO 以便读取数据(open() O_RDONLY 标记)将会阻塞直到另一个进程打开 FIFO 以写入数据(open() O_WRONLY 标记)为止。相应地,打开一个 FIFO 以写入数据将会阻塞直到另一个进程打开FIFO 以读取数据为止。换句话说,打开一个 FIFO 会同步读取进程和写入进程。如果一个 FIFO的另一端已经打开(可能是因为一对进程已经打开了 FIFO 的两端),那么 open()调用会立即成功。
在大多数 UNIX 实现(包括 Linux)上,当打开一个 FIFO 时可以通过指定 O_RDWR 标记来绕过打开 FIFO 时的阻塞行为。这样,open()就会立即返回,但无法使用返回的文件描述符在 FIFO 上读取和写入数据。这种做法破坏了 FIFO 的 I/O 模型,SUSv3 明确指出以O_RDWR标记打开一个 FIFO 的结果是未知的,因此出于可移植性的原因,开发人员不应该使用这项技术。对于那些需要避免在打开 FIFO 时发生阻塞的需求,open()的O_NONBLOCK 标记提供了一种标准化的方法来完成这个任务。
核心特性¶
一、核心特性
- 有名字:在文件系统中以特殊文件形式存在,可通过路径访问。
- 半双工:数据单向流动,双向通信需创建两个 FIFO。
- 生命周期:需手动删除(
unlink),否则会一直存在于文件系统中。 - 阻塞特性:读端打开时会阻塞,直到有写端打开;写端打开时会阻塞,直到有读端打开。
二、创建与使用流程
- 创建 FIFO:使用
mkfifo()系统调用,指定路径和权限。 - 打开 FIFO:进程通过
open()以O_RDONLY(读端)或O_WRONLY(写端)打开。 - 通信:通过
read()/write()读写数据。 - 清理:使用
unlink()删除 FIFO 文件。
/* Create a new FIFO named PATH, with permission bits MODE. */
extern int mkfifo (const char *__path, __mode_t __mode)
__THROW __nonnull ((1));
示例代码¶
void pipe_case4()
{
static char fifo_name[] = "/fifo_demo";
// 创建FIFO
mkfifo(fifo_name, 0666);
for (int i = 0; i < 2; i++) {
if (fork() != 0) {
continue;
}
if (i == 0) {
// 打开FIFO写端
int fd = open(fifo_name, O_WRONLY);
if (fd == -1) perror_exit("open");
const char *msg = "Hello from FIFO!";
write(fd, msg, strlen(msg) + 1);
printf("i=%d, pid=%d, fd=%d, write FIFO, msg is %s\n", i, getpid(), fd, msg);
// 关闭文件描述符
close(fd);
}
if (i == 1) {
// 打开FIFO读端
int fd = open(fifo_name, O_RDONLY);
if (fd == -1) perror_exit("open");
// 读取数据
char buf[100];
read(fd, buf, sizeof(buf));
printf("i=%d, pid=%d, fd=%d, read FIFO, msg is %s\n", i, getpid(), fd, buf);
// 关闭文件描述符
close(fd);
}
exit(EXIT_SUCCESS);
}
while (wait(NULL) != -1) {}
// 删除FIFO
unlink(fifo_name);
PRINT_SUCCESSFUL;
}
输出结果:
i=0, pid=21466, fd=3, write FIFO, msg is Hello from FIFO!
i=1, pid=21467, fd=3, read FIFO, msg is Hello from FIFO!
[pipe_case4] successful...
--------------------------------
总结¶
管道是 UNIX 系统上出现的第一种 IPC 方法,shell 以及其他应用程序经常会使用管道。管道是一个单项、容量有限的字节流,它可以用于相关进程之间的通信。尽管写入管道的数据块的大小可以是任意的,但只有那些写入的数据量不超过 PIPE_BUF 字节的写入操作才被确保是原子的。除了是一种 IPC 方法之外,管道还可以用于进程同步。
在使用管道时必须要小心地关闭未使用的描述符以确保读取进程能够检测到文件结束和写入进程能够收到 SIGPIPE 信号或 EPIPE 错误。(通常,最简单的做法是让向管道写入数据的应用程序忽略 SIGPIPE 并通过 EPIPE 错误检测管道是否“坏了”。)
FIFO 除了 mkfifo()创建和在文件系统中存在一个名称以及可以被拥有合适的权限的任意进程打开之外,其运作方式与管道完全一样。在默认情况下,为读取数据而打开一个 FIFO 会被阻塞直到另一个进程为写入数据而打开了该 FIFO,反之亦然。