管道
管道是内核管理的一个固定大小的缓冲区。在Linux 中,该缓冲区的大小为1 页,即4KB。
管道创建
首先,使用pipe函数创建一个匿名半双工管道。
#include <unistd.h>
int pipe(int fd[2])
fd[2]维护一个长度为2的文件描述符数组,fd[0]是读出端,fd[1]是写入端,函数值返回0表示成功,返回-1表示失败。当函数成功返回,自动建立了一个**从fd[1]到fd[0]**的数据通道。
最开始,两个文件描述符都连接在同一个进程上。
然后调用fork函数创建子进程,让两个进程连接到同一个PIPE上。调用fork会向父进程返回子进程pid(>0),向子进程返回父进程pid(=0)。当fork复制进程的时候,会将这两个连接也复制到新进程(具有相同的文件描述符)。随后,每个进程关闭自己不需要的一个连接 (例如,若要数据流从父进程流向子进程,则关闭父进程的读端(fd[0])与子进程的写端(fd[1]);反之,则可以使数据流从子进程流向父进程。)
管道通信
在Linux 中,管道实现借助了文件系统的file 结构和VFS(Virtual File System,虚拟文件系统)的索引节点inode(inode是文件的唯一标识),通过将两个 file 结构指向同一个临时VFS 索引节点,而这个 VFS 索引节点又指向一个物理页面来实现。
两个file数据结构定义的文件操作例程地址是不同的,一个例程地址向管道中写入数据,另一个例程地址从管道中读出数据。
当写进程向管道中写入时,首先利用标准库函数write(),根据传入write()函数的文件描述符找到该文件的 file 结构;file 结构中指定了写函数(pipe_wrtie())要写入的地址(数据页),然后内核调用写函数执行写操作,将字节复制到inode指向的物理内存。写入函数在向内存中写入数据之前,必须提前检查inode中的信息是否满足:
1)内存中有足够的空间可容纳所有要写入的数据;
2)内存没有被读程序锁定。
如果同时满足上述条件,写入函数首先锁定内存,然后从写进程的地址空间中复制数据到内存。否则,写入进程就阻塞在VFS 索引节点的等待队列中,当内存中有足够的空间可以容纳写入数据,或内存被解锁时,读进程会唤醒写入进程,此时写进程接收到信号。写入的内容每次都添加在管道缓冲区的末尾,当数据写入内存之后,内存被解锁,而所有阻塞在索引节点的读进程会被唤醒。
管道的读取过程(将物理内存中的字节复制出来)和写入过程类似。每次从缓冲区的头部读出数据,且数据一旦被读,它就从管道中被抛弃,释放空间以便写更多的数据。读取程序的判断条件为:内存不为空且内存没有被写程序锁定。
当所有的进程完成了管道操作之后,管道的索引节点被丢弃,而共享数据页也被释放。
注:
(1)内核使用了锁、等待队列和信号等机制进行同步。
(2)read/write调用模式
(3)文件描述符关闭
如果所有管道写端的文件描述符被关闭,那么管道中剩余的数据都被读取完后,再次 read 会返回 0。
如果所有管道读端的文件描述符被关闭,则 write 操作会产生 SIGPIPE 信号,进而可能导致 write 进程退出。
(4)原子性
当要写入的数据量n不大于PIPE_BUF时,将保证写入的原子性。
当要写入的数据量n大于PIPE_BUF时,将不再保证写入的原子性。
管道特点
(1)半双工(即数据只能在一个方向上流动),具有固定的读端和写端;双向通信需要建立两个管道;
(2)只能用于具有亲缘关系的进程之间的通信(也是父子进程或者兄弟进程之间)。
(3)单独构成一种独立文件系统,只存在于内存中。
命名管道
命名管道也被称为FIFO文件,它提供一个路径名与管道关联,路径名以FIFO文件形式存在于文件系统中,内容存放于内存中。没有亲缘关系的进程可以通过文件路径名来识别管道,从而建立通信连接。当删除FIFO文件时,管道连接也随之消失。
示例
int main(void)
{
int n;
int fd[2];
pid_t pid;
char line[MAXLINE];
if(pipe(fd) 0){ /* 建立管道得到一对文件描述符 */
exit(0);
}
if((pid = fork()) 0) /* 父进程把文件描述符复制给子进程 */
exit(1);
else if(pid > 0){ /* 父进程写 */
close(fd[0]); /* 关闭读描述符 */
write(fd[1], "\nhello world\n", 14);
}
else{ /* 子进程读 */
close(fd[1]); /* 关闭写端 */
n = read(fd[0], line, MAXLINE);
write(STDOUT_FILENO, line, n);
}
exit(0);
}