Linux系统编程(六):使用管道在进程间通信

进程间通信(IPC)的方式有很多种,常用方式有管道pipe、信号signal、映射mmap、本地套接字socket等。这些方式各有优势,其中管道的方式最为简单。本篇文章主要记录管道在进程间通信的使用,在编程实例中还有dupexec函数族的使用。

1 管道的介绍

在使用 Linux 命令的时,经常会借助管道来将一个命令的输出作为另一个命令的输入来执行,如

1
ps aux | grep init

通常所说的管道pipe即匿名管道,区别于FIFO命名管道。

管道是一种最基本的IPC机制,作用于有血缘关系的进程之间,完成数据传递。 其实现原理是:内核使用环形队列机制,借助内核缓冲区(4k)实现。

其有以下特质

  • 文件类型为伪文件,本质是内核缓冲区
  • 由两个文件描述符引用,一个表示读端,一个表示写端
  • 数据必须从管道写端流入,从读端流出

由以上特质可以看出其局限性

  • 使用同一个管道的两个进程,自己写入的数据不能自己读
  • 数据被读走后,便不在管道中存在,不可反复读取
  • 半双工通信,数据只能在一个方向上流动
  • 只能在有公共祖先的进程间使用

2 pipe()

2.1 函数原型

使用pipe()函数可以创建一个管道,函数原型如下

1
2
3
#include <unistd.h>
// 参数为传出参数,包含两个管道的文件描述符,规定pipefd[0]为读,pipefd[1]为写
int pipe(int pipefd[2]);

函数成功执行时返回0,否则返回-1。使用的时候要注意两点

  • 传出参数的pipefd[2]中,规定了必须使用pipefd[0]来读取数据,pipe[1]来写入数据。
  • 在使用读功能的进程中,要关闭pipe[1]描述符,在使用写功能中,要关闭pipe[2]描述符

2.2 管道读写行为

管道的读写行为特性如下

读管道时

  • 管道有数据时,read返回实际读到的字节数
  • 管道无数据时:1)无写端,read返回0,,类似于读到文件尾;2)有写端,read阻塞等待

写管道时

  • 无读端时,将会异常终止
  • 有读端时:1)管道已满,阻塞等待;2)管道未满,返回写出的字节个数

2.3 使用小demo

pipe.c
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
#include <string.h>
#include <unistd.h>
#include <stdio.h>
#include <fcntl.h>
#include <sys/wait.h>

#include "../helper.h" // sys_err定义于此

int main(){
int ret,fd[2];
pid_t pid;
char str[] = "hello pipe\n";

if(pipe(fd) == -1) sys_err("pipe error");
if((pid = fork()) == -1) sys_err("fork error");
// 父进程负责写
if(pid > 0) {
close(fd[0]);
if((ret = write(fd[1], str, strlen(str))) == -1)
sys_err("write error");
close(fd[1]);
wait(NULL);
}
else { // 子进程负责读
close(fd[1]);
char buf[128];
if((ret = read(fd[0], buf, 128)) == -1) sys_err("read error");
write(STDOUT_FILENO, buf, ret);
close(fd[0]);
return 0;
}
return 0;
}

将程序编译执行

1
2
3
$ gcc pipe.c -o pipe
$ ./pipe
hello pipe

3 dup和exec函数族

3.1 dup()和dup2()

在本系列的第二篇中,已经对文件描述符有了大概介绍。某种意义上说,文件描述符就是一个数组的下标,其对应了一个文件指针,文件指针指向了文件表,然后一层一层指向最终文件。

dup()dup2()的作用就是复制一个现存的文件描述符。先看函数原型

1
2
3
4
5
#include <unistd.h>
// 返回一个新文件描述符
int dup(int oldfd);
// 让 newfd 作为 oldfd的拷贝
int dup2(int oldfd, int newfd);

当调用dup函数时,内核在进程中创建一个新的文件描述符,此描述符是当前可用文件描述符的最小数值,这个文件描述符指向oldfd所拥有的文件表项。
  dup2dup的区别就是可以用newfd参数指定新描述符的数值,如果newfd已经打开,则先将其关闭。如果newfd等于oldfd,则dup2返回newfd, 而不关闭它。dup2函数返回的新文件描述符同样与参数oldfd共享同一文件表项。

3.2 execl()和execlp()

exec函数族可以使进程执行某一程序。当进程转去执行程序时,exec函数后面的代码将不会执行。执行的程序会继承原进程的PID。

1
2
3
4
5
6
7
8
9
10
11
#include <unistd.h>
// 参数1为执行程序路径,后面是执行程序的参数,最后一个参数必须为NULL表结束
int execl(const char *path, const char *arg, ...
/* (char *) NULL */);
int execlp(const char *file, const char *arg, ...
/* (char *) NULL */);
int execle(const char *path, const char *arg, ...
/*, (char *) NULL, char * const envp[] */);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);

execl()execlp()的区别在于,前者参数1需要输入执行程序的完整路径,而后者专用于执行系统命令,参数1只需输入命名名称即可;而之后的参数都是执行程序的参数,相当于C程序中的argv[],需要从argv[0]开始输入。

3.3 实现文件重定向

通过dup()函数,我们可以实现文件重定向功能。

下面的例子中,使用execl执行ps ajx命令,通过dup将结果重定向到文件中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include "../helper.h"

int main() {
int fd = open("psajx.txt", O_RDWR | O_CREAT);
if (fd == -1) sys_err("open error");
pid_t pid = fork();
if (pid == 0){
dup2(fd, STDOUT_FILENO);
// execlp("ps", "ps", "ajx", NULL);
execl("/bin/ps", "ps", "ajx", NULL);
sys_err("exec error");
}
else
close(fd);
return 0;
}

4 实现管道命令

4.1 实现思路

先看一个管道命令

1
ls | wc -l

该命令可以将ls的输出结果通过wc命令来计数。

由于使用exec函数来调用系统命令后,原程序会被命令程序替换,而这里我们需要执行两个命令lswc,显然如果不自己实现命令的话,是不能用一个进程来调用两个命令程序的。

因此需要使用fork()来创建子进程,用不同的子进程来调用命令,最后还需要父进程将其回收。而不同子进程之间的通信就可以使用上文所介绍的管道pipe来实现。

另外ls的输出默认是STDOUT_FILENO,需要使用dup2,将其重定向到管道中,以便传输到wc;同理,wc的输入默认是STDIN_FILENO,也需要使用dup2重定向。

4.2 实现代码

代码实现如下

ls_wc_l.c
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
#include <unistd.h>
#include <fcntl.h>
#include <sys/wait.h>

#include "../helper.h"

int main() {
int fd[2], i;
pid_t pid;

if(pipe(fd) == -1) sys_err("pipe error");
for(i = 0; i < 2; ++i){
if((pid = fork()) == -1)
sys_err("fork");
if(pid == 0) break;
}
if(i == 0) {
close(fd[0]);
dup2(fd[1], STDOUT_FILENO);
execlp("ls", "ls", NULL);
sys_err("execlp ls error");
}
if(i == 1) {
close(fd[1]);
dup2(fd[0], STDIN_FILENO);
execlp("wc", "wc", "-l", NULL);
sys_err("execlp wc -l error");
}
if(i == 2) {
close(fd[0]);
close(fd[1]);
while(i--){
pid = 0;
if((pid = wait(NULL)) == -1) sys_err("wait error");
printf("child process %d has been recycled\n", pid);
}
return 0;
}
}

将其编译执行效果如下

1
2
3
4
$ ./ls_wc_l2
9
child process 5221 has been recycled
child process 5222 has been recycled

可见该程序可以完成功能,且成功回收了两个子进程。

5 FIFO管道

5.1 FIFO介绍

由于基于fork机制,所以管道只能用于父进程和子进程之间,或者拥有相同祖先的两个子进程之间 (有亲缘关系的进程之间)。为了解决这一问题,Linux提供了FIFO方式连接进程。

FIFO (First in, First out)为一种特殊的文件类型,它在文件系统中有对应的路径。

当一个进程以读(r)的方式打开该文件,而另一个进程以写(w)的方式打开该文件,那么内核就会在这两个进程之间建立管道,所以FIFO实际上也由内核管理,不与硬盘打交道。之所以叫FIFO,是因为管道本质上是一个先进先出的队列数据结构,最早放入的数据被最先读出来,从而保证信息交流的顺序。

FIFO管道又称为命名管道。其实它只是借用了文件系统(file system,命名管道是一种特殊类型的文件,因为Linux中所有事物都是文件,它在文件系统中以文件名的形式存在。)来为管道命名。写模式的进程向FIFO文件中写入,而读模式的进程从FIFO文件中读出。当删除FIFO文件时,管道连接也随之消失 。

跟管道类似,FIFO也有读写规则

  • 从FIFO中读取数据: 约定:如果一个进程为了从FIFO中读取数据而阻塞打开了FIFO,那么称该进程内的读操作为设置了阻塞标志的读操作

  • 从FIFO中写入数据: 约定:如果一个进程为了向FIFO中写入数据而阻塞打开FIFO,那么称该进程内的写操作为设置了阻塞标志的写操作。

5.2 mkfifo()

可以使用mkfifo来创建一个FIFO管道,函数原型如下

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

传入参数1代表管道名,参数2则是设定创建出来管道文件的权限,具体权限为mode & ~umask

由于命名管道和匿名管道在底层实现上其实完全一致,区别只是命名管道具有一个全局可见的文件名供其他进程使用open()函数打开。

6 结束语

本篇文章主要介绍了管道这种进程间通信的方式,总结了pipefifo的读写规则。另外还介绍了能够实现文件重定向的dup函数、在程序中执行其他程序的exec函数,并相应地做了一些简单的练习。后续文章还需继续讲到其他进程间通信方式如mmapsignal以及socket

-------- 本文结束 感谢阅读 --------
给我加块红烧肉吧
  • 本文标题: Linux系统编程(六):使用管道在进程间通信
  • 本文作者: Chou Bin
  • 创建时间: 2020年02月24日 - 19时02分
  • 修改时间: 2020年02月24日 - 19时02分
  • 本文链接: http://yoursite.com/2020/02/24/apue-hm06/
  • 版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!