第9章 进程间通信:管道

在Linux系统中,有时候需要多个进程相互协作,共同完成某项任务。进程之间或线程之间有时候需要传递消息,有时候需要同步来协调彼此的工作。接下来的3章将讲述Linux中的进程间通信(interprocess communication,或者IPC)。

在第6章讲信号时曾提到,信号也是进程间通信的一种机制,尽管其主要作用不是这个。一个进程向另外一个进程发送信号,传递的信息是信号编号。当采用sigqueue函数发送信号时,还可以在信号上绑定数据(整型数字或指针),增强传递消息的能力。尽管如此,还是不建议将信号作为进程间通信的常规手段,原因在信号那一章中已经详细介绍过了。

在第7章讲线程时曾提到,线程在Linux中被实现为轻量级的进程,线程之间的同步手段(互斥量和条件等待),本质上也是进程间通信。

进程间通信的手段,大体可以分成以下两类:

  • 第一类是通信类。这类手段的作用是在进程之间传递消息,交换数据。若细分下来,通信类也可以分成两种,一种是用来传递消息的(比如消息队列),另外一种是通过共享一片内存区域来完成信息的交换的(比如共享内存),如图9-1所示。
  • 第二类是同步类。这类手段的目的是协调进程间的操作。某些操作,多个进程不能同时执行,否则可能会产生错误的结果,这就需要同步类的工具来协调。主要的同步类手段如图9-2所示。

从历史的角度来说,Linux下进程间通信的手段基本上是从Unix平台继承而来的。

管道是第一个广泛应用的进程间通信手段。日常在终端执行shell命令时,会大量用到管道。但管道的缺陷在于只能在有亲缘关系(有共同的祖先)的进程之间使用。为了突破这个限制,后来引入了命名管道。

图9-1 通信类工具 图9-2 同步类工具

接下来AT&T的贝尔实验室和加州大学伯克利分校的伯克利软件发布中心(BSD)分别开发出了风格迥异的进程间通信手段。前者通过对早期的进程间通信手段的改进和扩充,开发出System V IPC,包括消息队列、信号量和共享内存。但是这些方法,将进程间的通信始终局限在单个计算机这个范围之内。BSD则走了一条完全不同的道路,开发出了套接字(socket),跳出了单机的限制,可以实现不同计算机之间的进程间通信。Linux将System V IPC和BSD socket都继承了下来,丰富了进程间通信的方法。

System V IPC方法出现地比较早,几乎所有的Unix平台都支持System V IPC,其可移植性较好,但是在使用过程中也暴露出一些弱点。POSIX IPC提供了和System V IPC相对应的工具(它也包括消息队列、信号量和共享内存),它的出现晚于System V IPC。System V IPC广泛应用了一段时间后,才开始设计POSIX IPC的,因此,设计者可以借鉴System V IPC的长处,避免其缺点。从设计的角度上讲,POSIX IPC是优于System V IPC的,接口简单,易于使用。但是POSIX IPC的可移植性并不如System V IPC。

下面将分别介绍进程间通信的工具。其中的套接字在后面会有专门的章节来介绍,就不在进程间通信部分提及了。考虑到进程间通信的内容比较多,所以一共分成三章依次介绍,本章将主要介绍管道和命名管道。

9.1 管道

9.1.1 管道概述

管道是最早出现的进程间通信的手段。在shell中执行命令,经常会将上一个命令的输出作为下一个命令的输入,由多个命令配合完成一件事情。而这就是通过管道来实现的。

在图9-3中,进程who的标准输出,通过管道传递给下游的wc进程作为标准输入,从而通过相互配合完成了一件任务。

图9-3 管道的示意图

管道的作用是在有亲缘关系的进程之间传递消息。所谓有亲缘关系,是指有一个共同的祖先。所以管道并非只能用于父子进程之间,也可以用在兄弟进程之间,还可以用于祖孙进程之间甚至是叔侄进程之间。总而言之,只要共同的祖先曾经调用了pipe函数,打开的管道文件就会在fork之后,被各个后代进程所共享。打开的管道文件,就像是创建了一个家族私密场所,由远祖进程来创建,家族所有成员都知晓。家族成员可以将消息存放进该私密场所,等待另外一个接头的家族成员来取走消息,阅后即焚。

严格来说,家族里面的多个进程都可以往同一个秘密场所里面扔消息,也可以都从同一个秘密场所里面取消息,但是真的这么做的话又会存在风险。管道实质是一个字节流,并非前面提到的消息,没有消息的边界。如果多个进程发送的字节流混在一起,则无法辨认出各自的内容。所以一般是两个有亲缘关系的进程用管道来通信。从程序设计的角度来讲,当进程调用pipe函数时,哪两个有亲缘关系的进程使用该管道来通信应是事先约定好的,其他有亲缘关系的进程不应该进来搅局。其他进程想通信怎么办?那就创建它们之间需要用的另外的管道。

前面曾提到过,管道中的内容是阅后即焚的,这个特性指的是读取管道内容是消耗型的行为,即一个进程读取了管道内的一些内容之后,这些内容就不会继续在管道之中了。一般来讲管道是单向的。一个进程负责往管道里面写内容,另外一个进程读取管道里的内容。若两个有亲缘关系的进程发扬二杆子精神,都要往管道里面写,都要往管道里面读,自然也是可以的,但是管道中的内容可能会变得混乱,从而无法完成通信的任务。如果两个进程之间想双向通信怎么办?可以建立两个管道,如图9-4所示。

图9-4 利用两个管道双向通信

管道是一种文件,可以调用readwriteclose等操作文件的接口来操作管道。另一方面管道又不是一种普通的文件,它属于一种独特的文件系统:pipefs。管道的本质是内核维护了一块缓冲区与管道文件相关联,对管道文件的操作,被内核转换成对这块缓冲区内存的操作。下面我们来看一下如何使用管道。

9.1.2 管道接口

在Linux下,可以使用如下接口创建管道:

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

如果成功,则返回值是0,如果失败,则返回值是-1,并且设置errno。需要处理的errno如表9-1所示。

errno 值含义
EMFILE进程使用的文件描述符过多
ENFILE系统文件表已满
EFAULTpipefd 无效

表9-1 pipe函数的出错情况

成功调用pipe函数之后,会返回两个打开的文件描述符,一个是管道的读取端描述符pipefd[0],另一个是管道的写入端描述符pipefd[1]。管道没有文件名与之关联,因此程序没有选择,只能通过文件描述符来访问管道,只有那些能看到这两个文件描述符的进程才能够使用管道。那么谁能看到进程打开的文件描述符呢?只有该进程及该进程的子孙进程才能看到。这就限制了管道的使用范围。

成功调用pipe函数之后,可以对写入端描述符pipefd[1]调用write,向管道里面写入数据,代码如下所示:

write(pipefd[1], wbuf, count);

一旦向管道的写入端写入数据后,就可以对读取端描述符pipefd[0]调用read,读出管道里面的内容。如下所示,管道上的read调用返回的字节数等于请求字节数和管道中当前存在的字节数的最小值。如果当前管道为空,那么read调用会阻塞(如果没有设置O_NONBLOCK标志位的话)。

read(pipefd[0], rbuf, count);

管道一端是写入端(pipefd[1]),另一端是读取端(pipefd[0])。不应该对读取端描述符调用写操作,也不应该对写入端描述符调用读操作。如果我二杆子精神爆发,非要向读取端描述符写入,或者读取写入端描述符,结果会怎么样?

调用pipe函数返回的两个文件描述符中,读取端pipefd[0]支持的文件操作定义在read_pipefifo_fops,写入端pipefd[1]支持的文件操作定义在write_pipefifo_fops,其定义如下:

const struct file_operations read_pipefifo_fops = {
    .llseek     = no_llseek,
    .read       = do_sync_read,
    .aio_read   = pipe_read,
    .write      = bad_pipe_w,
    .poll       = pipe_poll,
    .unlocked_ioctl = pipe_ioctl,
    .open       = pipe_read_open,
    .release    = pipe_read_release,
    .fasync     = pipe_read_fasync,
};
 
const struct file_operations write_pipefifo_fops = {
    .llseek     = no_llseek,
    .read       = bad_pipe_r,
    .write      = do_sync_write,
    .aio_write  = pipe_write,
    .poll       = pipe_poll,
    .unlocked_ioctl = pipe_ioctl,
    .open       = pipe_write_open,
    .release    = pipe_write_release,
    .fasync     = pipe_write_fasync,
};

我们可以看到,对读取端描述符执行write操作,内核就会执行bad_pipe_w函数;对写入端描述符执行read操作,内核就会执行bad_pipe_r函数。这两个函数比较简单,都是直接返回-EBADF。因此对应的readwrite调用都会失败,返回-1,并置errnoEBADF

static ssize_t
bad_pipe_r(struct file *filp, char __user *buf, size_t count, loff_t *ppos)
{
    return -EBADF;
}
 
static ssize_t
bad_pipe_w(struct file *filp, const char __user *buf, size_t count, loff_t *ppos)
{
    return -EBADF;
}

我们只介绍了pipe函数接口,至今尚看不出来该如何使用pipe函数进行进程间通信。调用pipe之后,进程发生了什么呢?请看图9-5。

图9-5 进程调用pipe函数后

可以看到,调用pipe函数之后,系统给进程分配了两个文件描述符,即pipe函数返回的两个描述符。该进程既可以往写入端描述符写入信息,也可以从读取端描述符读出信息。可是一个进程管道,起不到任何通信的作用。这不是通信,而是自言自语。

如果调用pipe函数的进程随后调用fork函数,创建了子进程,情况就不一样了。fork以后,子进程复制了父进程打开的文件描述符(如图9-6所示),两条通信的通道就建立起来了。此时,可以是父进程往管道里写,子进程从管道里面读;也可以是子进程往管道里写,父进程从管道里面读。这两条通路都是可选的,但是不能都选。原因前面介绍过,管道里面是字节流,父子进程都写、都读,就会导致内容混在一起,对于读管道的一方,解析起来就比较困难。常规的使用方法是父子进程一方只能写入,另一方只能读出,管道变成一个单向的通道,以方便使用。如图9-7所示,父进程放弃读,子进程放弃写,变成父进程写入,子进程读出,成为一个通信的通道。

图9-6 fork之后 图9-7 fork之后,各自关闭不用的文件描述符

父进程如何放弃读,子进程又如何放弃写?其实很简单,父进程把读端口pipefd[0]这个文件描述符关闭掉,子进程把写端口pipefd[1]这个文件描述符关闭掉就可以了,示例代码如下:

int pipefd[2];
pipe(pipefd);
switch(fork())
{
case -1:
    /* fork failed, error handler here */
case 0:    /* 子进程 */
    close(pipefd[1]); /* 关闭掉写入端对应的文件描述符 */
    /* 子进程可以对 pipefd[0] 调用 read */
    break;
default: /* 父进程 */
    close(pipefd[0]); /* 父进程关闭掉读取端对应的文件描述符 */
    /* 父进程可以对 pipefd[1] 调用 write,写入想告知子进程的内容 */
    break;
}

从内核的角度看,调用pipe之后,系统给进程分配了两个文件描述符,调用fork之后,子进程也就有了与管道对应的两个文件描述符。和普通文件不同,这两个文件描述符对应的是一块内存缓冲区域,如图9-8所示。

图9-8也讲述了如何在兄弟进程之间通过管道通信。如图9-8所示,父进程再次创建一个子进程B,子进程B就持有管道写入端,这时候两个子进程之间就可以通过管道通信了。父进程为了不干扰两个子进程通信,很自觉地关闭了自己的写入端。从此管道成为了两个子进程之间的单向的通信通道。在shell中执行管道命令就是这种情景,只是略有特殊之处,其特殊的地方是管道描述符占用了标准输入和标准输出两个文件描述符。

图9-8 有亲缘关系的进程通过管道来通信

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

前面提到过,用管道通信的两个进程,各持有一个管道文件描述符,不相干的进程应自觉关闭掉这些文件描述符。这么做不仅仅是为了让数据的流向更加清晰,也不仅仅是为了节省文件描述符,更重要的原因是:关闭未使用的管道文件描述符对管道的正确使用影响重大。

管道有如下三条性质:

  1. 只有当所有的写入端描述符都已关闭,且管道中的数据都被读出,对读取端描述符调用read函数才会返回0(即读到EOF标志)。
  2. 如果所有读取端描述符都已关闭,此时进程再次往管道里面写入数据,写操作会失败,errno被设置为EPIPE,同时内核会向写入进程发送一个SIGPIPE的信号。
  3. 当所有的读取端和写入端都关闭后,管道才能被销毁。

由于管道具有这些特性,因此我们要及时关闭没用的管道文件描述符,下面我们来细细分析这样做的原因。

1. 关闭无用的管道写入端

从管道读取数据的进程,须要关闭其持有的管道写入端描述符。不参与通信的其他有亲缘关系的进程也应该关闭管道写入端描述符。

管道也符合生产者-消费者模型。写入管道,对应于生产内容;读取管道,对应于消费内容。当所有的生产者都退场以后,消费者应有办法判断这种情况,而不是傻傻地等待已不复存在的生产者继续生产内容,以至于陷入永久的阻塞。

如何判断?

答案是通过文件结束标志EOF。当对管道读取端调用read函数返回0时,就意味着所有的生产者都退场了,作为消费者的读取进程,就不需要再继续等待新的内容了。

什么情况下对管道读取端描述符调用read会返回0呢?

  • 所有相关的进程都已经关闭了管道的写入端描述符。
  • 管道中已有内容都被读取完毕。

同时满足上述条件,对管道读取端调用read会返回0。根据这个消费者就可以判断管道内容的生产者已经不存在了,它也不必傻傻等待,可以关闭读取端描述符了。

从上面的讨论可以看出,如果负责读取的进程,或者与通信无关的进程,不关闭管道的写入端描述符,就会有管道写入端描述符泄漏。当所有负责写入的进程都关闭了写入端描述符后,负责读的进程调用read时,仍会阻塞于此(如果没有设置O_NONBLOCK标志位的话),而且永不返回。这是因为内核维护的引用计数发现还有进程可以写入管道,因此read函数依旧会阻塞。

这个流程可以通过一个例子来验证。

#include <unistd.h>
#include <sys/types.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
 
int main()
{
    int pipe_fd[2];
    pid_t pid;
    char r_buf[4096];
    char w_buf[4096];
    int writenum;
    int rnum;
 
    memset(r_buf, 0, sizeof(r_buf));
 
    if(pipe(pipe_fd) < 0)
    {
        printf```c
    {
        printf("[PARENT] pipe create error\n");
        return -1;
    }
    if((pid=fork()) == 0)
    {
        /* 如果子进程忘记关闭管道写入端,那么,即使父进程关闭了写入端,
           while循环也无法跳出 */
        close(pipe_fd[1]);
        while(1)
        {
            rnum = read(pipe_fd[0], r_buf, 1000);
            printf("[CHILD ] readnum is %d\n", rnum);
            if(rnum == 0) /* meet EOF */
            {
                printf("[CHILD ] all the writer of pipe are closed. break and exit.\n");
                break;
            }
        }
        close(pipe_fd[0]);
        exit(0);
    }
    else if(pid > 0)
    {
        close(pipe_fd[0]);
        memset(w_buf, 0, sizeof(w_buf));
        if((writenum = write(pipe_fd[1], w_buf, 1024)) == -1)
            printf("[PARENT] write to pipe error\n");
        else
        {
            printf("[PARENT] the bytes write to pipe is %d \n", writenum);
        }
        sleep(15);
        printf("[PARENT] I will close the last write end of pipe.\n");
        close(pipe_fd[1]);
        sleep(2);
        return 0;
    }
}

示例代码的输出如下,与我们上面分析的一样:

[PARENT] the bytes write to pipe is 1024
[CHILD ] readnum is 1000
[CHILD ] readnum is 24
[PARENT] I will close the last write end of pipe.
[CHILD ] readnum is 0
[CHILD ] all the writer of pipe are closed. break and exit

父子进程配合地珠联璧合,但是如果子进程忘记关闭管道的写入端(删除上面示例代码中加粗的一行),结局就大相径庭了.纵然父进程关闭了管道的写入端,但是因为管道仍然存在一个写入端,所以子进程的read函数依旧会阻塞,无法返回.这显然不是我们期待的结果.

2. 关闭无用的管道读取端

如果对管道的写入端描述符调用write函数,则会走到内核的pipe_write函数.在该函数中可以看到如下代码:

if (!pipe->readers) {
    send_sig(SIGPIPE, current, 0);
    ret = -EPIPE;
    goto out;
}

当管道的读取端不复存在时,内核会向write函数的调用进程发送SIGPIPE信号,并且当前的write系统调用失败,错误码为EPIPE.

SIGPIPE信号默认情况下会杀死一个进程,当然我们也可以捕获或忽略该信号.事实上大多数情况下,服务器端的程序都会将SIGPIPE的信号处理函数设置成SIG_IGN,忽略掉该信号.这样的话,write系统调用就会返回失败,errnoEPIPE,通过返回值和errno,就可以及时获知所有的读取端都已关闭了.

当所有的管道读取端都不复存在时,管道的写入操作就会失败.为何要如此设计?

因为管道的读取端是管道内容的消费者,管道的写入端是管道内容的生产者.当消费者已经不复存在了,生产者自然没有继续生产的必要了.对于这个道理,电视剧《亮剑》中的山本一木都很清楚:

没有了观众,也就没有了表演.

所以不参与通信的进程,以及负责向管道写入内容的进程应该及时地关闭管道的读取端描述符.只有这样,当通信双方中的消费者关闭管道读取端时,管道内容的生产者才能在第一时间获知所有消费者都已不存在了这个事实.

如果写入管道的进程不关闭管道的读取文件描述符,哪怕其他进程都已经关闭了读取端,该进程仍可以向管道写入数据,但是只有生产者,没有消费者,管道最终会被写满,当管道被写满后,后续的写入请求就会被阻塞.

下面通过实例来证实:当最后一个读取端关闭时,向管道写入会触发SIGPIPE信号,同时write会返回失败,errnoEPIPE.

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <fcntl.h>
#include <signal.h>
#include <string.h>
#include <errno.h>
 
void sighandler(int signo);
 
int main(void)
{
    int fds[2];
    if(signal(SIGPIPE, sighandler) == SIG_ERR)
    {
        fprintf(stderr, "signal error (%s)\n", strerror(errno));
        exit(EXIT_FAILURE);
    }
    if(pipe(fds) == -1)
    {
        fprintf(stderr, "create pipe failed(%s)\n", strerror(errno));
        exit(EXIT_FAILURE);
    }
    pid_t pid;
    pid = fork();
    if(pid == -1)
    {
        fprintf(stderr, "fork error (%s)\n", strerror(errno));
        exit(EXIT_FAILURE);
    }
    if(pid == 0)
    {
        fprintf(stdout, "[CHILD ] I will close the last read end of pipe \n");
        close(fds[0]); // 子进程关闭读取端文件描述符
        exit(EXIT_SUCCESS);
    }
    close(fds[0]); // 父进程关闭读取端文件描述符
    sleep(1);      // 确保子进程也将读取端关闭
    int ret;
    ret = write(fds[1], "hello", 5);
    if(ret == -1)
    {
        fprintf(stderr, "[PARENT] write error(%s)\n", strerror(errno));
    }
    return 0;
}
 
void sighandler(int signo)
{
    printf("[PARENT] catch a SIGPIPE signal and signum = %d\n", signo);
}

fork之后,父子进程都立刻关闭了读取端,这时候,管道已经不存在任何读取端了.1秒钟之后,父进程尝试向管道写入.此时按照前面的分析,父进程应该会收到SIGPIPE信号,write返回失败,并且errnoEPIPE.父进程为SIGPIPE安装了信号处理函数,如果收到SIGPIPE信号,会有打印提示.下面来看看程序的输出:

[CHILD ] I will close the last read end(原始文本第一部分结束,无更多内容。)

9.1.4 管道对应的内存区大小

管道本质是一片内存区域,自然有大小。自 Linux 2.6.11 版本起,管道的默认大小是 65536 字节,可以调用 fcntl 来获取和修改这个值的大小,代码如下:

获取管道的大小

pipe_capacity = fcntl(fd, F_GETPIPE_SZ);

设置管道的大小

ret = fcntl(fd, F_SETPIPE_SZ, size);

管道内存区域的大小必须在页面大小(PAGE)和上限值之间,其上限记录在 /proc/sys/fs/pipe-max-size 里,对于特权用户,还可以修改该上限值。

cat /proc/sys/fs/pipe-max-size
1048576

管道的容量可以扩大,自然也可以缩小。缩小管道容量时会遇到一种比较有意思的场景,即当前管道中已存在的内容大于 fcntl 函数调用中指定的 size,此时 fcntl 函数会返回失败,并置错误码为 EBUSY

管道容量有大小这个事实对于编程有什么影响呢?

在使用管道的过程中要意识到:管道有大小,写入须谨慎,不能连续地写入大量的内容,一旦管道满了,写入就会被阻塞;对于读取端,要及时地读取,防止管道被写满,造成写入阻塞。

9.1.5 shell 管道的实现

shell 编程会大量使用管道,我们经常看到前一个命令的标准输出作为后一个命令的标准输入,来协作完成任务,如图 9-9 所示。管道是如何做到的呢?

兄弟进程可以通过管道来传递消息,这并不稀奇,前面已经图示了做法。关键是如何使得一个程序的标准输出被重定向到管道中,而另一个程序的标准输入从管道中读取呢?

图 9-9 管道在 shell 中的应用
(原图说明:展示了前一个命令的标准输出通过管道传递给后一个命令的标准输入)

答案就是复制文件描述符。

对于第一个子进程,执行 dup2 之后,标准输出对应的文件描述符 1,也成为了管道的写入端。这时候,管道就有了两个写入端,按照前面的建议,需要关闭不相干的写入端,使读取端可以顺利地读到 EOF,所以应将刚开始分配的管道写入端的文件描述符 pipefd[1] 关闭掉。

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

同样的道理,对于第二个子进程,如法炮制:

if (pipefd[0] != STDIN_FILENO)
{
    dup2(pipefd[0], STDIN_FILENO);
    close(pipefd[0]);
}

简单来说,就是第一个子进程的标准输出被绑定到了管道的写入端,于是第一个命令的输出,写入了管道,而第二个子进程将其标准输入绑定到管道的读取端,只要管道里面有了内容,这些内容就成了标准输入。

两个示例代码,为什么要判断管道的文件描述符是否等于标准输入和标准输出呢?原因是,在调用 pipe 时,进程很可能已经关闭了标准输入和标准输出,调用 pipe 函数时,内核会分配最小的文件描述符,所以 pipe 的文件描述符可能等于 0 或 1。在这种情况下,如果没有 if 判断加以保护,代码就变成了:

dup2(1, 1);
close(1);

这样的话,第一行代码什么也没做,第二行代码就把管道的写入端给关闭了,于是便无法传递信息了。

9.1.6 与 shell 命令进行通信(popen)

管道的一个重要作用是和外部命令进行通信。在日常编程中,经常会需要调用一个外部命令,并且要获取命令的输出。而有些时候,需要给外部命令提供一些内容,让外部命令处理这些输入。Linux 提供了 popen 接口来帮助程序员做这些事情。

就像 system 函数,即使没有 system 函数,我们通过 forkexecwait 家族函数一样也可以实现 system 的功能。但终归是不方便,system 函数为我们提供了一些便利。同样的道理,只用 pipe 函数及 dup2 等函数,也能完成 popen 要完成的工作,但 popen 接口给我们提供了便利。

popen 接口定义如下:

#include <stdio.h>
FILE *popen(const char *command, const char *type);
int pclose(FILE *stream);

popen 函数会创建一个管道,并且创建一个子进程来执行 shell,shell 会创建一个子进程来执行 command。根据 type 值的不同,分成以下两种情况。

  • 如果 typercommand 执行的标准输出,就会写入管道,从而被调用 popen 的进程读到。通过对 popen 返回的 FILE 类型指针执行 readfgets 等操作,就可以读取到 command 的标准输出,如图 9-10 所示。

图 9-10 r 模式调用 popen
(原图说明:调用 popen 的进程通过管道读取 command 的标准输出)

  • 如果 typew:调用 popen 的进程,可以通过对 FILE 类型的指针 fp 执行 writefputs 等操作,负责往管道里面写入,写入的内容经过管道传给执行 command 的进程,作为命令的输入,如图 9-11 所示。

图 9-11 w 模式调用 popen
(原图说明:调用 popen 的进程通过管道向 command 写入输入)

popen 函数成功时,会返回 stdio 库封装的 FILE 类型的指针,失败时会返回 NULL,并且设置 errno。常见的失败有 fork 失败,pipe 失败,或者分配内存失败。

I/O 结束了以后,可以调用 pclose 函数来关闭管道,并且等待子进程的退出。尽管 popen 函数返回的是 FILE 类型的指针,也不应调用 fclose 函数来关闭 popen 函数打开的文件流指针,因为 fclose 不会等待子进程的退出。pclose 函数成功时会返回子进程中 shell 的终止状态。popen 函数和 system 函数类似,如果 command 对应的命令无法执行,就如同执行了 exit(127) 一样。如果发生其他错误,pclose 函数则返回 -1。可以从 errno 中获取到失败的原因。

下面给出一个简单的例子,来示范下 popen 的用法:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>
#include <sys/wait.h>
#include <signal.h>
 
#define MAX_LINE_SIZE 8192
 
void print_wait_exit(int status)
{
    printf("status = %d\n", status);
    if (WIFEXITED(status))
    {
        printf("normal termination, exit status = %d\n", WEXITSTATUS(status));
    }
    else if (WIFSIGNALED(status))
    {
        printf("abnormal termination, signal number = %d%s\n",
                WTERMSIG(status),
#ifdef WCOREDUMP
                WCOREDUMP(status) ? " core file generated" : "");
#else
        "");
#endif
    }
}
 
int main(int argc, char *argv[])
{
    FILE *fp = NULL;
    char command[MAX_LINE_SIZE], buffer[MAX_LINE_SIZE];
    if (argc != 2)
    {
        fprintf(stderr, "Usage: %s filename \n", argv[0]);
        exit(1);
    }
    snprintf(command, sizeof(command), "cat %s", argv[1]);
    fp = popen(command, "r");
    if (fp == NULL)
    {
        fprintf(stderr, "popen failed (%s)", strerror(errno));
        exit(2);
    }
    while (fgets(buffer, MAX_LINE_SIZE, fp) != NULL)
    {
        fprintf(stdout, "%s", buffer);
    }
    int ret = pclose(fp);
    if (ret == 127)
    {
        fprintf(stderr, "bad command : %s\n", command);
        exit(3);
    }
    else if (ret == -1)
    {
        fprintf(stderr, "failed to get child status (%s)\n",
                strerror(errno));
        exit(4);
    }
    else
    {
        print_wait_exit(ret);
    }
    exit(0);
}

将文件名作为参数传递给程序,执行 cat filename 的命令。popen 创建子进程来负责执行 cat filename 的命令,子进程的标准输出通过管道传给父进程,父进程可以通过 fgets 来读取 command 的标准输出。

popen 函数和 system 有很多相似的地方,但是也有显著的不同。调用 system 函数时,shell 命令的执行被封装在了函数内部,所以若 system 函数不返回,调用 system 的进程就不再继续执行。但是 popen 函数不同,一旦调用 popen 函数,调用进程和执行 command 的进程便处于并行状态。然后 pclose 函数才会关闭管道,等待执行 command 的进程退出。换句话说,在 popen 之后,pclose 之前,调用 popen 的进程和执行 command 的进程是并行的,这种差异带来了两种显著的不同:

  • 在并行期间,调用 popen 的进程可能会创建其他子进程,所以标准规定 popen 不能阻塞 SIGCHLD 信号。这也意味着,popen 创建的子进程可能被提前执行的等待操作所捕获。若发生这种情况,调用 pclose 函数时,已经无法等待 command 子进程的退出,这种情况下,将返回 -1,并且 errnoECHILD
  • 调用进程和 command 子进程是并行的,所以标准要求 popen 不能忽略 SIGINTSIGQUIT 信号。如果是从键盘产生的上述信号,那么,调用进程和 command 子进程都会收到信号。

9.2 命名管道 FIFO

上一节介绍的管道也被称为无名管道。这种管道因为没有实体文件与之关联,靠的是世代相传的文件描述符,所以只能应用在有共同祖先的各个进程之间。对于没有亲缘关系的任意两个进程之间,无名管道就爱莫能助了。

命名管道就是为了解决无名管道的这个问题而引入的。FIFO 与管道类似,最大的差别就是有实体文件与之关联。由于存在实体文件,不相关的没有亲缘关系的进程也可以通过使用 FIFO 来实现进程之间的通信。

与无名管道相比,命名管道仅仅是披了一件马甲,其核心与无名管道是一模一样的。内核的 fs/fifo.c 文件仅有 153 行,说白了,这简短的代码只干了两件事:

  • 从外表看,我是一个 FIFO 文件,有文件名,任何进程通过文件名都可以打开我。
  • 我的内心与无名管道是一样的,支持的文件操作与无名管道也是一样的。

9.2.1 创建 FIFO 文件

创建命名管道的接口定义如下:

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

其中,第二个参数的含义是 FIFO 文件的读写执行权利,和 open 函数类似。当然真实的读写执行权限,还需要按照当前进程的 umask 来取掩码,即:

real_mode = (mode & ~umask)

除了用 C 接口,还可以用命令来创建一个命名管道:

mkfifo [-m mode] pathname

pathname 是创建命名管道文件的文件名,-m mode 的使用方法和 chmod 的方法一样。

除此外,mknod 命令也可以用来创建 FIFO 文件,使用方法如下:

mknod [-m mode] pathname p

命令末尾的 p 表示要创建命名管道(named pipe)。

创建出来的 FIFO 文件,用 ls -l 来查看,第一个字母是 p,表示这是命名管道文件。

prw-rw-r--  1 manu manu    0  2月 19 23:03 myfifo2

在 shell 编程中可以使用 -p file 来判断是否为 FIFO 文件。在 C 语言中如何判断是否为 FIFO 文件呢?通过 S_ISFIFO 宏可以判断,不过要先通过 statfstat 函数来获取到文件的属性信息,如下面的代码所示:

#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
int stat(const char *path, struct stat *buf);
int fstat(int fd, struct stat *buf);
S_ISFIFO(buf->st_mode)

9.2.2 打开 FIFO 文件

一旦 FIFO 文件创建好了,就可以把它用于进程间的通信了。一般的文件操作函数如 openreadwritecloseunlink 等都可以用在 FIFO 文件上。

FIFO 文件和普通文件相比,有一个明显的不同:程序不应该以 O_RDWR 模式打开 FIFO 文件。POSIX 标准规定,以 O_RDWR 模式打开 FIFO 文件,结果是未定义的。当然了,Linux 提供了对 O_RDWR 的支持,在某些场景下,O_RDWR 模式的打开是有价值的(9.4 节给出了一个例子)。

对 FIFO 文件推荐的使用方法是,两个进程一个以只读模式(O_RDONLY)打开 FIFO 文件,另一个以只写模式(O_WRONLY)打开 FIFO 文件。这样负责写入的进程写入 FIFO 的内容就可以被负责读取的进程读到,从而达到通信的目的。

打开一个 FIFO 文件和打开普通文件相比,又有不同。在没有进程以写模式(O_RDWRO_WRONLY)打开 FIFO 文件的情况下,以 O_RDONLY 模式打开一个 FIFO 文件时,调用进程会陷入阻塞,直到另一进程以 O_WRONLY(或者 O_RDWR)的标志位打开该 FIFO 文件为止。同样的道理,在没有进程以读模式(O_RDONLYO_RDWR)打开 FIFO 文件的情况下,如果一个进程以 O_WRONLY 的标志位打开一个 FIFO 文件,调用进程也会阻塞,直到另一个进程以 O_RDONLY(或者 O_RDWR)的标志位打开该 FIFO 文件为止。也就是说,打开 FIFO 文件会同步读取进程和写入进程。

乍看之下,O_RDONLY 模式打开不能返回,在等写打开,同样 O_WRONLY 打开不能返回,在等读打开,造成死锁,谁都返回不了。事实上不是这样的。当 O_RDONLY 打开和 O_WRONLY 打开的请求都到达 FIFO 文件时,两者就都能返回了。

内核之中,维护有引用计数 r_counterw_counter,分别记录 FIFO 文件两种打开模式的引用计数。对于 FIFO 文件,无论是读打开还是写打开,都会根据引用计数判断对方是否存在,进而决定后续的行为(是阻塞、返回成功,还是返回失败)。

FIFO 文件提供了 O_NONBLOCK 标志位,该标志位会显著影响 open 的行为模式。将 O_RDONLYO_WRONLYO_NONBLOCK 三种标志位结合在一起考虑,共有以下四种组合方式,如表 9-2 所示。

表 9-2 打开 FIFO 文件的不同情况

打开标志对方进程是否存在行为
O_RDONLY (无 O_NONBLOCK)尚无写打开阻塞,直到有写打开
O_RDONLY (带 O_NONBLOCK)尚无写打开成功返回(但读取将返回空)
O_WRONLY (无 O_NONBLOCK)尚无读打开阻塞,直到有读打开
O_WRONLY (带 O_NONBLOCK)尚无读打开失败返回 -1,errno 置为 ENXIO

同样是带 O_NONBLOCK 标志位的打开,没有写打开进程时,读打开请求可以成功返回,但没有读打开进程时,写打开请求却失败,返回 -1,并置 errnoENXIO,两相比较,是否太不公平了?

这样设计是有原因的:FIFO 只有读取端,没有写入端,并无显著的危害,所有尝试从 FIFO 中读取数据的操作都不会返回任何数据。反之则不然。如果允许只存在写入端,EPIPE 的错误,所以在源头上堵住(即让 open 函数返回失败)反倒更加合理。

打开 FIFO 文件的内核代码位于内核的 fs/fifo.c 文件中,代码简短,非常易懂。读者可以通过阅读源代码,加深对打开 FIFO 文件的理解。

9.3 读写管道文件

无名管道 pipe 和命名管道 FIFO 在内核实现部分有很大的重叠,都属于管道文件系统(pipefs)。无名管道,分裂成了读取文件描述符和写入文件描述符。而命名管道则将两个描述符合二为一,如果是读打开,就如同获取到了无名管道的读取文件描述符;如果是写打开,就如同获取到了无名管道的写入文件描述符。这种本质上的一致,造成 FIFO 的读写控制和无名管道的读写控制是一模一样的,因此在本节一并介绍。

影响管道或 FIFO 文件读写行为的因素有:

  • 当前管道中存在的字节数 p
  • 是否有 O_NONBLOCK 标志位。
  • 管道的最大容量 PIPE_BUF 和要读写的字节数 n 的关系。
  • 读写端是否都存在。

管道文件的读写中一个很重要的标志位是 O_NONBLOCK,该标志位会影响读写的行为模式。

对于无名管道,Linux 提供了特有的 pipe2 函数,该函数的接口如下:

#define _GNU_SOURCE
#include <unistd.h>
int pipe2(int pipefd[2], int flags);

可选的 flag 就有 O_NONBLOCK

对于命名管道 FIFO,打开文件时,可以带上 O_NONBLOCK 标志位来控制读写的行为(当然了,对于 FIFO 文件,O_NONBLOCK 也会影响打开的行为)。

如果打开时,忘记带上 O_NONBLOCK 标志位,那该如何补救呢?答案是用 fcntl 这把文件控制的瑞士军刀。

通过如下代码,可以给管道文件加上 O_NONBLOCK 标志位:

int flags = fcntl(fd, F_GETFL);
flags |= O_NONBLOCK;
fcntl(fd, F_SETFL, flags);

相反的,如果打开时,带有 O_NONBLOCK 标志位,而后面又想取消该标志位,又该怎么做?

int flags = fcntl(fd, F_GETFL);
flags &= ~O_NONBLOCK;
fcntl(fd, F_SETFL, flags);

花开两朵,各表一枝。先来说说从 FIFO 或管道读取端读,如表 9-3 所示。

表 9-3 从一个包含 p 字节的管道或 FIFO 读取 n 字节的含义

条件O_NONBLOCK 未设置O_NONBLOCK 已设置
管道非空 (p > 0)读取 min(p, n) 字节,返回实际读取字节数同左
管道为空 (p = 0) 且写入端存在阻塞直到有数据可读返回 -1,errno 置为 EAGAIN
管道为空 (p = 0) 且写入端已全部关闭返回 0(EOF)返回 0(EOF)

从表 9-3 可以看出:

  • O_NONBLOCK 标志位影响的仅仅是当管道为空并且存在写入端时的行为,读取操作的行为是阻塞,还是当即返回失败。
  • read 返回 0 时,表示已经遇到了 EOF,并且所有的写入端都已经关闭了。这一般出现在管道的使命结束时,此时读取端也可以关闭了。

说完读,然后说写(如表 9-4 所示)。对于管道的写入而言,POSIX 标准规定,如果一次写入的数据量不超过 PIPE_BUF 个字节,必须确保写入是原子的(atomic)。所谓原子是指:写入的内容必须确保是连续的,纵然有多个进程同时往管道中写入,写入的内容也不会被其他进程写入的内容打断,本次写入的内容不会混杂其他进程 write 函数写入的内容。标准规定,PIPE_BUF 最少为 512 字节,对于 Linux 而言,这个值是 4096,一个页面的大小。

表 9-4 向管道写入 n 字节

条件行为
n <= PIPE_BUF 且管道有足够空间原子写入 n 字节,返回 n
n <= PIPE_BUF 但管道空间不足阻塞(无 O_NONBLOCK)或返回 EAGAIN(有 O_NONBLOCK
n > PIPE_BUF非原子写入,可能被其他写入打断;若管道有足够空间,可一次写入部分;若空间不足,行为与 O_NONBLOCK 相关

关于单次写入的长度超出 PIPE_BUF,内核不能保证其原子性这个事实,我们可以通过一个简单的实验来验证,示例代码如下:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <errno.h>
#include <fcntl.h>
 
#define BUF_4K  4*1024
#define BUF_8K  8*1024
#define BUF_12K 12*1024
 
int main(void)
{
    char a[BUF_4K];
    char b[BUF_8K];
    char c[BUF_12K];
    memset(a, 'A', sizeof(a));
    memset(b, 'B', sizeof(b));
    memset(c, 'C', sizeof(c));
 
    int pipefd[2];
    int ret = pipe(pipefd);
    if (ret == -1)
    {
        fprintf(stderr, "failed to create pipe (%s)\n", strerror(errno));
        return 1;
    }
 
    pid_t pid;
    pid = fork();
    if (pid == 0) // 第一个子进程
    {
        close(pipefd[0]);
        int loop = 0;
        while (loop++ < 10)
        {
            ret = write(pipefd[1], a, sizeof(a));
            printf("apid=%d write %d bytes to pipe\n", getpid(), ret);
        }
        exit(0);
    }
 
    pid = fork();
    if (pid == 0) // 第二个子进程
    {
        close(pipefd[0]);
        int loop = 0;
        while (loop++ < 10)
        {
            ret = write(pipefd[1], b, sizeof(b));
            printf("bpid=%d write %d bytes to pipe\n", getpid(), ret);
        }
        exit(0);
    }
 
    pid = fork();
    if (pid == 0) // 第三个子进程
    {
        close(pipefd[0]);
        int loop = 0;
        while (loop++ < 10)
        {
            ret = write(pipefd[1], c, sizeof(c));
            printf("cpid=%d write %d bytes to pipe\n", getpid(), ret);
        }
        exit(0);
    }
 
    close(pipefd[1]);
    sleep(1);
    int fd = open("test.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    char buf[1024*4] = {0};
    int n = 1;
    while (1)
    {
        ret = read(pipefd[0], buf, sizeof(buf));
        if (ret == 0)
            break;
        printf("n=%02d pid=%d read %d bytes from pipe buf[4095]=%c\n", n++, getpid(), ret, buf[4095]);
        write(fd, buf, ret);
    }
    return 0;
}

上述代码,创建了三个子进程,第一个子进程每次向管道写入 4096 字节的 A 字符,循环 10 次;第二个子进程向管道写入 8192 字节(40962)的 B 字符,循环 10 次;第三个子进程每次向管道写入 12288 字节(40963)的 C 字符,循环 10 次。父进程负责从管道里面读取内容,写入到 test.txt 文件。

由于三个子进程和一个父进程是同时运行的,考虑到进程调度的因素,每次执行写入管道和从管道读取的时序并不完全一样。因此每次执行,产生的 test.txt 文件也不相同。对于每次写入 8KB 和每次写入 12KB 的情况,尽管管道不保证原子性,但是其内容也不是每次都必然会混入其他进程的写入。

多次执行该程序,总会遇到某次 8KB 或 12KB 的写入,中间混杂了其他字符。下面的输出是某次执行的结果:

0000000 4343 4343 4343 4343 4343 4343 4343 4343
*
0003000 4242 4242 4242 4242 4242 4242 4242 4242
*
0005000 4343 4343 4343 4343 4343 4343 4343 4343
*
0008000 4141 4141 4141 4141 4141 4141 4141 4141
*0009000 4242 4242 4242 4242 4242 4242 4242 4242
*
0010000 4141 4141 4141 4141 4141 4141 4141 4141
*
0015000 4343 4343 4343 4343 4343 4343 4343 4343
*002d000 4242 4242 4242 4242 4242 4242 4242 4242
*
002e000 4141 4141 4141 4141 4141 4141 4141 4141
*
0030000 4242 4242 4242 4242 4242 4242 4242 4242
*
0032000 4141 4141 4141 4141 4141 4141 4141 4141
*
0033000 4242 4242 4242 4242 4242 4242 4242 4242
*
0035000 4141 4141 4141 4141 4141 4141 4141 4141
*
0036000 4242 4242 4242 4242 4242 4242 4242 4242
*
003c000

从地址 002d000 到地址 002e000,只有 4KB 的大小,可是里面的内容却是 0x42 即 B 字符。从程序可以得知,B 字符每次写入 8KB,这里却只有 4KB 的内容,地址 002d000 之前是 C 字符,002e000 之后是 A 字符。唯一的解释就是某次 8KB 的写入内容被中途打断,混杂了其他进程的写入。

多次执行程序,解读输出的内容,从某些输出中可以看出,8KB 的写入和 12KB 的写入,都不是原子的。

当写入内容长度不超过 PIPE_BUF 时,内核确保写入操作是原子的这条性质非常重要,尤其是在有多个进程向管道写入的情况下。在不采取其他同步手段的情况下,消息体小于 PIPE_BUF 时,写入管道是安全的,即使多个进程一起写入也没关系,内核会保证写入内容不会和其他进程的写入内容混在一起。但是如果消息体太大,长度超过了 PIPE_BUF,就要警惕,需要采取必要的同步措施,来确保消息内容不会混杂其他进程的消息,否则会导致无法正确解析消息的内容。

9.4 使用管道通信的示例

前文介绍了无名管道 pipe 和命名管道 FIFO,了解了它们的很多性质,但是到目前为止,还没有介绍如何利用管道来实现进程间通信。

下面以 FIFO 为例,介绍如何使用管道来实现一个客户端/服务器的应用程序,具体流程如图 9-12 所示。

图 9-12 使用 FIFO 实现客户端服务器通信

首先服务器进程会创建一个公开的众所周知的命名管道文件,我们称之为 Public FIFO。服务器进程以 O_RDWR 的模式打开,但是,服务器进程只会从 Public FIFO 中读取内容,而不会向该命名管道中写入内容。之所以服务器进程要以 O_RDWR 模式打开(而不是 O_RDONLY 模式打开),是因为服务器进程是 daemon 进程,当所有的客户端都关闭曾经打开的 Public FIFO,只有自身也以写模式打开 Public FIFO,服务器进程的 read 才不会返回 0,而是继续阻塞在管道,等待新的客户发来请求。

server_fifo_fd = open(PUBLIC_FIFO_NAME, O_RDWR);

这个 Public FIFO 是众所周知的,所有向服务器进程发送请求的客户端程序都应该知道该 Public FIFO 的路径。一般来讲,客户端会将自己的请求作为消息体写入 Public 管道之中。除此以外,客户端会负责创建一个私有的 FIFO,用来和服务器进程进行通信。服务器进程从管道中读取了请求之后,就会十分默契地将回应写入到该客户端创建的私有的 FIFO 中。

问题来了,服务器进程如何知道该往哪个私有的 FIFO 里面写入,对应的客户端进程才能读到回应信息?方法有很多:

  • 客户端可以将自己私有的 FIFO 路径作为请求的一部分,写入到 Public FIFO 中,服务器进程可以从请求中获得对应客户端的私有 FIFO 路径。
  • 还有一种方法是,私有 FIFO 有一定的命名规范,比如 /tmp/fifo.client_pid,其中 client_pid 代表客户端进程的进程 ID。只要客户端发到 Public FIFO 的内容中包含自己的 PID,服务器进程就能根据事先的约定找到对应的私有 FIFO 文件,从而可以将响应写入对应的私有 FIFO 中。

上述的模型解决了如何利用 FIFO 编写客户端/服务器程序这个问题。一般来说,不会采用迭代服务的方式。因为某些客户请求处理起来可能非常耗时,那么其他客户端发过来的请求就会被阻塞,得不到及时响应。比较常见的是提供并发服务,即每取出一个请求,就创建一个进程或一个线程来响应该请求。当然还可以提供线程池,让空闲的线程来负责处理客户的请求。

然而,还有一个关键的因素没有讨论。事实上,客户端写入的内容并不是结构化的消息,写入管道之后,客户端进程写入的不过就是字节流。那么,多个进程都向管道写入时,如何正确地区分内容的边界,正确地拣出每个进程的发送内容就成了通信的关键。

一般来说,为了区分内容的边界,有以下办法:

  1. 固定长度
  2. 特殊分隔字符
  3. 具有长度字段的头

固定长度

固定长度的方法最简单,也最容易想到,但是对管道空间的使用效率不高,如图 9-13 所示。写入的内容长度固定,意味着不得不采用最长消息的长度作为固定长度。对于消息体长度参差不齐,短消息占大多数而最长消息的长度又很长的情况,效率太低,大大降低了管道容纳消息的能力。

图 9-13 固定长度的消息

特殊分隔字符

特殊分隔字符也是一种常用的方法,如图 9-14 所示。比如事先约定消息的最后一个字符总是换行符。这种方法有几个弊端:

  • 第一需要扫描数据,逐个分析字节,直到遇到特殊分隔字符。
  • 第二是特殊字符撞车,如果消息体中真的存在事先选定的特殊字符,那就不得不转义。

图 9-14 特殊分隔字符为结尾的消息

具有长度字段的头

具有长度字段的头是比较推荐的方法,如图 9-15 所示。管道中提取消息分成两步:

  1. 提取消息的长度,由于长度字段本身的长度是固定的,所以不会有问题。
  2. 根据第一步读取的消息长度 len,读取接下来的 len 字节内容作为消息体。

图 9-15 以长度字段作为头的消息


9.5 Packet 模式的管道

TIP

从内核版本 3.4 开始,内核开始提供 Packet 模式的管道。所谓 Packet 模式,就是写入管道的内容就像是一个 packet,或者说是一个消息,而不是原始的字节流。

ret = pipe2(pipefd, O_DIRECT);

当打开管道时,带上 O_DIRECT 标志位,创建的管道就是 Packet 模式的管道,代码如下所示。当然,老版本的 Linux 不支持 O_DIRECT 标志位,会返回 EINVAL 错误。

当写入 Packet 模式的管道时:

  • 如果写入内容少于 PIPE_BUF,该内容仍然完全占有一个页面。后面的写入(不管是本进程还是其他进程)不会与上一次的写入共用一个页面。
  • 当写入内容大于 PIPE_BUF 时,会分成多个包。

从 Packet 模式管道中读取时:

  • 存放读取内容的 buffer 有 PIPE_BUF 大小肯定足够了。
  • 如果指定的 buffer 太小,小于下一个要取出的 Packet 的大小,管道仍然是取出 Packet 大小,超出 buffer 的部分会被丢弃掉而不是仍旧留在管道的内存缓冲区。

WARNING

这种模式从使用内存的角度来看有点浪费,因为不管消息多大,都会至少占有 1 个页面的大小。但是从编程的角度来看接口更容易使用。