引   言

        我在完成linux操作系统作业时有一个疑问,为什么完成同一个任务时,会有如此简单的代码和相对复杂得多的代码?对于多行代码之间是否可以直接传输?

如题:使用命令添加10个用户 名字为user1 ....user10

方法一:

方法二:

我们可以看到,其中相差的是“|”这个符号,他像一个管道一样,将前后代码串联起来。

        管道是一种最基本的IPC机制,作用于有血缘关系的进程之间,完成数据传递。本质是:操作系统在内核中为进程创建的的一块缓冲区,若多个进程可以访问到同一块缓冲区,就可以实现进程间通信 —通过半双工通信(可以选择方向的单向通信)实现数据传输进程间通信的本质,就是两个进程看到同一份公共的资源,准确来说是同一个文件,并对这个资源进行read和write操作

        进程间因为每一个进程都有一个虚拟地址空间,在保证了进程独立性的同时,却使得进程间无法直接通信。因此需要操作系统来提供进程间通信方式,并且因为通信场景不同,提供的方式也有多种

        进程间通信,实际上可以转化为两个进程对同一个文件的读写操作。

        给多个进程之间提供一个大家都能访问到的传播介质,并且操作系统在提供进程间通信方式的时候也根据通信场景的不同提供不同的方式。

什么是管道

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

例:

[root@localhost pipe]$ ls  -l /etc/ > etc.txt

[root@localhost pipe]$ wc -l etc.txt

183 etc.txt

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

[root@localhost pipe]$ ls -l /etc/ | wc -l

183

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

        虽然实现形态上是文件,但是管道本身并不占用磁盘或者其他外部存储的空间。在Linux的实现上,它占用的是内存空间。所以,Linux上的管道就是一个操作方式为文件的内存缓冲区。

        Linux 管道使用竖线|连接多个命令,这被称为管道符。Linux 管道的具体语法格式如下:

command1 | command2
command1 | command2 [ | commandN... ]

        当在两个命令之间设置管道时,管道符|左边命令的输出就变成了右边命令的输入。只要第一个命令向标准输出写入,而第二个命令是从标准输入读取,那么这两个命令就可以形成一个管道。大部分的 Linux 命令都可以用来形成管道。

注意

command1必须有正确输出,而command2必须可以处理command2的输出结果;

command2只能处理command1的正确输出结果,不能处理command1的错误信息。

管道是半双工的,数据只能向一个方向流动;

需要双方通信时,需要建立起两个管道。

为什么使用管道

        使用 mysqldump(一个数据库备份程序)来备份一个叫做 wiki 的数据库:

mysqldump -u root -p '123456' wiki > /tmp/wikidb.backup
gzip -9 /tmp/wikidb.backup
scp /tmp/wikidb.backup username@remote_ip:/backup/mysql/

上述这组命令主要做了如下任务:

1.mysqldump 命令用于将名为 wike 的数据库备份到文件 /tmp/wikidb.backup;其中-u和-p选项分别指出数据库的用户名和密码。

2.gzip 命令用于压缩较大的数据库文件以节省磁盘空间;其中-9表示最慢的压缩速度最好的压缩效果。

3.scp 命令(secure copy,安全拷贝)用于将数据库备份文件复制到 IP 地址为 remote_ip 的备份服务器的 /backup/mysql/ 目录下。其中username是登录远程服务器的用户名,命令执行后需要输入密码。

        上述三个命令依次执行。然而,如果使用管道的话,你就可以将 mysqldump、gzip、ssh 命令相连接,这样就避免了创建临时文件 /tmp/wikidb.backup,而且可以同时执行这些命令并达到相同的效果。

使用管道后的命令如下所示:

mysqldump -u root -p '123456' wiki | gzip -9 | ssh username@remote_ip "cat > /backup/wikidb.gz"

        这些使用了管道的命令有如下特点:

1.命令的语法紧凑并且使用简单。

2.通过使用管道,将三个命令串联到一起就完成了远程 mysql 备份的复杂任务。

3.从管道输出的标准错误会混合到一起。
上述命令的数据流如下图所示:

2-1  上述命令的数据流

管道的分类和使用

Linux上的管道分两种类型:

1.匿名管道

2.命名管道

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

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

[root@localhost pipe]$ mkfifo pipe

[root@localhost pipe]$ ls -l pipe

prw-r--r-- 1 zorro zorro 0 Jul 14 10:44 pipe

可以看到创建出来的文件类型比较特殊,是p类型。表示这是一个管道文件。有了这个管道文件,系统中就有了对一个管道的全局名称,于是任何两个不相关的进程都可以通过这个管道文件进行通信了。比如我们现在让一个进程写这个管道文件:

[root@localhost pipe]$ echo xxxxxxxxxxxxxx > pipe

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

[root@localhost pipe]$ cat pipe xxxxxxxxxxxxxx

可以观察到,当我们cat完这个文件之后,另一端的echo命令也返回了。这就是命名管道。

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

[root@localhost pipe]$ cat /proc/filesystems |grep pipefs

nodev    pipefs

匿名管道的特性

1.进程间同步

如果管道里没有数据,父进程在干什么?等待,等管道内部有数据就绪,好比你妈在炒菜,10分钟炒一道,在她炒菜的时候,你就乖乖坐一边等,啥也别干

如果管道已经写满,写端等待,等管道内部有空闲时间

2.管道单向通信,是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道

3.管道面向字节流:读端写端都是认为我们是按照字节的方式进行写入的

4.管道只能保证具有血缘关系的进程通信,常见于父子

5.管道可以保证一定程度(4KB以内)数据读取的原子性:数据要么不被读取,要读就要全部被读走

通过管道创建的3个进程是兄弟进程

管道也是文件,管道的生命周期随进程

创建管道

int pipe(int pipefd[2]); 成功:0;失败:-1,设置errn

函数调用成功返回r/w两个文件描述符。无需open,但需手动close。规定:fd[0] → r; fd[1] → w,就像0对应标准输入,1对应标准输出一样。向管道文件读写数据其实是在读写内核缓冲区。

管道创建成功以后,创建该管道的进程(父进程)同时掌握着管道的读端和写端。如何实现父子进程间通信呢?通常可以采用如下步骤:

3-1  父子进程间通信

1. 父进程调用pipe函数创建管道,得到两个文件描述符fd[0]、fd[1]指向管道的读端和写端。

2. 父进程调用fork创建子进程,那么子进程也有两个文件描述符指向同一管道。

3. 父进程关闭管道读端,子进程关闭管道写端。父进程可以向管道中写入数据,子进程将管道中的数据读出。由于管道是利用环形队列实现的,数据从写端流入管道,从读端流出,这样就实现了进程间通信。

通过命令行体验一下命名管道

mkfifo myfifo #创建命名管道

while :; do echo "helloworld" ; sleep 1 ;done > myfifo #写

cat < myfifo #读

        可以看到,通过命名管道,我们实现了echo 和 cat两个进程之间的通信。所以说,如果我们需要2个毫不相关的进程间通信,就需要一个都能看到的资源。而普通文件,需要刷新磁盘。fifo管道文件,只存在于内存,不刷到磁盘。所以我们通过管道文件,就能实现进程间通信。

在系统编程中使用管道

        我们可以把匿名管道和命名管道分别叫做PIPE和FIFO。这主要因为在系统编程中,创建匿名管道的系统调用是pipe(),而创建命名管道的函数是mkfifo()。使用mknod()系统调用并指定文件类型为为S_IFIFO也可以创建一个FIFO。

41  管道的读写

        管道实现的源代码在fs/pipe.c中,在pipe.c中有很多函数,其中有两个函数比较重要,即管道读函数pipe_read()和管道写函数pipe_wrtie()。管道写函数通过将字节复制到 VFS 索引节点指向的物理内存而写入数据,而管道读函数则通过复制物理内存中的字节而读出数据。当然,内核必须利用一定的机制同步对管道的访问,为此,内核使用了锁、等待队列和信号。
当写进程向管道中写入时,它利用标准的库函数write(),系统根据库函数传递的文件描述符,可找到该文件的 file 结构。file 结构中指定了用来进行写操作的函数(即写入函数)地址,于是,内核调用该函数完成写操作。写入函数在向内存中写入数据之前,必须首先检查 VFS 索引节点中的信息,同时满足如下条件时,才能进行实际的内存复制工作:


1.内存中有足够的空间可容纳所有要写入的数据;
2.内存没有被读程序锁定。


        如果同时满足上述条件,写入函数首先锁定内存,然后从写进程的地址空间中复制数据到内存。否则,写入进程就休眠在 VFS 索 引节点的等待队列中,接下来,内核将调用调度程序,而调度程序会选择其他进程运行。写入进程实际处于可中断的等待状态,当内存中有足够的空间可以容纳写入 数据,或内存被解锁时,读取进程会唤醒写入进程,这时,写入进程将接收到信号。当数据写入内存之后,内存被解锁,而所有休眠在索引节点的读取进程会被唤醒。
管道的读取过程和写入过程类似。但是,进程可以在没有数据或内存被锁定时立即返回错误信息,而不是阻塞该进程,这依赖于文件或管道的打开模式。反之,进程可 以休眠在索引节点的等待队列中等待写入进程写入数据。当所有的进程完成了管道操作之后,管道的索引节点被丢弃,而共享数据页也被释放。

42  PIPE

使用pipe()系统调用可以创建一个匿名管道,这个系统调用的原型为:

#include <unistd.h>​

int pipe(int pipefd[2]);

        这个方法将会创建出两个文件描述符,可以使用pipefd这个数组来引用这两个描述符进行文件操作。pipefd[0]是读方式打开,作为管道的读描述符。pipefd[1]是写方式打开,作为管道的写描述符。从管道写端写入的数据会被内核缓存直到有人从另一端读取为止。

pipefd[0]:表示读端

pipefd[1]:表示写端

返回值:成功返回0,失败返回错误代码

在进程中使用管道:

[root@localhost 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);
}

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

4-2-1  程序状态

        一个进程自己给自己发送消息这当然不叫进程间通信,所以实际情况中我们不会在单个进程中使用管道。进程在pipe创建完管道之后,往往都要fork产生子进程,成为如下图表示的样子:

 4-2-2  程序状态

        fork产生的子进程会继承父进程对应的文件描述符。利用这个特性,父进程先pipe创建管道之后,子进程也会得到同一个管道的读写文件描述符。从而实现了父子两个进程使用一个管道可以完成半双工通信。此时,父进程可以通过fd[1]给子进程发消息,子进程通过fd[0]读。子进程也可以通过fd[1]给父进程发消息,父进程用fd[0]读。

程序如下:

[root@localhost 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);
}

        父进程先给子进程发一个消息,子进程接收到之后打印消息,之后再给父进程发消息,父进程再打印从子进程接收到的消息。

程序执行效果:

[root@localhost 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

        从这个程序中我们可以看到,管道实际上可以实现一个半双工通信的机制。使用同一个管道的父子进程可以分时给对方发送消息。

43  FIFO

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

例如:

[root@localhost 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进行偏移量调整。

命名管道(FIFO文件)的打开规则

1.访问一个FIFO文件

FIFO和通过pipe调用创建的管道不同,它是一个有名字的文件而表示一个打开的文件描述符。

在对它进行读或写操作之前必须先打开它

FIFO文件也要用open和close函数来打开或关闭,除了一些额外的功能外,整个操作过程与我们前面介绍的文件操作是一样的

传递给open调用的是一个FIFO文件的路径名,而不是一个正常文件的路径名

2.用open打开FIFO文件

在打开FIFO文件时需要注意一个问题:即程序不能以O_RDWR模式打开FIFO文件进行读写

如果确实需要在程序之间双向传递数据的话,我们可以同时使用一对FIFO或管道,一个方向配一个;还可以用先关闭再重新打开FIFO的办法明确地改变数据流的方向

3.命名管道的打开规则

        如果当前打开操作是为读而打开FIFO时,若已经有相应进程为写而打开该FIFO,则当前打开操作将成功返回;否则,可能阻塞直到有相应进程为写而打开该FIFO(当前打开操作设置了阻塞标志,即只设置了O_RDONLY),反之,如果当前打开操作没有设置了非阻塞标志,即O_NONBLOCK,则返回成功

        如果当前打开操作是为写而打开FIFO时,如果已经有相应进程为读而打开该FIFO,则当前打开操作将成功返回;否则,可能阻塞直到有相应进程为读而打开该FIFO(当前打开操作设置了阻塞标志);或者,返回ENXIO错误(当前打开操作没有设置阻塞标志)。

命名管道(FIFO文件)的读写规则

对于没有设置阻塞标志的写操作:

        当要写入的数据量大于PIPE_BUF时,linux将不再保证写入的原子性。在写满所有FIFO空闲缓冲区后,写操作返回

        当要写入的数据量不大于PIPE_BUF时,linux将保证写入的原子性。如果当前FIFO空闲缓冲区能够容纳请求写入的字节数,写完后成功返回;如果当前FIFO空闲缓冲区不能够容纳请求写入的字节数,则返回EAGAIN错误,提醒以后再写。

管道的大小

        由三可以看出管道读写的一些特点,即:

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

        当然写操作也不会再所有情况下都不阻塞。先来了解一下管道的内核实现。

        管道实际上就是内核控制的一个内存缓冲区,既然是缓冲区,就有容量上限。我们把管道一次最多可以缓存的数据量大小叫做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个字节长度,这取决于当前管道的剩余空间长度,并且这些数据可能跟别的进程的数据有交叉。

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

[root@localhost 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);
}

父进程关闭管道的读端,只写管道。子进程关闭管道的写端,只读管道。

整个管道的打开效果最后成为下图所示:

 5-1  程序状态

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

参考文献

[1] Linux Shell管道详解Linux Shell管道详解 (biancheng.net)

[2] Linux 的进程间通信:管道.Linux 的进程间通信:管道 - 知乎 (zhihu.com)

[3] linux管道详解.linux管道详解 - 知乎 (zhihu.com)

[4]【Linux】管道.(60条消息) 【Linux】管道_DanteIoVeYou的博客-CSDN博客

[5] 管道 - 看这一篇就够了.(60条消息) 8、管道 - 看这一篇就够了_拒绝刘亦菲的博客-CSDN博客

[6] linux管道pipe详解.(60条消息) linux管道pipe详解_oguro的博客-CSDN博客_管道

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐