博客中的图均来自UNPv2

进程间通信

IPC是进程间通信(interprocess communication)的简称。顾名思义,传统上该术语描述的是运行在某操作系统上的不同进程间各种消息传递的方式

按照传统的Unix编程模型,我们在一个系统上运行多个进程,每个进程都有各自独立的地址空间。基于此,Unix进程间的信息共享可以有多种方式:

  1. 左边的两个进程共享留存于文件系统中某个文件上的某些信息。为访问这些信息,这两个进程都得穿越内核(openreadwritelseek等)。
  2. 中间的两个进程共享驻留于内核中的某些信息。
  3. 右边的两个进程有一个双方都能访问的共享内存区。每个进程一旦设置好该共享内存区,就能根本不涉及内核而访问其中的数据。

匿名管道

匿名管道是最初的Unix IPC形式。它由pipe()函数创建,提供一个单向数据流。

#include <unistd.h>
int pipe(int fd[2]);
// Returns: 0 if OK, -1 on error

参数fd为输出型参数,调用完毕后会返回两个文件描述符:fd[0]fd[1]。前者相当于管道的读端,也只能用于读,后者相当于管道的写端,也只能用于写。

下图展示了单个进程中使用匿名管道的模样。此时单个进程享有管道的读写端。

匿名管道通常是单个进程创建的,但其典型用途是为两个进程(一个是父进程,一个是子进程)提供进程间通信的手段。首先,一个进程创建一个管道后调用fork派生一个自身的副本,如下图。

由于fork出的子进程继承了父进程的文件描述符,于是现在父子进程各掌管一对儿管道的读写描述符。接着,父进程关闭这个管道的读出端,子进程关闭该管道的写入端。这时就在父子进程间提供了一个单向的数据流。如下图。

Shell中的管道符就是这个原理,而且可以推广到多管道符的情况,例如who | sort | lp。Shell此时会创建三个进程和其间的两个管道。并且还会把每个管道的读出端复制到相应进程的标准输入,把每个管道的写入端复制到相应进程的标准输出。如下图所示。

上面所讨论的管道都只完成了单向通信,当需要完成双向通信的时候,我们必须创建两个管道,每个方向一个,可能的步骤如下:

  • 创建管道1(fd1[0]fd1[1])和管道2(fd2[0]fd2[1]
  • fork
  • 父进程关闭管道1的读出端(fd1[0]
  • 子进程关闭管道1的写入端(fd1[1]
  • 父进程关闭管道2的写入端(fd2[1]
  • 子进程关闭管道2的读出端(fd2[0]

最终的管道布局如下图:

此时两个进程就可以完成双向的通信。

为什么单个管道不能直接完成全双工通信呢?

首先,全双工通信在计算机领域是双向同时收发数据的意思。而管道其实只能完成半双工通信,即可以提供双向数据流,但同一时间只能有单个方向的数据流存在。

要解决这个问题,首先要了解Linux下管道的本质是什么。Linux中,管道其实就是内核中的一块缓冲区,只不过对外提供了类似于文件的读写接口即文件描述符,用户可以通过文件描述符来读写这一块缓冲区。

那么就可以解释单个管道即单个缓冲区为什么不能提供全双工通信了。假如有两个进程同时通过写文件描述符向管道中写入数据,那么在读取的时候就很难区分开哪些是自己写入的数据哪些是对方写入的数据,所以也就无法完成双向通信了。

而有些版本的Unix(如SVR4)的pipe()函数以及大部分Unix都提供的socketpair()函数都可以实现全双工通信。如下图所示:

其内部其实就是两个半双工的普通管道:

当通过fd[1]写数据的时候其实是向上面的管道写入数据,而读数据其实是从下面的管道读取数据,fd[0]与之类似,这样就完成了一个全双工通信。

匿名管道的读写特性

从上面我们了解到管道其实就是内核中的一块缓冲区,而这块缓冲区的大小其实是固定的,通过man 7 pipe可以看到管道的容量一般是65536字节。那么这就带来了一些局限性,例如管道满了怎么办。下面就总结管道相关的读写特性。特别的,还会介绍到如果在打开管道时指定了O_NONBLOCK标志则会出现的情景(对于匿名管道来说,文件描述符不是通过open()获得,所以无法直接指定打开标志,必须通过fcntl()系统调用来更改文件描述符的标志)。

我们知道正常来讲所有系统调用都由操作系统保证函数执行的原子性,但write()在这里是一个例外,如果向管道中写入的字节数小于等于PIPE_BUF(定义在limits.h中,我的电脑是4096字节,注意这个数据不同于管道容量,这个大小只与write()是否具有原子性有关),仍旧保证原子性,但如果写入字节数超过了PIPE_BUF,此时write()无法保证原子性。

  1. 若管道剩余空间小于当前要写入的字节数,则write()会先填满管道剩余空间然后被阻塞,直到管道中的数据被read()走一些。而如果指定了O_NONBLOCK标志,而且要写入的字节数小于PIPE_BUF,则立刻返回EAGAIN错误,提醒程序过会儿再来写。(这里还有一个更极端的情况,假如指定了O_NONBLOCK标志,而且要写入的字节数大于PIPE_BUF,而且管道中至少还有一字节的空间,则向管道写入所能容纳的最大字节,然后write()返回实际写入的字节数,反之若管道一字节空间都不剩,则write()返回EAGAIN错误)。
  2. 若管道空了,则read()会被阻塞,直到管道中write()进来一些数据。而如果指定了O_NONBLOCK标志,则立刻返回EAGAIN错误,提醒程序过会儿再来读。
  3. 若管道的所有读端都被关闭,则write()会触发异常,收到SIGPIPE信号,默认进程终止。如果对SIGPIPE信号设置了忽略动作或者设置了信号处理器程序并从该程序中返回,则write()返回EPIPE错误。
  4. 若管道的所有写端都被关闭,则read()读完剩余数据后返回0(而不是阻塞等待写入)。

匿名管道内的数据被读取过一次就从内核缓冲区中删除,也就是说如果有多个读端同时读管道,彼此一定读到的是不同的数据。

一个具有亲缘关系的客户和服务器例程

这一节提供一个关于匿名管道的例子。即一个Client-Server程序,Client通过管道将请求的文件名发送给Server,而Server通过另一个管道将文件内容传给Client。

主函数:

int main() {
    int pipe1[2], pipe2[2];
    pid_t childpid;

    pipe(pipe1);  // 建立两个管道
    pipe(pipe2);

    if((childpid = fork()) == 0) {  // child
        close(pipe1[1]);
        close(pipe2[0]);

        server(pipe1[0], pipe2[1]);
        exit(0);
    }

    // parent
    close(pipe1[0]);
    close(pipe2[1]);

    client(pipe2[0], pipe1[1]);

    waitpid(childpid, NULL, 0);
    exit(0);
}

client:

void client(int readfd, int writefd) {
    char filename[1024] = "index.html";

    write(writefd, filename, strlen(filename));  // 将文件名写入管道

    int n;
    char readbuf[1024] = { 0 };
    while((n = read(readfd, readbuf, 100)) > 0) {  // 从管道中循环读取文件内容
        write(1, readbuf, n);  // 将读取的文件内容写入到标准输出
    }
}

server:

void server(int readfd, int writefd) {
    int n, fd;
    char buff[1024] = { 0 };

    read(readfd, buff, 1024);  // 从管道读取文件名

    fd = open(buff, O_RDONLY);  // 打开该文件
    if(fd < 0) {
        perror("open() error");
        exit(-1);
    }

    while((n = read(fd, buff, 1024)) > 0) {  // 循环读取文件内容
        write(writefd, buff, n);  // 将文件内容写入管道
    }
}

FIFO

匿名管道没有名字,只有对应的读写文件描述符,因此它们最大的劣势是只能用于有一个公共祖先进程的各个进程间通信。我们无法在无亲缘关系的两个进程间创建一个匿名管道并将它用作IPC通道(不考虑描述符传递)。

而FIFO就在匿名管道的基础之上弥补了这个缺点。FIFO代指先进先出(first in first out),其也被称为命名管道(named pipe)。它同匿名管道一样,也是一个单向(半双工)数据流,而且底层同样是内核中的一块缓冲区。而不同于匿名管道的是,FIFO在文件系统中有一个可见的文件与之关联,从而允许无亲缘关系的进程通过这个文件来完成进程间通信。

FIFO由mkfifo()函数创建。

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

int mkfifo(const char *pathname, mode_t mode);
// Returns: 0 if OK, -1 on errors.

其中pathname为要创建的FIFO文件的路径文件名。mode为FIFO文件的权限,类似于open()的第三个参数。

FIFO文件创建后,任何一个进程都可以直接通过open()来打开这个文件并获取到操作句柄,打开时可指定只读、只写或读写。

FIFO的打开特性

FIFO由于需要进程调用open()打开,所以相对匿名管道具有打开特性。

  1. 当尝试以只读方式打开FIFO的时候,若之前该文件没有被写方式打开,则open()被阻塞,直到该文件被写方式打开。若设置了O_NONBLOCK,则不阻塞,正常返回文件描述符。
  2. 当尝试以只写方式打开FIFO的时候,若之前该文件没有被读方式打开,则open()被阻塞,直到该文件被读方式打开。若设置了O_NONBLOCK,则open()返回ENXIO错误。
  3. 当尝试以读写方式打开FIFO的时候,直接打开。

FIFO的读写特性

FIFO的读写特性与匿名管道的读写特性完全相同。

一个不具有亲缘关系的客户与服务器例程

稍微修改上一个例子,使用FIFO使得不具有亲缘关系的server和client可以互相通信。

client:

int main() {
    char buff[1024] = { 0 };
    int writefd = open("tmp1.fifo", O_WRONLY);
    int readfd = open("tmp2.fifo", O_RDONLY);

    write(writefd, "index.html", 10);  // 将文件名写入FIFO

    int n;
    while((n = read(readfd, buff, 1024)) > 0) {  // 从FIFO中循环读取文件内容
        write(1, buff, n);  // 将读取的文件内容写入标准输出                                                                             
    }
}

server:

int main() {
    char buff[1024] = { 0 };
    int readfd = open("tmp1.fifo", O_RDONLY);
    int writefd = open("tmp2.fifo", O_WRONLY);

    read(readfd, buff, 1024);  // 从FIFO中读取文件名
    int fd = open(buff, O_RDONLY);  // 打开该文件

    int n;
    while((n = read(fd, buff, 1024)) > 0) {  // 循环读取文件内容
        write(writefd, buff, n);  // 将文件内容写入FIFO
    }
} 
Last modification:November 29th, 2019 at 05:00 pm