Linux:IPC - 管道
- 管道原理
- 匿名管道
- 管道读写机制
- 管道特性
- 命名管道
- mkfifo指令
- mkfifo接口
进程间通信的目的,是为了让两个进程看到同一份资源,在Linux
中,主要的进程间通信有管道
,system V
,POSIX
,本博客讲解管道。
管道原理
以上是一个进程,它打开了一些文件,于是在进程PCB
中的struct file_struct
成员中,就会指向当前进程已经打开的文件的结构体struct file
。
那么假设该进程现在创建了一个子进程:
由于子进程会继承父进程的PCB,同时也会继承父进程的struct files_struct
,相当于同时打开了一份文件。那么现在父子进程就都可以看到同一份文件了!
以上就是管道通信的基本原理:让进程之间发生PCB的继承,从而通过PCB打开同一份文件。
现在来看看管道是如何实现的:
- 父进程创建管道
一开始父进程先同时打开一个文件的读端和写端,此时读端和写端分别被一个父进程的fd
管理。
- 父进程创建子进程
当父进程创建完管道后,此时创建出子进程,由于子进程会继承父进程的PCB
,也会继承父进程的fd
,此时父子进程都打开了管道的读端和写端。
- 父子进程分别关掉管道的读端或写端
管道的通信是单向的,必须是一端读,一端写。
-
- 父进程关闭读端,子进程关闭写端,此时父进程向管道写入,子进程从管道读取
-
- 父进程关闭写端,子进程关闭读端,此时子进程向管道写入,父进程从管道读取
上图中,父进程保留了写端,子进程保留了读端,就是父进程向管道写入,子进程从管道读取。现在父子进程之间就可以通信了。
管道分为两种,匿名管道
和命名管道
。
匿名管道
以上说的只是管道的基本原理,但是操作系统中的管道会有很多额外优化。
操作系统进行管道通信时,不会在磁盘上创造文件,而是在内存中临时开辟一个缓冲区
想要在系统中创建管道,需要调用系统调用接口pipe
,其包含在头文件<unistd.h>
中,函数原型如下:
int pipe(int pipefd[2]);
参数int pipefd[2]
是一个输出型参数,其本质是一个长度为2
的数组,其中fd[0]
是管道的读端fd
,而fd[1]
是管道的写端fd
。
返回值:
- 返回 0 表示开辟管道成功
- 返回 -1 表示开辟管道失败,并且会设置错误码
先写两个reader
和writer
函数:
void reader(int rfd)
{char buffer[1024];while (true){read(rfd, buffer, sizeof(buffer));cout << "get massage: " << buffer << endl;}
}
参数rfd
是读端fd
,通过这个fd
从管道读取。reader
函数会从管道中读取内容到buffer
中,随后输出buffer
的内容。
void writer(int wfd)
{char buffer[128];int count = 0;while(true){snprintf(buffer, sizeof(buffer), "pid = %d, count = %d", getpid(), count++);write(wfd, buffer, strlen(buffer));sleep(1);}
}
参数wfd
是写端fd
,写端每隔一秒向管道写入buffer
的内容。
主函数:
int main()
{int pipefd[2];pipe(pipefd);int rfd = pipefd[0];int wfd = pipefd[1];pid_t id = fork();if(id == 0){close(rfd);writer(wfd);exit(0);}close(wfd);reader(rfd);waitpid(id, NULL, 0);return 0;
}
通过pipe
创建了管道,此时读端rfd
就是pipe[0]
,写端wfd
就是pipe[1]
。随后通过fork
创建子进程,子进程关闭rfd
,进行写入,父进程关闭wfd
,进行读取。
输出结果:
此时父进程成功通过writer
函数读取到了管道的内容。
管道读写机制
当管道内没有数据,读端会一直阻塞等待,直到管道中出现数据
刚刚我们的reader
函数如下:
void reader(int rfd)
{char buffer[1024];while (true){read(rfd, buffer, sizeof(buffer));cout << "get massage: " << buffer << endl;}
}
你会发现,这个while
循环中,没有sleep
这样的语句,但是刚刚输出结果中,依然是每隔一秒输出一条语句。这是因为在read
读取管道数据的时候,如果管道中没有内容,就进行阻塞等待,直到管道出现数据。而写端每隔一秒写入,也就是说管道中每隔一秒才有数据,所以读端每隔一秒才能read
到数据。
写端一直写,读端不读取数据,当管道被写满的时候,读端就会阻塞等待
现在修改写端函数writer
如下:
void writer(int wfd)
{char buffer[128];int count = 0;while(true){write(wfd, "c", 1);cout << count++ << endl;}
}
写端一直向管道写入字符"c"
,并且利用count
计数。但是此时读端不读取,也就是不调用reader
函数。
输出结果:
最后输出到count = 65535
的样子,就不再写入数据了,此时写端停止写入数据,因为管道满了。我们也可以大概看出来,Ubuntu
的管道大小约为65536 byte
,也就是64 kb
的样子。
如果读写端关闭了管道,会发生什么?
当写端关闭管道,此时读端依然可以读取管道,但是
read
接收到返回值为`0
写端writer
如下:
void writer(int wfd)
{int count = 0;while(true){write(wfd, "c", 1);count++;if(count == 1024)break;}close(wfd);cout << "writer close..." << endl;
}
写端写入1024
个字符后,终止循环,随后close
关闭掉写端。
读端reader
如下:
void reader(int rfd)
{char buffer[1024];sleep(5);while (true){int n = read(rfd, buffer, sizeof(buffer));if(n != 0)cout << "get massage: " << buffer << endl;elsecout << "n = 0" << endl;sleep(1);}
}
一开始reader
睡眠五秒,等待writer
写入数据,随后通过read
读取管道,并接收返回值给n
。
输出结果:
五秒后,写端关闭了管道,此时读端依然可以读取所有的数据,把1024
个字符读取走后,随后的所有读取都是n = 0
。
因此可以证明,写端关闭管道时,读端依然可以正常读取,但是当管道读完后,read
返回值会变成0
。
当读端关闭管道,此时写端进程直接终止
写端writer
如下:
void writer(int wfd)
{int count = 0;while(true){write(wfd, "c", 1);cout << count++ << endl;sleep(1);}
}
子进程写端每隔一秒写入一个字符"c"
。
读端reader
如下:
void reader(int rfd)
{sleep(5);close(rfd);cout << "reader close..." << endl;
}
父进程读端五秒后,通过close
关闭读端。
而在main
函数中,父进程通过waitpid
等待子进程,随后输出子进程的瑞退出信号:
int status;
waitpid(id, &status, 0);
cout << "Sig = " << (status & 0X7F) << endl;
输出结果:
父进程是读端,子进程是写端,子进程一直死循环写入字符,父进程五秒后关闭读端。
可以发现,按理来说,子进程应该一直处于死循环中进行写入,所以waitpid
函数应该一直阻塞等待才对。可是最后子进程只写入了五个字符,就被强制终止了,并且waitpid
成功了。
这是因为父进程作为读端,五秒后关闭了读端,此时写端也会被强制终止。终止信号为13
号信号SIGPIPE
。
总结一下四个读写端的机制:
- 当管道内没有数据,读端会一直阻塞等待,直到管道中出现数据
- 写端一直写,读端不读取数据,当管道被写满的时候,读端就会阻塞等待
- 当写端关闭管道,此时读端依然可以读取管道,但是
read
接收到返回值为`0- 当读端关闭管道,此时写端进程直接终止
管道特性
通过以上案例,我们再来总结一下管道有哪些特性:
- 管道带有自同步机制:管道中,只有写了数据,读端才会读取,否则阻塞。而如果管道被写满了,读端不读取,那么写端停止写入。
简单来说就是,只有数据被读取走了,才会写入。只有数据被写入了,才会读取。
-
匿名管道用于带有血缘关系的进程通信:管道是通过继承来打开同一份管道文件,因此只有多个进程之间有血缘关系,才能通过管道通信(这个特性只针对匿名管道,后续讲解的命名管道可以在无血缘关系的进程之间通信)
-
管道具有面向字节流的特性:在刚刚的一个案例中,写端写入了
1024
个字符,而读端只读取了一次:
这个案例中,读端可以一次性把所有的字符都读取走,这个特性叫做面向字节流
。 -
管道生命周期和进程一致:当进程退出时,管道会自动释放
-
管道只能单向通信:通过管道通信的双方,都只能保留一个端口进行通信,因此管道通信是单向的
命名管道
先前我们创建的管道是匿名的,也就是说管道没有自己的名称,只是内存中的一块缓冲区,当进程结束时,管道也会自动释放。
如果说现在硬盘中存在一个有名字的文件,那么是不是就可以让两个进程同时打开这个文件,从而进行通信:
上图中,这个进程A
和B
都打开了文件log.txt
,此时两者就可以通过文件log.txt
通信了。这种管道叫做命名管道
。
mkfifo指令
我们可以通过mkfifo
指令来创建一个管道文件:
mkfifo 文件名
示例:
通过mkfifo log.txt
创建了一个管道,通过左侧第一个字符可知,这个文件的类型是p
,也就是管道文件
。
现在向管道中写入内容:
向管道log.txt
中写入了字符串hello
,此时命令行陷入了阻塞状态,在等待别人读取这个管道的内容。
我们在另外一个bash
中读取这个管道:
另外一个bash
成功读取到了内容,并且原先的阻塞态也终止了。
mkfifo接口
我们也可以在进程中通过系统调用接口来创建管道,mkfifo
接口,包含在头文件<sys.types.h>
和<sys/stat.h>
中,函数原型如下:
int mkfifo(const char* pathname, mode_t mode)
参数:
pathname
:即管道文件创建的路径mode
:管道文件的初始权限
返回值:
返回0
:管道创建成功返回 非0
:管道创建失败
示例:
int main()
{int n = mkfifo("./log.txt", 0666);if(n == 0)cout << "success" << endl;elsecout << "error" << endl;return 0;
}
通过mkfifo
接口,在当前路径下创建了log.txt
管道文件,管道的初始权限为0666
。
输出结果:
现在当前目录下就出现了log.txt
管道文件了。
后续只需要通过在不同进程中通过open
接口打开这个文件,就可以进行进程间通信了。
如果你想要在进程中销毁这个管道文件,使用unlink
接口即可:
unlink("./log.txt");