当前位置: 首页 > news >正文

Linux/AndroidOS中进程间的通信线程间的同步 - 管道和FIFO

前言

管道是 UNIX 系统上最古老的 IPC 方法,它在 20 世纪 70 年代早期 UNIX 的第三个版本上就出现了。管道为一个常见需求提供了一个优雅的解决方案:给定两个运行不同程序的进程,在 shell 中如何让一个进程的输出作为另一个进程的输入呢?管道可以用来在相关进程之间传递数据。FIFO 是管道概念的一个变体,它们之间的一个重要差别在于 FIFO 可以用于任意进程间的通信。

1 概述

每个 shell 用户都对在命令中使用管道比较熟悉,如下面这个统计一个目录中文件的数目的命令所示。

ls | wc -l

为执行上面的命令,shell 创建了两个进程来分别执行 ls 和 wc。(这是通过使用 fork()和exec()来完成的)下图展示了这两个进程是如何使用管道的。使用管道连接两个进程
两个进程都连接到了管道上,这样写入进程(ls)就将其标准输出(文件描述符为 1)连接到了管道的写入端,读取进程(wc)就将其标准输入(文件描述符为 0)连接到管道的读取端。实际上,这两个进程并不知道管道的存在,它们只是从标准文件描述符中读取数据和写入数据。

1.1 一个管道是一个字节流

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

1.2 从管道中读取数据

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

1.3 管道是单向的

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

1.4 管道的容量是有限的

管道其实是一个在内核内存中维护的缓冲器,这个缓冲器的存储能力是有限的。一旦管道被填满之后,后续向该管道的写入操作就会被阻塞直到读者从管道中移除了一些数据为止。
一般来讲,一个应用程序无需知道管道的实际存储能力。如果需要防止写者进程阻塞,那么从管道中读取数据的进程应该被设计成以尽可能快的速度从管道中读取数据。

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 中的某个描述符对应的文件流即可。

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

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

int filedes[2];
if(pipe(filedes)== -1)					/* Create the pipe */errExit("pipe”);switch(fork()){							/*Create a child process */
case -1:errExit("fork”);
case 0: /*Child */if(close(filedes[1]) == -1)			/* Close unused write end */errExit("close");/*Child now reads from pipe */break;
default: /*Parent */if(close(filedes[o]) == -1)			/*Close unused read end */errExit("close");/* Parent now writes to pipe */break ;

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

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

虽然可以有多个进程向单个管道中写入数据,但通常只存在一个写者。

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

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

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

2.2 关闭未使用管道文件描述符

关闭未使用管道文件描述符不仅仅是为了确保进程不会耗尽其文件描述符的限制——这对于正确使用管道是非常重要的。

从管道中读取数据的进程会关闭其持有的管道的写入描述符,这样当其他进程完成输出并关闭其写入描述符之后,读者就能够看到文件结束(在读完管道中的数据之后)。

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

写入进程关闭其持有的管道的读取描述符是出于不同的原因

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

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

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

2.3 示例程序

程序演示了如何将管道用于父进程和子进程之间的通信。这个例子演示了前面提及的管道的字节流特性——父进程在一个操作中写入数据,子进程一小块一小块地从管道中读取数据。

  1. 主程序调用 pipe()创建管道,然后调用 fork()创建一个子进程。
  2. 在 fork()调用之后,父进程关闭了其持有的管道的读取端的文件描述符并将通过程序的命令行参数传递进来的字符串写到管道的写入端。
  3. 父进程接着关闭管道的读取端并调用 wait()等待子进程终止 。
  4. 在关闭了所持有的管道的写入端的文件描述符之后,子进程进入了一个循环,在这个循环中从管道读取数据块并将它们写到⑥标准输出中。
  5. 当子进程碰到管道的文件结束时就退出循环,并写入一个结尾换行字符以及关闭所持有的管道的读取端的描述符,最后终止。

下面是运行程序时可能看到的输出。

./simple_pipe 'It was a bright cold day in April,and the clocks were striking thirteen.'
It was a bright cold day in April,and the clocks were striking thirteen.
#include <sys/wait.h>
#include "tlpi_hdr.h"#define BUF_SIZE 10int
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]);if (pipe(pfd) == -1)                    /* Create the pipe */errExit("pipe");switch (fork()) {case -1:errExit("fork");case 0:             /* Child  - reads from pipe */if (close(pfd[1]) == -1)            /* Write end is unused */errExit("close - child");for (;;) {              /* Read data from pipe, echo on stdout */numRead = read(pfd[0], buf, BUF_SIZE);if (numRead == -1)errExit("read");if (numRead == 0)break;                      /* End-of-file */if (write(STDOUT_FILENO, buf, numRead) != numRead)fatal("child - partial/failed write");}write(STDOUT_FILENO, "\n", 1);if (close(pfd[0]) == -1)errExit("close");exit(EXIT_SUCCESS);default:            /* Parent - writes to pipe */if (close(pfd[0]) == -1)            /* Read end is unused */errExit("close - parent");if (write(pfd[1], argv[1], strlen(argv[1])) != strlen(argv[1]))fatal("parent - partial/failed write");if (close(pfd[1]) == -1)            /* Child will see EOF */errExit("close");wait(NULL);                         /* Wait for child to finish */exit(EXIT_SUCCESS);}
}

3 将管道作为一种进程同步的方法

这个程序创建了多个子进程(每个命令行参数对应一个子进程),每个子进程都完成某个动作,在本例中则是睡眠一段时间。父进程等待直到所有子进程完成了自己的动作为止。为了执行同步:

  1. 父进程在创建子进程之前构建了一个管道。
  2. 每个子进程会继承管道的写入端的文件描述符并在完成动作之后关闭这些描述符。
  3. 当所有子进程都关闭了管道的写入端的文件描述符之后,父进程在管道上的 read()就会结束并返回文件结束(0)。

这时,父进程就能够做其他工作了。(注意在父进程中关闭管道的未使用写入端对于这项技术的正常运转是至关重要的,否则父进程在试图从管道中读取数据时会被永远阻塞。)

下面是创建三个分别睡眠 4、2 和 6 秒的子进程时所看到的输出。

./pipe_sync 4 2 6
00:59:08  Parent started
00:59:10  Child 2 (PID=181182) closing pipe
00:59:12  Child 1 (PID=181181) closing pipe
00:59:14  Child 3 (PID=181183) closing pipe
00:59:14  Parent ready to go
#include "curr_time.h"                      /* Declaration of currTime() */
#include "tlpi_hdr.h"int
main(int argc, char *argv[])
{int pfd[2];                             /* Process synchronization pipe */int j, dummy;if (argc < 2 || strcmp(argv[1], "--help") == 0)usageErr("%s sleep-time...\n", argv[0]);setbuf(stdout, NULL);                   /* Make stdout unbuffered, since weterminate child with _exit() */printf("%s  Parent started\n", currTime("%T"));if (pipe(pfd) == -1)errExit("pipe");for (j = 1; j < argc; j++) {switch (fork()) {case -1:errExit("fork %d", j);case 0: /* Child */if (close(pfd[0]) == -1)        /* Read end is unused */errExit("close");/* Child does some work, and lets parent know it's done */sleep(getInt(argv[j], GN_NONNEG, "sleep-time"));/* Simulate processing */printf("%s  Child %d (PID=%ld) closing pipe\n",currTime("%T"), j, (long) getpid());if (close(pfd[1]) == -1)errExit("close");/* Child now carries on to do other things... */_exit(EXIT_SUCCESS);default: /* Parent loops to create next child */break;}}/* Parent comes here; close write end of pipe so we can see EOF */if (close(pfd[1]) == -1)                /* Write end is unused */errExit("close");/* Parent may do other work, then synchronizes with children */if (read(pfd[0], &dummy, 1) != 0)fatal("parent didn't get EOF");printf("%s  Parent ready to go\n", currTime("%T"));/* Parent can now carry on to do other things... */exit(EXIT_SUCCESS);
}

与使用信号来同步相比,使用管道同步具备一个优势:它可以同来协调一个进程的动作使之与多个其他(相关)进程匹配。而多个(标准)信号无法排队的事实使得信号不适用于这种情形。(相反,信号的优势是它可以被一个进程广播到进程组中的所有成员处。)
其他同步结构也是可行的(如使用多个管道)。此外,还可以对这项技术进行扩展,即不关闭管道,每个子进程向管道写入一条包含其进程 ID 和一些状态信息的消息。或者每个子进程可以向管道写入一个字节。父进程可以计数和分析这些消息。这种方法考虑到了子进程意外终止而不是显式地关闭管道的情形。

4 使用管道连接过滤器

当管道被创建之后,为管道的两端分配的文件描述符是可用描述符中数值最小的两个。由于在通常情况下,进程已经使用了描述符 0、1 和 2,因此会为管道分配一些数值更大的描述符。那么如何形成图 44-1 中给出的情形呢,使用管道连接两个过滤器(即从 stdin 读取和写入到 stdout的程序)使得一个程序的标准输出被定向到管道中,而另一个程序的标准输入则从管道中读取?特别是如何在不修改过滤器本身的代码的情况下完成这项工作呢?
这个问题的答案是使用在 5.5 节中介绍的技术,即复制文件描述符。一般来讲会使用下面的系列调用来获得预期的结果。

int pfd[2];
pipe(pfd);					/*Allocates(say)file descriptors 3 and 4 for pipe *//* other steps here,e.g.,fork()*/
close(STDOUT_FILENO);		/* Free file descriptor 1 */
dup(pfd[1]);				/* Duplication uses lowest free file descriptor,i.e.,fd 1 */

上面这些调用的最终结果是进程的标准输出被绑定到了管道的写入端。而对应的一组调用可以用来将进程的标准输入绑定到管道的读取端上。
注意,上面这些调用假设已经为进程打开了文件描述符 0、1 和 2。(shell 通常能够确保为它执行的每个程序都打开了这三个描述符。)如果在执行上面的调用之前文件描述符 0 已经被关闭了,那么就会错误地将进程的标准输入绑定到管道的写入端上。为避免这种情况的发生,可以使用 dup2()调用来取代对 close()和 dup()的调用,因为通过这个函数可以显式地指定被绑定到管道一端的描述符。

dup2(pfd[1],STDOUT_FILENO);		/*Close descriptor 1,and reopen boundto write end of pipe */

在复制完 pfd[1]之后就拥有两个引用管道的写入端的文件描述符了:描述符 1 和 pfd[1]。由于未使用的管道文件描述符应该被关闭,因此在 dup2()调用之后需要关闭多余的描述符。

close(pfd[1]);

前面给出的代码依赖于标准输出在之前已经被打开这个事实。假设在 pipe()调用之前,标准输入和标准输出都被关闭了。那么在这种情况下,pipe()就会给管道分配这两个描述符,即 pfd[0]的值可能为 0,pfd[1]的值可能为 1。其结果是前面的 dup2()和 close()调用将下面的代码等价。

dup2(11);		/* Does nothing */
close(1);		/*Closes sole descriptor for write end of pipe */

因此按照防御性编程实践的要求最好将这些调用放在一个 if 语句中,如下所示。

if(pfd[1]!= STDOUT_FILENO){dup2(pfd[1],STDOUT_FILENO);close(pfd[1]);
}

示例程序(使用管道连接ls和wc)

在构建完一个管道之后,这个程序创建了两个子进程。第一个子进程将其标准输出绑定到管道的写入端,然后执行 ls。第二个子进程将其标准输入绑定到管道的写入端,然后执行 wc。

#include <sys/wait.h>
#include "tlpi_hdr.h"int
main(int argc, char *argv[])
{int pfd[2];                                     /* Pipe file descriptors */if (pipe(pfd) == -1)                            /* Create pipe */errExit("pipe");switch (fork()) {case -1:errExit("fork");case 0:             /* First child: exec 'ls' to write to pipe */if (close(pfd[0]) == -1)                    /* Read end is unused */errExit("close 1");/* Duplicate stdout on write end of pipe; close duplicated descriptor */if (pfd[1] != STDOUT_FILENO) {              /* Defensive check */if (dup2(pfd[1], STDOUT_FILENO) == -1)errExit("dup2 1");if (close(pfd[1]) == -1)errExit("close 2");}execlp("ls", "ls", (char *) NULL);          /* Writes to pipe */errExit("execlp ls");default:            /* Parent falls through to create next child */break;}switch (fork()) {case -1:errExit("fork");case 0:             /* Second child: exec 'wc' to read from pipe */if (close(pfd[1]) == -1)                    /* Write end is unused */errExit("close 3");/* Duplicate stdin on read end of pipe; close duplicated descriptor */if (pfd[0] != STDIN_FILENO) {               /* Defensive check */if (dup2(pfd[0], STDIN_FILENO) == -1)errExit("dup2 2");if (close(pfd[0]) == -1)errExit("close 4");}execlp("wc", "wc", "-l", (char *) NULL);errExit("execlp wc");default: /* Parent falls through */break;}/* Parent closes unused file descriptors for pipe, and waits for children */if (close(pfd[0]) == -1)errExit("close 5");if (close(pfd[1]) == -1)errExit("close 6");if (wait(NULL) == -1)errExit("wait 1");if (wait(NULL) == -1)errExit("wait 2");exit(EXIT_SUCCESS);
}

执行程序会看到下面的输出:

./pipe_ls_wc
18
ls | wc -l
18

5 FIFO

从语义上来讲,FIFO 与管道类似,它们两者之间最大的差别在于 FIFO 在文件系统中拥有一个名称,并且其打开方式与打开一个普通文件是一样的。这样就能够将 FIFO 用于非相关进程之间的通信(如客户端和服务器)。

一旦打开了 FIFO,就能在它上面使用与操作管道和其他文件的系统调用一样的 I/O 系统调用了(如 read()、write()和 close())。与管道一样,FIFO 也有一个写入端和读取端,并且从管道中读取数据的顺序与写入的顺序是一样的。FIFO 的名称也由此而来:先入先出。FIFO 有时候也被称为有名管道
与管道一样,当所有引用 FIFO 的描述符都被关闭之后,所有未被读取的数据会被丢弃。使用 mkfifo 命令可以在 shell 中创建一个 FIFO。

mkfifo [-m mode] pathname

pathname 是创建的 FIFO 的名称,–m 选项用来指定权限 mode,其工作方式与 chmod 命令一样。
当在 FIFO(或管道)上调用 fstat()和 stat()函数时它们会在 stat 结构的 st_mode 字段中返回一个类型为 S_IFIFO 的文件。当使用 ls –l 列出文件时,FIFO 文件在第一列的类型为 p,ls -F 会在 FIFO 路径名后面附加上一个管道符(|)。

$ mkfifo mxr
$ ls mxr -al
prw-rw-r-- 1 maxingrong maxingrong 0 Apr 29 05:29 mxr$ ls -F mxr
mxr|

mkfifo()函数创建一个名为 pathname 的全新的 FIFO。

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

一般来讲,使用 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 模型,对于那些需要避免在打开 FIFO 时发生阻塞的需求,open()的 O_NONBLOCK 标记提供了一种标准化的方法来完成这个任务。

使用FIFO和tee(1)创建双重管道线

shell 管道线的其中一个特征是它们是线性的,管道线中的每个进程都读取前一个进程产生的数据并将数据发送到其后一个进程中。使用 FIFO 就能够在管道线中创建子进程,这样除了将一个进程的输出发送给管道线中的后面一个进程之外,还可以复制进程的输出并将数据发送到另一个进程中。要完成这个任务需要使用 tee 命令,它将其从标准输入中读取到的数据复制两份并输出:一份写入到标准输出,另一份写入到通过命令行参数指定的文件中。

将传给tee命名的file参数设置为一个FIFO可以让两个进程同时读取tee产生的两份数据。下面的 shell 会话演示了这种用法,它创建了一个名为 mxr 的 FIFO,然后在后台启动一个wc 命令,该命令会打开 FIFO 以读取数据(这个操作会阻塞直到有进程打开 FIFO 写入数据为止),接着执行一条管道线将 ls 的输出发送给 tee,tee 会将输出传递给管道线中的下一个命令sort,同时还会将输出发送给名为 myfifo 的 FIFO。(sort 的–k5n 选项会导致 ls 的输出按照第五个以空格分隔的字段的数值升序排序。)
请添加图片描述

mkfifo mxr
wc -l < mxr &	#等待从mxr读数据
ls -l | tee mxr |sort -k5n

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

本节将介绍一个简单的使用 FIFO 进行 IPC 的客户端/服务器应用程序。服务器提供的(简单)服务是向每个发送请求的客户端赋一个唯一的顺序数字。在对这个应用程序进行讨论的过程中将会介绍与服务器设计有关的一些概念和技术。

6.1 应用程序概述

在这个示例应用程序中,所有客户端使用一个服务器 FIFO 来向服务器发送请求。头文件pipes/fifo_seqnum.h定义了众所周知的名称(/tmp/seqnum_sv),服务器的 FIFO 将使用这个名称。这个名称是固定的,因此所有客户端知道如何联系到服务器。

在这个示例应用程序中将会在/tmp 目录中创建 FIFO,这样在大多数系统上都能够在不修改程序的情况下方便地运行这个程序。在一个像/tmp 这样的公共可写的目录中创建文件可能会导致各种安全隐患,因此现实世界中的应用程序不应该使用这种目录。

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

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

本例中将会使用后面一种解决方案。每个客户端的 FIFO 是从一个由包含客户端的进程 ID 的路径名构成的模板(CLIENT_FIFO_TEMPLATE)中构建而来的。在生成过程中包含进程 ID 可以很容易地产生一个对各个客户端唯一的名称。
下图展示了这个应用程序如何使用 FIFO 来完成客户端和服务器进程之间的通信。

头文件 pipes/fifo_seqnum.h 定义了客户端发送给服务器的请求消息的格式和服务器发送给客户端的响应消息的格式。
请添加图片描述
记住管道和 FIFO 中的数据是字节流,消息之间是没有边界的。这意味着当多条消息被递送到一个进程中时,如本例中的服务器,发送者和接收者必须要约定某种规则来分隔消息。这可以使用多种方法:

  • 每条消息使用诸如换行符之类的分隔字符结束。这样就必须要保证分隔字符不会出现在消息中或者当它出现在消息中时必须要采用某种规则进行转义。例如,如果使用换行符作为分隔符,那么字符\加上换行可以用来表示消息中一个真实的换行符,而\则可以用来表示一个真实的\。这种方法的一个不足之处是读取消息的进程在从 FIFO 中扫描数据时必须要逐个字节地分析直到找到分隔符为止。
  • 在每条消息中包含一个大小固定的头,头中包含一个表示消息长度的字段,该字段指定了消息中剩余部分的长度。这样读取进程就需要首先从 FIFO 中读取头,然后使用头中的长度字段来确定需读取的消息中剩余部分的字节数。这种方法能够高效地读取任意大小的消息,但一旦不合规则(如错误的 length 字段)的消息被写入到管道中之后问题就出来了。
  • 使用固定长度的消息并让服务器总是读取这个大小固定的消息。这种方法的优势在于简单性。但它对消息的大小设置了一个上限,意味着会浪费一些通道容量(因为需要对较短的消息进行填充以满足固定长度)。此外,如果其中一个客户端意外地或故意发送了一条长度不对的消息,那么所有后续的消息都会出现步调不一致的情况,并且在这种情况下服务器是难以恢复的。

请添加图片描述
示例应用程序中将使用上面介绍的第三种技术,即每个客户端向服务器发送的消息的长度是固定的。代码中request 结构定义了消息。每个发送给服务器的请求都包含了客户端的进程 ID,这样服务器就能够构建客户端用来接收响应的 FIFO 的名称了。请求中还包含了一个 seqLen 字段,它指定了应该为这个客户端分配的序号的数量。服务器向客户端发送的响应消息由一个字段 seqNum 构成,它是为这个客户端分配的一组序号的起始值。

/*pipes/fifo_seqnum.h
*/#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include "tlpi_hdr.h"#define SERVER_FIFO "/tmp/seqnum_sv"/* Well-known name for server's FIFO */
#define CLIENT_FIFO_TEMPLATE "/tmp/seqnum_cl.%ld"/* Template for building client FIFO name */
#define CLIENT_FIFO_NAME_LEN (sizeof(CLIENT_FIFO_TEMPLATE) + 20)/* Space required for client FIFO pathname(+20 as a generous allowance for the PID) */struct request {                /* Request (client --> server) */pid_t pid;                  /* PID of client */int seqLen;                 /* Length of desired sequence */
};struct response {               /* Response (server --> client) */int seqNum;                 /* Start of sequence */
};

6.2 服务器程序

服务器按序完成了下面的工作。

  • 创建服务器的众所周知的 FIFO并打开 FIFO 以便读取。服务器必须要在客户端之前运行,这样服务器 FIFO 在客户端试图打开它之前就已经存在了。服务器的 open()调用将会阻塞直到第一个客户端打开了服务器的 FIFO 的另一端以写入数据为止。
  • 再次打开服务器的 FIFO,这次是为了写入数据。这个调用永远不会被阻塞,因为之前已经因需读取而打开 FIFO 了。第二个打开操作是为了确保服务器在所有客户端关闭了 FIFO 的写入端之后不会看到文件结束。
  • 忽略 SIGPIPE 信号,这样如果服务器试图向一个没有读者的客户端 FIFO 写入数据时不会收到 SIGPIPE 信号(默认会杀死进程),而是会从 write()系统调用中收到一个EPIPE 错误。
  • 进入一个循环从每个进入的客户端请求中读取数据并响应。要发送响应,服务器需要构建客户端 FIFO 的名称,然后打开这个 FIFO。
  • 如果服务器在打开客户端 FIFO 时发生了错误,那么就丢弃那个客户端的请求。

这是一种迭代式服务器,这种服务器会在读取和处理完当前客户端之后才会去处理下一个客户端。当每个客户端请求的处理和响应都能够快速完成时采用这种迭代式服务器设计是合理的,因为不会对其他客户端请求的处理产生延迟。另一种设计方法是并发式服务器,在这种设计中主服务器进程使用单独的子进程(或线程)来处理各个客户端的请求。

/*
pipes/fifo_seqnum_server.c
*/
#include <signal.h>
#include "fifo_seqnum.h"int
main(int argc, char *argv[])
{int serverFd, dummyFd, clientFd;char clientFifo[CLIENT_FIFO_NAME_LEN];struct request req;struct response resp;int seqNum = 0;                     /* This is our "service" *//* Create well-known FIFO, and open it for reading */umask(0);                           /* So we get the permissions we want */if (mkfifo(SERVER_FIFO, S_IRUSR | S_IWUSR | S_IWGRP) == -1&& errno != EEXIST)errExit("mkfifo %s", SERVER_FIFO);serverFd = open(SERVER_FIFO, O_RDONLY);if (serverFd == -1)errExit("open %s", SERVER_FIFO);/* Open an extra write descriptor, so that we never see EOF */dummyFd = open(SERVER_FIFO, O_WRONLY);if (dummyFd == -1)errExit("open %s", SERVER_FIFO);/* Let's find out about broken client pipe via failed write() */if (signal(SIGPIPE, SIG_IGN) == SIG_ERR)    errExit("signal");for (;;) {                          /* Read requests and send responses */if (read(serverFd, &req, sizeof(struct request))!= sizeof(struct request)) {fprintf(stderr, "Error reading request; discarding\n");continue;                   /* Either partial read or error */}/* Open client FIFO (previously created by client) */snprintf(clientFifo, CLIENT_FIFO_NAME_LEN, CLIENT_FIFO_TEMPLATE,(long) req.pid);clientFd = open(clientFifo, O_WRONLY);if (clientFd == -1) {           /* Open failed, give up on client */errMsg("open %s", clientFifo);continue;}/* Send response and close FIFO */resp.seqNum = seqNum;if (write(clientFd, &resp, sizeof(struct response))!= sizeof(struct response))fprintf(stderr, "Error writing to FIFO %s\n", clientFifo);if (close(clientFd) == -1)errMsg("close");seqNum += req.seqLen;           /* Update our sequence number */}
}

6.3 客户端程序

客户端按序完成了下面的工作。

  • 创建一个 FIFO 以从服务器接收响应。这项工作是在发送请求之前完成的,这样才能确保 FIFO 在服务器试图打开它并向其发送响应消息之前就已经存在了。
  • 构建一条发给服务器的消息,消息中包含了客户端的进程 ID 和一个指定了客户端希望服务器赋给它的序号长度的数字(从可选的命令行参数中获取)。(如果没有提供命令行参数,那么默认的序号长度是 1。)
  • 打开服务器 FIFO并将消息发送给服务器。
  • 打开客户端 FIFO,然后读取和打印服务器的响应。
    另一个需要注意的地方是通过 atexit()③建立的退出处理器①,它确保了当进程退出之后客户端的 FIFO 会被删除。或者可以在客户端 FIFO 的 open()调用之后立即调用 unlink()。在那个时刻这种做法是能够正常工作的,因为它们都执行了阻塞的 open()调用,服务器和客户端各自持有了 FIFO 的打开着的文件描述符,而从文件系统中删除 FIFO 名称不会对这些描述符以及它们所引用的打开着的文件描述符产生影响。
    下面是运行这个客户端和服务器程序时看到的输出:
./fifo_seqnum_server &
[1] 203048
$ ./fifo_seqnum_client 3
0
$ ./fifo_seqnum_client 2
3
$ ./fifo_seqnum_client
5
/*
pipes/fifo_seqnum_client.c
*/
#include "fifo_seqnum.h"static char clientFifo[CLIENT_FIFO_NAME_LEN];static void             /* Invoked on exit to delete client FIFO */
removeFifo(void)
{unlink(clientFifo);
}int
main(int argc, char *argv[])
{int serverFd, clientFd;struct request req;struct response resp;if (argc > 1 && strcmp(argv[1], "--help") == 0)usageErr("%s [seq-len]\n", argv[0]);/* Create our FIFO (before sending request, to avoid a race) */umask(0);                   /* So we get the permissions we want */snprintf(clientFifo, CLIENT_FIFO_NAME_LEN, CLIENT_FIFO_TEMPLATE,(long) getpid());if (mkfifo(clientFifo, S_IRUSR | S_IWUSR | S_IWGRP) == -1&& errno != EEXIST)errExit("mkfifo %s", clientFifo);if (atexit(removeFifo) != 0)errExit("atexit");/* Construct request message, open server FIFO, and send message */req.pid = getpid();req.seqLen = (argc > 1) ? getInt(argv[1], GN_GT_0, "seq-len") : 1;serverFd = open(SERVER_FIFO, O_WRONLY);if (serverFd == -1)errExit("open %s", SERVER_FIFO);if (write(serverFd, &req, sizeof(struct request)) !=sizeof(struct request))fatal("Can't write to server");/* Open our FIFO, read and display response */clientFd = open(clientFifo, O_RDONLY);if (clientFd == -1)errExit("open %s", clientFifo);if (read(clientFd, &resp, sizeof(struct response))!= sizeof(struct response))fatal("Can't read response from server");printf("%d\n", resp.seqNum);exit(EXIT_SUCCESS);
}

6.4 非阻塞I/O

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

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

如果 FIFO 的另一端已经被打开,那么 O_NONBLOCK 对 open()调用不会产生任何影响——它会像往常一样立即成功地打开 FIFO。只有当 FIFO 的另一端还没有被打开的时候O_NONBLOCK 标记才会起作用,而具体产生的影响则依赖于打开 FIFO 是用于读取还是用于写入的

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

为读取而打开 FIFO 和为写入而打开 FIFO 时 O_NONBLOCK 标记所起的作用不同是有原因的。当 FIFO 的另一个端没有写者时打开一个 FIFO 以便读取数据是没有问题的,因为任何试图从 FIFO 读取数据的操作都不会返回任何数据。但当试图向没有读者的 FIFO 中写入数据时将会导致 SIGPIPE 信号的产生以及 write()返回 EPIPE 错误。

下表对打开 FIFO 的语义进行了总结,包括上面介绍的 O_NONBLOCK 标记的作用。
在这里插入图片描述
在打开一个 FIFO 时使用 O_NONBLOCK 标记存在两个目的。
y 它允许单个进程打开一个 FIFO 的两端。这个进程首先会在打开 FIFO 时指定O_NONBLOCK 标记以便读取数据,接着打开 FIFO 以便写入数据。
y 它防止打开两个 FIFO 的进程之间产生死锁。
当两个或多个进程中每个进程都因等待对方完成某个动作而阻塞时会产生死锁。图 44-8给出了两个进程发生死锁的情形。各个进程都因等待打开一个 FIFO 以便读取数据而阻塞。如果各个进策划那个都可以执行其第二个步骤(打开另一个 FIFO 以便写入数据)的话就不会发生阻塞。这个特定的死锁问题是通过颠倒进程 Y 中的步骤 1 和步骤 2 并保持进程 X 中两个步骤的顺序不变来解决,反之亦然。但在一些应用程序中进行这样的调整可能并不容易。相反,可以通过在为读取而打开 FIFO 时让其中一个进程或两个进程都指定 O_NONBLOCK 标记来解决这个问题。
请添加图片描述
非阻塞 read()和 write()
O_NONBLOCK 标记不仅会影响 open()的语义,而且还会影响——因为在打开的文件描述中这个标记仍然被设置着——后续的 read()和 write()调用的语义。下一节将会对这些影响进行描述。
有些时候需要修改一个已经打开的 FIFO(或另一种类型的文件)的 O_NONBLOCK 标记的状态,具体存在这个需求的场景包括以下几种。
y 使用 O_NONBLOCK 打开了一个 FIFO 但需要让后续的 read()和 write()调用在阻塞模式下运作。
y 需要启用从 pipe()返回的一个文件描述符的非阻塞模式。更一般地,可能需要更改从除open()调用之外的其他调用中——如每个由shell运行的新程序中自动被打开的三个标准描述符的其中一个或 socket()返回的文件描述符——取得的任意文件描述符的非阻塞状态。
y 出于一些应用程序的特殊需求,需要切换一个文件描述符的 O_NONBLOCK 设置的开启和关闭状态。
当碰到上面的需求时可以使用 fcntl()启用或禁用打开着的文件的 O_NONBLOCK 状态标记。通过下面的代码(忽略的错误检查)可以启用这个标记。

int flags;
flags =fcntl(fd,F_GETFL);		/*Fetch open files status flags */
flagS |= O_NONBLOCK;			/* Enable O NONBLOCK bit */
fcntl(fd,F_SETFL, flags);		/*Update open files status flags */

通过下面的代码可以禁用这个标记。

flags = fcntl(fd,F_GETFL);
flags &= ~O_NONBLOCK;			/* Disable 0 NONBLOCK bit */
fcntl(fd,F_SETFL,flags);

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

表 44-2 对管道和 FIFO 上的 read()操作进行了总结,包括 O_NONBLOC 标记的作用。
在这里插入图片描述
只有当没有数据并且写入端没有被打开时阻塞和非阻塞读取之间才存在差别。在这种情况下,普通的 read()会被阻塞,而非阻塞 read()会失败并返回 EAGAIN 错误。
当 O_NONBLOCK 标记与 PIPE_BUF 限制共同起作用时 O_NONBLOCK 标记对象管道或FIFO 写入数据的影响会变得复杂。表 44-3 对 write()的行为进行了总结。
在这里插入图片描述
当数据无法立即被传输时 O_NONBLOCK 标记会导致在一个管道或 FIFO 上的 write()失败(错误是 EAGAIN)。这意味着当写入了 PIPE_BUF 字节之后,如果在管道或 FIFO 中没有足够的空间了,那么 write()会失败,因为内核无法立即完成这个操作并且无法执行部分写入,否则就会破坏不超过 PIPE_BUF 字节的写入操作的原子性的要求。
当一次写入的数据量超过 PIPE_BUF 字节时,该写入操作无需是原子的。因此,write()会尽可能多地传输字节(部分写)以充满管道或 FIFO。在这种情况下,从 write()返回的值是实际传输的字节数,并且调用者随后必须要进行重试以写入剩余的字节。但如果管道或 FIFO已经满了,从而导致哪怕连一个字节都无法传输了,那么 write()会失败并返回 EAGAIN 错误。

http://www.xdnf.cn/news/214417.html

相关文章:

  • 【C++编程入门】:基本语法
  • Java 多线程基础:Thread 类详解
  • 云数据中心整体规划方案PPT(113页)
  • VIT(ICLR2021)
  • foc控制 - clarke变换和park变换
  • 【后端】【Docker】 Docker 动态代理 取消代理完整脚本合集(Ubuntu)
  • 内网服务器映射到公网上怎么做?网络将内网服务转换到公网上
  • 学习基本宠物美容
  • 零基础实现把知识库接到聆思CSK6大模型开发板上
  • 请简述一下什么是 Kotlin?它有哪些特性?
  • C++ 红黑树
  • 第14讲:科研图表的导出与排版艺术——高质量 PDF、TIFF 输出与投稿规范全攻略!
  • Java 基础--运算符全解析
  • Ubuntu搭建 Nginx以及Keepalived 实现 主备
  • ‘WebDriver‘ object has no attribute ‘find_element_by_class‘
  • 咖啡的功效与作用及副作用,咖啡对身体有哪些好处和坏处
  • 什么是缓冲区溢出?NGINX是如何防止缓冲区溢出攻击的?
  • [逆向工程]什么是CPU寄存器(三)
  • Qt开发之C++泛型编程进阶
  • C语言教程(二十五):C 语言函数可变参数详解
  • 机器学习-入门-决策树(1)
  • 大模型微调之LLaMA-Factory 系列教程大纲
  • 面试篇 - LoRA(Low-Rank Adaptation) 原理
  • java每日精进 4.29【框架之自动记录日志并插入如数据库流程分析】
  • C++ 单例对象自动释放(保姆级讲解)
  • 马井堂-区块链技术:架构创新、产业变革与治理挑战(马井堂)
  • python用切片的方式取元素
  • 基于GPT 模板开发智能写作辅助应用
  • 1.PowerBi保姆级安装教程
  • HarmonyOS运动开发:如何监听用户运动步数数据