【Linux0.11 内核源码剖析】进程间通信——管道(pipe)

来源:互联网 发布:巴基斯坦怎样国家知乎 编辑:程序博客网 时间:2024/05/16 08:03

一、简介

所谓管道,是指用于连接一个读进程和一个写进程,以实现它们之间通信的共享文件,又称pipe文件。向管道(共享文件)提供输入的发送进程(即写进程),以字符流形式将大量的数据送入管道,而接收管道输出的接收进城(即读进程),可从管道中接收数据。由于发送进程和接收进程是利用管道进行通信的,故又称管道通信。这种方式能够传送大量数据,且很有效。

为了协调双方的通信,管道通信机制必须提供以下三方面的协调能力:

  • 互斥。当一个进程正在对pipe进行读/写操作时,另一个进程必须等待
  • 同步。当写(输入)进程把一定数量数据写入pipe后,便去睡眠等待,直到读(输出)进程取走数据后,再把它唤醒。当读进程读到一空pipe时,也应睡眠等待,直至写进程将数据写入管道后,才将它唤醒
  • 对方是否存在。只有确定对方已存在时,才能进行通信
从本质上来说,管道也是一种文件(记得有人说过,Linux中,一切皆文件),为了克服上面的问题,管道通信机制 限制了管道的大小,实际上管道是一个固定大小的缓冲区,在Linux中,该缓冲区的大小为4KB,恰好为一个页面大小。这使得它的大小收到限制,不像文件那样无限制的不加检验的增长。但同时也会导致当管道已满,而写通道仍有数据待输入,此时,管道的输入端write()调用则必须被阻塞(向管道的写入和读取,使用的是标准的库函数write() 和 read()),直到管道的输出端有数据被读取,管道腾出了足够的空间供 write()调用,才被唤醒写入。同样当读取进程工作的比写入进程快时,会存在管道中所有数据已被读取,此时,管道为空,则管道的输出端read()也将被阻塞,直到管道中数据被唤醒。
从管道读数据是一次性操作,数据一旦被读,它就从管道中被抛弃,释放空间一遍写更多的数据。事实上,C/C++编程时,从首位置开始逐步读取字符串中的数据,前面的字符会随之被抛弃。
二、管道的结构
在Linux 中,管道的实现并没有使用专门的数据结构,而是借助了文件系统中file 结构和 VFS的索引节点 inode。通过将两个 file 结构指向同一个临时的 VFS 索引节点inode,这个索引节点又指向同一个物理页面来实现的。如下图所示,相信可以看出管道的内部实现机制了:进程1 将自己的进程地址空间中的数据先复制到inode 指定的物理页面上,然后进程2 则复制 inode 指定的页面(同一个页面,即共享的数据页)上的数据到自己的进程地址空间上。这就完成了进程1的写,进程2的读,反之一样。换句话说共享了一个物理页。
                                                 
三、源码解读内部实现(Linux 0.11)
管道实现的源码在fs/pipe.c 中,pipe.c 中只有三个函数,write_pipe()、read_pipe() 和 sys_pipe()。下面分别看看这三个函数:
3.1、创建管道——sys_pipe
把管道看做一个循环队列,清楚管道的结构和机制,这对理解管道的读写操作非常重要!
//// 创建管道系统调用函数。// 在fildes 所指的数组中创建一对文件句柄(描述符)。这对文件句柄指向一管道i 节点。fildes[0]// 用于读管道中数据,fildes[1]用于向管道中写入数据。// 成功时返回0,出错时返回-1。int sys_pipe(unsigned long * fildes){struct m_inode * inode;struct file * f[2];int fd[2];int i, j;/*为管道文件在文件管理表中射你请空闲项*/// 从内核文件管理表中取两个空闲项(引用计数字段为0 的项),并分别设置引用计数为1。j = 0;for (i = 0; j<2 && i<NR_FILE; i++)if (!file_table[i].f_count)(f[j++] = i + file_table)->f_count++;// 如果只有一个空闲项,则释放该项(引用计数复位)。必须两个才有效if (j == 1)f[0]->f_count = 0;// 如果没有找到两个空闲项,则返回-1。if (j<2)return -1;/*为管道文件与进程建立联系创造条件。一旦父进程fork子进程,子进程将会拷贝这一切,同样与管道文件表项建立了联系*/// 针对上面取得的两个文件结构项,分别分配一文件句柄,并使进程的文件结构指针分别指向这两个// 文件结构,一个写入端,一个输出端。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 节点*/// 申请管道i 节点,并为管道分配缓冲区(1 页内存)。如果不成功,则相应释放两个文件句柄和文// 件结构项,并返回-1。if (!(inode = get_pipe_inode())) {//申请一个空闲内存页面,并将该页面的地址载入i节点current->filp[fd[0]] =current->filp[fd[1]] = NULL;f[0]->f_count = f[1]->f_count = 0;return -1;}/*将管道文件i 节点与文件管理表建立联系*/// 初始化两个文件结构,都指向同一个i 节点,读写指针都置零。第1 个文件结构的文件模式置为读,// 第2 个文件结构的文件模式置为写。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;}
get_pipe_inode() 函数
//// 获取管道节点。返回为i 节点指针(如果是NULL 则失败)。// 首先扫描i 节点表,寻找一个空闲i 节点项,然后取得一页空闲内存供管道使用。// 然后将得到的i 节点的引用计数置为2(读者和写者),初始化管道头和尾,置i 节点的管道类型表示。struct m_inode * get_pipe_inode(void){struct m_inode * inode;if (!(inode = get_empty_inode()))// 如果找不到空闲i 节点则返回NULL。return NULL;if (!(inode->i_size = get_free_page())) {// 节点的i_size 字段指向缓冲区。i_size表示内存页面的起始地址inode->i_count = 0;// 如果已没有空闲内存,则return NULL;// 释放该i 节点,并返回NULL。}inode->i_count = 2;/* 读/写两者总计 */PIPE_HEAD(*inode) = PIPE_TAIL(*inode) = 0;// 复位管道头尾指针。inode->i_pipe = 1;// 置节点为管道使用的标志。return inode;// 返回i 节点指针。}
从上面可以看出,管道的创建过程:1、为管道文件在文件管理表中申请空闲项;2、为管道文件与进程建立联系创造条件;3、创建管道文件i 节点;4、将管道文件i 节点与文件管理表建立联系;5、将管道文件句柄返回给用户进程。
// 管道头、管道尾、管道大小、管道空、管道满、管道头指针递增。#define PIPE_HEAD(inode) ((inode).i_zone[0])#define PIPE_TAIL(inode) ((inode).i_zone[1])#define PIPE_SIZE(inode) ((PIPE_HEAD(inode)-PIPE_TAIL(inode))&(PAGE_SIZE-1))#define PIPE_EMPTY(inode) (PIPE_HEAD(inode)==PIPE_TAIL(inode))#define PIPE_FULL(inode) (PIPE_SIZE(inode)==(PAGE_SIZE-1))/*下面用C语言实现就是 #define INC_PIPE(head) (((*head)++)&(PAGE_SIZE-1))用汇编实现,将数据放入寄存器中,速度更快*/#define INC_PIPE(head) _INC_PIPE(&(head))extern _inline void _INC_PIPE(unsigned long *head) {_asm mov ebx, head  //head存入ebx中_asm inc dword ptr[ebx] //将ebx指向的数据加1_asm and dword ptr[ebx], 4095 //结果与4095}
3.2、写管道——write_pipe
如果执行 write 函数(文件描述符为管道文件描述符),write函数会映射到系统调用函数 sys_write 中去执行,并最终执行到 write_pipe 函数中。
/*管道类似于一个循环队列*///// 管道写操作函数。// 参数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) { /* 没有读进程 */current->signal |= (1 << (SIGPIPE - 1));return written ? written : -1;}sleep_on(&inode->i_wait);//挂起写管道进程}// 取管道头部到缓冲区末端空间字节数chars。如果其大于还需要写入的字节数count,则令其等于// count。如果chars 大于当前管道中空闲空间长度size,则令其等于size。/*管道可以看做一个循环队列,清楚chars 与 size的区别*/chars = PAGE_SIZE - PIPE_HEAD(*inode);//chars <= sizeif (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;}

把管道理解为一个循环队列,一个指针标明写入,一个指针标明读出。所以在判断管道满和空的情况下,是以循环队列的标准来判断,在写入的过程中,写管道指针一直指向数据的写入位置,一直向管道尾端移动,写满后,指针已经从管道的首段移动到尾端了,如下图所示。再次执行写管道操作时,系统会将写管道指针从管道尾端移动到管道首端,继续将数据写入管道。

Linux 0.11 默认:每次写管道执行完毕后,管道中就已经拥有了可以读出的数据,所以就会唤醒读管道进程;每次读管道操作完毕后,管道中就已经拥有了可以写入数据的空间,所以就会唤醒写管道进程。
如果管道中没有空间可以写入数据,此时系统将挂起写管道进程,然后切换到读管道进程去执行。但是需要注意的是,任何进程挂起的一个原因是该进程的时间片已经用完了,必须挂起,然后根据公平调度原则去运行其余进程。所以这里的读管道、写管道进程不一定非要管道空、满了才挂起,一旦该进程的时间片用完了,那么下一次先执行的便是另一个进程,所以常有中间态的情况发生(管道首指针和尾指针位于中间,谁前谁后不一定)。
3.3、读管道——read_pipe
前面说到了LInux 0.11 默认规则,管道满了,就会唤醒读管道进程(唤醒不表示读管道进程可以立即执行)。写管道是移动管道首指针,读管道则是移动管道尾指针。
//// 管道读操作函数。// 参数inode 是管道对应的i 节点,buf 是数据缓冲区指针,count 是读取的字节数。int read_pipe(struct m_inode * inode, char * buf, int count){int chars, size, read = 0;// 若欲读取的字节计数值count 大于0,则循环执行以下操作。while (count>0) {// 若当前管道中没有数据(size=0),则唤醒等待该节点的进程,如果已没有写管道者,则返回已读// 字节数,退出。否则在该i 节点上睡眠,等待信息。while (!(size = PIPE_SIZE(*inode))) {//没有数据可读wake_up(&inode->i_wait);if (inode->i_count != 2) /* 没有写管道 */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;// 读字节计数减去此次可读的字节数chars,并累加已读字节数。count -= chars;read += chars;// 令size 指向管道尾部,调整当前管道尾指针(前移chars 字节)。size = PIPE_TAIL(*inode);PIPE_TAIL(*inode) += chars;PIPE_TAIL(*inode) &= (PAGE_SIZE - 1);// 将管道中的数据复制到用户缓冲区中。对于管道i 节点,其i_size 字段中是管道缓冲块指针。while (chars-->0)put_fs_byte(((char *)inode->i_size)[size++], buf++);}// 唤醒等待该管道i 节点的进程,并返回读取的字节数。wake_up(&inode->i_wait);return read;}
读管道进程开始执行后,将从上一次读的位置开始把管道中的数据读出,当操作到管道尾端后也会将读管道指针从管道尾端移动到管道首端,从首端继续读取管道中的内容,直至将数据彻底读完。管道中的数据被读完后,读管道进程将再次被挂起,并切换到写管道进程去执行。
  • 对于读管道操作,如果管道中有数据,它就可以读出数据,如果没有数据,系统就会将它挂起。
  • 对于写管道操作,如果管道中有空间,它就可以写入数据,如果没有空间,系统就会将它挂起。
另外,无论哪种操作,一旦该操作进程的时间片用完了,同样会被挂起,当下次分配时间片时,将会接着上次的写/读位置继续操作。由于管道数据的写入和读出都是在内核代码中进行的,上面用户态进程切换并不会对数据的操作产生影响。虽然读管道进程和写管道进程都在同一个内存页面中进程操作,但数据永远不会出现混乱。

参考资料:(Linux 0.11源码)《Linux 内核设计的艺术》、《Linux 内核设计与实现》

0 0
原创粉丝点击