管道通信
进程间通信
多进程编程,如果进程之间需要进行各种消息传递(message passing),那么必然要通信,我们称之为IPC(Inter-process communication)。
在Unix系统中,消息传递经历了如下几个发展阶段:
- 管道(pipe) 是第一个广泛使用的IPC形式,既可在程序中使用,也可以从shell中使用。管道的问题在于它们只能在具有共同祖先(指父子进程关系)的进程间使用,不过该问题已随 有名管道(named pipe) 即FIFO的引入而解决了。
- System V 消息队列(System V message queue) 是在20世纪80年代早期加到System V内核中的。它们可用在同一主机上有亲缘关系或无亲缘关系的进程之间。
- Posix 消息队列 是由Posix实时标准加入的。它们可用在同一主机上有亲缘关系或无亲缘关系的进程之间。
- 远程过程调用(Remote Procedure Call,简称RPC) 出现在20世纪80年代中期,它是从一个系统(客户主机)上某个程序调用另一个系统(服务器主机)上某个函数的一种方法,是作为显式网络编程的一种替换方法开发。也算是另一种形式的消息传递。
本文主要介绍第一种方式——管道。
架构
父进程
|
| ---- 子进程
在父进程中创建一个子进程,子进程从26个英文字母中随机产生一个,并返回给父进程。
子进程中要执行的程序
random_letter.c
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int main(void)
{
int a;
const char * a_to_z = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
srand((unsigned)time(NULL));
a = rand() % 26;
printf("%c\n",a_to_z[a]);
}
使用rand()%26产生一个从0-25的随机数,然后通过这个随机数在a_to_z字符串指针只获取当前位置的字符来达到随机字母的效果。
fork()
在Unix中,使用fork()来克隆进程,fork()被调用一次会返回两次,在子进程中会返回0,在父进程中返回子进程的进程标识符,可以根据这个特性来区分现在在哪个进程中。
pid_t pid = fork(); // 调用fork()克隆进程
if(pid == -1){ // 如果fork()返回-1,就说明在克隆进程时出了问题
fprintf(stderr, "Can't fork process: %s\n", strerror(errno));
return 1;
}
if(!pid){ // 相当于if(pid == 0),如果fork()返回0,说明代码运行在子进程中
// code 子进程中的代码
}
// code 父进程中的代码
输入输出重定向
在介绍管道之前需要先讲下数据流,顾名思义,数据流就是流动的数据,数据从一个进程流出,然后流入另一个进程。
操作系统中有三大默认数据流:
- 标准输入 stdin 默认是键盘输入
- 标准输出 stdout 默认是屏幕输出
- 标准错误 stderr 默认是屏幕输出
还有其他形式的数据流,例如文件连接和网络连接也属于数据流。
重定向进程的输出,相当于改变进程发送数据的方向。原本标准输出会把数据发送到屏幕,现在可以让它把数据发送到文件。
进程含有它正在运行的程序,还有栈和堆数据空间。除此之外,进程还需要记录数据流的连向,比如标准输出连到了哪里。进程用文件描述符表示数据流,所谓的描述符其实就是一个数字。进程会把文件描述符和对应的数据流保存在描述符表中。
| # | 数据流 | |
|---|---|---|
| 0 | 键盘 | 标准输入 |
| 1 | 屏幕 | 标准输出 |
| 2 | 屏幕 | 标准错误 |
| 3 | 其他数据流形式 |
描述符表前三项万年不变:0号标准输入,1号标准输出,2号标准错误。
标准输入/输出/错误在描述表中的位置是固定的,但是它们指向的数据流可以改变。也就是说,如果想重定向标准输出,只需要修改表中1号描述符对应的数据流就行了。所有向标准输出发送数据的函数会先查看描述符表,看1号描述符指向哪条数据流,然后再把数据写到这条数据流中,printf()便是如此。
命令行中的数据流重定向
在命令行中用>和<运算符重定向数据流
./myprog > output.txt 2> errors.log
上面的命令表示,标准输出到output.txt文件,标准错误到errors.log文件。2>中的2就是标准错误在描述符表中的编号。在类Unix系统中,还可以把标准错误和标准输出到同一个地方:
./myprog 2>&1
fileno()返回描述符号
每打开一个文件,操作系统都会在描述符表中新注册一项。假设你打开了某个文件:
FILE *my_file = fopen("guitar.mp3", "r");
操作系统会打开guitar.mp3文件,然后返回一个指向它的指针,操作系统还会历遍描述符表寻找空项,把新文件注册在其中。
使用fileno()函数来查询文件指针它的描述符:
int descriptor = fileno(my_file);
dup2()复制数据流
每次打开文件都会使用描述符表中新的一项。如果想修改某个已经注册过的数据流,比如想让3号描述符重新指向其他数据流,可以用dup2()函数,dup2可以复制数据流。假设你在4号描述符中注册了guitar.mp3文件指针,下面这行代码能同时把它连接到3号描述符:
dup2(4,3);
管道pipe
如果创建管道?
因为子进程需要把数据发送到父进程,所以要用管道连接子进程的标准输出和父进程的标准输入。
pipe()函数会创建两条相连的数据流,并把它们加入到描述符表中,然后只要你往其中一条数据流中写数据,就可以从另一条数据流中读取。
#include <unistd.h>
int fd[2]; // 描述符将保存在这个数组中
if(pipe(fd) == -1){
error("Can't creat the pipe");
}
| # | 数据流 |
|---|---|
| 0 | 标准输入 |
| 1 | 标准输出 |
| 2 | 标准错误 |
| 3 | fd[0] 管道读取端 |
| 4 | fd[1] 管道写入端 |
pipe()函数创建了管道,并返回了两个描述符:fd[1]用来向管道写数据,fd[0]用来向管道读数据,将会在父进程和子进程中使用它们。
最终程序
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h> // 提供 fork(),pipe() 函数
#include <errno.h> // 提供 error()函数
#include <sys/types.h> // 提供 pid_t
void error(char *msg)
{
fprintf(stderr,"%s: %s\n", msg, strerror(errno));
exit(1);
}
int main(void){
// 描述符将保存在这个数组中
int fd[2];
// pipe() 函数创建了管道,fd[1] 写管道,fd[0] 读管道
if(pipe(fd) == -1){
error("Can't create the pipe");
}
// 克隆进程
pid_t pid = fork();
if(pid == -1){
error("Can't fork process");
}
// 进入子进程中
if(!pid){
dup2(fd[1], 1); // 将标准输出(描述符号是1)重定向到管道的写入端
close(fd[0]); // 因为子进程不需要从管道读取数据,所以关闭
if(execl("/.../random_letter", "/.../random_letter", NULL) == -1){
error("Can't run random_letter");
}
}
// 在父进程中
dup2(fd[0], 0); // 将标准输入(描述符号是0)重定向到管道的读取端
close(fd[1]); // 因为父进程不需要向管道写入数据,所以关闭
char res[255];
// 从标准输入中获取数据
while (fgets(res, 255, stdin))
{
printf("%s\n", res);
}
return 0;
}
参考资料:
《嗨翻C语言》