Linux IPC之管道和FIFO

Linux大全评论1.3K views阅读模式

导言:管道是UNIX系统上最古老的IPC方法,管道提供了一种优雅的解决方案:给定两个运行不同程序的进程,在shell中如何让一个进程的输出作为另一个进程的输入?管道可以用来在相关(一个共同的祖先进程创建管道)进程之间传递数据。FIFO是管道概念的一个变体,它们之间的一个重要差别在于FIFO可以用于任意进程间的通信。

概述

每个shell用户都对在命令中使用管道比较熟悉,例如,统计一个目录中文件的数目:

ls | wc -l

解释:为了执行上面的命令,shell创建了两个进程来分别执行ls和wc(通过使用fork()和exec()来完成)。如下图所示:

Linux IPC之管道和FIFO

管道的特征

  1. 一个管道是一个字节流(无边界,顺序的)
    意味着在使用管道时,是不存在消息或消息边界的概念,从管道中读取数据的进程可以读取任意大小的数据块,而不管写入进程写入管道的数据块的大小是什么。此外,通过管道传递的数据是顺序的,从管道中读取出来的字节顺序与它们被写入管道的顺序是完全一样的,在管道中无法使用lseek()来随机地访问数据。

    如果需要在管道中实现离散消息的传递,就必须要在应用程序中完成这些工作,但是对于此类需求,最好使用其他IPC机制,比如,消息队列,数据报socket。

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

  3. 管道是单向的
    在管道中数据的传递方向是单向的,管道的一端用于写入,另一端用于读取。

  4. 可以确保写入不超过PIPE_BUF字节的操作是原子的
    如果多个进程写入同一个管道,那么如果每个进程在一个时刻写入的数据量不超过PIPE_BUF字节,那么就可以确保写入的数据不会发生相互交叉的情况。SUSv3要求PIPE_BUF至少为_POSIX_PIPE_BUF(512),不同的UNIX实现上的PIPE_BUF不同,在Linux上,PIPE_BUF的值为4096

  5. 管道的容量是有限的
    管道其实是一个在内核中维护的缓冲器,这个缓冲器的存储能力是有限的。一旦管道被填满后,后续向管道的写入操作就会被阻塞,直到读者从管道中移除了一些数据为止。

SUSv3并没有规定管道的存储能力,从Linux2.6.11起,管道的存储能力是65536字节(64KB),其他UNIX实现上的管道的存储能力可能是不同的。一般来讲,一个应用程序无需知道管道的实际存储能力,如果需要防止写者进程阻塞,那么管道中读取数据的进程应该被设计成以尽可能快的速度从管道中读取数据。

在内核中针对管道使用较大的缓冲器的原因是:效率。每当写者充满管道时,内核必须要执行一个上下文切换以允许读者被调度来消耗管道中的一些数据。使用较大的缓冲器意味着需要执行的上下文切换次数更少。
从Linux2.6.35开始就可以修改一个管道的存储能力了。Linux特有的fcntl(fd, F_SETPIPE_SZ, size)调用会将fd引用的管道的存储能力修改为至少size字节。非特权进程可以将管道的存储能力修改为范围在系统的页面大小到/proc/sys/fs/pipe-max-size中规定的值之内的任何一个值。pipe-max-size的默认值是1048576字节(1MB)。fcntl(fd, F_GETPIPE_SZ)调用返回为管道分配的实际大小。

管道的用法

#include <unistd.h>
int pipe(int filedes[2]);

成功的pipe()调用会在数组filedes中返回两个打开的文件描述符:一个表示管道的读取端(filedes[0]),另一个表示管道的写入端(filedes[1])。与所有文件描述符一样,可以使用read()write()系统调用来在管道上执行I/O,管道上的read()调用会读取的数据量为所请求的字节数与管道中当前存在的字节数两者之间较小的那个,但当管道为空时阻塞。

ioctl(fd, FIONREAD, &cnt)调用返回文件描述符fd所引用的管道或FIFO中未读取的字节数。其他一些实现也提供了这个特性,但SUSv3并没有对此进行规定。

通常,使用管道让两个进程进程通信,为了让两个进程通过管道进行连接,在调用完pipe()之后可以调用fork()。在fork()期间,子进程会继承父进程的文件描述符。虽然,父进程和子进程都可以从管道中读取和写入数据,但这种做法并不常见,因此,在fork()调用之后,其中一个进程应该立即关闭管道的写入端的描述符,另一个进程应该关闭读取端的描述符。

int filedes[2];

if (pipe(filedes) == -1)
    errExit("pipe");

switch(fork()) {
    case -1:
        errExit("fork");

    case 0:/* Child */
        // close unused write end
        if (close(filedes[1]) == -1)
            errExit("close");
        // Child now reads from pipe
        break;

    default:/* Parent */
        // close unused read end
        if (close(filedes[0]) == -1)
            errExit("close");
        // Parent now writes to pipe
        break;     
}

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

  1. 从2.6.27内核开始,Linux支持一个全新的非标准系统调用pipe2(),这个系统调用执行的任务和pipe()一样,但支持额外的参数flags。
  2. 管道只能用于相关进程之间的通信,有一种例外,通过UNIX domain socket将管道的文件描述符传递给一个非相关的进程使用。

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

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

写入进程关闭其持有的管道的读取描述符是出于不同的原因。当一个进程试图向一个管道中写入数据但没有任何进程拥有该管道的打开着的读取描述符时,内核会向写入进程发送一个SIGPIPE信号,在默认情况下,这个信号会杀死一个进程,但进程可以捕获或忽略该信号,这样就会导致管道上的write()操作因为EPIPE错误而失败,收到SIGPIPE信号或得到EPIPE错误对于标示出管道的状态是有用的。如果写入进程没有关闭管道的读取端,那么即使在其他进程已经关闭了管道的读取端之后写入进程仍然能够向管道写入数据,最后写入进程会将数据充满整个管道,后续的写入请求会被永远阻塞。

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

例子:在父进程和子进程之间使用管道通信。
https://github.com/gerryyang/TLPI/blob/master/src/pipes/simple_pipe.c

FIFO(命名管道)

FIFO与管道类似,它们最大的差别是,FIFO在文件系统中拥有一个名称,并且其打开方式与打开一个普通文件是一样的,这样就能够将FIFO用于非相关进程之间的通信。

# 使用mkfifo命令可以在shell中创建一个fifo
$ mkfifo [-m mode] pathname

mkfifo()函数创建一个名为pathname的全新FIFO。大多数UNIX实现提供了mkfifo(),它是构建于mknod()之上的一个库函数。一旦FIFO被创建,任何进程都能够打开它,只要它能够通过常规的文件权限检测。

#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);

在大多数UNIX实现(包括Linux)上,当打开一个FIFO时可以通过指定O_RDWR标记来绕开打开FIFO时的阻塞行为,这样open()就会立即返回,但��法使用返回的文件描述符在FIFO上读取和写入数据。这种做法破坏了FIFO的I/O模型,SUSv3明确指出以O_RDWR标记打开一个FIFO的结果是未知的,因此出于可移植性的原因,开发人员不应该使用这项技术。对于那些需要避免在打开FIFO时发生阻塞地需求,open()O_NONBLOCK标记提供了一种标准化的方法来完成这个任务。

使用管道实现一个客户端/服务器应用程序

所有客户端使用一个服务器FIFO来向服务器发送请求,头文件定义了众所周知的名称(/tmp/seqnum_sv),服务器的FIFO将使用这个名称。这个名称是固定的,因此所有客户端知道如何联系到服务器。(在一个像/tmp这样公共可写的目录中创建文件可能会导致各种安全隐患,因此实际应用中的程序不应该使用这种目录)

无法使用单个FIFO向所有客户端发送响应,因为多个客户端在从FIFO中读取数据时会相互竞争,这样就可能出现各个客户端读取到了其他客户端的响应消息。因此,每个客户端需要创建一个唯一的FIFO,服务器使用这个FIFO来向客户端传递响应。并且服务器需要知道如何找到各个客户端的FIFO。

解决这个问题的一种方法是,让客户端生成自己的FIFO路径名,然后将路径名作为请求消息的一部分传递给服务器。另一种方法是,客户端和服务器可以约定一个构建客户端FIFO路径名的规则,然后客户端可以将构建自己的路径名所需要的相关信息作为请求的一部分发送给服务器。

记住管道和FIFO中的数据时字节流,消息之间是没有边界的。这意味着当多条消息被传递到一个进程中时,发送者和接收者必须要约定某种规则来分隔消息。这可以使用多种方法:

  • 每条消息使用诸如换行符之类的分割字符结束。
    特点:读取消息的进程在从FIFO中扫描数据时必须要逐个字节地分析直到找到分隔符为止。

  • 在每条消息中包含一个大小固定的头,头中包含一个表示消息长度的字段,该字段指定了消息中剩余部分的长度。这样读取进程就需要首先从FIFO中读取头,然后使用头中的长度字段来确定需要读取的消息中剩余部分的字节数。
    特点:这种方法能够高效地读取任意大小的消息

  • 使用固定长度的消息,并让服务器总是读取这个大小固定的消息。
    特点:这种方法的优势在于简单性,但是它对消息的大小设置了一个上限,意味着会浪费一些通道容量(因为需要对较短的消息进行填充以满足固定长度),此外,如果其中一个客户端意外地或故意发送了一条长度不对的消息,那么所有后续的消息都会出现步调不一致的情况,并且在这种情况下服务器是难以恢复的。

注意,不管使用这三种技术中的哪种,每条消息的总长度必须要小于PIPE_BUF字节,以防止内核对消息进行拆分,从造成与其他写者发送的消息错乱的情况发生。

Linux IPC之管道和FIFO

TODO

非阻塞I/O

当一个进程打开一个FIFO的一端时,如果FIFO的另一端还没有被打开,那么该进程会被阻塞,但有些时候阻塞并不是期望的行为,而这可以通过在调用open()时指定O_NONBLOCK标记来实现。

fd = open("filepath", O_RDONLY | O_NONBLOCK);
if (fd == -1) errExit("open");

如果FIFO的另一端已经被打开,那么O_NONBLOCKopen()调用不会产生任何影响。只有当FIFO的另一端还没有被打开的时候,O_NONBLOCK标记才会起作用,而具体产生的影响则依赖于打开FIFO是用于读取还是用于写入的:

  • 如果是为了读取,不管FIFO的写入端当前是否已经被打开,open()调用都会立即成功。
  • 如果是为了写入,并且还没有打开FIFO的另一端来读取数据,那么open()调用会失败,并将errno设置为ENXIO

打开一个FIFO时使用O_NONBLOCK标记存在两个目的:
1. 它允许单个进程打开一个FIFO的两端。
2. 它防止打开两个FIFO的进程之间产生死锁。

在FIFO上调用open()的语义
Linux IPC之管道和FIFO

管道和FIFO中read()和write()的语义

从一个包含p字节的管道或FIFO中读取n字节的语义
Linux IPC之管道和FIFO

企鹅博客
  • 本文由 发表于 2019年8月9日 21:37:27
  • 转载请务必保留本文链接:https://www.qieseo.com/230131.html

发表评论