管道
概述
管道为一个常见需求提供了一个优雅的解决方案:给定两个运行不同程序(命令)的进程,在shell中如何让一个进程的输出作为另一个进程的输入呢?管道可以用来在相关进程之间传递数据。
管道其实就和真实的管道类似是,它可以进行数据的传递,比如说水管,它就可以把水流从一端送到另一端。管道也是一样的,它可以把数据一字节流的形式从一端送到另一端。这种方式可以用作进程间通信
简而言之,管道允许数据从一个进程流向另一个进程
特征
字节流
管道是一个字节流,这意味着在管道中处理数据时,不存在消息或消息边界的概念。简单来说,管道仅仅是一个字节的序列,没有其他特殊的结构
- 可以从管道中读取任意大小的数据块
- 管道传递的数据是顺序的,即读取的顺序和写入的顺序保持一致
单向
在管道中数据的传递方向是单向的。管道的一段用于写入,另一端则用于读取。
写入不超过PIPE_BUF字节的操作是原子的
对于超过 PIPE_BUF 字节 的写入数据来说,内核可能会将其分割成较小的片段依次写入,如果只有一个写入进程那没有关系,如果是多个写入进程,可能会发生 数据交叉 的情况,除此之外,write调用会阻塞直到所用数据被写入管道
write 调用阻塞 是指当一个进程试图向管道、文件或其他 I/O 设备写入数据时,如果该操作无法立即完成,进程会被暂停(阻塞),直到能够继续写入
有限容量
管道其实是一个在内核内存中维护的缓冲器,这个缓冲器的存储能力是有限的。一旦管道被填满之后,后续向该管道的写入操作就会被阻塞直到读者从管道中移除了一些数据为止。
管道的创建和使用
// 由pipe系统调用创建管道
int pipe(int filedes[2]);
// 创建示例
int pipe_fd[2];
if (pipe(pipe_fd) == -1) { // 创建管道
/* 错误信息输出 */
}
成功的pipe()调用会在数组filedes中返回两个打开的文件描述符:一个表示管道的读取端(filedes[0]),另一个表示管道的写入端(filedes[1])。
与所有文件描述符一样,可以使用read()和write()系统调用来在管道上执行I/O。一旦向管道的写入端写入数据之后立即就能从管道的读取端读取数据。管道上的 read()调用会读取的数据量为所请求的字节数与管道中当前存在的字节数两者之间较小的那个(但当管道为空时阻塞)。
管道一般用于多个进程,单个进程用到的不多。如下所示,左边是父进程创建完管道,然后调用fork,子进程会 复制 管道的文件描述符。右边是一种规范吧,及时 关掉不使用的管道文件描述符,这样就能够实现父进程的数据流向子进程了
为什么要 关掉不使用的管道文件描述符 呢?
- 读取进程 如果没有关闭 管道的写入端,那么读取将永远不会结束,即 read()会一直阻塞等待数据,这是因为,一直有一个管道的写入描述符存在,即读取进程没有关闭的那个
从管道中读取数据的进程会关闭其持有的管道的写入描述符,这样当其他进程完成输出并关闭其写入描述符之后,读者就能够看到文件结束(在读完管道中的数据之后)。
- 写入进程 如果没有关闭 管道的读取端,那么写入将永远不会结束,管道写满了,write()就会阻塞,这是应为,一直有一个管道的读取描述符存在,即读取进程没有关闭的那个
关闭未使用文件描述符的最后一个原因是只有当所有进程中所有引用一个管道的文件描述符被关闭之后才会销毁该管道以及释放该管道占用的资源以供其他进程复用。此时,管道中所有未读取的数据都会丢失
管道用作进程同步
- 父进程在创建子进程之前构建了一个管道
- 每个子进程会继承管道的写入端的文件描述符并在完成动作之后关闭这些描述符
- 当所有子进程都关闭了管道的写入端的文件描述符之后,父进程在管道上的read()就会结束并返回文件结束(0),父进程就能做其他事情了
同步的关键在于,及时关掉不使用的管道文件描述符,即一个进程不同时持有管道的写入端和读取端
管道连接过滤器
管道连接过滤器 就是指通过管道将多个数据处理程序(过滤器)连接在一起,使得数据流从一个过滤器传递到下一个过滤器,经过多个处理步骤,最终得到所需的输出
简单来说就是文件描述符的绑定,一般来说就是 标准输出和输入(STDOUT_FILENO 和 STDIN_FILENO) ,我的读入端是你的写入端,这样就能形成数据的流通了
// 使用示例
int pfd[2];
pipe(pfd); // 创建管道
if (pfd[0] != STDIN_FILENO) { // 如果相同那就没有多余描述符,也就不用关闭了
dup2(pfd[0], STDIN_FILENO); // 将标准输入绑定为管道的读取端
close(pfd[0]); // 关闭多余的文件描述符
}
if (pfd[1] != STDOUT_FILENO) {
dup2(pfd[1], STDOUT_FILENO); // 将标准输出绑定为管道的读取端
close(pfd[1]); // 关闭多余的文件描述符
}
通过管道与shell命令进行通信
popen() 和 pclose() 函数用于通过管道与 shell 命令进行通信,简化了执行 shell 命令并读取其输出或向其发送输入的任务。
popen()
- 功能
- 创建一个管道,并启动一个子进程来执行指定的 shell 命令
- 提供一种简单的方式来读取命令的输出或向命令发送输入
popen()函数创建了一个管道,然后创建了一个子进程来执行shell,而 shell又创建了一个子进程来执行 command 字符串。
- 语法
FILE *popen(const char *command, const char *mode);
-
参数
- command:要执行的 shell 命令字符串
- mode:打开模式:
- "r":读取模式,从命令的标准输出读取数据
- "w":写入模式,向命令的标准输入发送数据
-
返回值
- 成功时返回管道的读取或写入文件流,与mode相关
- 失败时返回 NULL,并设置 errno 以指示错误原因
pclose()
-
功能
- 关闭通过 popen() 打开的管道,并等待子进程的终止
-
语法
int pclose(FILE *stream);
-
参数
- stream:由 popen() 返回的指向 FILE 对象的指针
-
返回值
- 返回子进程的终止状态。如果成功,返回子进程的退出状态;如果失败,返回 -1
// 示例
#include <stdio.h>
int main() {
FILE *fp;
char buffer[128];
// 使用 popen 执行 'ls' 命令,读取输出
fp = popen("ls", "r");
if (fp == NULL) {
perror("popen failed");
return 1;
}
// 读取并打印命令的输出
while (fgets(buffer, sizeof(buffer), fp) != NULL) {
printf("%s", buffer);
}
// 关闭管道
pclose(fp);
return 0;
}
注意事项
-
管道是单向的:
- 在 mode 为 "r" 时,命令的标准输出连接到管道
- 在 mode 为 "w" 时,命令的标准输入连接到管道
-
信号处理:
popen() 创建的子进程与调用进程在同一进程组中,因此来自终端的信号会同时发送到两个进程
-
资源管理:
使用 popen() 后,必须用 pclose() 关闭管道,避免资源泄露,不应使用 fclose()
-
错误处理:
在调用 popen() 和 pclose() 时应检查返回值,以确保操作成功
管道和stdio缓冲区
由于 popen() 返回的文件流指针没有引用终端,stdio 库对该文件流使用块缓冲。
popen()函数的文件流指针指向的是管道,而不是终端,所以称为 没有引用终端
当使用 mode 为 w 调用 popen() 时,输出默认只在 缓冲区满或调用 pclose() 后才发送到子进程。如果需要子进程 立即接收数据,则应定期调用 fflush() 或使用 setbuf(fp, NULL) 禁用缓冲
以 mode 为 r 调用 popen() 时,子进程的输出只有在 填满缓冲区或调用 pclose() 后才对调用进程可用。若无法修改子进程代码以调用 setbuf() 或 fflush(),可以使用伪终端替代管道,伪终端让 stdio 库逐行输出数据
FIFO (命名管道)
从语义上来讲,FIFO 与管道类似,它们两者之间最大的差别在于FIFO在文件系统中拥有一个名称,并且其打开方式与打开一个普通文件是一样的。这样就能够将FIFO用于非相关进程之间的通信(如客户端和服务器)。
FIFO的创建
// 函数原型
int mkfifo(const char *pathname, mode_t mode);
// 使用示例
umask(0); // 将掩码设置为0,这样我们赋予fifo的权限就不会被影响
if (mkfifo(FIFO_NAME, 0320) == -1) {
/* 错误信息 */
}
int fifo_r_fd = open(FIFO_NAME, O_RDONLY); // 读进程
...
int fifo_w_fd = open(FIFO_NAME, O_WRONLY); // 写进程
一般来讲,使用FIFO时唯一明智的做法是在两端分别设置一个读取进程和一个写入进程。,打开一个FIFO会同步读取进程和写入进程。
简单来说,当你用open打开fifo时,它会阻塞直到另一个进程用open打开fifo,并且两者打开的方式是 只读或只写
使用FIFO实现一个客户端/服务器应用程序
服务器无法使用单个FIFO响应多个客户端的请求,这样多个客户端读取数据时会发生数据竞争,所以每个客户端单独创建一个FIFO用来接收响应(假设服务器为所有客户所知)
这里服务器使用单个FIFO接收所有客户端请求,因为管道是字节流,没有消息边界,所以得 约定某种规则来分隔消息,以下是3种方法
-
分隔字符 就是用某个特殊字符来分隔消息,这要求 读取消息的进程在从FIFO中扫描数据时必须要逐个字节地分析直到找到分隔符为止
-
具有长度字段的头 指明了消息剩余部分的长度,能够高效地读取任意大小的消息,但一旦不合规则(如错误的length字段)的消息被写入到管道中之后问题就出来了
-
固定长度的消息 让服务器总是读取这个大小固定的消息,但 有可能会浪费通道容量,或者说如果其中一个客户端意外地或故意发送了一条长度不对的消息,那么所有后续的消息都会出现步调不一致的情况
打开FIFO进程的死锁
非阻塞IO
FIFO和管道的open语义
这是打开FIFO文件的一些情况,如果读进程和写进程都没有打开,对应的open会阻塞
可以使用标志 O_NONBLOCK 以非阻塞的方式打开,这样open会立即返回
// 可以直接在open中指明
int fifo_r_fd = open(FIFO_NAME, O_RDONLY | O_NONBLOCK);
// 或者使用fcntl
int flags;
flags = fcntl(fd, F_GETFL);
flags |= O_NONBLOCK; // 打开非阻塞标志
flags ~= O_NONBLOCK; // 禁用非阻塞标志
fcntl(fd, F_SETFL, flags);