C高级:有关匿名管道与命名管道的学习记录
管道(Pipe)是 Linux 系统中最古老、最常用的进程间通信(IPC)方式之一。它基于"先进先出"的数据流模型,为进程之间提供了一条单向或双向的数据传输通道。本文将从概念到代码,系统讲解匿名管道和命名管道的原理、区别,以及 popen()、pclose()、pipe()、mkfifo() 四个核心函数的用法。以下是我在职坐标的学习记录:
一、管道的分类
Linux 中的管道分为两种:匿名管道(Anonymous Pipe) 和 命名管道(Named Pipe / FIFO)。
1.1 匿名管道
匿名管道是一种半双工的通信机制,数据只能沿一个方向流动:从写端流向读端。它没有名字,不存在于文件系统中,只能在具有亲缘关系的进程之间使用——通常是父子进程或兄弟进程。
核心特点:
- 半双工通信:数据只能单向流动,一端写、另一端读。
- 无名字:不占用文件系统路径,通过文件描述符(fd)引用。
- 亲缘关系限制:只能用于有血缘关系的进程间(通过
fork()创建的子进程继承了父进程的管道描述符)。 - 基于内存:管道内部维护一个内核缓冲区(Linux 默认大小为 64KB),数据写入后存储在缓冲区中,读取后自动清除。
- 生命周期:随进程消亡而自动释放,不需要手动清理。
典型使用场景: 父进程通过管道向子进程传递数据,或者在 shell 中使用 | 符号将两个命令串联起来(如 ls | grep .c),本质上就是匿名管道。
1.2 命名管道(FIFO)
命名管道也叫 FIFO(First In, First Out),它在文件系统中拥有一个路径名,任何知道该路径的进程都可以打开它进行通信,因此不要求进程之间存在亲缘关系。
核心特点:
- 存在于文件系统:通过
mkfifo()创建后,可以用ls看到它,文件类型为p(pipe)。 - 无亲缘限制:任意两个进程只要知道 FIFO 的路径,就可以通过它进行通信。
- 半双工或全双工:传统用法是半双工的(一个进程读、另一个写),但如果双方都以读写模式(
O_RDWR)打开,某些系统上也支持全双工。 - 阻塞行为:默认以阻塞方式打开——读端会阻塞直到有写端打开,写端也会阻塞直到有读端打开。可以设置
O_NONBLOCK标志改变这一行为。 - 持久性:FIFO 文件存在于文件系统中,不会随进程退出自动消失,需要手动删除(
unlink()或rm)。
1.3 两者的对比
| 对比维度 | 匿名管道(pipe) | 命名管道(FIFO) |
|---|---|---|
| 文件系统可见 | 不可见,仅通过 fd 访问 | 可见,有文件路径 |
| 使用范围 | 仅限有亲缘关系的进程 | 任意进程之间 |
| 创建方式 | pipe() |
mkfifo() |
| 生命周期 | 随进程自动销毁 | 持久存在,需手动删除 |
| 通信方向 | 半双工(单向) | 半双工(也可全双工,取决于实现) |
| 阻塞行为 | 通常不阻塞(除非缓冲区满或空) | 打开时默认阻塞,直到对端也打开 |
| 典型场景 | 父子进程通信、shell 管道符 | 无关系进程间的协调通信 |
二、popen() 函数
2.1 函数原型
#include <stdio.h>
FILE *popen(const char *command, const char *type);
2.2 函数作用
popen() 会创建一个管道,然后 fork() 出一个子进程,在子进程中通过 exec() 执行 command 所指定的 shell 命令。父进程可以通过返回的 FILE* 流来读取命令的输出,或者向命令写入数据。
简单来说,popen() 封装了 创建管道 + fork + exec 这一整套流程,让开发者可以用一行代码就实现"启动一个命令并读取其输出"或"向一个命令写入数据"的功能。
2.3 参数详解
| 参数 | 类型 | 说明 |
|---|---|---|
command |
const char * |
要执行的 shell 命令字符串。它会作为参数传递给 /bin/sh -c,因此支持 shell 的通配符、管道符等语法。例如 "ls -l" 或 "cat /etc/passwd | grep root"。 |
type |
const char * |
管道的工作模式,只能取 "r" 或 "w"。 |
type 参数深入解释:
-
"r"(read 模式):父进程以读方式打开管道,子进程执行command后的标准输出会连接到管道的写端。父进程通过返回的FILE*可以读取命令的输出结果。FILE *fp = popen("ls -l", "r"); // 父进程可以通过 fp 读取 ls -l 的输出 -
"w"(write 模式):父进程以写方式打开管道,父进程写入的数据会成为子进程执行command时的标准输入。FILE *fp = popen("grep error > result.txt", "w"); // 父进程可以向 fp 写入数据,这些数据会作为 grep 的标准输入
2.4 返回值
- 成功:返回一个指向
FILE的指针(标准 I/O 流),可以用fgets()、fread()、fprintf()、fwrite()等标准库函数来操作。 - 失败:返回
NULL,可能的原因包括无法fork子进程、无法创建管道等。
2.5 示例代码
#include <stdio.h>
#include <stdlib.h>
int main() {
FILE *fp;
char buf[1024];
// 以读模式打开管道,执行 ls -l 命令
fp = popen("ls -l", "r");
if (fp == NULL) {
perror("popen failed");
exit(EXIT_FAILURE);
}
// 逐行读取命令输出
while (fgets(buf, sizeof(buf), fp) != NULL) {
printf("%s", buf);
}
pclose(fp);
return 0;
}
三、pclose() 函数
3.1 函数原型
#include <stdio.h>
int pclose(FILE *stream);
3.2 函数作用
pclose() 用于关闭由 popen() 创建的管道流。它不仅会关闭底层的管道文件描述符,还会调用 waitpid() 等待子进程终止,并回收子进程的资源(避免产生僵尸进程)。
这一点非常重要:如果 popen() 之后不调用 pclose(),子进程退出后会变成僵尸进程,占用系统资源。
3.3 参数详解
| 参数 | 类型 | 说明 |
|---|---|---|
stream |
FILE * |
由 popen() 返回的文件流指针。必须是 popen() 的返回值,不能传入其他来源的 FILE*。 |
3.4 返回值
- 成功:返回子进程的终止状态(与
waitpid()的返回格式相同)。可以使用WIFEXITED()、WEXITSTATUS()等宏来解析退出码。 - 失败:返回
-1,并设置errno。常见错误包括传入无效的stream指针。
3.5 获取子进程退出码
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
int main() {
FILE *fp = popen("ls /nonexistent_dir", "r");
if (fp == NULL) {
perror("popen failed");
exit(EXIT_FAILURE);
}
int status = pclose(fp);
if (WIFEXITED(status)) {
printf("子进程正常退出,退出码: %d\n", WEXITSTATUS(status));
} else {
printf("子进程异常终止\n");
}
return 0;
}
四、pipe() 函数
4.1 函数原型
#include <unistd.h>
int pipe(int pipefd[2]);
4.2 函数作用
pipe() 创建一个匿名管道,并通过 pipefd 数组返回两个文件描述符:pipefd[0] 是读端,pipefd[1] 是写端。数据从写端流入,从读端流出。
pipe() 通常与 fork() 配合使用:父进程创建管道后调用 fork(),子进程继承管道的文件描述符,父子进程之间就可以通过管道通信了。
4.3 参数详解
| 参数 | 类型 | 说明 |
|---|---|---|
pipefd |
int[2] |
一个长度为 2 的整型数组,用于接收返回的两个文件描述符。pipefd[0] 为读端 fd,pipefd[1] 为写端 fd。 |
4.4 返回值
- 成功:返回
0,pipefd[0]和pipefd[1]被填充为有效的文件描述符。 - 失败:返回
-1,并设置errno。常见原因包括进程打开的文件描述符数量已达上限(EMFILE)、系统内存不足(ENOMEM)等。
4.5 使用模式
创建管道后,通常的做法是:
- 父进程调用
pipe()创建管道。 - 父进程调用
fork()创建子进程。 - 父进程关闭不需要的端口(例如关闭读端),子进程关闭不需要的端口(例如关闭写端)。
- 父子进程分别通过
write()/read()进行通信。 - 通信结束后,双方各自
close()自己持有的 fd。
为什么要关闭不需要的端口? 如果写端没有全部关闭,读端的 read() 永远不会收到 EOF(返回 0),会一直阻塞等待数据;如果读端没有全部关闭,可能会导致数据被错误的进程读走。
4.6 示例代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>
int main() {
int pipefd[2];
pid_t pid;
char buf[1024];
// 1. 创建管道
if (pipe(pipefd) == -1) {
perror("pipe failed");
exit(EXIT_FAILURE);
}
// 2. fork 子进程
pid = fork();
if (pid == -1) {
perror("fork failed");
exit(EXIT_FAILURE);
}
if (pid == 0) {
// ---- 子进程:作为写端 ----
close(pipefd[0]); // 关闭读端
const char *msg = "Hello from child!";
write(pipefd[1], msg, strlen(msg));
close(pipefd[1]); // 写完关闭
exit(EXIT_SUCCESS);
} else {
// ---- 父进程:作为读端 ----
close(pipefd[1]); // 关闭写端
int n = read(pipefd[0], buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0';
printf("父进程收到: %s\n", buf);
}
close(pipefd[0]); // 读完关闭
waitpid(pid, NULL, 0); // 等待子进程结束
}
return 0;
}
4.7 pipe2() 扩展(Linux 特有)
Linux 还提供了一个扩展版本 pipe2(),可以在创建管道时直接设置标志位:
#define _GNU_SOURCE
#include <fcntl.h>
#include <unistd.h>
int pipe2(int pipefd[2], int flags);
| flags 值 | 说明 |
|---|---|
O_NONBLOCK |
将管道的两个 fd 设置为非阻塞模式 |
O_CLOEXEC |
设置 close-on-exec 标志,exec 时自动关闭这两个 fd |
五、mkfifo() 函数
5.1 函数原型
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);
5.2 函数作用
mkfifo() 在文件系统中创建一个命名管道(FIFO 文件)。创建完成后,任何进程都可以通过 open()、read()、write()、close() 等系统调用来操作这个 FIFO,实现无亲缘关系的进程间通信。
5.3 参数详解
| 参数 | 类型 | 说明 |
|---|---|---|
pathname |
const char * |
FIFO 文件的路径名。可以是绝对路径(如 "/tmp/myfifo"),也可以是相对路径。如果该路径已经存在一个同名文件,mkfifo() 会失败并设置 errno 为 EEXIST。 |
mode |
mode_t |
FIFO 文件的权限位,与 open() 的 mode 参数含义相同,如 0666(所有用户可读写)。注意:最终权限还会受到进程的 umask 值影响。例如 umask 为 022 时,0666 实际生效的权限为 0644。 |
常见 mode 值说明:
| mode 值 | 含义 |
|---|---|
0666 |
所有用户可读可写 |
0644 |
所有者可读写,其他人只读 |
0600 |
仅所有者可读写 |
5.4 返回值
- 成功:返回
0。 - 失败:返回
-1,并设置errno。常见错误包括:EEXIST:路径已存在同名文件。ENOENT:路径中某个目录不存在。EACCES:对路径中的某个目录没有权限。
5.5 FIFO 的阻塞行为
FIFO 的默认(阻塞)打开方式有一个重要的行为特征:
- 以只读方式打开(
O_RDONLY):会阻塞,直到有进程以写方式打开该 FIFO。 - 以只写方式打开(
O_WRONLY):会阻塞,直到有进程以读方式打开该 FIFO。
这意味着读端和写端必须配对打开,否则会一直等待。可以使用 O_NONBLOCK 标志来避免阻塞,但需要注意处理错误(如写入时没有读端会返回 ENXIO)。
5.6 示例代码
以下展示两个独立的程序——一个读端、一个写端,通过 FIFO 进行通信:
写端(fifo_writer.c):
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#include <string.h>
#define FIFO_PATH "/tmp/myfifo"
int main() {
// 创建 FIFO(如果已存在则忽略错误)
if (mkfifo(FIFO_PATH, 0666) == -1) {
perror("mkfifo");
// 如果已存在,不退出,继续打开使用
}
// 以写方式打开 FIFO(会阻塞,直到有读端打开)
int fd = open(FIFO_PATH, O_WRONLY);
if (fd == -1) {
perror("open");
exit(EXIT_FAILURE);
}
const char *msg = "Hello from FIFO writer!";
write(fd, msg, strlen(msg));
printf("已写入: %s\n", msg);
close(fd);
// 注意:FIFO 文件不会自动删除,如需清理可手动 unlink
// unlink(FIFO_PATH);
return 0;
}
读端(fifo_reader.c):
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#define FIFO_PATH "/tmp/myfifo"
int main() {
char buf[1024];
// 以读方式打开 FIFO(会阻塞,直到有写端打开)
int fd = open(FIFO_PATH, O_RDONLY);
if (fd == -1) {
perror("open");
exit(EXIT_FAILURE);
}
int n = read(fd, buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0';
printf("读取到: %s\n", buf);
}
close(fd);
return 0;
}
编译运行:
gcc fifo_writer.c -o fifo_writer
gcc fifo_reader.c -o fifo_reader
# 先运行写端(会阻塞等待读端)
./fifo_writer &
# 再运行读端
./fifo_reader
六、总结与选型建议
| 需求场景 | 推荐方案 |
|---|---|
| 父子进程间快速传递数据 | pipe() + fork() |
| 执行一个 shell 命令并读取/写入数据 | popen() + pclose() |
| 无亲缘关系的进程间通信 | mkfifo() 创建命名管道 |
| 需要在 shell 脚本或命令行中使用 | 直接使用 |(匿名管道)或 mkfifo 命令(命名管道) |
管道是 Linux IPC 的基础设施,理解它的原理和行为对于编写健壮的多进程程序至关重要。实际开发中,还需要注意管道的容量限制(默认 64KB)、SIGPIPE 信号的处理(向已关闭读端的管道写入时触发)以及多读者/多写者场景下的数据交错问题。
浙公网安备 33010602011771号