Unix 系统提供的进程间通信的基本机制:
- 管道和 FIFO(命名管道)。最适合在进程之间实现生产者/消费者的交互。
有些进程向管道中写入数据,另外一些进程则从管道中读出数据。
- 信号量。
- 消息。允许进程在预定义的消息队列中读和写消息来交换消息。
Linux 内核提供两种不同的消息版本:System V IPC 消息和 POSIX 消息。
- 共享内存区。允许进程通过共享内存块来交换消息。在必须共享大量数据的应用中,可能是最高效的进程通信形式。
- 套接字。允许不同计算机上的进程通过网络交换数据。还可用作相同主机上的进程之间的通信工具。
管道
管道是所有 Unix 都愿意提供的一种进程间通信机制。
管道是进程之间的一个单向数据流:一个进程写入管道的所有数据都由内核定向到另一个进程,另一个进程由此可以从管道中读取数据。
在 Unix 的命令 shell 中,可以使用”|“操作符创建管道。
使用管道
管道被看作是打开的文件,但在已安装的文件系统中没有相应的映像。
可以使用 pipe() 创建一个新管道,该系统调用返回一对文件描述符;
然后进程通过 fork() 把这两个描述符传递给它的子进程,由此与子进程共享管道。
进程可以在 read() 中使用第一个文件描述符从管道中读取数据,同样也可以在 write() 中使用第二个文件描述符向管道中写入数据。
POSIX 只定义了半双工的管道,因此即使 pipe() 返回了两个描述符,每个进程在使用一个文件描述符之前仍得把另一个文件描述符关闭。
如果所需要的是双向数据流,那么进程必须通过两次调用 pipe() 来使用两个不同的管道。
有些 Unix 系统,如 System V Release 4,实现了全双工的管道。
Linux 采用另外一种解决方法:每个管道的文件描述符仍然都是单向的,但是在使用一个描述符前不必把另一个描述符关闭。
当 shell 命令对 ls | more 语句进行解释时,实际上执行以下操作:
- 调用 pipe();假设 pipe() 返回文件描述符 3(管道的读通道) 和 4(管道的写通道)。
- 两次调用 fork()。
- 两次调用 close() 释放文件描述符 3 和 4。
第一个子进程必须执行 ls 程序,它执行以下操作:
- 调用 dup2(4, 1) 把文件描述符 4 拷贝到文件描述符 1。
从现在开始,文件描述符 1 就代表该管道的写通道。
- 两次调用 close() 释放文件描述符 3 和 4。
- 调用 execve() 执行 ls 程序。
缺省情况下,该程序要把自己的输出写到文件描述符为 1 的那个文件(标准输出)中,也就是说,写入管道中。
第二个子程序必须执行 more 程序;因此,该进程执行以下操作:
- 调用 dup2(3, 0) 把文件描述符 3 拷贝到文件描述符 0。
从现在开始,文件描述符 0 就代表管道的读通道。
- 两次调用 close() 释放文件描述符 3 和 4。
- 调用 execve() 执行 more 程序。
缺省情况下,该程序要从文件描述符为 0 的那个文件(标准输入)中读取输入,即,从管道中读取输入。
如果多个进程对同一管道进行读写,必须使用文件加锁机制或 IPC 信号量机制对自己的访问进行显式同步。
popen() 可创建一个管道,然后使用包含在 C 函数库中的高级 I/O 函数对该管道进行操作。
Linux 中,popen() 和 pclose() 都包含在 C 库函数中。
popen() 参数为:可执行文件的路径名 filename 和定义数据传输方向的字符串 type。
返回一个指向 FILE 数据结构的指针。
popen() 执行以下操作:
- 使用 pipe() 创建一个新管道。
- 创建一个新进程,该进程执行以下操作:
a. 如果 type 是 r,就把与管道的写通道相关的文件描述符拷贝到我呢见描述符 1(标准输出);
否则,如果 type 是 w,就把管道的读通道相关的文件描述符拷贝到文件描述符 0(标准输入)。
b. 关闭 pipe() 返回的文件描述符。
c. 调用 execve() 执行 filename 所指定的程序。
- 如果 type 是 r,就关闭与管道的写通道相关的文件描述符;
否则,如果 type 是 w,就关闭与管道的读通道相关的文件描述符。
- 返回 FILE 文件指针所指向的地址,该指针指向仍然打开的管道所涉及的任一文件描述符。
在 popen() 被调用后,父进程和子进程就可以通过管道交换信息:父进程可以使用该函数返回的 FILE 指针来读(如果 type 是 r)写(如果 type 是 w)数据。
子进程所指向的程序分别把数据写入标准输出或从标准输入中读取数据。
pclose() 参数为 popen() 所返回的文件指针,它会简单地调用 wait4() 并等待 popen() 所创建的进程结束。
管道数据结构
只要管道一被创建,进程就可以使用 read() 和 write() 这两个 VFS 系统调用来访问管道。
因此,对于每个管道来说,内核都要创建一个索引节点对象和两个文件对象,一个文件对象用于读,另一个对象用于写。
当进程希望从管道中读取数据或向管道中写入数据时,必须使用适当的文件描述符。
当索引节点指的是管道时,其 i_pipe 字段指向一个 pipe_inode_info 结构。
除了一个索引节点对象和两个文件对象外,每个管道都还有自己的管道缓冲区。
实际上,它是一个单独页,其中包含了已经写入管道等待读出的数据。
Linux 2.6.11 中,每个管道可以使用 16 个管道缓冲区。
该改变大大增强了向管道写大量数据的用户态应用的性能。
pipe_inode_info 的 bufs 字段存放一个具有 16 个 pipe_buffer 对象的数组,每个对象代表一个管道缓冲区。
ops 字段指向管道缓冲区方法表 anon_pipe_buf_ops,其类型为 pipe_buf_operations,有三个方法:
- map,在访问缓冲区数据之前调用。
它只在管道缓冲区在高端内存时对管道缓冲区页框调用 kmap()。
- unmap,不再访问缓冲区数据时调用。它对管道缓冲区页框调用 kunmap()。
- release,当释放管道缓冲区时调用。
该方法实现了一个单页内存高速缓存:释放的不是存放缓冲区的那个页框,而是由 pipe_inode_info 的 tmp_page 字段指向的高速缓存页框。
存放缓冲区的页框变成新的高速缓存页框。
16 个缓冲区可以被看作一个整体环形缓冲区:写进程不断向这个大缓冲区追加数据,而读进程则不断移出数据。
所有管道缓冲区中当前写入而等待读出的字节数就是管道大小。
为提高效率,仍然要读的数据可以分散在几个未填充满的管道缓冲区内:事实上,在上一个管道缓冲区没有足够空间存放新数据时,每个写操作都可能把数据拷贝到一个新的空管道缓冲区。
因此,内核必须记录:
- 下一个待读字节所在的管道缓冲区、页框中的对应偏移量。
该管道缓冲区的索引存放在 pipe_inode_info 的 curbuf 字段,而偏移量在相应 pipe_buffer 对象的 offset 字段。
- 第一个空管道缓冲区。
它可以通过增加当前管道缓冲区的索引得到(模为 16),并存放在 pipe_inode_info 的 curbuf 字段,而存放有效数据的管道缓冲区号存放在 nrbufs 字段。
pipefs 特殊文件系统
管道是作为一组 VFS 对象来实现的,因此没有对应的磁盘映像。
在 Linux 2.6 中,把这些 VFS 对象组织为 pipefs 特殊文件系统以加速它们的处理。
因为这种文件系统在系统目录树中没有安装点,因此用户看不到它。
但是,有了 pipefs,管道完全被整合到 VFS 层,内核就可以命名管道或 FIFO 的方式处理它们,FIFO 是以终端用户认可的文件而存在的。
init_pipe_fs() 注册并安装 pipefs 文件系统。
1
2
3
4
5
6
7
8 1struct file_system_type pipe_fs_type;
2pipe_fs_type.name = "pipefs";
3pipe_fs_type.get_sb = pipefs_get_sb;
4pipe_fs.kill_sb = kill_anon_super;
5register_filesystem(&pipe_fs_type);
6pipe_mnt = do_kern_mount("pipefs", 0, "pipefs", NULL);
7
8
表示 pipefs 根目录的已安装文件系统对象存放在 pipe_mnt 变量中。
为避免对管道的竞争条件,内核使用包含在索引节点对象中的 i_sem 信号量。
创建和撤销管道
pipe() 由 sys_pipe() 处理,后者又会调用 do_pipe()。
为了创建一个新的管道,do_pipe() 执行以下操作:
- 调用 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。
- 为管道的读通道分配一个文件对象和一个文件描述符,并把该文件对象的 f_flag 字段设置为 O_RDONLY,把 f_op 字段初始化为 read_pipe_fops 表的地址。
- 为管道的写通道分配一个文件对象和一个文件描述符,并把该文件对象的 f_flag 字段设置为 O_WRONLY,把 f_op 字段初始化为 write_pipe_fops 表的地址。
- 分配一个目录项对象,并使用它把两个文件对象和索引节点对象连接在一起;然后,把新的索引节点插入 pipefs 特殊文件系统中。
- 把两个文件描述符返回给用户态进程。
发出一个 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 方法,向伙伴系统释放所有管道缓冲区页框;
此外,函数还释放由 tmp_page 字段执行的高速缓存页框。
否则,readers 或者 writers 字段不为 0,函数唤醒在管道的等待队列上睡眠的任一进程,以使它们可以识别管道状态的变化。
从管道中读取数据
在管道的情况下,read 方法在 read_pipe_fops 表中的表项指向 pipe_read(),从一个管道大小为 p 的管道中读取 n 个字节。
可能以两种方式阻塞当前进程:
- 当系统调用开始时管道缓冲区为空。
- 管道缓冲区没有包含所请求的字节,写进程在等待缓冲区的空间时曾被设置为睡眠。
读操作可以是非阻塞的,只要所有可用的字节(即使为 0)一旦被拷贝到用户地址空间中,读操作就完成。
只有在管道为空且当前没有进程正在使用与管道的写通道相关的文件对象时,read() 才会返回 0。
pipe_read() 执行下列操作:
- 获取索引节点的 i_sem 信号量。
- 确定存放在 pipe_inode_info 的 nrbufs 字段中的管道大小是否为 0。如果是,说明所有管道缓冲区为空。
这时还要确定函数必须返回还是进程在等待时必须被阻塞,直到其它进程向管道中写入一些数据。
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 步。
- 从 pipe_inode_info 的 curbuf 字段得到当前管道缓冲区索引。
- 执行管道缓冲区的 map 方法。
- 从管道缓冲区拷贝请求的字节数(如果较小,就是管道缓冲区可用字节数)到用户地址空间。
- 执行管道缓冲区的 unmap 方法。
- 更新相应 pipe_buffer 对象的 offset 和 len 字段。
- 如果管道缓冲区已空(pipe_buffer 对象的 len 字段现在等于 0),则调用管道缓冲区的 release 方法释放对应的页框,把 pipe_buffer 对象的 ops 字段设置为 NULL,增加在 pipe_inode_info 的 curbuf 字段中存放的当前管道缓冲区索引,并减小 nrbufs 字段中非空管道缓冲区计数器的值。
- 如果所有请求字节拷贝完毕,则跳至第 12 步。
- 目前,还没有把所有请求字节拷贝到用户态地址空间。
如果管道大小大于 0(pipe_inode_info 的 nrbufs 字段不为 NULL),则跳到第 3 步。
- 管道缓冲区内没有剩余字节。
如果至少有一个写进程正在睡眠(即 pipe_inode_info 的 waiting_writers 字段大于 0),且读操作是阻塞的,
那么调用 wake_up_interruptible_sync() 唤醒在管道等待队列中所有睡眠的进程,然后跳到第 2 步。
- 释放索引节点的 i_sem 信号量。
- 调用 wake_up_interruptible_sync() 唤醒在管道的等待队列中所有睡眠的写进程。
- 返回拷贝到用户地址空间的字节数。
向管道中写入数据
write_pipe_fops 表中相应的项指向 pipe_write(),向管道中写入数据。
如果管道没有读进程(管道的索引节点对象的 readers 字段值是 0),那么任何对管道执行的写操作都会失败。
在这种情况下,内核会向写进程发送一个 SIGPIPE 信号,并停止 write(),使其返回一个 -EPIPE 码,含义为“Broken pipe(损坏的管道)”。
pipe_write() 执行以下操作:
- 获取索引节点的 i_sem 信号量。
- 检查管道是否至少有一个读进程。
如果不是,就向当前进程发送一个 SIGPIPE 信号,释放索引节点信号量并返回 -EPIPE 值。
- 将 pipe_inode_info 的 curbuf 和 nrbufs 字段相加并减一得到最后写入的管道缓冲区索引
如果该管道缓冲区有足够空间存放待写字节,就拷入这些数据:
a. 执行管道缓冲区的 map 方法。
b. 把所有字节拷贝到管道缓冲区。
c. 执行管道缓冲区的 unmap 方法。
d. 更新相应 pipe_buffer 对象的 len 字段。
e. 跳到第 11 步。
- 如果 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 步。
- 现在至少有一个空缓冲区,将 pipe_inode_info 的 curbuf 和 nrbufs 字段相加得到第一个空管道缓冲区索引。
- 除非 pipe_inode_info 的 tmp_page 字段不是 NULL,否则从伙伴系统中分配一个新页框。
- 从用户态地址空间拷贝多达 4096 个字节到页框(如果必要,在内核态线性地址空间作临时映射)。
- 更新与管道缓冲区关联的 pipe_buffer 对象的字段:将 page 字段设为页框描述符的地址,ops 字段设为 anon_pipe_buf_ops 表的地址,offset 字段设为 0,len 字段设为写入的字节数。
- 增加非空管道缓冲区计数器的值,该缓冲区计数器存放在 pipe_inode_inf 的 nr_bufs 字段。
- 如果所有请求的字节还没写完,则跳到第 4 步。
- 释放索引节点信号量。
- 唤醒在管道等待队列上睡眠的所有读进程。
- 返回写入管道缓冲区的字节数(如果无法写入,返回错误码)。
FIFO
管道的优点:简单、灵活、有效。
管道的缺点:无法打开已经存在的管道。使得任意的两个进程不能共享同一个管道,除非管道由一个共同的祖先进程创建。
Unix 引入了命名管道,或者 FIFO 的特殊文件类型。
FIFO 与管道的共同点:在文件系统中不拥有磁盘块,打开的 FIFO 总是与一个内核缓冲区关联,这一缓冲区中临时存放两个或多个进程之间交换的数据。
然而,有了磁盘索引节点,任何进程都可以访问 FIFO,因为 FIFO 文件名包含在系统的目录树中。
服务器在启动时创建一个 FIFO,由客户端用来发出自己的请求。
每个客户端程序在建立连接前都另外创建一个 FIFO,并在自己对服务器发出的最初请求中包含该 FIFO 的名字,服务器程序就可以把查询结果写入该 FIFO。
FIFO 与管道只有两点主要的差别:
- FIFO 索引节点出现在系统目录树上而不是 pipefs 特殊文件系统中。
- FIFO 是一种双向通信管道,即可能以读/写模式打开一个 FIFO。
创建并打开 FIFO
进程通过执行 mknod() 创建一个 FIFO “设备文件”,参数为新 FIFO 的路径名以及 S_IFIFO(0x10000)与该新文件的权限位掩码进行逻辑或的结果。
POSIX 引入了一个名为 mkfifo() 的系统调用专门创建 FIFO。
该系统调用在 Linux 及 System V Release 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 的数据结构,执行下列操作:
- 获取 i_sem 索引节点信号量。
- 检查索引节点对象 i_pipe 字段;如果为 NULL,则分配并初始化一个新的 pipe_inode_info 结构。
- 根据 open() 的参数中指定的访问模式,用合适的文件操作表的地址初始化文件对象的 f_op 字段。
- 如果访问模式为只读或者读/写,则把 1 加到 pipe_inode_info 的 readers 字段和 r_counter 字段。
此外,如果访问模式是只读的,且没有其它的读进程,则唤醒等待队列上的任何写进程。
- 如果访问模式为只写或者读/写,则把 1 加到 pipe_inode_info 的 writers 字段和 w_counter 字段。
此外,如果访问模式是只写的,且没有其它的写进程,则唤醒等待队列上的任何读进程。
- 如果没有读进程或者写进程,则确定函数是应当阻塞还是返回一个错误码而终止。
- 释放索引节点信号量,并终止,返回 0(成功)。
FIFO 的三个专用文件操作表的主要区别是 read 和 write 方法的实现不同。
如果访问类型允许读操作,那么 read 方法是使用 pipe_read() 实现的;
否则,read 方法就是使用 bad_pipe_r() 实现的。
write 方法同理。
System V IPC
IPC 通常指允许用户态进程执行下列操作的一组机制:
- 通过信号量与其它进程进行同步。
- 向其它进程发送消息或者从其它进程接收消息。
- 和其它进程共享一段内存区。
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 标识符;否则,就返回一个错误码。
假设两个独立的进程想共享一个公共的 IPC 资源。
这可以使用两种方法达到:
- 这两个进程统一使用固定的、预定义的 IPC 关键字。
这是最简单的情况,对于由很多进程实现的任一复杂的应用程序也有效。
然而,另外一个无关的程序也可能使用了相同的 IPC 关键字。
这种情况下,IPC 可能被成功调用,但返回错误资源的 IPC 标识符。
- 一个进程通过指定 IPC_PRIVATE 作为自己的 IPC 关键字调用 semget()、msgget() 或 shmget()。
一个新的 IPC 资源因此被分配,这个进程或者可以与应用程序中的另一个进程共享自己的 IPC 标识符,或者自己创建另一个进程。
这种方法确保 IPC 资源不会偶然地被其它应用程序使用。
semget()、msgget() 和 shmget() 的最后一个参数可包括三个标志。
IPC_CREAT 说明如果 IPC 资源不存在,就必须创建它;
IPC_EXCL 说明如果资源已经存在且设置了 IPC_CREAT 标志,则函数必定失败;
IPC_NOWAIT 说明访问 IPC 资源时进程从不阻塞。
即使进程使用了 IPC_CREAT 和 IPC_EXCL 标志,也没有办法保证对一个 IPC 资源进行排它访问,因为其它进程也可能用自己的 IPC 标识符引用该资源。
为了把不正确地引用错误资源的风险降到最小,内核不会在 IPC 标识符一空闲就再利用它。
相反,分配给资源的 IPC 标识符总是大于给同类型的前一个资源所分配的标识符(溢出例外)。
每个 IPC 标识符都是通过结合使用与资源类型相关的位置使用序号 s、已分配资源的任一位置索引 i 以及内核中可分配资源所选定的最大值 M 而计算出。
0 <= i < M,则每个 IPC 资源的 ID 可按如下公式计算:
IPC 标识符 = s * M + i
Linux 2.6 中,M = 32768(IPCMIN 宏)。s = 0,每次分配资源时增加 1,到达阈值时,重新从 0 开始。
IPC 资源的每种类型(信号量、消息队列和共享内存区)都拥有 ipc_ids 数据结构。
ipc_id_ary 有两个字段:p 和 size。
p 是指向一个 kern_ipc_perm 数据结构的指针数组,每个结构对应一个可分配资源。size 是这个数组的大小。
最初,数组为共享内存区、消息队列与信号量分别存放 1、16 或 128 个指针。
当太小时,内核动态地增大数组。但每种资源都有上限。
系统管理员可修改 /proc/sys/kernel/sem、/proc/kernel/msgmni 和 /proc/sys/kernel/shmmni 这三个文件以改变这些上限。
每个 kern_ipc_perm 与一个 IPC 资源相关联。
uid、gid、cuid 和 cgid 分别存放资源的创建者的用户标识符和组标识符以及当前资源数组的用户标识符和组标识符。
mode 位掩码包括六个标志,分别存放资源的属主、组以及其它用户的读、写访问权限。
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()
实际上,在 80×86 体系结构中,只有一个名为 ipc() 的 IPC 系统调用。
当进程调用一个 IPC 函数时,如 msgget(),实际上调用 C 库中的一个封装函数,该函数又通过传递 msgget() 的所有参数加上一个适当的子命令代码来调用 ipc() 系统调用。
sys_ipc() 服务例程检查子命令代码,并调用内核函数实现所请求的服务。
ipc() “多路复用”系统调用实际上是从早期的 Linux 版本中继承而来,早期 Linux 版本把 IPC 代码包含在动态模块中。
在 system_call 表中为可能未实现的内核部件保留几个系统调用入口并没有什么意义,因此内核设计者就采用了多路复用的方法。
现在,System V IPC 不再作为动态模板被编译,因此也就没有理由使用单个 IPC 系统调用。
IPC 信号量
IPC 信号量与内核信号量类似:两者都是计数器,用来为多个进程共享的数据结构提供受控访问。
如果受保护的资源是可用的,则信号量的值就是正数;
如果受包含的资源不可用,则信号量的值就是 0。
要访问资源的进程试图把信号量的值减 1,但是,内核阻塞该进程,直到该信号量上的操作产生一个正值。
当进程释放受保护的资源时,就把信号量的值增加 1;
在该处理过程中,其它所有正在等待该信号量的进程都被唤醒。
IPC 信号量比内核信号量的处理更复杂是由于两个主要的原因:
- 每个 IPC 信号量都是一个或者多个信号量值的集合,而不像内核信号量一样只有一个值。
这意味着同一个 IPC 资源可以保护多个独立、共享的数据结构。
- System V IPC 信号量提供了一种失效安全机制,这是用于进程不能取消以前对信号量执行的操作就死亡的情况的。
当进程死亡时,所有 IPC 信号量都可以恢复原值,就好像从来都没有开始它的操作。
当进程访问 IPC 信号量所包含的一个或者多个资源时所执行的典型步骤:
- 调用 semget() 获得 IPC 信号量标识符,通过参数指定对共享资源进行保护的 IPC 信号量的 IPC 关键字。
如果进程希望创建一个新的 IPC 信号量,则还要指定 IPC_CREATE 或者 IPC_PRIVATE 标志以及所需要的原始信号量。
- 调用 semop() 测试并递减所有原始信号量所涉及的值。
如果所有的测试全部成功,就执行递减操作,结束函数并允许该进程访问受保护的资源。
如果有些信号量正在使用,则进程通常都会被挂起,直到某个其它进程释放这个资源为止。
参数为 IPC 信号量标识符、用来指定对原始信号量所进行的原子操作的一组整数以及这种操作的个数。
作为选项,进程也可以指定 SEM_UNDO 标志,该标志通知内核:如果进程没有释放原始信号量就退出,那么撤销那些操作。
- 当放弃受保护的资源时,就再次调用 semop() 来原子地增加所有有关的原始信号量。
- 作为选择,调用 semctl(),在参数中指定 IPC_RMID 命令把该 IPC 信号量从系统中删除。
图 19-1 中的 sem_ids 变量存放 IPC 信号量资源类型 ipc_ids;对应的 ipc_id_ary 包含一个指针数组,它指向 sem_array ,每个元素对应一个 IPC 信号量资源。
从形式上,该数组存放指向 kern_ipc_perm 的指针,每个结构是 sem_array 的第一个字段。
sem_array 中的 sembase 字段是指向 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 信号量资源时,内核就可以快速访问这些结构。
正是由于两个链表(称之为每个进程的链表和每个信号量的链表),使得内核可以有效地处理这些任务。
第一个链表记录给定进程可以取消操作处理的所有信号量。
第二个链表记录可取消操作对给定信号量进行操作的所有进程。
更确切地说:
- 每个进程链表包含所有的 sem_undo 数据结构,该机构对应于进程执行了可取消操作的 IPC 信号量。
进程描述符的 sysvsem.undo_list 字段指向一个 sem_undo_list 类型的数据结构,而该结构又包含了指向该链表的第一个元素的指针。
每个 sem_undo 的 proc_next 字段指向链表的下一个元素。
- 每个信号量链表包含的所有 sem_undo 数据结构对应于在该信号量上执行可取消操作的进程。
sem_array 的 undo 字段执行链表的第一个元素,而每个 sem_undo 的 id_next 字段指向链表的下一个元素。
当进程结束时,每个进程的链表才被使用。
exit_sem() 由 do_exit() 调用,后者会遍历该链表,并为进程所涉及的每个 IPC 信号量平息错乱操作产生的影响。
与此对照,当进程调用 semctl() 强行给一个原始信号量赋一个明确的值时,每个信号量的链表才被使用。
内核把指向 IPC 信号量资源的所有 sem_undo 中的数组的相应元素都设置为 0,
因为撤销原始信号量的一个可取消操作不再有任何意义。
此外,在 IPC 信号量被清除时,每个信号量链表也被使用。
通过把 semid 字段设置成 -1 而使所有有关的 sem_undo 数据结构变为无效。
挂起请求的队列
内核给每个 IPC 信号量否分配了一个挂起请求队列,用来标识正在等待数组中的一个(或多个)信号量的进程。
该队列是一个 sem_queue 数据结构的双向链表。
队列中的第一个和最后一个挂起请求分别由 sem_array 中的 sem_pending 和 sem_pending_last 字段指向。
最后一个字段允许把链表作为一个 FIFO 进行简单的处理。
新的挂起请求都被追加到链表的末尾,这样就可以稍后得到服务。
挂起请求最重要的字段是 nsops 和 sops,前者存放挂起操作所涉及的原始信号量的个数,后者指向描述符每个信号量操作的整型数组。
sleeper 字段存放发出请求操作的睡眠进程的描述符地址。
IPC 消息
进程彼此之间可通过 IPC 消息进行通信。
进程产生的每条消息都被发送到一个 IPC 消息队列中,该消息存放在队列中直到另一个进程将其读走为止。
消息是由固定大小的首部和可变长度的正文组成,可以使用一个整数值(消息类型)标识消息,这就允许进程有选择地从消息队列中获取消息。
只要进程从 IPC 消息队列中读出一条消息,内核就把该消息删除;因此,只有一个进程接收一条给定的消息。
为了发送一条消息,进程要调用 msgsnd(),传递给它以下参数:
- 目标消息队列的 IPC 标识符。
- 消息正文的大小。
- 用户态缓冲区的地址,缓冲区中包含消息类型,之后紧跟消息正文。
进程要获得一条消息就要调用 msgcv(),传递给它如下参数:
- 消息队列资源的 IPC 标识符。
- 指向用户态缓冲区的指针,消息类型和消息正文应该被拷贝到这个缓冲区。
- 缓冲区的大小。
- 一个值 t,指定应该获得什么消息。
如果 t 的值为 0,就返回队列中的第一条消息。
如果 t 为正数,就返回队列中类型等于 t 的第一条消息。
如果 t 为负数,就返回消息类型小于等于 t 绝对值的最小的第一条消息。
为了避免资源耗尽,IPC 消息队列资源在这几个方面是有限制的:IPC 消息队列数(缺省为 16),
每个消息的大小(缺省为 8192 字节)及队列中全部消息的大小(缺省为 16384 字节)。
系统管理员可分别修改 /proc/sys/kernel/msgmni、/proc/sys/kernel/msgmnb 和 /proc/sys/kernel/msgmax 调整这些值。
msg_ids 变量存放 IPC 消息队列资源类型的 ipc_ids 数据结构;
相应的 ipc_id_ary 数据结构包含一个指向 shmid_kernel 数据结构的指针数组。每个 IPC 消息资源对应一个元素。
从形式上看,数组中存放指向 kern_ipc_perm 数据结构的指针,每个这样的结构是 msg_queue 数据结构的第一个字段。
msg_queue 数据结构的字段如图 19-12 所示。
msg_queue 中最重要的字段是 q_messages,它表示包含队列中当前所有消息的双向循环链表的首部。
每条消息分开存放在一个或多个动态分配的页中。
第一页的起始部分存放消息头,消息头是一个 msg_msg 类型的数据结构。
m_list 字段指向队列中前一条和后一条消息。
消息的正文正好从 msg_msg 描述符之后开始;
如果消息(页的大小减去 msg_msg 描述符的大小)大于 4072 字节,就继续放在另一页,它的地址存放在 msg_msg 描述符的 next 字段中。
第二个页框以 msg_msgseg 类型的描述符开始,该描述符只包含一个 next 指针,该指针存放可选的第三个页,以此类推。
当消息队列满时(或者达到了最大消息数,或者达到了队列最大字节数),则试图让新消息入队的进程可能被阻塞。
msg_queue 的 q_senders 字段是所有阻塞的发送进程的描述符形成的链表的头。
当消息队列为空时(或者当进程指定的一条消息类型不在队列中时),则接收进程也会被阻塞。
msg_queue 的 q_receivers 字段是 msg_receiver 链表的头,每个阻塞的接收进程对应其中一个元素。
每个结构本质上都包含一个指向进程描述符的指针、一个指向消息的 msg_msg 的指针和所请求的消息类型。
IPC 共享内存
共享内存允许两个或多个进程通过把公共数据结构放入一个共享内存区来访问它们。
如果进程要访问这种存放在共享内存区的数据结构,就必须在自己的地址空间中增加一个新内存区,它将映射与该共享内存区相关的页框。
这样的页框可以很容易地由内核通过请求调页处理。
与信号量与消息队列一样,调页 shmget() 来获得一个共享内存区的 IPC 标识符,如果该共享内存区不存在,就创建它。
调用 shmat() 把一个共享内存区“附加”到一个进程上。
该函数的参数为 IPC 共享内存资源的标识符,并试图把一个共享内存区加入到调用进程的地址空间中。
调用进程可获得该内存区域的起始线性地址,但该地址通常并不重要,访问该共享内存区域的每个进程都可以使用自己地址空间中的不同地址。
shmat() 不修改进程的页表。
调用 shmdt() 来“分离”由 IPC 标识符所指定的共享内存区域,也就是把相应的共享内存区域从进程地址空间中删除。
IPC 共享内存资源是持久的;即使现在没有进程使用它,相应的页也不能丢弃,但可以被换出。
图 19-3 显示与 IPC 共享内存区相关的数据结构。
shm_ids 变量存放 IPC 共享内存资源类型的 ipc_ids 的数据结构;相应的 ipc_id_ary 数据结构包含一个指向 shmid_kernel 数据结构的指针数组,每个 IPC 共享内存资源对应一个数组元素。
该数组存放指向 kern_ipc_perm 的指针,每个这样的结构是 msg_queue 的第一个字段。
shhmid_kernel 中最重要的字段是 shm_file,该字段存放文件对象的地址。
每个 IPC 共享内存区与属于 shm 特殊文件系统的一个普通文件关联。
因为 shm 文件夹系统在目录树中没有安装点,因此,用户不能通过普通的 VFS 系统调用打开并访问它的文件。
但是,只要进程“附加”一个内存段,内核就调用 do_mmap(),并在进程的地址空间创建文件的一个新的共享内存映射。
因此,属于 shm 特殊文件系统的文件只有一个文件对象方法 mmap,该方法由 shm_mmap() 实现。
与 IPC 共享内存区对应的内存区是用 vm_area_struct 描述的。
它的 vm_file 字段指向特殊文件的文件对象,而特殊文件又依次引用目录项对象和索引节点对象。
存放在索引节点 i_ino 字段的索引节点号实际上是 IPC 共享内存区的位置索引,因此,索引节点对象间接引用 shmid_kernel 描述符。
同样,对于任何共享内存映射,通过 address_space 对象把页框包含在页高速缓存中,而 address_space 对象包含在索引节点中且被索引节点的 i_mapping 字段引用。
万一页框属于 IPC 共享内存区,address_space 对象的方法就存放在全局变量 shem_aops 中。
换出 IPC 共享内存区的页
因为 IPC 共享内存区映射的是在磁盘上没有映像的特殊索引节点,因此其页是可交换的(而不是可同步的)。
因此,为了回收 IPC 共享内存区的页,内核必须把它写入交换区。
因为 IPC 共享内存区是持久的,也就是说即使内存段不附加到进程,也必须保留这些页。
因此,即使这些页没有被进程使用,内核也不能简单地删除它们。
PFRA 回收 IPC 共享内存区页框:一直到 shrink_list() 处理页之前,都与“内存紧缺回收”一样。
因为该函数并不为 IPC 共享内存区域作任何检查,因此它会调用 try_to_unmap() 从用户态地址空间删除队页框的每个引用,并删除相应的页表项。
然后,shrink_list() 检查页的 PG_dirty 标志。
pageout() 在 IPC 共享内存区域的页框分配时被标记为脏,并调用所映射文件的 address_space 对象的 writepage 方法。
shmem_writepage() 实现了 IPC 共享内存区页的 writepage 方法。
它实际上给交换区域分配一个新页槽,然后将它从页高速缓存移到交换高速缓存(改变页所有者的 address_space 对象)。
该函数还在 shmem_indoe_info 中存放换出页页标识符,该结构包含了 IPC 共享内存区的索引节点对象,它再次设置页的 PG_dirty 标志。
shrink_list() 检查 PG_dirty 标志,并通过把页留在非活动链表而中断回收过程。
当 PFRA 再处理该页框时,shrink_list() 又一次调用 pageout() 尝试将页刷新到磁盘。
但这一次,页已在交换高速缓存内,因而它的所有者是交换子系统的 address_space 对象,即 swapper_space。
相应的 writepage 方法 swap_writepage() 开始有效地向交换区进行写入操作。
一旦 pageout() 结束,shrink_list() 确认该页已干净,于是从交换高速缓存删除页并释放给伙伴系统。
IPC 共享内存区的请求调页
通过 shmat() 加入进程的页都是哑元页;该函数把一个新内存区加入一个进程的地址空间中,但是它不修改该进程的页表。
此外,IPC 共享内存区的页可以被换出。
因此,可以通过请求调页机制处理这些页。
当进程试图访问 IPC 共享内存区的一个单元,而其基本的页框还没有分配时则发生缺页异常。
相应的异常处理程序确定引起缺页的地址是在进程地址空间内,且相应的页表项为空;因此,调用 do_no_page() 。
该函数又调用 nopage 方法,并把页表设置成所返回的地址。
IPC 共享内存所使用的内存区通常都定义了 nopage 方法。
这是通过 shmem_nopage() 实现的,该函数执行以下操作:
- 遍历 VFS 对象的指针链表,并导出 IPC 共享内存资源的索引节点对象的地址。
- 从内存区域描述符的 vm_start 字段和请求的地址计算共享段内的逻辑页号。
- 检查页是否已经在交换高速缓存中,如果是,则结束并返回该描述符的地址。
- 检查页是否在交换高速缓存内且是否是最新,如果是,则结束并返回该描述符的地址。
- 检查内嵌在索引节点对象的 shmem_inode_info 是否存放着逻辑页号对应的换出页标识符。
如果是,就调用 read_swap_cache_async() 执行换入操作,并一直等到数据传送完成,然后结束并返回页描述符的地址。
- 否则,页不在交换区中;从伙伴系统分配一个新页框,把它插入页高速缓存,并返回它的地址。
do_no_page() 对引起缺页的地址在进程的页表中所对应的页表项进行设置,以使该函数指向 nopage 方法所返回的页框。
POSIX 消息队列
POSIX 消息队列比老的队列具有许多优点:
- 更简单的基于文件的应用接口。
- 完全支持消息优先级(优先级最终决定队列中消息的位置)。
- 完全支持消息到达的异步通知,这通过信号或线程创建实现。
- 用于阻塞发送与结束操作的超时机制。
首先,调用 mq_open() 打开一个 POSIX 消息队列。
第一个参数是一个指定队列名字的字符串,与文件名类似,且必须以“/”开始。
该函数接收一个 open() 的标志子集:O_RDONLY、O_WRONLY、O_RDWR、O_CREAT、O_EXCL 和 O_NONBLOCK。
应用可以通过指定一个 O_CREAT 标志创建一个新的 POSIX 消息队列。
mq_open() 返回一个队列描述符,与 open() 返回的文件描述符类似。
一旦 POSXI 消息队列打开,应用可以通过 mq_send() 和 mq_receive() 来发送与接收消息,参数为 mq_open() 返回的队列描述符。
应用也可以通过 mq_timedsend() 和 mq_timedreceive() 指定应用程序等待发送与接收操作完成所需的最长时间。
应用除了在 mq_receive() 上阻塞,或者如果 O_NONBLOCK 标志置位则继续在消息队列上轮询外,还可以通过执行 mq_notify() 建立异步通知机制。
实际上,当一个消息插入空队列时,应用可以要求:要么给指定进程发出信号,要么创建一个新线程。
最后,当应用使用完消息队列,调用 mq_close() 函数,参数为队列描述符。
调用 mq_unlink() 删除队列。
Linux 2.6 中,POSIX 消息队列通过引入 mqeueu 的特殊文件系统实现,每个现存队列在其中都有一个相应的索引节点。
内核提供了几个系统调用:mq_open()、mq_unlink()、mq_timesend()、mq_timedreceive()、mq_notify() 和 mq_getsetattr()。
当这些系统调用透明地对 mqueue 文件系统的文件进行操作时,大部分工作交由 VFS 层处理。
如 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 V IPC 中使用的消息描述符完全一样。