IPC 通信之管道
程序员文章站
2022-05-09 16:25:56
...
管道是 UNIX 系统 IPC 的最古老但也是最常用的形式,其有以下两种局限性。
(1)历史上,管道是半双工的(即数据只能在一个方向上流动),不过现在有些系统也提供全双工管道。但为了移植性,不应预先假定系统支持全双工管道。
(2)管道只能在具有公共祖先的两个进程之间使用。通常,一个管道由一个进程创建,在进程调用 fork 之后,该管道就能在父进程和子进程之间使用了。
后面会看到,FIFO 没有第二种局限性,UNIX 域套接字没有这两种局限性。
每当在管道中键入一个命令序列让 shell 执行时,shell 都会为每一条命令单独创建一个进程,然后用管道将前一条命令进程的标准输出与后一条命令的标准输入相连接。
管道是通过调用 pipe 函数创建的。
经由参数 fd 返回两个文件描述符:fd[0] 为读而打开,fd[1] 为写而打开。fd[1] 的输出是 fd[0] 的输入。对于支持全双工管道的实现,fd[0] 和 fd[1] 都以读/写方式打开。fstat 函数对管道的每一端都返回一个 FIFO 类型的文件描述符,可以用 S_ISFIFO 宏来测试管道。
下图显示了两种描绘半双工管道的方法:左图显示管道的两端在一个进程中相互连接,右图则强调数据需要通过内核在管道中流动。
单个进程中的管道几乎没有任何用处。进程通常会先调用 pipe,接着调用 fork,从而创建从父进程到子进程的 IPC 通道,反之亦然。下图显示了这种情况。
fork 之后,对于从父进程到子进程的管道,父进程关闭管道的读端 fd[0],子进程关闭写端 fd[1];而对于从子进程到父进程的管道,父进程关闭 fd[1],子进程关闭 fd[0]。
当管道的一端被关闭后,下列两条规则就会起作用。
(1)当 read 一个写端已被关闭的管道时,在所有数据都被读取后,read 返回 0,表示文件结束。
(2)如果 write 一个读端已被关闭的管道,则会产生 SIGPIPE 信号。如果忽略或捕捉该信号,则 write 返回 -1,errno 设置为 EPIPE。
在写管道(或 FIFO)时,常量 PIPE_BUF 规定了内核的管道缓冲区大小。当对管道写的字节数大于 PIPE_BUF,并且有多个进程同时写一个管道(或 FIFO)时,所写的数据可能会与其他进程的数据相互交叉。使用 pathconf 或 fpathconf 函数可以确定 PIPE_BUF 的值。
下面这个程序通过管道将父进程的输出直接送到子进程中调用的分页程序(忽略了对函数调用返回值的检查)。
由于经常创建一个连接到另一个进程的管道,然后读其输出或向其输入端发送数据,所以标准 I/O 库提供了 popen 和 pclose 函数(类似于 fopen 和 fclose)。
函数 popen 先执行 fork,然后子进程调用 exec 执行 cmd,并返回一个文件指针。如果 type 是“r”,则文件指针连接到子进程的标准输出,是可读的;如果 type 是“w”,则文件指针连接到子进程的标准输入,是可写的。
函数 pclose 则关闭标准 I/O 流,等待命令终止,然后返回 shell 的终止状态。如果 shell 不能被执行,则 pclose 返回的终止状态与 shell 执行 exit(127) 一样。
cmd 由 Bourne shell 以“sh -c cmd”的方式执行。这表示 shell 将扩展 cmd 中的任何特殊字符,比如可以使用:
fp = popen("ls *.c", "r");
但同时也要注意,设置用户 ID 或设置组 ID 程序决不应该调用 popen,因为它是使用调用者继承的 shell 环境来执行 cmd,一个恶意用户可以使 shell 以设置 ID 文件模式所授予的提升了的权限以非预期的方式来执行命令。
使用 popen 函数重写上面的程序将能减少很多代码量。
下面的代码是 popen 和 pclose 函数实现。
其中,因为一个进程可能调用 popen 多次,所以使用了 childpids 数组来保存该进程打开的子进程 ID 和打开的文件描述符,这里选择用文件描述符作为其下标来保存子进程 ID。另外,POSIX.1 要求 popen 关闭那些以前调用 popen 打开的、现在仍然在子进程中打开着的 I/O 流,所以子进程中逐个关闭 childpids 中仍旧打开着的描述符。还有若 pclose 的调用者捕捉了 SIGCHLD 信号或其他可能中断阻塞的信号,则 waitpid 调用可能返回中断错误 EINTR,对于这种情况,我们应该再次调用 waitpid。如果 pclose 调用 waitpid 时,发现子进程已经不再存在,将返回 -1,并且 errno 会被设置为 ECHILD。
(1)历史上,管道是半双工的(即数据只能在一个方向上流动),不过现在有些系统也提供全双工管道。但为了移植性,不应预先假定系统支持全双工管道。
(2)管道只能在具有公共祖先的两个进程之间使用。通常,一个管道由一个进程创建,在进程调用 fork 之后,该管道就能在父进程和子进程之间使用了。
后面会看到,FIFO 没有第二种局限性,UNIX 域套接字没有这两种局限性。
每当在管道中键入一个命令序列让 shell 执行时,shell 都会为每一条命令单独创建一个进程,然后用管道将前一条命令进程的标准输出与后一条命令的标准输入相连接。
管道是通过调用 pipe 函数创建的。
#include <unistd.h> int pipe(int fd[2]); /* 返回值:若成功,返回 0;否则,返回 -1 */
经由参数 fd 返回两个文件描述符:fd[0] 为读而打开,fd[1] 为写而打开。fd[1] 的输出是 fd[0] 的输入。对于支持全双工管道的实现,fd[0] 和 fd[1] 都以读/写方式打开。fstat 函数对管道的每一端都返回一个 FIFO 类型的文件描述符,可以用 S_ISFIFO 宏来测试管道。
下图显示了两种描绘半双工管道的方法:左图显示管道的两端在一个进程中相互连接,右图则强调数据需要通过内核在管道中流动。
单个进程中的管道几乎没有任何用处。进程通常会先调用 pipe,接着调用 fork,从而创建从父进程到子进程的 IPC 通道,反之亦然。下图显示了这种情况。
fork 之后,对于从父进程到子进程的管道,父进程关闭管道的读端 fd[0],子进程关闭写端 fd[1];而对于从子进程到父进程的管道,父进程关闭 fd[1],子进程关闭 fd[0]。
当管道的一端被关闭后,下列两条规则就会起作用。
(1)当 read 一个写端已被关闭的管道时,在所有数据都被读取后,read 返回 0,表示文件结束。
(2)如果 write 一个读端已被关闭的管道,则会产生 SIGPIPE 信号。如果忽略或捕捉该信号,则 write 返回 -1,errno 设置为 EPIPE。
在写管道(或 FIFO)时,常量 PIPE_BUF 规定了内核的管道缓冲区大小。当对管道写的字节数大于 PIPE_BUF,并且有多个进程同时写一个管道(或 FIFO)时,所写的数据可能会与其他进程的数据相互交叉。使用 pathconf 或 fpathconf 函数可以确定 PIPE_BUF 的值。
下面这个程序通过管道将父进程的输出直接送到子进程中调用的分页程序(忽略了对函数调用返回值的检查)。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <string.h> #include <sys/wait.h> #define MAXLINE 1024 #define DEF_PAGER "/bin/more" // default pager program int main(int argc, char *argv[]){ if(argc != 2){ printf("Usage: %s <filename>\n", argv[0]); exit(1); } int fds[2]; pipe(fds); pid_t pid = fork(); if(pid > 0){ // parent close(fds[0]); char buf[MAXLINE]; FILE *fp = fopen(argv[1], "r"); while(fgets(buf, MAXLINE, fp) != NULL){ write(fds[1], buf, strlen(buf)); } if(ferror(fp)){ printf("fgets error\n"); exit(1); } close(fds[1]); // close write end of pipe for reader fclose(fp); waitpid(pid, NULL, 0); // Note: this is necessary exit(0); } // child close(fds[1]); if(fds[0] != STDIN_FILENO) dup2(fds[0], STDIN_FILENO); close(fds[0]); char *argv0, *pager; if((pager = getenv("PAGER")) == NULL) pager = DEF_PAGER; if((argv0 = strrchr(pager, '/')) != NULL) argv0++; // step past rightmost slash else argv0 = pager; // no slash in pager execl(pager, argv0, (char *)0); exit(0); }
由于经常创建一个连接到另一个进程的管道,然后读其输出或向其输入端发送数据,所以标准 I/O 库提供了 popen 和 pclose 函数(类似于 fopen 和 fclose)。
#include <stdio.h> FIFE *popen(const char *cmd, const char *type); /* 返回值:若成功,返回文件指针;否则,返回 NULL */ int pclose(FILE *fp); /* 返回值:若成功,返回子进程的终止状态;否则,返回 -1 */
函数 popen 先执行 fork,然后子进程调用 exec 执行 cmd,并返回一个文件指针。如果 type 是“r”,则文件指针连接到子进程的标准输出,是可读的;如果 type 是“w”,则文件指针连接到子进程的标准输入,是可写的。
函数 pclose 则关闭标准 I/O 流,等待命令终止,然后返回 shell 的终止状态。如果 shell 不能被执行,则 pclose 返回的终止状态与 shell 执行 exit(127) 一样。
cmd 由 Bourne shell 以“sh -c cmd”的方式执行。这表示 shell 将扩展 cmd 中的任何特殊字符,比如可以使用:
fp = popen("ls *.c", "r");
但同时也要注意,设置用户 ID 或设置组 ID 程序决不应该调用 popen,因为它是使用调用者继承的 shell 环境来执行 cmd,一个恶意用户可以使 shell 以设置 ID 文件模式所授予的提升了的权限以非预期的方式来执行命令。
使用 popen 函数重写上面的程序将能减少很多代码量。
#include <stdio.h> #include <stdlib.h> #define MAXLINE 1024 #define PAGER "${PAGER:-/bin/more}" // environment variable, or default int main(int argc, char *argv[]){ if(argc != 2){ printf("Usage: %s <filename>\n", argv[0]); exit(1); } FILE *fpin = fopen(argv[1], "r"); FILE *fpout = popen(PAGER, "w"); char buf[MAXLINE]; while(fgets(buf, MAXLINE, fpin) != NULL){ fputs(buf, fpout); } pclose(fpout); if(ferror(fpin)){ printf("fgets error\n"); exit(1); } exit(0); }
下面的代码是 popen 和 pclose 函数实现。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <errno.h> #include <sys/wait.h> #define MAX_FD 1024 // It depends on the system static pid_t *childpids = NULL; // Pointer to array allocated at run-time FILE *myPopen(const char *cmd, const char *type){ if(type[0]!='r'&&type[0]!='w' || type[1]!=0){// only allow "r" or "w" errno = EINVAL; return NULL; } if(childpids == NULL){ // first time through if((childpids = calloc(MAX_FD, sizeof(pid_t))) == NULL) return NULL; } int fds[2]; if(pipe(fds) < 0) return NULL; // errno set by pipe() if(fds[0] >= MAX_FD || fds[1] >= MAX_FD){ close(fds[0]); close(fds[1]); errno = EMFILE; // too many files are open. return NULL; } pid_t pid; if((pid=fork()) < 0){ return NULL; // errno set by fork() }else if(pid == 0){ // child if(*type == 'r'){ close(fds[0]); if(fds[1] != STDOUT_FILENO){ dup2(fds[1], STDOUT_FILENO); close(fds[1]); } }else{ close(fds[1]); if(fds[0] != STDIN_FILENO){ dup2(fds[0], STDIN_FILENO); close(fds[0]); } } int i; for(i=0; i<MAX_FD; i++) // close all descriptors in childpids if(childpids[i] > 0) close(i); execl("/bin/sh", "sh", "-c", cmd, (char *)0); _exit(127); // execl() failed } // parent continues... FILE *fp; if(*type == 'r'){ close(fds[1]); if((fp = fdopen(fds[0], type)) == NULL) return NULL; }else{ close(fds[0]); if((fp = fdopen(fds[1], type)) == NULL) return; } childpids[fileno(fp)] = pid; // remember child pid for this fd return fp; } int myPclose(FILE *fp){ if(childpids == NULL){ // popen has never been called errno = EINVAL; return -1; } int fd = fileno(fp); if(fd >= MAX_FD){ // invalid file descriptor errno = EINVAL; return -1; } pid_t pid = childpids[fd]; if(pid == 0){ // fp wasn't opened by popen() errno = EINVAL; return -1; } childpids[fd] = 0; if(fclose(fp) == EOF) return -1; int stat; while(waitpid(pid, &stat, 0) < 0) if(errno != EINTR) // error other than EINTR from waitpid return -1; return stat; // return child's termination status }
其中,因为一个进程可能调用 popen 多次,所以使用了 childpids 数组来保存该进程打开的子进程 ID 和打开的文件描述符,这里选择用文件描述符作为其下标来保存子进程 ID。另外,POSIX.1 要求 popen 关闭那些以前调用 popen 打开的、现在仍然在子进程中打开着的 I/O 流,所以子进程中逐个关闭 childpids 中仍旧打开着的描述符。还有若 pclose 的调用者捕捉了 SIGCHLD 信号或其他可能中断阻塞的信号,则 waitpid 调用可能返回中断错误 EINTR,对于这种情况,我们应该再次调用 waitpid。如果 pclose 调用 waitpid 时,发现子进程已经不再存在,将返回 -1,并且 errno 会被设置为 ECHILD。
下一篇: MongoDB 管道的概念