前言
本文所引用的内核代码,来源于linux0.11
什么是管道
进程用户空间是相互独立的,一般而言是不能相互访问的。但很多情况下进程间需要互相通信,来完成系统的某项功能。进程通过与内核及其它进程之间的互相通信来协调它们的行为。管道就是作为进程间的一种通信方式,为啥这种通信方式就做管道呢?
我想这是它的通信特性决定,通俗点来讲,我们来内核中申请一块缓存区,这个缓存区留有两个接口,分别接在两个不同的进程上,这个缓冲区不需要很大,它被设计成为环形的数据结构,以便可以被循环利用,是不是听上去跟管子一样。
当管道中没有信息的话,从管道中读取的进程会等待,直到另一端的进程放入信息。当管道被放满信息的时候,尝试放入信息的进程会等待,直到另一端的进程取出信息。当两个进程都终结的时候,管道也自动消失。
管道是如何实现的
前提回要:
索引节点,简称为 inode,用来记录文件的元数据,比如 inode 编号、文件大小、访问 权限、修改日期、数据的位置等。索引节点和文件一一对应,它跟文件内容一样,都会 被持久化存储到磁盘中。所以记住,索引节点同样占用磁盘空间, 所以这里我们知道了,索引节点是指向了一块实实在在物理空间
在 Linux 中,管道的实现并没有使用专门的数据结构,而是借助了文件系统的file结构和VFS的索引节点inode。通过将两个 file 结构指向同一个临时的 VFS 索引节点,而这个 VFS 索引节点又指向一块物理空间而实现的。
底层源码分析
在linux0.11中文件pipe.c文件通过,sys_pipe(), read_pipe() ,write_pipe() 三个函数实现了,管道通信的底层机制,下面我们来看一看,这三个函数是如何实现的
**在sys_pipe() 函数中,在 fildes 所指的数组中创建一对文件句柄(描述符)。这对文件句柄指向一管道 i 节点。fildes[0],用于读管道中数据,fildes[1]用于向管道中写入数据。得到的效果图见下: **
创建管道系统调用函数。
// 在 fildes 所指的数组中创建一对文件句柄(描述符)。这对文件句柄指向一管道 i 节点。fildes[0]
// 用于读管道中数据,fildes[1]用于向管道中写入数据。
int sys_pipe(unsigned long * fildes)
{
struct m_inode * inode;
struct file * f[2];
int fd[2];
int i,j;
j=0;
// 从系统文件表中取两个空闲项(引用计数字段为 0 的项),并分别设置引用计数(f_count++)为 1
for(i=0;j<2 && i<NR_FILE;i++)
if (!file_table[i].f_count)
//(f[j++]=i+file_table)->f_count++; //原代码
(f[j++]=file_table[i])->f_count++ ; // 我觉得写成这样更好理解
// 如果只有一个空闲项,则释放该项(引用计数复位)。有可能一个空闲项都没有,所以判断一下还是有必要
if (j==1)
f[0]->f_count=0;
//得到不到两个空闲项,则返回
if (j<2)
return -1;
j=0;
// 针对上面取得的两个文件结构项,分别分配一文件句柄,并使进程的文件结构指针分别指向这两个
// 文件结构
for(i=0;j<2 && i<NR_OPEN;i++)
if (!current->filp[i]) {
current->filp[ fd[j]=i ] = f[j];
j++;
}
// 如果只有一个空闲文件句柄,则释放该句柄。
if (j==1)
current->filp[fd[0]]=NULL;
// 如果没有找到两个空闲句柄,则释放上面获取的两个文件结构项(复位引用计数值),并返回-1。
if (j<2) {
f[0]->f_count=f[1]->f_count=0;
return -1;
}
// 申请管道 i 节点,并为管道分配缓冲区(1 页内存)。如果不成功,则相应释放两个文件句柄和文
// 件结构项,并返回-1。
if (!(inode=get_pipe_inode())) {
current->filp[fd[0]] =
current->filp[fd[1]] = NULL;
f[0]->f_count = f[1]->f_count = 0;
return -1;
}
f[0]->f_inode = f[1]->f_inode = inode;
f[0]->f_pos = f[1]->f_pos = 0;
f[0]->f_mode = 1; /* read */
f[1]->f_mode = 2; /* write */
// 将文件句柄数组复制到对应的用户数组中,并返回 0,退出。
put_fs_long(fd[0],0+fildes);
put_fs_long(fd[1],1+fildes);
return 0;
}
关于管道数据的读写,其实跟文件数据的读写类似,管道写函数通过将字节复制到 VFS 索引节点指向的物理内存而写入数据,而管道读函数则通过复制物理内存中的字节而读出数据。不同的是,在读或者写之前,会进行一些条件的判断,如管道中是否有数据,有多少数据。其次是利用一定的机制同步对管道的访问,在读进程进行时,写进程进入等待状态。读进程完事后,会唤醒写进程,反之亦然。
// 参数 inode 是管道对应的 i 节点,buf 是数据缓冲区指针,count 是读取的字节数
int read_pipe(struct m_inode * inode, char * buf, int count)
{
int chars, size, read = 0;
while (count>0) {// 若欲读取的字节计数值 count 大于 0,则循环执行以下操作。
while (!(size=PIPE_SIZE(*inode))) {// 若当前管道中没有数据(size=0),则唤醒等待该节点的进程,如果已没有写管道者,则返回已读
// 字节数,退出。否则在该 i 节点上睡眠,等待信息。
wake_up(&inode->i_wait);
if (inode->i_count != 2) /* are there any writers? */
return read;
sleep_on(&inode->i_wait);
}
// 取管道尾到缓冲区末端的字节数 chars。如果其大于还需要读取的字节数 count,则令其等于 count。
// 如果 chars 大于当前管道中含有数据的长度 size,则令其等于 size。
chars = PAGE_SIZE-PIPE_TAIL(*inode);
if (chars > count)
chars = count;
if (chars > size)
chars = size;
count -= chars;// 读字节计数减去此次可读的字节数 chars,并累加已读字节数
read += chars;
// 令 size 指向管道尾部,调整当前管道尾指针(前移 chars 字节)。
size = PIPE_TAIL(*inode);
PIPE_TAIL(*inode) += chars;
PIPE_TAIL(*inode) &= (PAGE_SIZE-1);
while (chars-->0)
put_fs_byte(((char *)inode->i_size)[size++],buf++);
}
// 唤醒等待该管道 i 节点的进程,并返回读取的字节数。
wake_up(&inode->i_wait);
return read;
}
管道写操作函数。
// 参数 inode 是管道对应的 i 节点,buf 是数据缓冲区指针,count 是将写入管道的字节数。
int write_pipe(struct m_inode * inode, char * buf, int count)
{
int chars, size, written = 0;
// 若将写入的字节计数值 count 还大于 0,则循环执行以下操作。
while (count>0) {
// 若当前管道中没有已经满了(size=0),则唤醒等待该节点的进程,如果已没有读管道者,则向进程
// 发送 SIGPIPE 信号,并返回已写入的字节数并退出。若写入 0 字节,则返回-1。否则在该 i 节点上
// 睡眠,等待管道腾出空间。
while (!(size=(PAGE_SIZE-1)-PIPE_SIZE(*inode))) {
wake_up(&inode->i_wait);
if (inode->i_count != 2) { /* no readers */
current->signal |= (1<<(SIGPIPE-1));
return written?written:-1;
}
sleep_on(&inode->i_wait);
}
// 取管道头部到缓冲区末端空间字节数 chars。如果其大于还需要写入的字节数 count,则令其等于
// count。如果 chars 大于当前管道中空闲空间长度 size,则令其等于 size。
chars = PAGE_SIZE-PIPE_HEAD(*inode);
if (chars > count)
chars = count;
if (chars > size)
chars = size;
// 写入字节计数减去此次可写入的字节数 chars,并累加已写字节数到 written
count -= chars;
written += chars;
// 令 size 指向管道数据头部,调整当前管道数据头部指针(前移 chars 字节)。
size = PIPE_HEAD(*inode);
PIPE_HEAD(*inode) += chars;
PIPE_HEAD(*inode) &= (PAGE_SIZE-1);
// 从用户缓冲区复制 chars 个字节到管道中。对于管道 i 节点,其 i_size 字段中是管道缓冲块指针。
while (chars-->0)
((char *)inode->i_size)[size++]=get_fs_byte(buf++);
}
// 唤醒等待该 i 节点的进程,返回已写入的字节数,退出。
wake_up(&inode->i_wait);
return written;
}
以上我们了解到了管道的底层实现原理,但令人疑惑的事,不是说管道是两个进程分别对一块缓存区进行读写么,从上面的代码来看只实现了当前进程对于管道的读写呀?
事实上,管道利用fork机制建立,从而让两个进程可以连接到同一个node上。最开始的时候,上面的两个箭头都连接在同一个进程Process 1上(连接在Process 1上的两个箭头)。当fork复制进程的时候,会将这两个连接也复制到新的进程(Process 2)。随后,每个进程关闭自己不需要的一个连接 (两个黑色的箭头被关闭; Process 1关闭从PIPE来的输入连接,Process 2关闭输出到PIPE的连接),这样,剩下的红色连接就构成了如下图所示。
让我们来看看具体实现吧
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);
}
这里小伙伴又要问了,按这个说法那管道是不是只能用于父子进程,或者兄弟进程?
这里就要引出管道的分类了,管道分为两种 ,有名管道和无名管道 ,在早期版本中只实现无名管道,后面为了实现不相关进程之间的通信,才引入了有名管道机制FIFO,又叫做命名管道(named PIPE)。
FIFO (First in, First out)为一种特殊的文件类型,它在文件系统中有对应的路径。当一个进程以读®的方式打开该文件,而另一个进程以写(w)的方式打开该文件,那么内核就会在这两个进程之间建立管道,所以FIFO实际上也由内核管理,不与硬盘打交道。之所以叫FIFO,是因为管道本质上是一个先进先出的队列数据结构,最早放入的数据被最先读出来,从而保证信息交流的顺序。FIFO只是借用了文件系统(file system,命名管道是一种特殊类型的文件,因为Linux中所有事物都是文件,它在文件系统中以文件名的形式存在。)来为管道命名。写模式的进程向FIFO文件中写入,而读模式的进程从FIFO文件中读出。当删除FIFO文件时,管道连接也随之消失。FIFO的好处在于我们可以通过文件的路径来识别管道,从而让没有亲缘关系的进程之间建立连接
是因为管道本质上是一个先进先出的队列数据结构,最早放入的数据被最先读出来,从而保证信息交流的顺序。FIFO只是借用了文件系统(file system,命名管道是一种特殊类型的文件,因为Linux中所有事物都是文件,它在文件系统中以文件名的形式存在。)来为管道命名。写模式的进程向FIFO文件中写入,而读模式的进程从FIFO文件中读出。当删除FIFO文件时,管道连接也随之消失。FIFO的好处在于我们可以通过文件的路径来识别管道,从而让没有亲缘关系的进程之间建立连接**
这次关于管道的分析就到这了,因为笔者暂时只对低版本的源码进行了阅读,所以这里只引入了有名管道的概率,并未对其进行展开,以后有时间了再补上