深入理解linux内核--进程间通信

管道

管道(pipe)是所有Unix都愿意提供的一种进程间通信机制。管道是进程之间的一个单向数据流:一个进程写入管道的所有数据都由内核定向到另一个进程,另一个进程由此就可以从管道中读取数据。在Unix的命令shell中,可以使用“1”操作符来创建管道。例如,下面的语句通知shell 创建两个进程,并使用一个管道把这两个进程连接在一起:$ ls | more

第一个进程(执行ls程序)的标准输出被重定向到管道中;第二个进程(执行more程序)从这个管道中读取输入。注意,执行下面这两条命令也可以得到相同的结果:

$ ls > temp
$ more < temp

第一个命令把ls的输出重定向到一个普通文件中;接下来,第二个命令强制more从这个普通文件中读取输入。

当然,通常使用管道比使用临时文件更方便,这是因为:
1.shell语句比较短,也比较简单。
2.没有必要创建将来还必须删除的临时普通文件。

使用管道

管道被看作是打开的文件,但在已安装的文件系统中没有相应的映像。可以使用pipe()系统调用来创建一个新管道,这个系统调用返回一对文件描述符;然后进程通过fork()把这两个描述符传递给它的子进程,由此与子进程共享管道。进程可以在read()系统调用中使用第一个文件描述符从管道中读取数据,同样也可以在write()系统调用中使用第二个文件描述符向管道中写入数据。

POSIX只定义了半双工的管道,因此即使pipe()系统调用返回了两个描述符,每个进程在使用一个文件描述符之前仍得把另外一个文件描述符关闭。

如果所需要的是双向数据流,那么进程必须通过两次调用pipe()来使用两个不同的管道。有些Unix系统,例如System VReleas 4,实现了全双工的管道。在全双工管道中,允许两个文件描述符既可以被写入也可以被读取,这就有两个双向信息通道。

Linux采用了另外一种解决方法;每个管道的文件描述符仍然都是单向的,但是在使用一个描述符之前不必把另外一个描述符关闭。让我们回顾一下前面的那个例子。当shell命令对ls I more语句进行解释时,实际上要执行以下操作:shell进程:

  1. 调用pipe()系统调用;让我们假设pipe()返回文件描述符3(管道的读通道)和4(管道的写通道)。
  2. 两次调用fork()系统调用。
  3. 两次调用close()系统调用来释放文件描述符3和4。

第一个子进程必须执行ls程序,它执行以下操作:
a.调用dup2(4,1)把文件描述符4拷贝到文件描述符1。从现在开始,文件描述符1就代表该管道的写通道。
b.两次调用close()系统调用来释放文件描述符3和4。
c.调用execve()系统调用来执行ls程序。缺省情况下,这个程序要把自己的输出写到文件描述符为1的那个文件(标准输出)中,也就是说,写入管道中。

第二个子进程必须执行more程序;因此,该进程执行以下操作:
1.调用dup2(3,0)把文件描述符3拷贝到文件描述符0。从现在开始,文件描述符0就代表管道的读通道。
2.两次调用close()系统调用来释放文件描述符3和4。
3.调用execve()系统调用来执行more程序。缺省情况下,这个程序要从文件描述符为0的那个文件(标准输入)中读取输入,也就是说,从管道中读取输入。

在这个简单的例子中,管道完全被两个进程使用。但是,由于管道的这种实现方式,二个管道可以供任意个进程使用。显然,如果两个或者更多个进程对同一个管道进行读写,

那么这些进程必须使用文件加锁机制或者IPC信号量机制,对自己的访问进行显式的同步。除了pipe()系统调用之外,很多Unix系统都提供了两个名为popen()和pclose()的封装函数来处理在使用管道的过程中产生的所有脏工作。只要使用popen()函数创建二个管道,就可以使用包含在C函数座中的高级IO函数(fprintf(),fscanf()等等)对这个管道进行操作。在Linux中,popen()和pclose()都包含在C函数库中。popen()函数接收两个参数:可执行文件的路径名filename和定义数据传输方向的字符串type。该函数返回一个指向FILE数据结构的指针。

popen()函数实际上执行以下操作:
1.使用pipe()系统调用创建一个新管道。
2.创建一个新进程,该进程又执行以下操作:
a.如果type是r,就把与管道的写通道相关的文件描述符拷贝到文件描述符1(标准输出);如果type是w,就把与管道的读通道相关的文件描述符拷贝到文件描述符0(标准输入)。
b.关闭pipe()返回的文件描述符。
c.调用execve()系统调用执行filename所指定的程序。
3.如果type是r,就关闭与管道的写通道相关的文件描述符;如果type是w,就关闭与管道的读通道相关的文件描述符。
4.返回FILE文件指针所指向的地址,这个指针指向仍然打开的管道所涉及的任一文件描述符。在popen()函数被调用之后,父进程和子进程就可以通过管道交换信息:

1,2,3,4说的是父进程。
a,b,c说的是子进程。

父进程可以使用该函数所返回的FILE指针来读(如果type是r)写(如果type是w)数据。子进程所执行的程序分别把数据写入标准输出或从标准输入中读取数据。pclose()函数接收popen()所返回的文件指针作为参数,它会简单地调用wait4()系统调用并等待popen()所创建的进程结束。

管道数据结构

我们现在又一次在系统调用的层次考虑问题。只要管道一被创建,进程就可以使用read()和write()这两个VFS系统调用来访问管道。因此,对于每个管道来说,内核都要创建一个索引节点对象和两个文件对象,一个文件对象用于读,另外一个对象用于写。

当进程希望从管道中读取数据或向管道中写入数据时,必须使用适当的文件描述符。当索引节点指的是管道时,其i_pipe字段指向一个如表19-1所示的pipe_inode_info 结构。
在这里插入图片描述
在这里插入图片描述
除了一个索引节点对象和两个文件对象之外,每个管道都还有自己的管道缓冲区(pipe buffer)。实际上,它是一个单独的页,其中包含了已经写入管道等待读出的数据。

在Linux 2.6.10以前,每个管道一个管道缓冲区。而2.6.11内核中,管道(与FIFO)的数据缓冲区已有很大改变,每个管道可以使用16个管道缓冲区。这个改变大大增强了向管道写大量数据的用户态应用的性能。

pipe_inode_info数据结构的bufs字段存放一个具有16个pipe_buffer对象的数组,每个对象代表一个管道缓冲区。该对象的字段如表19-2所示。
在这里插入图片描述
ops字段指向管道缓冲区方法表anon_pipe_buf_ops,它是一个类型为pipe_buf_operations 的数据结构。实际上,它有三个方法:

map
	在访问缓冲区数据之前调用。它只在管道缓冲区在高端内存时对管道缓冲区页框调用kmap()。
unmap
	不再访问缓冲区数据时调用。它对管道缓冲区页框调用kunmap()。
release
	当释放管道缓冲区时调用。
	该方法实现了一个单页内存高速缓存:
	释放的不是存放缓冲区的那个页框,
	而是由pipe_inode_info数据结构(如果不是NULL)的tmp_page字段指向的高速缓存页框。
	存放缓冲区的页框变成新的高速缓存页框。

16个缓冲区可以被看作一个整体环形缓冲区:写进程不断向这个大缓冲区追加数据,而读进程则不断移出数据。

所有管道缓冲区中当前写入而等待读出的字节数就是所谓的管道大小。为提高效率,仍然要读的数据可以分散在几个未填充满的管道缓冲区内:事实上,在上一个管道缓冲区没有足够空间存放新数据时,每个写操作都可能会把数据拷贝到一个新的空管道缓冲区。因此,内核必须记录:
1.下一个待读字节所在的管道缓冲区、页框中的对应偏移量。该管道缓冲区的索引存放在pipe_inode_info数据结构的curbuf字段,而偏移量在相应pipe_buffer对象的offset字段。
2.第一个空管道缓冲区。它可以通过增加当前管道缓冲区的索引得到(模为16),并存放在pipe_inode_info数据结构的curbuf字段,而存放有效数据的管道缓冲区号存放在nrbufs字段。为了避免对管道数据结构的竟争条件,内核使用包含在索引节点对象中的i_sem信号量。

pipefs特殊文件系统

管道是作为一组VFS对象来实现的,因此没有对应的磁盘映象。在Linux 2.6中,把这些VFS对象组织为pipefs特殊文件系统以加速它们的处理。因为这种文件系统在系统目录树中没有安装点,因此用户根本看不到它。

但是,有了pipefs,管道完全被整合到VFS层,内核就可以以命名管道或FIFO的方式处理它们,FIFO是以终端用户认可的文件而存在的。init_pipe_fs()函数(一般是在内核初始化期间执行)注册pipefs文件系统并安装它:

struct file_system_type pipe_fs_type;
pipe_fs_type.name ="pipefs";
pipe_fs_type.get_sb = pipefs_get_sb;
pipe_fs.kill_sb = kill_anon_super;
register_filesystem(&pipe_fs_type);
pipe_mnt = do_kern_mount("pipefs", 0, "pipefs", NULL);

表示pipefs根目录的已安装文件系统对象存放在pipe_mnt变量中。

创建和撤消管道

pipe()系统调用由sys_pipe()函数处理,后者又会调用do_ pipe()函数。为了创建一个新的管道,do_ pipe()函数执行以下操作:

  1. 调用get_pipe_inode()函数,该函数为pipefs文件系统中的管道分配一个索引节点对象并对其进行初始化。具体来说,该函数执行下列操作:
    a.在pipefs文件系统中分配一个新的索引节点。
    b.分配pipe_inode_info数据结构,并把它的地址存放在索引节点的i_pipe字段。
    c.设置pipe_inode_info的curbuf和nrbufs字段为0,并将bufs数组中的管道缓冲区对象的所有字段都清0。
    d.把pipe_inode_info结构的r_counter和w_counter字段初始化为1。
    e.把pipe_inode_info结构的readers和writers字段初始化为1。
  2. 为管道的读通道分配一个文件对象和一个文件描述符,并把这个文件对象的f_flag字段设置成O_RDONLY,把f_op字段初始化成read pipe_fops表的地址。
  3. 为管道的写通道分配一个文件对象和一个文件描述符,并把这个文件对象的flag字段设置成O_WRONLY,把f_op字段初始化成write_pipe_fops表的地址。
  4. 分配一个目录项对象,并使用它把两个文件对象和索引节点对象连接在一起;
    然后,把新的索引节点插入pipefs特殊文件系统中。
  5. 把两个文件描述符返回给用户态进程。发出一个pipe()系统调用的进程是最初唯一一个可以读写访问新管道的进程。为了表示该管道实际上既有一个读进程,又有一个写进程,就要把pipe_inode_info数据结构的readers和writers字段都初始化成1。通常,只要相应管道的文件对象仍然由某个进程打开,这两个字段中的每个字段就应该都被设置成1;如果相应的文件对象已经被释放,那么这个字段就被设置成0,因为不会再有任何进程访问这个管道。创建一个新进程并不增加readers和writers字段的值,因此这两个值从不超过1。但是,父进程仍然使用的所有文件对象的引用计数器的值都会增加。因此,即使父进程死亡时这个对象都不会被释放,管道仍会一直打开供子进程使用。只要进程对与管道相关的一个文件描述符调用close()系统调用,内核就对相应的文件对象执行fput()函数,这会减少它的引用计数器的值。如果这个计数器变成0,那么该函数就调用这个文件操作的release方法。

根据文件是与读通道还是与写通道关联,release方法或者由pipe_read_release()或者由pipe_write_release()函数来实现。这两个函数都调用pipe_release(),后者把pipe_inode_info结构的readers字段或writers字段设置成0。

pipe_release()还要检查readers和writers是否都等于0。如果是,就调用所有管道缓冲区的release 方法,向伙伴系统(buddy system)释放所有管道缓冲区页框;此外,函数还释放由tmp_page字段指向的高速缓存页框。否则,readers或者writers字段不为0,函数唤醒在管道的等待队列上睡眠的任一进程,以使它们可以识别管道状态的变化。

从管道中读取数据

希望从管道中读取数据的进程发出一个read()系统调用,为管道的读端指定一个文件描述符。内核最终调用与这个文件描述符相关的文件操作表中所找到的read方法。在管道的情况下,read 方法在read_pipe_fops表中的表项指向pipe_read()函数。pipe_read()相当复杂,因为POSIX标准定义了管道的读操作的一些要求。表19-3概述了所期望的read()系统调用的行为,该系统调用从一个管道大小(管道缓冲区中待读的字节数)为p的管道中读取n个字节。
在这里插入图片描述

这个系统调用可能以两种方式阻塞当前进程:
1.当系统调用开始时管道缓冲区为空。
2.管道缓冲区没有包含所有请求的字节,写进程在等待缓冲区的空间时曾被置为睡眠。

注意,读操作可以是非阻塞的。在这种情况下,只要所有可用的字节(即使是0个)一被拷贝到用户地址空间中,读操作就完成(注3)。还要注意,只有在管道为空而且当前没有进程正在使用与管道的写通道相关的文件对象时,read()系统调用才会返回0。

pipe_read()函数执行以下操作:

  1. 获取索引节点的i_sem信号量。
  2. 确定存放在pipe_inode_info结构nrbufs字段中的管道大小是否为0。如果是,说明所有管道缓冲区为空。这时还要确定函数必须返回还是进程在等待时必须被阻塞,直到其他进程向管道中写入一些数据(参见表19-3)。I/O操作的类型(阻塞或非阻塞)是通过文件对象的f_flags字段中的O_NONBLOCK标志来表示的。如果当前进程必须被阻塞,则函数执行下列操作:
    a.调用prepare_to_wait()把current加到管道的等待队列(pipe_inode_info结构的wait字段)。
    b.释放索引节点的信号量。
    c.调用schedule()。

d.一旦current被唤醒,就调用finish_wait()把它从等待队列中删除,再次获取i_sem索引节点信号量,然后跳回第2步。
3. 从pipe_inode_info数据结构的curbuf字段得到当前管道缓冲区索引。
4. 执行管道缓冲区的map方法。
5. 从管道缓冲区拷贝请求的字节数(如果较小,就是管道缓冲区可用字节数)到用户地址空间。
6. 执行管道缓冲区的unmap方法。
7. 更新相应pipe_buffer对象的offset和len字段。
8. 如果管道缓冲区已空(pipe_buffer对象的len字段现在等于0),则调用管道缓冲区的release方法释放对应的页框,把pipe_buffer对象的ops字段置为NULL,增加在pipe_inode_info数据结构的curbuf字段中存放的当前管道缓冲区索引,并减小nrbufs字段中非空管道缓冲区计数器的值。

  1. 如果所有请求字节拷贝完毕,则跳至第12步。
    10.目前,还没有把所有请求字节拷贝到用户态地址空间。如果管道大小大于0(pipe_inode_info的nrbufs字段不是NULL),则跳到第3步。
    11.管道缓冲区内已没有剩余字节。如果至少有一个写进程正在睡眠(即pipe_inode_info数据结构的waiting_writers字段大于0),且读操作是阻塞的,那么调用wake_up_interruptible_sync()唤醒在管道等待队列中所有睡眠的进程,然后跳至第2步。
    12.释放索引节点的i_sem信号量。
  2. 调用wake_up_interruptible_sync()函数唤醒在管道的等待队列中所有睡眠的写者进程。
    14.返回拷贝到用户地址空间的字节数。

向管道中写入数据

希望向管道中写入数据的进程发出一个write()系统调用,为管道的写端指定一个文件描述符。内核通过调用适当文件对象的write方法来满足这个请求;write_pipe_fops 表中相应的项指向pipe_write()函数。

表19-4概述了由POSIX标准所定义的write()系统调用的行为,该系统调用请求把n 个字节写入一个管道中,而该管道在它的缓冲区中有u个未用的字节。

具体地说,该标准要求涉及少量字节数的写操作必须原子地执行。更确切地说,如果两个或者多个进程并发地在写入一个管道,那么任何少于4096个字节(管道缓冲区的大小)的写操作都必须单独完成,而不能与唯一进程对同一个管道的写操作交叉进行。但是,超过4096个字节的写操作是可分割的,也可以强制调用进程睡眠。
在这里插入图片描述
还有,如果管道没有读进程(也就是说,如果管道的索引节点对象的readers字段的值是0),那么任何对管道执行的写操作都会失败。在这种情况下,内核会向写进程发送一个SIGPIPE信号,并停止write()系统调用,使其返回一个-EPIPE错误码,这个错误码就表示我们熟悉的“Broken pipe(损坏的管道)”消息。

pipe_write()函数执行以下操作:

  1. 获取索引节点的i_sem信号量。
  2. 检查管道是否至少有一个读进程。如果不是,就向当前进程发送一个SIGPIPE信号,释放索引节点信号量并返回-EPIPE值。
  3. 将pipe_inode_info数据结构curbuf和nrbufs字段相加并减一得到最后写入的管道缓冲区索引。如果该管道缓冲区有足够空间存放待写字节,就拷入这些数据:
    a.执行管道缓冲区的map方法。
    b.把所有字节拷贝到管道缓冲区。
    c.执行管道缓冲区的unmap方法。
    d.更新相应pipe_buffer对象的len字段。
    e.跳至第11步。
  4. 如果pipe_inode_info数据结构的nrbufs字段等于16,就表明没有空闲管道缓冲区来存放待写字节。这种情况下:
    a. 如果写操作是非阻塞的,跳至第11步,结束并返回错误码-EAGAIN
    b. 如果写操作是阻塞的,将pipe_inode_info结构的waiting_writers字段加1,调用prepare_to_wait()将当前操作加入管道等待队列(pipe_inode_info结构的wait字段),释放索引节点信号量,调用schedule()。一旦唤醒,则调用finish_wait()从等待队列中移出当前操作,重新获得索引节点信号量,递减waiting_writers字段,
    然后跳回第4步。
  5. 现在至少有一个空缓冲区,将pipe_inode_info数据结构的curbuf和nrbufs字段相加得到第一个空管道缓冲区索引。
  6. 除非pipe_inode_info数据结构的tmp_page字段不是NULL,否则从伙伴系统中分配一个新页框。
  7. 从用户态地址空间拷贝多达4096个字节到页框(如果必要,在内核态线性地址空间作临时映射)。
  8. 更新与管道缓冲区关联的pipe_buffer对象的字段:将page字段设为页框描述符的地址,ops字段设为anon_pipe_buf_ops表的地址,offset字段设为0,len字段设为写入的字节数。
  9. 增加非空管道缓冲区计数器的值,该缓冲区计数器存放在pipe_inode_inf结构的nrbufs字段。
    10.如果所有请求的字节还没有写完,则跳至第4步。
    11.释放索引节点信号量。
    12.唤醒在管道等待队列上睡眠的所有读进程。
    13.返回写入管道缓冲区的字节数(如果无法写入,则返回错误码)。

FIFO

虽然管道是一种十分简单、灵活、有效的通信机制,但它们有一个主要的缺点,也就是无法打开已经存在的管道。这就使得任意的两个进程不可能共享同一个管道,除非管道由一个共同的祖先进程创建。这个缺点在很多应用程序中都存在。

例如,考虑一个数据库引擎服务器,该服务器连续地轮流询问发出查询请求的客户端进程,并把数据库查询的结果返回客户端进程。

服务器和给定客户端之间的每次交互都可以使用一个管道进行处理。但是,当用户显式查询数据库时,通常由shell命令根据需要创建客户端进程;因此,服务器进程和客户端进程就不能方便地共享管道。

为了突破这种限制,Unix系统引入了一种称为命名管道(named pipe)或者FIFO[FIFO 代表“先进先出(first in,first out)”;最先写入文件的字节总是被最先读出]的特殊文件类型。

FIFO在这几个方面都非常类似于管道:在文件系统中不拥有磁盘块,打开的FIFO 总是与一个内核缓冲区相关联,这一缓冲区中临时存放两个或多个进程之间交换的数据。然而,有了磁盘索引节点,使得任何进程都可以访问FIFO,因为FIFO文件名包含在系统的目录树中。

因此,在前面那个数据库的例子中,服务器和客户端之间的通信可以很容易地使用FIFO而不是管道。服务器在启动时创建一个FIFO,由客户端程序用来发出自己的请求。每个客户端程序在建立连接之前都另外创建一个FIFO,并在自己对服务器发出的最初请求中包含这个FIFO的名字,服务器程序就可以把查询结果写入这个FIFO。在Linux 2.6中,FIFO和管道几乎是相同的,并使用相同的pipe_inode_info结构。

事实上,FIFO的read和write操作就是由前面“从管道中读取数据”和“向管道中写入数据”这两节描述的pipe_read()和pipe_write()函数实现的。事实上,只有两点主要的差别:
1.FIFO索引节点出现在系统目录树上而不是pipefs特殊文件系统中。
2.FIFO是一种双向通信管道;
也就是说,可能以读/写模式打开一个FIFO。因此,为了完成我们的描述,我们仅说明如何创建和打开FIFO。

创建并打开FIFO

进程通过执行mknod()系统调用创建一个FIFO,传递的参数是新FIFO的路径名以及S_IFIFO(0x10000)与这个新文件的权限位掩码进行逻辑或的结果。POSIX引入了一个名为mkfifo()的系统调用专门用来创建FIFO。这个系统调用在Linux以及System VRelease 4中是作为调用mknod()的C库函数实现的。

FIFO一旦被创建,就可以使用普通的open()、read()、write()和close()系统调用访问FIFO,但是VFS对FIFO的处理方法比较特殊,因为FIFO的索引节点及文件操作都是专用的,并且不依赖于FIFO所在的文件系统。POSIX标准定义了open()系统调用对FIFO的操作;这种操作本质上与所请求的访问类型、I/O操作的种类(阻塞或非阻塞)以及其他正在访问FIFO的进程的存在状况有关。

进程可以为读操作、写操作或者读写操作打开一个FIFO。根据这三种情况,把与相应的文件对象相关的文件操作设置成特定的方法。

当进程打开一个FIFO时,VFS就执行一些与设备文件所执行的操作相同的操作。与打开的FIFO相关的索引节点对象是由依赖于文件系统的read_inode超级块方法进行初始化的。

这个方法总要检查磁盘上的索引节点是否表示一个特殊文件,并在必要时调用init_special_inode()函数。这个函数又把索引节点对象的i_fop字段设置为def_fifo_fops表的地址。

随后,内核把文件对象的文件操作表设置为def_fifo_fops,并执行它的open方法,这个方法由fifo_open()实现。fifo_open()函数初始化专用于FIFO的数据结构;具体来说,它执行下列操作:

  1. 获取i_sem索引节点信号量。
  2. 检查索引节点对象的i_pipe字段;如果为NULL,则分配并初始化一个新的pipe_inode_info结构,这与本章前面“创建和撤销管道”一节的第1b~le步相同。
  3. 根据open()系统调用的参数中指定的访问模式,用合适的文件操作表的地址初始化文件对象的f_op字段(如表I9-5所示)。
    在这里插入图片描述
  4. 如果访问模式或者为只读或者为读/写,则把1加到pipe_inode_info结构的readers字段和r_counter字段。此外,如果访问模式是只读的,且没有其他的读进程,则唤醒等待队列上的任何写进程。
  5. 如果访问模式或者为只写或者为读/写,则把1加到pipe_inode_info结构的writers字段和w_counter字段。此外,如果访问模式是只写的,且没有其他的写进程,则唤醒等待队列上的任何读进程。
  6. 如果没有读进程或没有写进程,则确定函数是应当阻塞还是返回一个错误码而终止(如表19-6所示)。
    在这里插入图片描述
  7. 释放索引节点信号量,并终止,返回0(成功)。
  8. FIFO的三个专用文件操作表的主要区别是read和write方法的实现不同。如果访问类型允许读操作,那么read方法是使用pipe_read()函数实现的;否则,read方法就是使用bad pipe_r()函数实现的,该函数只是返回一个错误码。如果访问类型允许写操作,那么write方法就是使用pipe_write()函数实现的;否则,write方法就是使用bad pipe_w()函数实现的,该函数也只是返回一个错误代码。

System V IPC

IPC是进程间通信(Interprocess Communication)的缩写,通常指允许用户态进程执行下列操作的一组机制:
1.通过信号量与其他进程进行同步
2.向其他进程发送消息或者从其他进程接收消息和其他进程共享一段内存,System VIPC最初是在一个名为“Columbus Unix”的开发版Unix变体中引入的,之后在AT&T的System II中采用。现在在大部分Unix系统(包括Linux)中都可以找到。

IPC数据结构是在进程请求IPC资源(信号量、消息队列或者共享内存区)时动态创建的。每个IPC资源都是持久的:除非被进程显式地释放,否则永远驻留在内存中(直到系统关闭)。IPC资源可以由任一进程使用,包括那些不共享祖先进程所创建的资源的进程。

由于一个进程可能需要同类型的多个IPC资源,因此每个新资源都是使用一个32位的IPC关键字来标识的,这和系统的目录树中的文件路径名类似。每个IPC资源都有一个32位的IPC标识符,这与和打开文件相关的文件描述符有些类似。IPC标识符由内核分配给IPC资源,在系统内部是唯一的,而IPC关键字可以由程序员自由地选择。

当两个或者更多的进程要通过一个IPC资源进行通信时,这些进程都要引用该资源的IPC标识符。

使用IPC资源

根据新资源是信号量、消息队列还是共享内存区,分别调用semget()、msgget()或者shmget()函数创建IPC资源。

这三个函数的主要目的都是从IPC关键字(作为第一个参数传递)中导出相应的IPC标识符,进程以后就可以使用这个标识符对资源进行访问。

如果还没有IPC资源和IPC关键字相关联,就创建一个新的资源。如果一切都顺利,那么函数就返回一个正的IPC标识符;否则,就返回一个如表19-7所示的错误码。
在这里插入图片描述
假设两个独立的进程想共享一个公共的IPC资源。这可以使用两种方法来达到:
1.这两个进程统一使用固定的、预定义的IPC关键字。这是最简单的情况,对于由很多进程实现的任一复杂的应用程序也工作得很好。然而,另外一个无关的程序也可能使用了相同的IPC关键字。在这种情况下,IPC函数可能被成功地调用,但返回错误资源的IPC标识符(注5)。

2.一个进程通过指定IPC_PRIVATE作为自己的IPC关键字来调用semget()、msgget()或shmget()函数。一个新的IPC资源因此而被分配,这个进程或者可以与应用程序中的另一个进程共享自己的IPC标识符(注6),或者自己创建另一个进程。这种方法确保IPC资源不会偶然被其他应用程序使用。

semget()、msgget()和shnget()函数的最后一个参数可以包括三个标志。
IPC_CREAT 说明如果IPC资源不存在,就必须创建它;IPC_EXCL说明如果资源已经存在而且设置了IPC_CREAT标志,那么函数就必定失败;IPC_NOWAIT说明访问IPC资源时进程从不阻塞(典型的情况如取得消息或获取信号量)。

即使进程使用了IPC_CREAT和IPC_EXCL标志,也没有办法保证对一个IPC资源进行排它访问,因为其他进程也可能用自己的IPC标识符引用了这个资源。

为了把不正确地引用错误资源的风险降到最小,内核不会在IPC标识符一空闲时就再利用它。相反,分配给资源的IPC标识符总是大于给同类型的前一个资源所分配的标识符(唯一的例外发生在32位的IPC标识符溢出时)。

每个IPC标识符都是通过结合使用与资源类型相关的位置使用序号(slot usage sequence number)、已分配资源的任意位置索引(slot index)以及内核中为可分配资源所选定的的最大值而计算出来的。

如果我们使用s来代表位置使用序号,M来代表可分配资源的最大数目,i来代表位置索引,此处0 ≤ i < M,则每个IPC资源的ID都可以按如下公式来计算:IPC标识符 = s × M + i。在Linux 2.6中,M的值设为32768(IPCMNI宏)。位置使用序号s被初始化成0,每次分配资源时增加1。当s达到预定的阈值时(这取决于IPC资源类型),它从0重新开始。

IPC资源的每种类型(信号量、消息队列和共享内存区)都拥有ipc_ids数据结构,该结构包括的字段如表19-8所示。
在这里插入图片描述
ipc_id_ary数据结构有两个字段:p和size。p字段是一个指向kern_ipc_perm数据结构的指针数组,每个结构对应一个可分配资源。size字段是这个数组的大小。

最初,数组为共享内存区、消息队列与信号量分别存放1、16或128个指针。当太小时,内核动态地增大数组。但是每种资源都有个上限。系统管理员可以修改/proc/sys/kernel/sem、/proc/sys/kernel/msgmni和/proc/sys/kernel/shmmni这三个文件以改变这些上限。每个kern_ipc_perm数据结构与一个IPC资源相关联,并且包含如表19-9所示的字段。uid、gid、cuid和cgid分别存放资源的创建者的用户标识符和组标识符以及当前资源属主的用户标识符和组标识符。mode位掩码包括六个标志,分别存放资源的属主、组以及其他用户的读、写访问权限。

IPC访问许可权和第一章的“访问权限和文件模式”一节中介绍的文件访问许可权类似,唯一不同的是这里没有执行许可权标志。
在这里插入图片描述
在这里插入图片描述
kern_ipc_perm数据结构也包括一个key字段和一个seq字段,前者指的是相应资源的IPC关键字,后者存放的是用来计算该资源的IPC标识符所使用的位置使用序号。

semctl()、msgctl()和shmctl()函数都可以用来处理IPC资源。IPC_SET子命令允许进程改变属主的用户标识符和组标识符以及ipc_perm数据结构中的许可权位掩码。IPC_STAT和IPC_INFO子命令取得和资源有关的信息。最后,IPC_RMID子命令释放IPC资源。

根据IPC资源的种类不同,还可以使用其他专用的子命令。一旦一个IPC资源被创建,进程就可以通过一些专用函数对这个资源进行操作。进程可以执行semop()函数获得或释放一个IPC信号量。当进程希望发送或接收一个IPC消息时,就分别使用msgsnd()和msgrcv()函数。最后,进程可以分别使用shmat()和shmdt()函数把一个共享内存区附加到自己的地址空间中或者取消这种附加关系。

ipc()系统调用

所有的IPC函数都必须通过适当的Linux系统调用实现。实际上,在80×86体系结构中,只有一个名为ipc()的IPC系统调用。当进程调用一个IPC函数时,比如说msgget(),该函数实际上调用C库中的一个封装函数,该函数又通过传递msgget()的所有参数加上一个适当的子命令代码(在本例中是MSGGET)来调用ipc()系统调用。sys_ipc()服务例程检查子命令代码,并调用内核函数实现所请求的服务。ipc()“多路复用”系统调用是从早期的Linux版本中继承而来的,早期Linux版本把IPC 代码包含在动态模块中(参见附录二)。在system_call表中为可能未实现的内核部件保留几个系统调用入口并没有什么意义,因此内核设计者就采用了这种多路复用的方法。现在,System V IPC不再作为动态模块被编译,因此也就没有理由使用单个IPC系统调用。事实上,Linux在HP的Alpha体系结构和Intel的IA-64上为每个IPC函数都提供了一个系统调用。

IPC信号量

IPC信号量和在第五章中介绍的内核信号量非常类似:二者都是计数器,用来为多个进程共享的数据结构提供受控访问。

如果受保护的资源是可用的,那么信号量的值就是正数;如果受保护的资源现不可用,那么信号量的值就是0。要访问资源的进程试图把信号量的值减1,但是,内核阻塞这个进程,直到在这个信号量上的操作产生一个正值。

当进程释放受保护的资源时,就把信号量的值增加1;在这样处理的过程中,其他所有正在等待这个信号量的进程就都被唤醒。

实际上,IPC信号量比内核信号量的处理更复杂是由于两个主要的原因:
1.每个IPC信号量都是一个或者多个信号量值的集合,而不像内核信号量一样只有一个值。这意味着同一个IPC资源可以保护多个独立、共享的数据结构。在资源正在被分配的过程中,必须把每个IPC信号量中的信号量的个数指定为semget()函数的一个参数。

从现在开始,我们就把信号量内部的计数器作为原始信号量(primitive semaphore)来引用。IPC信号量资源的个数和单个IPC资源内原始信号量的个数都有界限,其缺省值前者为128,后者为250;

不过,系统管理员可以通过/proc/sys/kernel/sem文件很容易地修改这两个界限。

2.System V IPC信号量提供了一种失效安全机制,这是用于进程不能取消以前对信号量执行的操作就死亡的情况的。当进程选择使用这种机制时,由此引起的操作就是所谓的可取消的(undoable)信号量操作。当进程死亡时,所有IPC信号量都可以恢复成原来的值,就好像从来都没有开始它的操作。这有助于防止出现这种情况:由于正在结束的进程不能手工取消它的信号量操作,其他使用相同信号量的进程无限地停留在阻塞状态。首先我们简要描绘一下,当进程想访问IPC信号量所保护的一个或者多个资源时所执行的典型步骤:

  1. 调用semget()封装函数来获得IPC信号量标识符,作为参数指定对共享资源进行保护的IPC信号量的IPC关键字。如果进程希望创建一个新的IPC信号量,则还要指定IPC_CREATE或者IPC_PRIVATE标志以及所需要的原始信号量。
  2. 调用semop()封装函数来测试并递减所有原始信号量所涉及的值。如果所有的测试全部成功,就执行递减操作,结束函数并允许这个进程访问受保护的资源。

如果有些信号量正在使用,那么进程通常都会被挂起,直到某个其他进程释放这个资源为止。函数接收的参数为IPC信号量标识符、用来指定对原始信号量所进行的原子操作的一组整数以及这种操作的个数。作为选项,进程也可以指定SEM_UNDO标志,这个标志通知内核:如果进程没有释放原始信号量就退出,那么撤消那些操作。
4. 当放弃受保护的资源时,就再次调用semop()函数来原子地增加所有有关的原始信号量。
5. 作为选择,调用semctl()封装函数,在参数中指定IPC_RMID命令把这个IPC信号量从系统中删除。

现在我们就可以讨论内核是如何实现IPC信号量的。有关的数据结构如图19-1所示。sem_ids变量存放IPC信号量资源类型的ipc_ids数据结构;对应的ipc_id_ary数据结构包含一个指针数组,它指向sem_array数据结构,每个元素对应一个IPC信号量资源。
在这里插入图片描述
从形式上说,这个数组存放指向kern_ipc_perm数据结构的指针,但是每个结构只不过是sem_array数据结构的第一个字段。sem_array数据结构的所有字段如表19-10所示。
在这里插入图片描述
sem_base字段指向sem数据结构的数组,每个元素对应一个IPC原始信号量。sem数据结构只包括两个字段:

semval
	信号量的计数器的值。
sempid
	最后一个访问信号量的进程的PID。进程可以使用semctl()封装函数查询该值

可取消的信号量操作

如果一个进程突然放弃执行,那么它就不能取消已经开始执行的操作(例如,释放自己保留的信号量);因此通过把这些操作定义成可取消的,进程就可以让内核把信号量返回到一致状态并允许其他进程继续执行。进程可以在semop()函数中指定SEM_UNDO标志来请求可取消的操作。

为了有助于内核撤消给定进程对给定的IPC信号量资源所执行的可撤销操作,有关的信息存放在sem_undo数据结构中。这个结构实际上包含信号量的IPC标识符及一个整数数组,这个数组表示由进程执行的所有可取消操作对原始信号量值引起的修改。

有一个简单的例子可以说明如何使用这种sem_undo元素。考虑一个进程使用具有4个原始信号量的一个IPC信号量资源,并假设该进程调用semop()函数把第一个计数器的值增加1并把第二个计数器的值减2。如果该函数指定了SEM_UNDO标志,sem_undo数据结构中的第一个数组元素中的整数值就被减少1,而第二个元素就被增加2,其他两个整数都保持不变。

同一进程对这个IPC信号量执行的更多的可取消操作将相应地改变存放在sem_undo结构中的整数值。当进程退出时,该数组中的任何非零值就表示对相应原始信号量的一个或者多个错乱操作;

内核只简单地给相应的原始信号量计数器增加这个非零值来取消这些操作。换而言之,把异常中断的进程所做的修改退回,而其他进程所做的修改仍然能反映信号量的状态。

对于每个进程来说,内核都要记录以可取消操作处理的所有信号量资源,这样如果进程意外退出,就可以回滚这些操作。还有,内核还必须对每个信号量都记录它所有的sem_undo结构,
这样只要进程使用semctl()来强行给一个原始信号量的计数器赋一个明确的值或者撤消一个IPC信号量资源时,内核就可以快速访问这些结构。

正是由于两个链表(我们称之为每个进程的链表和每个信号量的链表),使得内核可以有效地处理这些任务。第一个链表记录给定进程以可取消操作处理的所有信号量。第二个链表记录对以可取消操对给定信号量进行操作的所有进程。更确切地说:
1.每个进程链表包含所有的sem_undo数据结构,该结构对应于进程执行了可取消操作的IPC信号量。进程描述符的sysvsem.undo_list字段指向一个sem_undo_list 类型的数据结构,而该结构又包含了指针指向该链表的第一个元素。

每个sem_undo 数据结构的proc_next字段指向该链表的下一个元素,因为都共享一个sem_undo_list描述符,将CLONE_SYSVSEM标志传给clone()系统调用而克隆的进程都共享同一个可取消信号量操作链表。

2.每个信号量链表包含的所有sem_undo数据结构对应于在该信号量上执行可取消操作的进程。sem_array数据结构的undo字段指向链表的第一个元素,而每个sem_undo数据结构的id_next字段指向链表的下一个元素。

当进程结束时,每个进程的链表才被使用。exit_sem()函数由do_exit()调用,后者会遍历这个链表,并为进程所涉及的每个IPC信号量平息错乱操作产生的影响。

与此相对照,当进程调用semctl()函数强行给一个原始信号量赋一个明确的值时,每个信号量的链表才被使用。内核把指向IPC信号量资源的所有sem_undo数据结构中的数组的相应元素都设置成0,因为撤消原始信号量的一个可取消操作不再有任何意义。此外,在IPC信号量被清除时,每个信号量链表也被使用。通过把semid字段设置成-1而使所有有关的sem_undo数据结构都变为无效(注8)。

挂起请求的队列

内核给每个IPC信号量都分配了一个挂起请求队列,用来标识正在等待数组中的一个(或多个)信号量的进程。
这个队列是一个sem_queue数据结构的双向链表,其字段如表19-11所示。队列中的第一个和最后一个挂起请求分别由sem_array结构中的sem pending和sem_pending_last字段所指向。
这最后一个字段允许把链表作为一个FIFO进行简单的处理。新的挂起请求都被追加到链表的末尾,这样就可以稍后得到服务。
挂起请求最重要的字段是nsops和sops,前者存放挂起操作所涉及的原始信号量的个数,后者指向描述每个信号量操作的整型数组。sleeper字段存放发出请求操作的睡眠进程的描述符地址。
在这里插入图片描述

图19-1显示有三个挂起请求的一个IPC信号量。第二个和第三个请求涉及可取消操作,因此sem_queue数据结构的undo字段指向相应的sem_undo结构;第一个挂起请求的undo字段为NULL,因为相应的操作是不可取消的。

IPC消息

进程彼此之间可以通过IPC消息进行通信。进程产生的每条消息都被发送到一个IPC消息队列中,这个消息一直存放在队列中直到另一个进程将其读走为止。
消息是由固定大小的首部和可变长度的正文组成的,可以使用一个整数值(消息类型)标识消息,这就允许进程有选择地从消息队列中获取消息(注9)。只要进程从IPC消息队列中读出一条消息,内核就把这个消息删除;因此,只能有一个进程接收一条给定的消息。

为了发送一条消息,进程要调用msgsnd()函数,传递给它以下参数:
1.目标消息队列的IPC标识符
2.消息正文的大小
3.用户态缓冲区的地址,缓冲区中包含消息类型,之后紧跟消息正文

进程要获得一条消息就要调用msgrcv()函数,传递给它如下参数:
a.IPC消息队列资源的IPC标识符
b.指向用户态缓冲区的指针,消息类型和消息正文应该到被拷贝这个缓冲区
c.缓冲区的大小
4.一个值t,指定应该获得什么消息

如果t的值为0,就返回队列中的第一条消息。如果t为正数,就返回队列中类型等于t 的第一条消息。最后,如果1为负数,就返回消息类型小于等于t绝对值的最小的第一条消息。为了避免资源耗尽,IPC消息队列资源在这几个方面是有限制的:

IPC消息队列数(缺省为16),每个消息的大小(缺省为8192字节)及队列中全部信息的大小(缺省为16384 字节)。不过和前面类似,系统管理员可以分别修改/proc/sys/kernel/msgmni、/proc/sys/kernel/msgmnb和/proc/sys/kernel/msgmax文件调整这些值。与IPC消息队列有关的数据结构如图19-2所示。msg_ids变量存放IPC消息队列资源类型的ipc_ids数据结构;相应的ipc_id_ary数据结构包含一个指向shmid_kernel数据结构的指针数组–每个IPC消息资源对应一个元素。

从形式上看,数组中存放指向kern_ipc_perm数据结构的指针,但是,每个这样的结构只不过是msg_queue数据结构的第一个字段。msg_queue数据结构的所有字段如表19-12所示。
在这里插入图片描述

在这里插入图片描述
最重要的字段是qmessages,它表示包含队列中当前所有消息的双向循环链表的首部(也就是第一个哑元素)。每条消息分开存放在一个或多个动态分配的页中。
第一页的起始部分存放消息头,消息头是一个msg_msg类型的数据结构;它的字段如表19-13所示。m_list字段指向队列中前一条和后一条消息的指针。消息的正文正好从msg_msg描述符之后开始;如果消息(页的大小减去msg_msg描述符的大小)大于4072字节,就继续放在另一页,它的地址存放在msg_msg描述符的next字段中。

第二个页框以msg_msgseg类型的描述符开始,这个描述符只包含一个next指针,该指针存放可选的第三个页,以此类推。
在这里插入图片描述当消息队列满时(或者达到了最大消息数,或者达到了队列最大字节数),则试图让新消息入队的进程可能被阻塞。
msg_queue数据结构的q_senders字段是所有阻塞的发送进程的描述符形成的链表的头。当消息队列为空时(或者当进程指定的一条消息类型不在队列中时),则接收进程也会被阻塞。

msg_queue数据结构的qreceivers字段是msg_receiver数据结构链表的头,每个阻塞的接收进程对应其中一个元素。其中的每个结构本质上都包含。一个指向进程描述的指针、一个指向消息的msg_msg结构的指针和所请求的消息类型。

IPC共享内存

最有用的IPC机制是共享内存,这种机制允许两个或多个进程通过把公共数据结构放入一个共享内存区(IPC shared memory region)来访问它们。

如果进程要访问这种存放在共享内存区的数据结构,就必须在自己的地址空间中增加一个新内存区,它将映射与这个共享内存区相关的页框。

这样的页框可以很容易地由内核通过请求调页进行处理。与信号量以及消息队列一样,调用shmget()函数来获得一个共享内存区的IPC标识符,如果这个共享内存区不存在,就创建它。

调用shmat()函数把一个共享内存区“附加(attach)”到一个进程上。该函数使用IPC 共享内存资源的标识符作为参数,并试图把一个共享内存区加入到调用进程的地址空间中。

调用进程可以获得这个内存区域的起始线性地址,但是这个地址通常并不重要,访问这个共享内存区域的每个进程都可以使用自己地址空间中的不同地址。

shmat()函数不修改进程的页表。我们稍后会介绍在进程试图访问属于新内存区域的页时内核究竟怎样进行处理。调用shmdt()函数来“分离”由IPC标识符所指定的共享内存区域,也就是说把相应的共享内存区域从进程的地址空间中删除。

回想一下,IPC共享内存资源是持久的;即使现在没有进程在使用它,相应的页也不能被丢弃,但是可以被换出。与IPC资源的其他类型一样,为了避免用户态进程过分使用内存,也有一些限制施加于所允许的,IPC共享内存区域数(缺省为4096)、每个共享段的大小(缺省为32MB)以及所有共享段的最大字节数(缺省为8GB)。

不过,系统管理员照样可以调整这些值,这是通过分别修改/proc/sys/kernel/shmmni、/proc/sys/kernel/shmmax和/proc/sys/kernel/shmall文件完成的。
在这里插入图片描述

图19-3显示与IPC共享内存区相关的数据结构。shm_ids变量存放IPC共享内存资源类型ipc_ids的数据结构;相应的ipc_id_ary数据结构包含一个指向shmid_kernel数据结构的指针数组,每个IPC共享内存资源对应一个数组元素。

从形式上看,这个数组存放指向kern_ipc_perm数据结构指针,但是每个这样的结构只不过是shmid_kernel数据结构的第一个字段。shmid_kernel数据结构的所有字段如表19-14所示。
在这里插入图片描述

最重要的字段是shm_file,该字段存放文件对象的地址。这反映Linux 2.6中IPC共享内存与VFS的紧密结合。具体来说,每个IPC共享内存区与属于shm特殊文件系统的一个普通文件相关联。

因为shm文件系统在系统目录树中没有安装点,因此,用户不能通过普通的VFS系统调用打开并访问它的文件。

但是,只要进程“附加”一个内存段,内核就调用do_mmap(),并在进程的地址空间创建文件的一个新的共享内存映射。因此,属于shm特殊文件系统的文件只有一个文件对象方法mmap,该方法是由shm_mmap()函数实现的。
如图19-3所示,与IPC共享内存区对应的内存区是用vm_area_struct对象描述的;
它的vm_file字段指回特殊文件的文件对象,而特殊文件又依次引用目录项对象和索引节点对象。

存放在索引节点i_ino字段的索引节点号实际上是IPC共享内存区的位置索引,因此,索引节点对象间接引用shmid_kernel描述符。

同样,对于任何共享内存映射,通过address_space对象把页框包含在页高速缓存中,而address_space对象包含在索引节点中而且被索引节点的i_mapping字段引用(你也可以参看图16-2)。

万一页框属于IPC共享内存区,address_space对象的方法就存放在全局变量shmem_aops中。

换出IPC共享内存区的页

内核在把包含在共享内存区的页换出时一定要谨慎,并且交换高速缓存的作用是至关紧要的。
因为IPC共享内存区映射的是在磁盘上没有映像的特殊索引节点,因此其页是可交换的。因此为了回收IPC共享内存区的页,内核必须把它写入交换区。因为IPC共享内存区是持久的——也就是说即使内存段不附加到进程,也必须保留这些页。

因此即使这些页没有进程在使用,内核也不能简单地删除它们。让我们看看PFRA是如何回收IPC共享内存区页框的。一直到shrink_list()函数处理页之前,都与第十七章“内存紧缺回收”一节所描述的一样。因为这个函数并不为IPC 共享内存区域作任何检查,因此它会调用try_to_unmap()函数从用户态地址空间删除对页框的每个引用。正如第十七章“反向映射”一节描述的一样,相应的页表项就被删除。

然后,shrink_list()函数检查页的PG_dirty标志,调用pageout()(当IPC共享内存区域的页框在分配时总是被标记为脏,因此pageout()总是被调用)。而pageout()函数又调用所映射文件的address_space对象的writepage方法。

shmem_writepage()函数实现了IPC共享内存区页的writepage方法。它实际上给交换区域分配一个新页槽(page slot),然后将它从页高速缓存移到交换高速缓存(实际上就是改变页所有者的address_space对象)。

该函数还在shmem_inode_info结构中存放换出页页标识符,这个结构包含了IPC共享内存区的索引节点对象,它再次设置页的PG_dirty标志。

如第十七章的表17-5所示,shrink_list()函数检查PG_dirty标志,并通过把页留在非活动链表而中断回收过程。

迟早,PFRA还会处理该页框。shrink_list()又一次调用pageout()尝试将页刷新到磁盘。

但这一次,页已在交换高速缓存内,因而它的所有者是交换子系统的address_space对象,即swapper_space。相应的writepage方法swap_writepage()开始有效地向交换区进行写入操作。

一旦pageout()结束,shrink_list()确认该页已干净,于是从交换高速缓存删除页并释放给伙伴系统。

IPC共享内存区的请求调页

通过shmat()加入进程的页都是哑元页(dummy page);该函数把一个新内存区加入一个进程的地址空间中,但是它不修改该进程的页表。

此外,我们已经看到,IPC共享内存区的页可以被换出。因此,可以通过请求调页机制来处理这些页。

我们知道,当进程试图访问IPC共享内存区的一个单元,而其基本的页框还没有分配时则发生缺页异常。相应的异常处理程序确定引起缺页的地址是在进程的地址空间内,且相应的页表项为空;因此,它就调用do_no_page()函数。这个函数又检查是否为这个内存区定义了nopage方法。然后调用这个方法,并把页表项设置成所返回的地址。

IPC共享内存所使用的内存区通常都定义了nopage方法。这是通过shmem_nopage()函数实现的,该函数执行以下操作:

  1. 遍历VFS对象的指针链表,并导出IPC共享内存资源的索引节点对象的地址(参见图19-3)。

  2. 从内存区域描述符的vm_start字段和请求的地址计算共享段内的逻辑页号。

  3. 检查页是否已经在页高速缓存中,如果是,则结束并返回该描述符的地址。

  4. 检查页是否在交换高速缓存内且是否最新。如果是,则结束并返回该描述符的地址。

  5. 检查内嵌在索引节点对象的shmem_inode_info是否存放着逻辑页号对应的换出页标识符。如果是,就调用read_swap_cache_async()执行换入操作,并一直等到数据传送完成,然后结束并返回页描述符的地址。

  6. 否则,页不在交换区中;因此就从伙伴系统分配一个新页框,把它插入页高速缓存,并返回它的地址。do_no_page()函数对引起缺页的地址在进程的页表中所对应的表项进行设置,以使该函数指向nopage方法所返回的页框。

POSIX消息队列

POSIX标准(IEEE Std 1003.1-2001)基于消息队列定义了一个IPC机制,就是大家知道的POSIX消息队列。它很像本章前面“IPC消息”一节介绍的System V IPC消息队列。但是POSIX消息队列比老的队列具有许多优点:
1.更简单的基于文件的应用接口
2.完全支持消息优先级(优先级最终决定队列中消息的位置)
3.完全支持消息到达的异步通知,这通过信号或是线程创建实现
4.用于阻塞发送与接收操作的超时机制
POSIX消息队列通过一套库函数来实现,参见表19-15。
在这里插入图片描述

我们来看看应用如何典型地使用这些函数。首先,应用调用mq_open()库函数打开一个POSIX消息队列。函数的第一个参数是一个指定队列名字的字符串,这与文件名类似,而且必须以“/”开始。
该库函数接收一个open()系统调用的标志子集:0_RDONLY、0_WRONLY、O_RDWR、O_CREAT、O_EXCL和O_NONBLOCK(用于非阻塞发送与接收)。

注意应用可以通过指定一个0_CREAT标志来创建一个新的POSIX消息队列。mq_open()函数返回一个队列描述符,与open()系统调用返回的文件描述符非常类似。一旦POSIX消息队列打开,应用可以通过库函数mq_send()和mq_receive()来发送与接收消息,并传给它们mq_open()返回的队列描述符作为参数。

应用也可以通过mq_timedsend()和mq_timedreceive()指定应用程序等待发送与接收操作完成所需的最长时间。应用除了在mq_receive()上阻塞,或者如果O_NONBLOCK标志置位则继续在消息队列上轮询外,还可以通过执行mq_notify()库函数建立异步通知机制。
实际上当一个消息插入空队列时,应用可以要求:要么给指定进程发出信号,要么创建一个新线程。
最后,当应用使用完消息队列,它调用mq_close()库函数,传给它队列描述符。

注意这个函数并不删除队列,这与close()系统调用不会删除文件一样。要删除队列,应用需要调用mq_unlink()函数。

在Linux 2.6中,POSIX消息队列的实现是简单的。已经引入了一个叫做mqueue的特殊文件系统,每个现存队列在其中都有一个相应的索引节点。内核提供了几个系统调用:mq_open()、mq_unlink(),mq_timedsend()、mq_timedreceive()、mq_notify()和mq_getsetattr()。这些系统调用大略对应前面表19-15中的库函数。

这些系统调用透明地对mqueue文件系统的文件进行操作,而大部分工作交由VFS层处理。 如,注意到内核不提供mq_close()函数,而事实上返回给应用的队列描述符实际上是一个文件描述符,因此mq_close()的工作由close()系统调用来做就可以了。mqueue特殊文件系统不能安装在系统目录树中。但是如果安装了,用户可以通过使用文件系统根目录中的文件来创建POSIX消息队列,也可以读入相应文件来得到队列的有关信息。最后,应用可以使用select()和poll()获得队列状态变化的通知。

每个队列有一个mqueue_inode_info描述符,它包含有inode对象,该对象与mqueue 特殊文件系统的一个文件相对应。

当POSIX消息队列系统调用接收一个队列描述符作为参数时,它就调用VFS的fget()函数计算出对应文件对象的地址。然后,系统调用得到mqueue文件系统中文件的索引节点对象。最后,就可以得到该索引节点对象所对应的mqueue_inode_info描述符地址。

队列中挂起的消息被收集到mqueue_inode_info描述符中的一个单向链表。每个消息由一个msg_msg类型的描述符来表示,这与System VIPC中使用的消息描述符是完全一样的。

猜你喜欢

转载自blog.csdn.net/x13262608581/article/details/132465685