管道通信

管道通信

进程间通信

多进程编程,如果进程之间需要进行各种消息传递(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语言》