TLPI读书笔记第44章-管道和FIFO1

管道为一个常见需求提供了一个优雅的解决方案:给定两个运行不同程序(命令)的进程,在 shell 中如何让一个进程的输出作为另一个进程的输入呢?管道可以用来在相关进程之间传递数据

FIFO 是管道概念的一个变体,它们之间的一个重要差别在于 FIFO 可以用于任意进程间的通信

44.1 概述

ls | wc -l 

为执行上面的命令, shell 创建了两个进程来分别执行 ls 和 wc。 (这是通过使用 fork()和exec()来完成的 )图 44-1 展示了这两个进程是如何使用管道的

 

 

除了说明管道的用法之外,图 44-1 的另外一个目的是阐明管道这个名称的由来。可以将管道看成是一组铅管,它允许数据从一个进程流向另一个进程。 在图 44-1 中有一点值得注意的是两个进程都连接到了管道上,这样写入进程( ls)就将其标准输出(文件描述符为 1)连接到了管道的写入端,读取进程( wc)就将其标准输入 连接到管道的读取端。实际上,这两个进程并不知道管道的存在,它们只是从标准文件描述符中读取数据和写入数据。 shell 必须要完成相关的工作,在 44.4 节中将会介绍shell 是如何完成这些工作的。

一个管道是一个字节流

当讲到管道是一个字节流时意味着在使用管道时是不存在消息或消息边界的概念的。从管道中读取数据的进程可以读取任意大小的数据块,而不管写入进程写入管道的数据块的大小是什么。此外,通过管道传递的数据是顺序的——从管道中读取出来的字节的顺序与它们被写入管道的顺序是完全一样的。在管道中无法使用 lseek()来随机地访问数据。 如果需要在管道中实现离散消息的概念,那么就必须要在应用程序中完成这些工作。虽然这是可行的,但如果碰到这种需求的话最好使用其他 IPC 机制,如消息队列和数据报 socket。

从管道中读取数据

试图从一个当前为空的管道中读取数据将会被阻塞直到至少有一个字节被写入到管道中为止。如果管道的写入端被关闭了,那么从管道中读取数据的进程在读完管道中剩余的所有数据之后将会看到文件结束(即 read()返回 0)。

管道是单向的

在管道中数据的传递方向是单向的。管道的一段用于写入,另一端则用于读取。 在其他一些 UNIX 实现上——特别是那些从 System V Release 4 演化而来的系统——管道是双向的(所谓的流管道)。双向管道并没有在任何 UNIX 标准中进行规定,因此即使在提供了双向管道的实现上最好也避免依赖这种语义。

作为替代方案,可以使用 UNIX domain 流socket 对(通过使用 57.5 节中介绍的 socketpair()系统调用来创建),它提供了一种标准的双向通信机制,并且其语义与流管道是等价的。

管道的容量是有限的

管道其实是一个在内核内存中维护的缓冲器,这个缓冲器的存储能力是有限的。一旦管道被填满之后,后续向该管道的写入操作就会被阻塞直到读者从管道中移除了一些数据为止。 SUSv3 并没有规定管道的存储能力。在早于 2.6.11 的 Linux 内核中,管道的存储能力与系统页面的大小是一致的(如在 x86-32 上是 4096 字节),而从 Linux 2.6.11 起,管道的存储能力是 65,536 字节。其他 UNIX 实现上的管道的存储能力可能是不同的。 一般来讲,一个应用程序无需知道管道的实际存储能力。如果需要防止写者进程阻塞,那么从管道中读取数据的进程应该被设计成以尽可能快的速度从管道中读取数据。

44.2 创建和使用管道

/*pipe()系统调用创建一个管道*/
include<unistd.h>
int pipe(int filedes[2]);

 

成功的 pipe()调用会在数组 filedes 中返回两个打开的文件描述符: 一个表示管道的读取端( filedes[0]),另一个表示管道的写入端( filedes[1])。

与所有文件描述符一样,可以使用 read()和 write()系统调用来在管道上执行 I/O。一旦向管道的写入端写入数据之后立即就能从管道的读取端读取数据。管道上的 read()调用会读取的数据量为所请求的字节数与管道中当前存在的字节数两者之间较小的那个(但当管道为空时阻塞)。 也可以在管道上使用 stdio 函数( printf()、 scanf()等),只需要首先使用 fdopen()获取一个与 filedes 中的某个描述符对应的文件流即可。但在这样做的时候需要清楚在 stdio 缓冲问题。

图 44-2 给出了使用 pipe()创建完管道之后的情况,其中调用进程通过文件描述符引用了管道的两端。 在单个进程中管道的用途不多。一般来讲都是使用管道让两个进程进行通信。为了让两个进程通过管道进行连接,在调用完 pipe()之后可以调用 fork()。在fork()期间,子进程会继承父进程的文件描述符的副本,这样就会出现图 44-3 中 左边那样的情形

 

 

虽然父进程和子进程都可以从管道中读取和写入数据,但这种做法并不常见。因此,在fork()调用之后,其中一个进程应该立即关闭管道的写入端的描述符,另一个则应该关闭读取端的描述符。如,如果父进程需要向子进程传输数据,那么它就会关闭管道的读取端的描述符 filedes[0],而子进程就会关闭管道的写入端的描述符 filedes[1],这样就出现了图 44-3 中右边那样的情形。程序清单 44-1 给出了创建这个管道的代码。

int filedes[2];
if(pipe(filedes)==-1)
    errExit("pipe");
switch(fork()){
    case -1:
        errExit("fork");
    case 0:
        if(close(filedes[1])==-1)
            errExit("close");
        /*child do some read from pipe */
        break;
    default:
        if(close(filedes[0])==-1)
            errExit("close");
        /*parent do some write to pipe */
        break;
}

 

让父进程和子进程都能够从同一个管道中读取和写入数据这种做法并不常见的一个原因是如果两个进程同时试图从管道中读取数据,那么就无法确定哪个进程会首先读取成功—两个进程竞争数据了。要防止这种竞争情况的出现就需要使用某种同步机制。

但如果需要双向通信则可以使用一种更加简单的方法:创建两个管道,在两个进程之间发送数据的两个方向上各使用一个。 (如果使用这种技术,那么就需要考虑死锁的问题了,因为如果两个进程都试图从空管道中读取数据或尝试向已满的管道中写入数据就可能会发生死锁。 )

虽然可以有多个进程向单个管道中写入数据,但通常只存在一个写者。相反,在有些情况下让 FIFO 拥有多个写者是比较有用的

从 2.6.27 内核开始, Linux 支持一个全新的非标准系统调用 pipe2()。这个系统调用执行的任务与 pipe()一样,但支持额外的参数 flags,这个参数可以用来修改系统调用的行为。 这个系统调用支持两个标记,一个是 O_CLOEXEC,它会导致内核为两个新的文件描述符启用 close-on-exec 标记( FD_CLOEXEC)。这个标记之所以有用的原因与在 4.3.1 节中介绍的 open() O_CLOEXEC 标记有用的原因一样。另一个是 O_NONBLOCK 标记,它会导致内核将底层的打开的文件描述符标记为非阻塞,这样后续的 I/O 操作会是非阻塞的。这样就能够在不调用 fcntl()的情况下达到同样的效果了

管道允许相关进程间的通信

目前为止本章已经介绍了如何使用管道来让父进程和子进程之间进行通信,其实管道可以用于任意两个(或更多)相关进程之间的通信,只要在创建子进程的系列 fork()调用之前通过一个共同的祖先进程创建管道即可。

如管道可用于一个进程和其孙子进程之间的通信。第一个进程创建管道,然后创建子进程,接着子进程再创建第一个进程的孙子进程。管道通常用于两个兄弟进程之间的通信——它们的父进程创建了管道,然后创建两个子进程。这就是在构建管道线时 shell所做的工作

为什么要关闭未使用管道文件描述符

关闭未使用管道文件描述符不仅仅是为了确保进程不会耗尽其文件描述符的限制——这对于正确使用管道是非常重要的。下面介绍为何必须要关闭管道的读取端和写入端的未使用文件描述符。

1.从管道中读取数据的进程会关闭其持有的管道的写入描述符,这样当其他进程完成输出并关闭其写入描述符之后,读者就能够看到文件结束(在读完管道中的数据之后)。 如果读取进程没有关闭管道的写入端,那么在其他进程关闭了写入描述符之后,读者也不会看到文件结束,即使它读完了管道中的所有数据。相反, read()将会阻塞以等待数据,这是因为内核知道至少还存在一个管道的写入描述符打开着,即读取进程自己打开了这个描述符。从理论上来讲,这个进程仍然可以向管道写入数据,即使它已经被读取操作阻塞了。

2.写入进程关闭其持有的管道的读取描述符是出于不同的原因。当一个进程试图向一个管道中写入数据但没有任何进程拥有该管道的打开着的读取描述符时,内核会向写入进程发送一个 SIGPIPE 信号。在默认情况下,这个信号会杀死一个进程。但进程可以捕获或忽略该信号,这样就会导致管道上的 write()操作因 EPIPE 错误(已损坏的管道)而失败。收到 SIGPIPE信号或得到 EPIPE 错误对于标示出管道的状态是有用的,这就是为何需要关闭管道的未使用读取描述符的原因。

3.如果写入进程没有关闭管道的读取端,那么即使在其他进程已经关闭了管道的读取端之后写入进程仍然能够向管道写入数据,最后写入进程会将数据充满整个管道,后续的写入请求会被永远阻塞。

4.关闭未使用文件描述符的最后一个原因是只有当所有进程中所有引用一个管道的文件描述符被关闭之后才会销毁该管道以及释放该管道占用的资源以供其他进程复用。此时,管道中所有未读取的数据都会丢失。

 

#include <sys/wait.h>
#include "tlpi_hdr.h"

#define BUF_SIZE 10

int
main(int argc, char *argv[])
{
    int pfd[2];                             /* Pipe file descriptors */
    char buf[BUF_SIZE];
    ssize_t numRead;

    if (argc != 2 || strcmp(argv[1], "--help") == 0)
        usageErr("%s string\n", argv[0]);
    /* 1.创建管道 */
    if (pipe(pfd) == -1)
        errExit("pipe");
    /* 2.创建一个子进程 */
    switch (fork()) {
    case -1:
        errExit("fork");

    case 0:             /* fork返回0是子进程 */
        if (close(pfd[1]) == -1)            /* 3.子进程负责读管道,关闭写文件描述符 */
            errExit("close - child");

        for (;;) {              /* 4.子进程从管道读数据到末尾 */
            numRead = read(pfd[0], buf, BUF_SIZE);
            if (numRead == -1)
                errExit("read");
            if (numRead == 0)   /*5.退出循环*/
                break;                      /* 6.子进程把读到的内容写到标准输出 */
            if (write(STDOUT_FILENO, buf, numRead) != numRead)
                fatal("child - partial/failed write");
        }
        /*7.退出循环,关闭读文件描述符*/
        write(STDOUT_FILENO, "\n", 1);

        if (close(pfd[0]) == -1)
            errExit("close");
        _exit(EXIT_SUCCESS);

    default:            /* 8.父进程负责写,关闭读文件描述符 */
        if (close(pfd[0]) == -1)            /* Read end is unused */
            errExit("close - parent");
        /* 9.父进程将命令行参数得到的数据写入管道 */
        if (write(pfd[1], argv[1], strlen(argv[1])) != strlen(argv[1]))
            fatal("parent - partial/failed write");
        /* 10.关闭写文件描述符 */
        if (close(pfd[1]) == -1)            /* Child will see EOF */
            errExit("close");
        wait(NULL);                         /* 11.等待子进程结束 */
        exit(EXIT_SUCCESS);
    }
}

 

posted @ 2021-04-09 14:26  Mars.wang  阅读(82)  评论(0编辑  收藏  举报