Linux 的进程间通讯:管道

本文由云+社区发表

做者:邹立巍javascript

版权声明:

本文章内容在非商业使用前提下可无需受权任意转载、发布。

转载、发布请务必注明做者和其微博、微信公众号地址,以便读者询问问题和甄误反馈,共同进步。

微博ID:orroz

微信公众号:Linux系统技术

前言

管道是UNIX环境中历史最悠久的进程间通讯方式。本文主要说明在Linux环境上如何使用管道。阅读本文能够帮你解决如下问题:java

  1. 什么是管道和为何要有管道?
  2. 管道怎么分类?
  3. 管道的实现是什么样的?
  4. 管道有多大?
  5. 管道的大小是否是能够调整?如何调整?

什么是管道?

管道,英文为pipe。这是一个咱们在学习Linux命令行的时候就会引入的一个很重要的概念。它的发明人是道格拉斯.麦克罗伊,这位也是UNIX上早期shell的发明人。他在发明了shell以后,发现系统操做执行命令的时候,常常有需求要将一个程序的输出交给另外一个程序进行处理,这种操做可使用输入输出重定向加文件搞定,好比:node

[zorro@zorro-pc pipe]$ ls  -l /etc/ > etc.txt
[zorro@zorro-pc pipe]$ wc -l etc.txt 
183 etc.txt

可是这样未免显得太麻烦了。因此,管道的概念应运而生。目前在任何一个shell中,均可以使用“|”链接两个命令,shell会将先后两个进程的输入输出用一个管道相连,以便达到进程间通讯的目的:程序员

[zorro@zorro-pc pipe]$ ls -l /etc/ | wc -l
183

对比以上两种方法,咱们也能够理解为,管道本质上就是一个文件,前面的进程以写方式打开文件,后面的进程以读方式打开。这样前面写完后面读,因而就实现了通讯。实际上管道的设计也是遵循UNIX的“一切皆文件”设计原则的,它本质上就是一个文件。Linux系统直接把管道实现成了一种文件系统,借助VFS给应用程序提供操做接口。shell

虽然实现形态上是文件,可是管道自己并不占用磁盘或者其余外部存储的空间。在Linux的实现上,它占用的是内存空间。因此,Linux上的管道就是一个操做方式为文件的内存缓冲区。编程

管道的分类和使用

Linux上的管道分两种类型:数组

  1. 匿名管道
  2. 命名管道

这两种管道也叫作有名或无名管道。匿名管道最多见的形态就是咱们在shell操做中最经常使用的”|”。它的特色是只能在父子进程中使用,父进程在产生子进程前必须打开一个管道文件,而后fork产生子进程,这样子进程经过拷贝父进程的进程地址空间得到同一个管道文件的描述符,以达到使用同一个管道通讯的目的。此时除了父子进程外,没人知道这个管道文件的描述符,因此经过这个管道中的信息没法传递给其余进程。这保证了传输数据的安全性,固然也下降了管道了通用性,因而系统还提供了命名管道。缓存

咱们可使用mkfifo或mknod命令来建立一个命名管道,这跟建立一个文件没有什么区别:安全

[zorro@zorro-pc pipe]$ mkfifo pipe
[zorro@zorro-pc pipe]$ ls -l pipe 
prw-r--r-- 1 zorro zorro 0 Jul 14 10:44 pipe

能够看到建立出来的文件类型比较特殊,是p类型。表示这是一个管道文件。有了这个管道文件,系统中就有了对一个管道的全局名称,因而任何两个不相关的进程均可以经过这个管道文件进行通讯了。好比咱们如今让一个进程写这个管道文件:微信

[zorro@zorro-pc pipe]$ echo xxxxxxxxxxxxxx > pipe

此时这个写操做会阻塞,由于管道另外一端没有人读。这是内核对管道文件定义的默认行为。此时若是有进程读这个管道,那么这个写操做的阻塞才会解除:

[zorro@zorro-pc pipe]$ cat pipe 
xxxxxxxxxxxxxx

你们能够观察到,当咱们cat完这个文件以后,另外一端的echo命令也返回了。这就是命名管道。

Linux系统不管对于命名管道和匿名管道,底层都用的是同一种文件系统的操做行为,这种文件系统叫pipefs。你们能够在/etc/proc/filesystems文件中找到你的系统是否是支持这种文件系统:

[zorro@zorro-pc pipe]$ cat /proc/filesystems |grep pipefs
nodev    pipefs

观察完了如何在命令行中使用管道以后,咱们再来看看如何在系统编程中使用管道。

PIPE

咱们能够把匿名管道和命名管道分别叫作PIPE和FIFO。这主要由于在系统编程中,建立匿名管道的系统调用是pipe(),而建立命名管道的函数是mkfifo()。使用mknod()系统调用并指定文件类型为为S_IFIFO也能够建立一个FIFO。

使用pipe()系统调用能够建立一个匿名管道,这个系统调用的原型为:

#include <unistd.h>

int pipe(int pipefd[2]);

这个方法将会建立出两个文件描述符,可使用pipefd这个数组来引用这两个描述符进行文件操做。pipefd[0]是读方式打开,做为管道的读描述符。pipefd[1]是写方式打开,做为管道的写描述符。从管道写端写入的数据会被内核缓存直到有人从另外一端读取为止。咱们来看一下如何在一个进程中使用管道,虽然这个例子并无什么意义:

[zorro@zorro-pc pipe]$ cat pipe.c
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>

#define STRING "hello world!"

int main()
{
    int pipefd[2];
    char buf[BUFSIZ];

    if (pipe(pipefd) == -1) {
        perror("pipe()");
        exit(1);
    }

    if (write(pipefd[1], STRING, strlen(STRING)) < 0) {
        perror("write()");
        exit(1);
    }

    if (read(pipefd[0], buf, BUFSIZ) < 0) {
        perror("write()");
        exit(1);
    }

    printf("%s\n", buf);

    exit(0);
}

这个程序建立了一个管道,而且对管道写了一个字符串以后从管道读取,并打印在标准输出上。用一个图来讲明这个程序的状态就是这样的:

img

一个进程本身给本身发送消息这固然不叫进程间通讯,因此实际状况中咱们不会在单个进程中使用管道。进程在pipe建立完管道以后,每每都要fork产生子进程,成为以下图表示的样子:

img

如图中描述,fork产生的子进程会继承父进程对应的文件描述符。利用这个特性,父进程先pipe建立管道以后,子进程也会获得同一个管道的读写文件描述符。从而实现了父子两个进程使用一个管道能够完成半双工通讯。此时,父进程能够经过fd[1]给子进程发消息,子进程经过fd[0]读。子进程也能够经过fd[1]给父进程发消息,父进程用fd[0]读。程序实例以下:

[zorro@zorro-pc pipe]$ cat pipe_parent_child.c
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>

#define STRING "hello world!"

int main()
{
    int pipefd[2];
    pid_t pid;
    char buf[BUFSIZ];

    if (pipe(pipefd) == -1) {
        perror("pipe()");
        exit(1);
    }

    pid = fork();
    if (pid == -1) {
        perror("fork()");
        exit(1);
    }

    if (pid == 0) {
        /* this is child. */
        printf("Child pid is: %d\n", getpid());
        if (read(pipefd[0], buf, BUFSIZ) < 0) {
            perror("write()");
            exit(1);
        }

        printf("%s\n", buf);

        bzero(buf, BUFSIZ);
        snprintf(buf, BUFSIZ, "Message from child: My pid is: %d", getpid());
        if (write(pipefd[1], buf, strlen(buf)) < 0) {
            perror("write()");
            exit(1);
        }

    } else {
        /* this is parent */
        printf("Parent pid is: %d\n", getpid());

        snprintf(buf, BUFSIZ, "Message from parent: My pid is: %d", getpid());
        if (write(pipefd[1], buf, strlen(buf)) < 0) {
            perror("write()");
            exit(1);
        }

        sleep(1);

        bzero(buf, BUFSIZ);
        if (read(pipefd[0], buf, BUFSIZ) < 0) {
            perror("write()");
            exit(1);
        }

        printf("%s\n", buf);

        wait(NULL);
    }


    exit(0);
}

父进程先给子进程发一个消息,子进程接收到以后打印消息,以后再给父进程发消息,父进程再打印从子进程接收到的消息。程序执行效果:

[zorro@zorro-pc pipe]$ ./pipe_parent_child 
Parent pid is: 8309
Child pid is: 8310
Message from parent: My pid is: 8309
Message from child: My pid is: 8310

从这个程序中咱们能够看到,管道实际上能够实现一个半双工通讯的机制。使用同一个管道的父子进程能够分时给对方发送消息。咱们也能够看到对管道读写的一些特色,即:

在管道中没有数据的状况下,对管道的读操做会阻塞,直到管道内有数据为止。当一次写的数据量不超过管道容量的时候,对管道的写操做通常不会阻塞,直接将要写的数据写入管道缓冲区便可。

固然写操做也不会再全部状况下都不阻塞。这里咱们要先来了解一下管道的内核实现。上文说过,管道实际上就是内核控制的一个内存缓冲区,既然是缓冲区,就有容量上限。咱们把管道一次最多能够缓存的数据量大小叫作PIPESIZE。内核在处理管道数据的时候,底层也要调用相似read和write这样的方法进行数据拷贝,这种内核操做每次能够操做的数据量也是有限的,通常的操做长度为一个page,即默认为4k字节。咱们把每次能够操做的数据量长度叫作PIPEBUF。POSIX标准中,对PIPEBUF有长度限制,要求其最小长度不得低于512字节。PIPEBUF的做用是,内核在处理管道的时候,若是每次读写操做的数据长度不大于PIPEBUF时,保证其操做是原子的。而PIPESIZE的影响是,大于其长度的写操做会被阻塞,直到当前管道中的数据被读取为止。

在Linux 2.6.11以前,PIPESIZE和PIPEBUF其实是同样的。在这以后,Linux从新实现了一个管道缓存,并将它与写操做的PIPEBUF实现成了不一样的概念,造成了一个默认长度为65536字节的PIPESIZE,而PIPEBUF只影响相关读写操做的原子性。从Linux 2.6.35以后,在fcntl系统调用方法中实现了F_GETPIPE_SZ和F_SETPIPE_SZ操做,来分别查看当前管道容量和设置管道容量。管道容量容量上限能够在/proc/sys/fs/pipe-max-size进行设置。

#define BUFSIZE 65536

......

ret = fcntl(pipefd[1], F_GETPIPE_SZ);
if (ret < 0) {
    perror("fcntl()");
    exit(1);
}

printf("PIPESIZE: %d\n", ret);

ret = fcntl(pipefd[1], F_SETPIPE_SZ, BUFSIZE);
if (ret < 0) {
    perror("fcntl()");
    exit(1);
}

......

PIPEBUF和PIPESIZE对管道操做的影响会由于管道描述符是否被设置为非阻塞方式而有行为变化,n为要写入的数据量时具体为:

O_NONBLOCK关闭,n <= PIPE_BUF:

n个字节的写入操做是原子操做,write系统调用可能会由于管道容量(PIPESIZE)没有足够的空间存放n字节长度而阻塞。

O_NONBLOCK打开,n <= PIPE_BUF:

若是有足够的空间存放n字节长度,write调用会当即返回成功,而且对数据进行写操做。空间不够则当即报错返回,而且errno被设置为EAGAIN。

O_NONBLOCK关闭,n > PIPE_BUF:

对n字节的写入操做不保证是原子的,就是说此次写入操做的数据可能会跟其余进程写这个管道的数据进行交叉。当管道容量长度低于要写的数据长度的时候write操做会被阻塞。

O_NONBLOCK打开,n > PIPE_BUF:

若是管道空间已满。write调用报错返回而且errno被设置为EAGAIN。若是没满,则可能会写入从1到n个字节长度,这取决于当前管道的剩余空间长度,而且这些数据可能跟别的进程的数据有交叉。

以上是在使用半双工管道的时候要注意的事情,由于在这种状况下,管道的两端均可能有多个进程进行读写处理。若是再加上线程,则事情可能变得更复杂。实际上,咱们在使用管道的时候,并不推荐这样来用。管道推荐的使用方法是其单工模式:即只有两个进程通讯,一个进程只写管道,另外一个进程只读管道。实现为:

[zorro@zorro-pc pipe]$ cat pipe_parent_child2.c
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>

#define STRING "hello world!"

int main()
{
    int pipefd[2];
    pid_t pid;
    char buf[BUFSIZ];

    if (pipe(pipefd) == -1) {
        perror("pipe()");
        exit(1);
    }

    pid = fork();
    if (pid == -1) {
        perror("fork()");
        exit(1);
    }

    if (pid == 0) {
        /* this is child. */
        close(pipefd[1]);

        printf("Child pid is: %d\n", getpid());
        if (read(pipefd[0], buf, BUFSIZ) < 0) {
            perror("write()");
            exit(1);
        }

        printf("%s\n", buf);

    } else {
        /* this is parent */
        close(pipefd[0]);

        printf("Parent pid is: %d\n", getpid());

        snprintf(buf, BUFSIZ, "Message from parent: My pid is: %d", getpid());
        if (write(pipefd[1], buf, strlen(buf)) < 0) {
            perror("write()");
            exit(1);
        }

        wait(NULL);
    }


    exit(0);
}

这个程序实际上比上一个要简单,父进程关闭管道的读端,只写管道。子进程关闭管道的写端,只读管道。整个管道的打开效果最后成为下图所示:

img

此时两个进程就只用管道实现了一个单工通讯,而且这种状态下不用考虑多个进程同时对管道写产生的数据交叉的问题,这是最经典的管道打开方式,也是咱们推荐的管道使用方式。另外,做为一个程序员,即便咱们了解了Linux管道的实现,咱们的代码也不能依赖其特性,因此处理管道时该越界判断仍是要判断,该错误检查仍是要检查,这样代码才能更健壮。

FIFO

命名管道在底层的实现跟匿名管道彻底一致,区别只是命名管道会有一个全局可见的文件名以供别人open打开使用。再程序中建立一个命名管道文件的方法有两种,一种是使用mkfifo函数。另外一种是使用mknod系统调用,例子以下:

[zorro@zorro-pc pipe]$ cat mymkfifo.c
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <stdlib.h>

int main(int argc, char *argv[])
{

    if (argc != 2) {
        fprintf(stderr, "Argument error!\n");
        exit(1);
    }

/*
    if (mkfifo(argv[1], 0600) < 0) {
        perror("mkfifo()");
        exit(1);
    }
*/
    if (mknod(argv[1], 0600|S_IFIFO, 0) < 0) {
        perror("mknod()");
        exit(1);
    }

    exit(0);
}

咱们使用第一个参数做为建立的文件路径。建立完以后,其余进程就可使用open()、read()、write()标准文件操做等方法进行使用了。其他全部的操做跟匿名管道使用相似。须要注意的是,不管命名仍是匿名管道,它的文件描述都没有偏移量的概念,因此不能用lseek进行偏移量调整。

关于管道的其它议题,好比popen、pclose的使用等话题,《UNIX环境高级编程》中的相关章节已经讲的很清楚了。若是想学习补充这些知识,请参见此书。

此文已由腾讯云+社区在各渠道发布

获取更多新鲜技术干货,能够关注咱们腾讯云技术社区-云加社区官方号及知乎机构号

相关文章
相关标签/搜索