1. 什么是管道
管道是unix系统最古老的IPC通信方式了,适合于有血缘关系的进程之间完成数据传输,比如父子进程,兄弟进程。
管道允许一个数据流向另一个进程,管道中的数据流向是单向的。这样进程可以通过文件描述符1连接到管道写入端,另一个进程通过文件描述符0连接到管道读取端,实际上,这两个进程并不知道管道的存在,它们只是通过文件描述符中读写数据,所以管道也称为匿名管道。
2. 管道的通信方式
管道的数据通信是单向的,采用半双工通信方式。
对于半双工通信来说,典型的例子就是对讲机,比如:当我说话的时候,你不能说话,只能听;而当你说话的时候,我也只能听,只有当你说完了,我才能说话,通常是一问一答的形式。也就是说,管道可以进行双向通信,但同一时刻数据只能进行单向通信。
3. 创建管道
pipe函数用于创建一个管道,本质上管道是进程内核空间创建的一个缓冲区。
<unistd.h>
int pipe(int pipefd[2]);
返回值说明:成功返回0;失败则返回-1,并设置errno
pipefd参数:pipefd数组用于保存pipe函数调用成功返回读端和写端的两个文件描述符。pipefd[0]表示管道的读端, pipefd[1]表示管道的写端,可以通过这两个文件描述符向管道进行读写数据操作。
4. 用管道进行进程通信
调用pipe创建管道成功后,调用pipe函数的进程会同时掌握管道的读端和写端,如下图所示:
父进程调用pipe函数创建管道,得到两个文件描述符fd[0]、fd[1]指向管道的读端和写端,但是系统是不允许一个进程同时读写的,因为管道是采用半双工通信,一个进程只能同一时刻读(写),另一个进程写(读),所以父进程只能通过调用fork来创建子进程。
父进程调用fork创建子进程,那么子进程也有两个文件描述符指向同一管道,这样显然是不合理的。父进程应该关闭管道读端,子进程应该关闭管道写端。然后父进程向管道中写入数据,子进程将管道中的数据读出,这样就实现了进程间通信。
进程间通信实验:
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/wait.h>
#include <errno.h>
int main(void) {
int fd[2];
int ret;
pid_t pid;
char buf[1024];
//调用pipe函数,创建管道
ret = pipe(fd);
if (ret == -1){
perror("pipe error:");
}
//创建子进程
pid = fork();
if (pid == -1){
perror("fork error");
}
//父进程
else if (pid > 0) {
//关闭父进程的读端
close(fd[0]);
read(STDIN_FILENO , buf , sizeof(buf));
//往管道写数据
write(fd[1], buf, strlen(buf));
//父进程阻塞等待回收子进程
wait(NULL);
//关闭写端
close(fd[1]);
} else if (pid == 0) {
//关闭子进程写端
close(fd[1]);
//从管道读数据
ret = read(fd[0], buf, sizeof(buf));
printf("------------\n");
write(STDOUT_FILENO, buf, ret);
//关闭读端
close(fd[0]);
}
return 0;
}
程序执行结果:
5. 为何要关闭未使用的管道文件描述符
其实,关闭未使用的管道文件描述符主要有两个原因:
- 防止进程的文件描述符耗尽
- 保证正确的使用管道进行进程间通信
通常,从管道中读取数据的进程会关闭其持有的管道写端文件描述符,而往管道写数据的进程会关闭其持有的读端文件描述符。
这样的话,那么当所有指向管道写端的文件描述符全部都被关闭时,然后进程从管道读完剩余的所有数据后,read将会返回0,表示读到文件末尾。
假设该进程在创建管道后,没有关闭写端文件描述符,那么即便进程从管道中读完剩余的所有数据后,read依然不会返回0(不会读到文件末尾),而是阻塞等待数据到来,这是因为内核发现管道的写端文件描述符并未全部被关闭,认为将来会有数据写入管道。
如果所有指向管道读端的文件描述符全部都关闭了,此时仍然有进程向管道写入数据的话,那么该进程会收到信号SIGPIPE(管道已损坏),通常会导致进程异常终止,write返回返回EPIPE错误。
下面通过两个实验来验证这两个问题。
5.1 未关闭未使用的管道文件描述符实验一
一个进程创建管道后,没有关闭未使用的管道文件描述符,然后对管道进行读写。
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/wait.h>
#include <errno.h>
int main(void) {
int fd[2];
int ret;
char buf1[64];
char buf2[64];
//调用pipe函数,创建管道
ret = pipe(fd);
if (ret == -1){
//创建失败
perror("pipe error:");
}
read(STDIN_FILENO , buf1 , sizeof(buf1));
//小写转大写
int i;
for(i = 0; i < strlen(buf1); i++){
buf1[i] = toupper(buf1[i]);
}
//往管道写数据
write(fd[1], buf1, strlen(buf1));
//从管道读数据
read(fd[0] , buf2 , sizeof(buf2));
//写到标准输出
write(STDOUT_FILENO , buf2 , strlen(buf2));
//再次从管道中读取数据,此时会阻塞
read(fd[0] , buf2 , sizeof(buf2));
return 0;
}
程序执行结果:
从程序的执行结果来看,此时进程已经阻塞在read处,正等待管道中有数据到来。
5.2 未关闭未使用的管道文件描述符实验二
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/wait.h>
#include <errno.h>
#include <signal.h>
//SIGPIPE信号处理函数
void sig_handler(int sig){
if(sig == SIGPIPE){
puts("catch SIGPIPE");
}
}
int main(void) {
int fd[2];
int ret;
pid_t pid;
char buf1[64];
char buf2[64];
//调用pipe函数,创建管道
ret = pipe(fd);
if (ret == -1){
//创建失败
perror("pipe error:");
}
//注册SIGPIPE信号
signal(SIGPIPE , sig_handler);
//fork子进程
pid = fork();
if (pid == -1){
//fork失败
perror("fork error");
}
//父进程
else if (pid > 0) {
//关闭父进程的读端
close(fd[0]);
read(STDIN_FILENO , buf1 , sizeof(buf1));
//往管道写数据
write(fd[1], buf1, strlen(buf1));
//保证子进程抢到cpu
sleep(1);
//第二次往管道写数据
ret = write(fd[1] , buf1 , strlen(buf1));
if(ret < 0){
//判断对方读端是否以关闭,导致EPIPE错误
if(errno == EPIPE){
puts("peer is close");
}
}
//父进程阻塞等待回收子进程
wait(NULL);
//关闭写端
close(fd[1]);
} else if (pid == 0) {
//关闭子进程写端
close(fd[1]);
//从管道读数据
ret = read(fd[0], buf2, sizeof(buf2));
//小写转大写
int i;
for(i = 0; i < ret; i++){
buf2[i] = toupper(buf2[i]);
}
printf("------------\n");
write(STDOUT_FILENO, buf2, ret);
//模拟读端关闭
close(fd[0]);
//休眠2秒
sleep(2);
}
return 0;
}
程序执行结果:
从程序的执行结果来看,如果管道的读端已经关闭,那么写端继续写入的话就会收到SIGPIPE信号,导致进程终止。
通过这两个实验相信你已经明白了为什么要关闭未使用的管道读端文件描述符的原因,因为有时候我们需要这种错误来判断管道的状态,如果未关闭这些未使用的管道文件描述符的话,这可能会导致进程间无法正确使用管道进行通信。
6. 管道中的数据流
管道中的数据基本都是字节流形式的,这意味着管道中没有消息或消息边界的概念,进程从管道中可以读取任意大小的数据,同理,往管道中写入数据也可以写入任意大小的数据。
另外管道中的数据传输是顺序的,从管道中读取数据必须按照一定的顺序读取数据,数据一旦被读走就不存在了,往管道写数据同理,每次新写入的数据都会加入到管道末尾。换句话说,使用管道通信的话不能调用lseek函数来随机读写数据
,因为管道本质上是内核的一块内存,管道中的数据都是以一定顺序读写的
。
举个例子:比如往管道中写入hello world这样的字符串,写入数据的顺序是先写入hello,再写入world,然后从管道中读取数据的顺序是先读取hello,再读world。也就是说,读和写的顺序是一样的(类似于网络编程中的socket通信)。
7. 总结
优点:
跟其他IPC通信方式相比,管道使用相对简单
缺点:
- 管道只能单向的通信
- 只能用父子进程间通信
- 管道中的数据一旦被读走就不存在了,不可反复读取