UNIX中实现进程间通信的方式很多,比如管道、消息队列、共享内存、信号量、套接字等,前面四种主要用于同一台机器上的进程间通信,而套接字主要用于不同机器之间的通信。但是极少方法能在所有的UNIX系统中移植,而作为半双工的管道则是唯一一种方法。

管道特点

  • 1. 管道是半双工的,数据只能在一个方向上面流动(数据只能由一个进程流向另一个进程,其中一个读管道,一个写管道)
  • 2. 管道只能在具有公共祖先的两个进程之间使用(具有亲缘关系,如父子进程或者兄弟进程)。
    • 父子进程之间不共享数据段和堆栈段,他们之间通过管道通信。

除了以上两点局限,管道还有其他一些不足,如果管道没有名字(无名管道)则管道的缓冲区大小是受限制的,管道所传输是无格式的字节流。

管道的创建

  • 管道通过调用pipe函数创建,通过pipe(2)系统调用,让系统构建一个匿名管道,这样在进程中就打开了两个新的,打开的文件描述符:一个只读端和一个只写端。管道的两端是两个普通的,匿名的文件描述符,这就让其他进程无法连接该管道。 为了避免死锁并利用进程的并行运行的好处,有一个或多个管道的UNIX进程通常会调用fork(2)产生新进程。 并且每个子进程在开始读或写管道之前都会关掉不会用到的管道端。或者进程会产生一个子线程并使用管道来让线程进行数据交换。
1
2
3
  #include <unistd.h>
  
  int pipe (int df[2]);
  • 通常一个管道由一个进程创建,在进程调用fork之后这个管道就能在父子进程之间使用了。管道两端分别用描述字fd[0]和fd[1]描述,管道的两端固定了任务,即一端只能用于读,由fd[0]表示;一端只能用于写,由fd[1]表示。一般的I/O函数如close、read、write等都可以用于管道。
  • 管道的两端在一个进程中相互连接,此外管道处于内核之中,即数据都将通过内核中的管道流动。
  • 单个进程几乎没有任何用处,进程通常会先调用pipe接着调用fork从而创建父进程到子进程的IPC通道。 fork之后需要根据数据的流动方向来对父进程和子进程中的管道两端进行调整(关闭无关的读端和写端)。

父进程到子进程的管道

  • 当管道的一端被关闭后,管道遵守一下两条规则

    • 1. 当读一个写端已经关闭的管道时,所有数据被读取后,read返回0表示文件结束。
    • 2. 如果写一个读端已经关闭的管道时,则会产生信号SIGPIPE。如果忽略或者捕捉该信号并从其处理程序返回则write返回1,errno设置为EPIPE。
  • 如果管道的写端还有进程,就不会产生文件的结束符。此外可以通过复制一个管道的文件描述符使得有多个进程对它具有写打开文件描述符。

  • 在写管道时,常量PIPE_BUF规定了内核的管道缓冲区的大小,如果对管道调用write,而且写入的字节数小于等于PIPE_BUF,则此操作不会与其他进程对同一管道的write操作交叉进行。

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

  • 关闭未使用的管道文件描述符可以确保进程不会耗尽其文件描述符限制,即节省文件描述符开销。(这不是最主要的原因)
  • 因为从管道读取数据的进程会关闭其持有的管道写入描述符,所以当其他进程完成输出并关闭管道写入描述符后,读端进程就能看到文件结束,若是读取端进程没有关闭写入端,则在其他写入端进程关闭写入端后,读取端也不会看到文件结束,即使其已经读取完管道中的数据。此外读取端的read()函数会一直阻塞以等待数据到来,这是因为内核还知道至少存在一个管道的写入描述符还打开着,即读取进程自己打开的写入文件描述符,理论上讲这个进程仍然可以向管道写入数据,即使其已经被读取操作阻塞了。因为在真实的环境下,read()有可能会被一个向管道写入数据的信号处理器中断。
  • 而写入进程关闭其读取文件描述符的原因则与读取进程不同。当一个进程试图向一个没有打开着的读取文件描述符发管道写入数据时,内核会向写入进程发送一个SIGPIPE信号。默认情况下,这个信号会杀死一个进程。但是进程可以捕获或忽略这信号,这样会导致管道的write()操作因EPIPE错误而失败。(已损坏的管道)。此外如果写入进程没有关闭读取端,那么即使在其他进程已经关闭了管道的读取端之后,写入端进程仍然能够向管道写入数据,最后数据将充满整个管道,后续的写入请求会被永远阻塞。

通过管道实现进程同步

根据之前的分析可以知道,管道可以实现父子进程之间的通信,并且读取进程会阻塞等到所有的写入进程关闭后,才会收到文件结束符。由此通过一下步骤可以实现父子间同步。(引至 _The Linux Programming Interface_) 1. 父进程在创建子进程之前构建管道 2. 每个子进程会继承管道的写入端的文件描述符并在完成动作之后关闭这些描述符 3. 当所有的子进程都关闭了管道的写入端描述符之后,父进程在管道的read()就会结束并返回文件结束 + ps: 必须在父进程中关闭管道的未使用的写入端

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#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 we
                                               terminate 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);
}

使用管道连接过滤器

当管道被创建之后,系统为管道两端分配的文件描述符是可用文件描述符中数值最小的两个。通常进程已经使用了0、1、2分别代表标准输入、标准输出和错误输出。使用管道连接两个过滤器(从stdin读取和写入到stdout的程序)使得一个程序的标准输出被重定向到管道中,而另外一个程序的标准输入则从管道中读取,要实现这一技术就会使用文件描述符。

以下两个函数都可以用来复制一个现有的文件描述符。

1
2
3
4
5
6
  #include <unistd.h>

  int dup (int fd);

  int dup2 (int fd, int fd2);
  //若成功返回新文件描述符;若出错返回-1
  • dup返回的新文件描述符一定是当前可用文件描述符中最小数值。dup2则是用参数fd2指定新的文件描述符的值,若fd2已经打开则先将其关闭;若fd与fd2相等则返回fd2而不关闭它。这些返回的新的文件描述符与参数fd共享同一个文件表项。

  • 复制文件描述符的另一个方法是fcntl函数。事实上调用

dup (fd);

等价于调用

fcntl (fd, F_FDUPFD, 0); //0文件描述符可用的情况下

而调用

dup2 (fd, fd2);

等价于调用

close (fd2);

fcntl (fd, F_DUPFD, fd2);

dup2是一个原子操作并不完全等同于close和fcntl。

一般情况下,如果要重定向进程的标准输出到管道的写入端可以通过 1. close(STDOUT_FIFLENO); dup(df[1]);或者 2. dup2(df[1], STDOUT_FILENO);

其中方法1中必须要确保在复制文件描述符之前文件描述符0没有关闭,否则就会错误地把进程的标准输入绑定到管道的写入端上面。一般shell能够保证为其执行的每一个程序都打开了0、1、2这三个文件描述符。 方法2通过dup2函数可以显式地指定被绑定的管道文件描述符,可以避免方法一中的隐患。

为了防止标准输入和标准输出都被关闭的情况在使用重定向时,应该先确定其处于打开状态。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
> if (df[1] != STDOUT_FILENO) {
>   duf2(df[1], STDOUT_FILENO);
>   close(df[1]);
> }
>```
>这一方法在tinyHTTP中应该加以考虑
---

## 命名管道——FIFO

  + 不同于匿名管道,FIFO提供一个路径名与之关联,以FIFO的文件形式存在于文件系统中。因此即使与FIFO的创建进程没有亲缘关系的进程**只要可以访问该路径**就能够彼此通信。
  + 该管道可以通过路径名来指出,并且在文件系统中是可见的(是一种文件类型)。建立管道后,通信的进程就可以把其当作普通文件读写,使用方便。
  + FIFO严格遵循先进先出原则
  + 命名管道由mkfifo或者mkfifoat函数创建

  ```c
  #include <sys/types.h>
  #include <sys/stat.h>

  int mkfifo (const char *pathname, mode_t mode);

  int mkfifoat (int fd, const char *pathname, mode_t mode);

mkfifo函数中的mode参数与open函数的mode参数相同。如果mkfifo函数第一个参数是一个已经存在的路径名,会返回EEXIST错误。 mkfifoat函数可以被用来在fd文件描述符表示的目录相关位置创建一个FIFO。

  • FIFO的主要用途
    • shell命令使用FIFO将数据从一条管道传送到另一条管道,无需创建中间临时文件
    • 客户进程-服务器进程应用程序中,FIFO作为汇聚点,在二者之间传递数据。
  • 当一个给定的FIFO有多个写进程时,如果不希望多个进程所写的数据交叉,则必须考虑原子写操作。
  • 和管道一样,常量PIPE_BUF说明了可被原子地写到FIFO的最大数据量。