共享内存区是可用IPC形式中最快的。一旦这样的内存区映射到共享它的进程的地址空间,这些进程间数据的传递就不再涉及内核。然而往该共享内存区存放信息或从中取走信息的进程间通常需要某种形式的同步。我们在第三部分中已经讨论了各种形式的同步:互斥锁、条件变量、读写锁、记录锁、信号量。
这里说的“不再涉及内核”的含义是:进程不再通过执行任何进入内核的系统调用来彼此传递数据。显然,内核必须建立允许各个进程共享该内存区的内存映射关系,然后一直管理该内存区(处理页面故障等)。
考虑用来传递各种类型消息的一个示例客户-服务器文件复制程序中涉及的通常步骤。
服务器从输入文件读。该文件的数据由内核读入自己的内存空间,然后从内核复制到服务器进程。
服务器往一个管道、FIFO或消息队列以一条消息的形式写入这些数据。这些IPC形式通常需要把这些数据从进程复制到内核。
这里使用限定词“通常”是因为Posix消息队列可使用内存映射I/O(本章将描述的mmap函数)实现,如5.8节和习题12.2的解答中所示。在图12-1中,我们假设Posix消息队列是在内核中实现的,这是另外一种可能实现。然而管道、FIFO和System V消息队列的write或msgsnd都涉及从进程到内核的数据复制,它们的read或msgrcv都涉及从内核到进程的数据复制。
客户从该IPC通道读出这些数据,这通常需要把这些数据从内核复制到进程。
最后,将这些数据从由write函数的第二个参数指定的客户缓冲区复制到输出文件。
这里通常需要总共四次数据复制。而且这四次复制是在内核和某个进程间进行的,往往开销很大(比纯粹在内核中或单个进程内复制数据的开销大)。图12-1展示了客户与服务器之间通过内核桥接的数据转移。
图12-1 从服务器到客户的文件数据流
这些IPC形式(管道、FIFO和消息队列)的问题在于,两个进程要交换信息时,这些信息必须经由内核传递。
通过让两个或多个进程共享一个内存区,共享内存区这种IPC形式提供了绕过上述问题的办法。当然,这些进程必须协调或同步对该共享内存区的使用。(共享一个公共的内存区跟共享一个硬盘文件类似,例如本书所有文件上锁例子中都使用的那个序列号文件。)第三部分讲述的任何技巧都可用于这样的同步目的。
前面的客户-服务器例子现在涉及的步骤如下。
服务器使用(譬如说)一个信号量取得访问某个共享内存区对象的权力。
服务器将数据从输入文件读入到该共享内存区对象。read函数的第二个参数所指定的数据缓冲区地址指向这个共享内存区对象。
服务器读入完毕时,使用一个信号量通知客户。
客户将这些数据从该共享内存区对象写出到输出文件中。
图12-2展示了这个情形。
图12-2 使用共享内存区将文件数据从服务器复制到客户
本图中数据只复制两次:一次从输入文件到共享内存区,另一次从共享内存区到输出文件。我们画了一个包围客户和该共享内存区对象的虚框,又画了另一个包围服务器和该共享内存区对象的虚框,目的是强调该共享内存区对象同时出现在客户和服务器的地址空间中。
使用共享内存区所涉及的概念对于Posix接口和System V接口都类似。我们将在第13章中讲述前者,在第14章中讲述后者。
本章中我们返回到第9章中开始介绍的序列号加1的例子。不过我们现在把序列号存放在内存中而不是某个文件里。
我们首先再次强调,默认情况下通过fork派生的子进程并不与其父进程共享内存区。图12-3中的程序让父子进程都给一个名为count的全局整数加1。
创建并初始化信号量
12~14 创建并初始化一个信号量,它保护我们认为其为一个共享变量的对象(全局变量count)。由于这样的假设不正确,该信号量实际上并非必要。注意,我们通过调用sem_unlink从系统中删除了该信号量的名字,但是尽管这么一来删除了它的路径名,对于已经打开的信号量却没有影响。这样做后即使本程序中止了,该路径名也已从系统中删除。
图12-3 父子进程都给同一个全局变量加1
把标准输出设置为非缓冲,然后fork
15 把标准输出设置为非缓冲模式,因为父子进程都要往它写出结果。这样可以防止这两个进程的输出不恰当地交插。 [1]
16~29 父子进程都执行一个循环,该循环对计数器执行指定次数的加1,并小心地保证只在持有保护它的信号量时才给该变量加1。
运行该程序,只查看系统在父子进程间切换时的输出,我们得到如下结果:
child: 0 子进程首先运行,计数器从0开始计数
child: 1
. . .
child: 678
child: 679
parent: 0 子进程被阻止,父进程运行,计数器从0开始计数
parent: 1
. . .
parent: 1220
parent: 1221
child: 680 父进程被阻止,子进程接着运行
child: 681
. . .child: 2078
child: 2079
parent: 1222 子进程被阻止,父进程接着运行
parent: 1223
如此等等
可以看出这两个进程都有各自的全局变量count的副本。每个进程都从该变量为0的初始值开始,而且每次加1的对象是各自的变量的副本。图12-4展示了调用fork之前的父进程。
图12-4 调用fork之前的父过程
调用fork后,子进程从其父进程数据空间的映射副本开始运行。图12-5展示了fork返回后的两个进程。
图12-5 fork返回之后的父子进程
我们看到父子进程都有各自的变量count的副本。
mmap函数把一个文件或一个Posix共享内存区对象映射到调用进程的地址空间。使用该函数有三个目的:
(1)使用普通文件以提供内存映射I/O(12.3节);
(2)使用特殊文件以提供匿名内存映射(12.4节和12.5节);
(3)使用shm_open以提供无亲缘关系进程间的Posix共享内存区(第13章)。
#include <sys/mman.h>
void *mmap(void *addr,size_t len,int prot,int flags,int fd,off_t offset);
返回:若成功则为被映射区的起始地址,若出错则为MAP_FAILED
其中addr可以指定描述符fd应被映射到的进程内空间的起始地址。它通常被指定为一个空指针,这样告诉内核自己去选择起始地址。无论哪种情况下,该函数的返回值都是描述符fd所映射到内存区的起始地址。
len是映射到调用进程地址空间中的字节数,它从被映射文件开头起第offset个字节处开始算。offset通常设置为0。图12-6展示了这个映射关系。
图12-6 内存映射文件的例子
内存映射区的保护由prot参数指定,它使用图12-7中的常值。该参数的常见值是代表读写访问的PROT_READ | PROT_WRITE。
图12-7 mmap的prot参数
flags使用图12-8中的常值指定。MAP_SHARED或MAP_PRIVATE这两个标志必须指定一个,并可有选择地或上MAP_FIXED。如果指定了MAP_PRIVATE,那么调用进程对被映射数据所作的修改只对该进程可见,而不改变其底层支撑对象(或者是一个文件对象,或者是一个共享内存区对象)。如果指定了MAP_SHARED,那么调用进程对被映射数据所作的修改对于共享该对象的所有进程都可见,而且确实改变了其底层支撑对象。
图12-8 mmap的flags参数
从移植性上考虑,MAP_FIXED不应该指定。如果没有指定该标志,但是addr不是一个空指针,那么addr如何处置取决于实现。不为空的addr值通常被当作有关该内存区应如何具体定位的线索。可移植的代码应把addr指定成一个空指针,并且不指定MAP_FIXED。
父子进程之间共享内存区的方法之一是,父进程在调用fork前先指定MAP_SHARED调用mmap。Posix.1保证父进程中的内存映射关系存留到子进程中。而且父进程所作的修改子进程能看到,反过来也一样。我们稍后将给出这样的一个例子。
mmap成功返回后,fd参数可以关闭。该操作对于由mmap建立的映射关系没有影响。
为从某个进程的地址空间删除一个映射关系,我们调用munmap。
#include <sys/mman.h>
int munmap(void *addr,size_t len);
返回:若成功则为0,若出错则为−1
其中addr参数是由mmap返回的地址,len是映射区的大小。再次访问这些地址将导致向调用进程产生一个SIGSEGV信号(当然这里假设以后的mmap调用并不重用这部分地址空间)。
如果被映射区是使用MAP_PRIVATE标志映射的,那么调用进程对它所作的变动都会被丢弃掉。
在图12-6中,内核的虚拟内存算法保持内存映射文件(一般在硬盘上)与内存映射区(在内存中)的同步,前提是它是一个MAP_SHARED内存区。这就是说,如果我们修改了处于内存映射到某个文件的内存区中某个位置的内容,那么内核将在稍后某个时刻相应地更新文件。然而有时候我们希望确信硬盘上的文件内容与内存映射区中的内容一致,于是调用msync来执行这种同步。
#include <sys/mman.h>
int msync(void *addr,size_t len,int flags);
返回:若成功则为0,若出错则为−1
其中addr和len参数通常指代内存中的整个内存映射区,不过也可以指定该内存区的一个子集。flags参数是图12-9中所示各常值的组合。
图12-9 msync函数的flags参数
MS_ASYNC和MS_SYNC这两个常值中必须指定一个,但不能都指定。它们的差别是,一旦写操作已由内核排入队列,MS_ASYNC即返回,而MS_SYNC则要等到写操作完成后才返回。如果还指定了MS_INVALIDATE,那么与其最终副本不一致的文件数据的所有内存中副本都失效。后续的引用将从文件中取得数据。
为何使用mmap
到此为止就mmap的描述间接说明了内存映射文件:我们open它之后调用mmap把它映射到调用进程地址空间的某个文件。使用内存映射文件所得到的奇妙特性是,所有的I/O都在内核的掩盖下完成,我们只需编写存取内存映射区中各个值的代码。 [2] 我们决不调用read、write或lseek。这么一来往往可以简化我们的代码。
回想使用mmap完成的Posix消息队列的实现,其中图5-30有往内存映射区中某个msg_hdr结构存入值的代码,图5-32有从内存映射区中某个msg_hdr结构取出值的代码。
然而需要了解以防误解的说明是,不是所有文件都能进行内存映射。例如,试图把一个访问终端或套接字的描述符映射到内存将导致mmap返回一个错误。这些类型的描述符必须使用read和write(或者它们的变体)来访问。
mmap的另一个用途是在无亲缘关系的进程间提供共享的内存区。这种情形下,所映射文件的实际内容成了被共享内存区的初始内容,而且这些进程对该共享内存区所作的任何变动都复制回所映射的文件(以提供随文件系统的持续性)。这里假设指定了MAP_SHARED标志,它是进程间共享内存所需求的。
有关mmap的实现以及它与内核虚拟内存算法之间的关系具体参见[McKusick et al.1996] (适用于4.4BSD)以及[Vahalia 1996]和[Goodheart and Cox 1994](适用于SVR4)。
现在对(不工作的)图12-3进行修改,以使父子进程共享存放着计数器的一个内存区。为此目的,我们使用一个内存映射文件:我们open它之后调用mmap把它映射到调用进程地址空间的某个文件。图12-10给出了这个新程序。
新的命令行参数
11~14 新增的命令行参数是有待内存映射的一个文件的名字。我们打开该文件用于读和写,若不存在则创建之,然后写一个值为0的整数到该文件中。
mmap后关闭描述符
15~16 调用mmap把刚才打开的文件映射到本进程的内存空间。第一个参数是一个空指针,因而由系统来选择起始地址。长度参数是一个int的大小,保护模式参数指定读写访问。
通过把第四个参数指定为MAP_SHARED,父进程所作的任何变动子进程都能看到,反过来也一样。函数返回值是待共享内存区的起始地址,我们把它保存在ptr中。
fork
20~34 把标准输出设置成非缓冲模式后调用fork。父子进程都要对由ptr指向的整数计数器执行加1操作。
图12-10 父子进程给共享内存区中的一个计数器加1
fork对内存映射文件进行特殊处理,也就是父进程在调用fork之前创建的内存映射关系由子进程共享。因此,我们刚才在打开文件后以MAP_SHARED标志调用mmap的操作实际上提供了一个由父子进程共享的内存区。而且,既然该共享内存区是一个内存映射文件,因而对它(由ptr指向的大小为sizeof(int)的内存区)所作的任何变动还会反映到真正的文件中(该文件的名字由命令行参数指定)。
执行这个程序,我们发现由ptr指向的内存区确实在父子进程间共享。下面只给出内核在这两个进程间来回切换上下文时输出的值。
solaris % incr2 /tmp/temp.1 10000
child: 0 子进程首先运行
child: 1
. . .
child: 128 solaris % od -D /tmp/temp.1
0000000 0000020000
0000004
parent: 130 子进程被阻止,父进程启动
child: 638 父进程被阻止,子进程接着运行
parent: 1519 子进程被阻止,父进程接着运行
parent: 19999 最后一行输出
parent: 1520 child: 1517
child: 1518
child: 639 parent: 636
parent: 637
parent: 131
child: 129
. . .
. . .
. . .
既然文件/tmp/temp.1已被内存映射,incr2程序运行终止后我们可以用od程序查看该文件的内容,发现其中确实存放着计数器的最终值(20000)。
图12-11是对图12-5的修改,它画出了共享内存区,并表示出信号量也是共享的。这里的信号量画成是在内核中,然而正如我们讲述Posix信号量时提到的那样,这并不是必须的。不论使用什么来实现,信号量必须至少具有随内核的持续性。如10.15节所展示的那样,该信号量也可作为另一个内存映射文件存放。
图12-11 共享一个内存区和一个信号量的父子进程
图中画出父子进程都各自有属于自己的指针ptr的副本,但是每个副本都指向共享内存区中的同一个整数:这两个进程都对它执行加1操作的计数器。
现在把图12-10中的程序改为使用一个Posix基于内存的信号量,而不是一个Posix有名信号量,并把该信号量存放在共享内存区中。图12-12给出了这个新程序。
定义将存放在共享内存区中的结构
2~5 定义一个结构,其中含有整数计数器以及保护它的信号量。该结构将存放到共享内存区对象中。
图12-12 计数器和信号量都在共享内存区中
映射到内存
14~19 创建一个将被映射到内存的文件,将一个值为0的上述结构写到该文件中。我们所做的只是初始化其中的计数器,因为信号量的值是通过调用sem_init初始化的。然而把整个结构写成0要比试图只写一个值为0的整数容易。
初始化信号量
20~21 现在是用一个基于内存的信号量代替一个有名信号量,因此我们调用sem_init把它的值初始化为1。第二个参数必须不为0,以指示该信号量将在进程间共享。
图12-13是对图12-11的修改,注意其中的信号量已从内核挪到了共享内存区中。
图12-13 现在计数器和信号量都在共享内存区中
图12-10和图12-12中的例子程序工作正确,然而我们不得不在文件系统中创建一个文件(其名字由命令行参数给出),调用open,然后往该文件中write一些0以初始化它。如果调用mmap的目的是提供一个将穿越fork由父子进程共享的映射内存区,那么我们可以简化上述情形,具体方法取决于实现。
(1)4.4BSD提供匿名内存映射(anonymous memory mapping),它彻底避免了文件的创建和打开。其办法是把mmap的flags参数指定成MAP_SHARED | MAP_ANON,把fd参数指定为−1。offset参数则被忽略。这样的内存区初始化为0。我们将在图12-14中给出这种内存映射的一个例子。
(2)SVR4提供/dev/zero设备文件,我们open它之后可在mmap调用中使用得到的描述符。从该设备读时返回的字节全为0,写往该设备的任何字节则被丢弃。我们将在图12-15中给出使用该设备进行内存映射的一个例子。(许多源自Berkeley的实现也支持/dev/zero,例如SunOS 4.1.x和BSD/OS 3.1。)
图12-14给出了改用4.4BSD匿名内存映射后,与图12-10中程序相比唯一有变动的部分。
___________________________________________________________________________________ shm/incr_map_anon.c
图12-14 4.4BSD匿名内存映射
6~11 自动变量fd和zero,以及指定待创建路径名的命令行参数都被去掉了。
12~14 我们不再open一个文件。在调用mmap时指定了MAP_ANON标志,并置第五个参数(描述符)为−1。
图12-15给出了改为映射/dev/zero后与图12-10中程序相比唯一有变动部分。
图12-15 SVR4 /dev/zero内存映射
6~11 自动变量zero以及指定待创建路径名的命令行参数都被去掉了。
12~15 open文件/dev/zero后把得到的描述符用于mmap调用中。这样的映射保证内存映射区被初始化为0。
内存映射一个普通文件时,内存中映射区的大小(mmap的第二个参数)通常等于该文件的大小。例如图12-12中,文件大小由write设置成我们的shared结构的大小,它同时也是内存映射区的大小。然而文件大小和内存映射区大小可以不同。
我们将使用图12-16给出的程序更为细致地探讨mmap函数。
命令行参数
8~11 命令行参数有三个,分别指定即将创建并映射到内存的文件的路径名、该文件将被设置成的大小以及内存映射区的大小。
创建、打开并截短文件;设置文件大小
12~15 待打开的文件若不存在则创建之,若已存在则把它的大小截短成0。接着把该文件的大小设置成由命令行参数指定的大小,办法是把文件读写指针移动到这个大小减去1的字节位置,然后写1个字节。
内存映射文件
16~17 使用作为最后一个命令行参数指定的大小对该文件进行内存映射。其描述符随后被关闭。
输出页面大小
18~19 使用sysconf获取系统实现的页面大小并将其输出。
读出和存入内存映射区
20~26 读出内存映射区中每个页面的首字节和尾字节,并输出它们的值。我们预期这些值全为0。同时把每个页面的这两个字节设置为1。我们预期某个引用会最终引发一个信号,它将终止程序。当for循环结束时,我们输出下一页的首字节,并预期这会失败(假设
此前程序还没有失败)。
图12-16 访问其大小可能不同于文件大小的内存映射区
我们要展示的第一种情形的前提是:文件大小等于内存映射区大小,但这个大小不是页面大小的倍数。
solaris % ls -l foo
foo: No such file or directory
solaris % test1 foo 5000 5000
PAGESIZE = 4096
ptr[0] = 0
ptr[4095] = 0
ptr[4096] = 0
ptr[8191] = 0
Segmentation Fault(coredump)
solaris % ls -l foo
-rw-r--r-- 1 rstevens other1 5000 Mar 20 17:18 foo solaris % od -b -A d foo
0000000 001 000 000 000 000 000 000 000 000 000 000 000 000 000 000 000
0000016 000 000 000 000 000 000 000 000 000 000 000 000 000 000 000 000
*
0004080 000 000 000 000 000 000 000 000 000 000 000 000 000 000 000 001
0004096 001 000 000 000 000 000 000 000 000 000 000 000 000 000 000 000
0004112 000 000 000 000 000 000 000 000 000 000 000 000 000 000 000 000
*
0005000
页面大小为4096字节,我们能够读完整的第2页(下标为4096~8191),但是访问第3页时(下标为8192)引发SIGSEGV信号,shell将它输出成“Segmentation Fault(分段故障)”。尽管我们把ptr[8191]设置成1,它也不写到foo文件中,因而该文件的大小仍然是5000。内核允许我们读写最后一页中映射区以远部分(内核的内存保护是以页面为单位的)。但是我们写向这部分扩展区的任何内容都不会写到foo文件中。设置成1的其他3个字节(下标分别为0、4905和4906)复制回foo文件,这一点可使用od命令来验证。(-b选项指定以八进制数输出各个字节,-A d选项指定以十进制数输出地址。)图12-17展示了这个例子。
图12-17 mmap大小等于文件大小时的内存映射
在Digital Unix下运行同样的例子,得到的结果类似,不过页面大小现在是8192字节。
alpha % ls -l foo
foo not found
alpha % test1 foo 5000 5000
PAGESIZE = 8192
ptr[0] = 0
ptr[8191] = 0
Memory fault(coredump)
alpha % ls -l foo
-rw-r--r-- 1 rstevens operator 5000 Mar 21 08:40 foo
我们仍然能访问内存映射区以远部分,不过只能在边界所在的那个内存页面内(下标为5000~8191)。访问ptr[8192]将引发SIGSEGV信号,这是我们预期的。
在执行图12-16所示程序的下一个例子中,我们把内存映射区大小(15000字节)指定成大于文件大小(5000字节)。
solaris % rm foo
solaris % test1 foo 5000 15000
PAGESIZE = 4096
ptr[0] = 0
ptr[4095] = 0
ptr[4096] = 0
ptr[8191] = 0
Bus Error(coredump)
solaris % ls -l foo
-rw-r--r-- 1 rstevens other1 5000 Mar 20 17:37 foo
其结果与先前那个文件大小等于内存映射区大小(都是5000字节)的例子类似。本例子引发SIGBUS信号(其shell输出为“Bus Error(总线出错)”),前一个例子则引发SIGSEGV信号。两者的差别是,SIGBUS意味着我们是在内存映射区内访问,但是已超出了底层支撑对象的大小。上一个例子中的SIGSEGV则意味着我们已在内存映射区以远访问。可以看出,内核知道被映射的底层支撑对象(本例子中为文件foo)的大小,即使该对象的描述符已经关闭也一样。内核允许我们给mmap指定一个大于该对象大小的大小参数,但是我们访问不了该对象以远的部分(最后一页上该对象以远的那些字节除外,它们的下标为5000~8191)。图12-18展示了这个例子。
图12-18 mmap大小超过文件大小时的内存映射
图12-19给出了我们的下一个程序。它展示了处理一个持续增长的文件的一种常用技巧:指定一个大于该文件大小的内存映射区大小,跟踪该文件的当前大小(以确保不访问当前文件尾以远的部分),然后就让该文件的大小随着往其中每次写入数据而增长。
图12-19 允许文件大小增长的内存映射区例子
打开文件
9~11 打开一个文件,若不存在则创建之,若已存在则把它截短成大小为0。以32768字节的
大小对该文件进行内存映射,尽管它当前的大小为0。
增长文件大小
12~16 通过调用ftruncate(13.3节)把该文件的大小每次增长4096字节,然后取出现在是该文件最后一个字节的那个字节。
现在运行这个程序,我们看到随着文件大小的增长,我们能通过所建立的内存映射区访问新的数据。
alpha % ls -l test.data
test.data: No such file or directory
alpha % test2
setting file size to 4096
ptr[4095] = 0
setting file size to 8192
ptr[8191] = 0
setting file size to 12288
ptr[12287] = 0
setting file size to 16384
ptr[16383] = 0
setting file size to 20480
ptr[20479] = 0
setting file size to 24576
ptr[24575] = 0
setting file size to 28672
ptr[28671] = 0
setting file size to 32768
ptr[32767] = 0
alpha % ls -l test.data
-rw-r--r-- 1 rstevens other1 32768 Mar 20 17:53 test.data
本例子表明,内核跟踪着被内存映射的底层支撑对象(本例子中为文件test.data)的大小,而且我们总是能访问在当前文件大小以内又在内存映射区以内的那些字节。在Sloaris 2.6下我们取得了同样的结果。
本节处理的是内存映射文件和mmap。习题13.1中我们要求把这两个程序改为处理Posix共享内存区,将看到相同的结果。
共享内存区是可用IPC形式中最快的,因为共享内存区中的单个数据副本对于共享该内存区的所有线程或进程都是可用的。然而为协调共享该内存区的各个线程或进程,通常需要某种形式的同步。
本章集中于mmap函数以及普通文件的内存映射,因为这是有亲缘关系的进程间共享内存空间的一种方法。一旦内存映射了一个文件,我们就不再使用read、write和lseek来访问该文件,而只是存取已由mmap映射到该文件的内存位置。把显式的文件I/O操作变换成存取内存单元往往能够简化我们的程序,有时候还能改善性能。
如果设置共享内存区的目的是为了穿越某个后续的fork在父子进程间共享它,那么通过使用匿名内存映射可简化其步骤,这样就不需要创建一个待映射的普通文件。这里或者涉及MAP_ANON这个新标志(适用于源自Berkeley的内核),或者涉及/dev/zero设备文件的映射(适用于源自SVR4的内核)。
我们如此详尽地讨论mmap的理由有两个:一是文件的内存映射是一种很有用的技巧,二是Posix共享内存区也使用mmap,它是下一章的主题。
Posix还定义了(我们没有讨论过的)处理内存管理的4个额外函数。
mlockall会使调用进程的整个内存空间常驻内存。munlockall则撤销这种锁定。
mlock会使调用进程地址空间的某个指定范围常驻内存,该函数的参数指定了这个范围的起始地址以及从该地址算起的字节数。munlock则撤销某个指定内存区的锁定。
12.1 在图12-19中,如果多执行一次for循环内的那段代码,那么会发生什么?
12.2 假设有两个进程,一个是发送者,一个是接收者,前者只是向后者发送消息。再假设它们采用System V消息队列发送消息,请画出消息从发送者去往接收者的示意图。现在假设这两个进程采用我们在5.8节提供的Posix消息队列的实现来发送消息,请画出新的消息传递示意图。
12.3 在讨论mmap的MAP_SHARED标志时,我们说过内核虚拟内存算法将把对内存映像的任何变动更新到实际的文件中。查看/dev/zero的手册页面,判定在内核把对内存映像的变动写回该文件时,发生了什么。
12.4 把图12-10改为指定MAP_PRIVATE标志而不是MAP_SHARED标志,并验证其结果与图12-3的类似。被映射到内存的文件的内容是什么?
12.5 在6.9节中我们提到过,对System V消息队列select读写条件的方法之一是:创建一个匿名共享内存区,派生一个子进程,让该子进程阻塞在msgrcv调用中,以将消息读入到该匿名共享内存区中。父进程还创建两个管道,其中一个管道由子进程用来向父进程通知已在共享内存区中准备好一个消息,另一个管道则由父进程用来向子进程通知共享内存区已可用。这就允许父进程对前一个管道的读出端select可读条件,同时对它想要选择的其他描述符select读写条件。请把上述办法编写成代码。其中匿名共享内存对象的分配调用我们的my_shm函数(图A-46)完成。创建消息队列使用我们在6.6节提供的msgcreate和msgsnd程序,然后把记录放到该队列中。父进程应该只输出由子进程读入的每个消息的大小和类型。
上一章较为笼统地讨论了共享内存区以及mmap函数,并给出了使用mmap提供父子进程间的共享内存区的例子:
使用内存映射文件(图12-10);
使用4.4BSD匿名内存映射(图12-14);
使用/dev/zero匿名内存映射(图12-15)。
我们现在把共享内存区的概念扩展到将无亲缘关系进程间共享的内存区包括在内。Posix.1提供了两种在无亲缘关系进程间共享内存区的方法。
(1)内存映射文件(memory-mapped file):由open函数打开,由mmap函数把得到的描述符映射到当前进程地址空间中的一个文件。我们在第12章中讲述了这种技术,并给出了它在父子进程间共享内存区时的用法。内存映射文件也可以在无亲缘关系的进程间共享。
(2)共享内存区对象(shared-memory object):由shm_open打开一个Posix.1 IPC名字(也许是在文件系统中的一个路径名),所返回的描述符由mmap函数映射到当前进程的地址空间。我们将在本章讲述这种技术。
这两种技术都需要调用mmap,差别在于作为mmap的参数之一的描述符的获取手段:通过open或通过shm_open。图13-1展示了这个差别。Posix把两者合称为内存区对象(memory object)。
图13-1 Posix内存区对象:内存映射文件和共享内存区对象
Posix共享内存区涉及以下两个步骤要求。
(1)指定一个名字参数调用shm_open,以创建一个新的共享内存区对象或打开一个已存在的共享内存区对象。
(2)调用mmap把这个共享内存区映射到调用进程的地址空间。
传递给shm_open的名字参数随后由希望共享该内存区的任何其他进程使用。
Posix共享内存区采用这样的两步过程,而不是只用单个步骤,即取得一个名字后直接返回调用进程内存空间中的某个地址,其原因在于当Posix发明自己的共享内存区形式时,mmap已经存在。显然,单个函数完全可以做那两步工作。shm_open返回一个描述符(回想一下, mq_open返回一个mqd_t值,sem_open返回一个指向某个sem_t值的指针)的原因是:mmap用于把一个内存区对象映射到调用进程地址空间的是该对象的一个已打开描述符。
#include <sys/mman.h>
int shm_open(const char *name,int oflag,mode_t mode);
int shm_unlink(const char *name);
返回:若成功则为非负描述符,若出错则为−1
返回:若成功则为0,若出错则为−1
我们已在2.2节描述过有关name参数的规则。
oflag参数必须或者含有O_RDONLY(只读)标志,或者含有O_RDWR(读写)标志,还可以指定如下标志:O_CREAT、O_EXCL或O_TRUNC。O_CREAT和O_EXCL标志已在2.3节描述过。如果随O_RDWR指定O_TRUNC标志,而且所需的共享内存区对象已经存在,那么它将被截短成0长度。
mode参数指定权限位(图2-4),它在指定了O_CREAT标志的前提下使用。注意,与mq_open和sem_open函数不同,shm_open的mode参数总是必须指定。如果没有指定O_CREAT标志,那么该参数可以指定为0。
shm_open的返回值是一个整数描述符,它随后用作mmap的第五个参数。
shm_unlink函数删除一个共享内存区对象的名字。跟所有其他unlink函数(删除文件系统中一个路径名的unlink,删除一个Posix消息队列的mq_unlink,以及删除一个Posix有名信号量的sem_unlink)一样,删除一个名字不会影响对于其底层支撑对象的现有引用,直到对于该对象的引用全部关闭为止。删除一个名字仅仅防止后续的open、mq_open或sem_open调用取得成功。
处理mmap的时候,普通文件或共享内存区对象的大小都可以通过调用ftruncate修改。
#include <unistd.h>
int ftruncate(int fd,off_t length);
返回:若成功则为0,若出错则为−1
Posix就该函数对普通文件和共享内存区对象的处理的定义稍有不同。
对于一个普通文件:如果该文件的大小大于length参数,额外的数据就被丢弃掉。如果该文件的大小小于length,那么该文件是否修改以及其大小是否增长是未加说明的。实际上对于一个普通文件,把它的大小扩展到length字节的可移植方法是:先lseek到偏移为length−1处,然后write 1个字节的数据。所幸的是几乎所有Unix实现都支持使用ftruncate扩展一个文件。
对于一个共享内存区对象:ftruncate把该对象的大小设置成length字节。
我们调用ftruncate来指定新创建的共享内存区对象的大小,或者修改已存在的对象的大小。当打开一个已存在的共享内存区对象时,我们可调用fstat来获取有关该对象的信息。
#include <sys/types.h>
#include <sys/stat.h>
int fstat(int fd,struct stat *buf);
返回:若成功则为0,若出错则为−1
stat结构有12个或以上的成员(APUE第4章详细讨论它的所有成员),然而当fd指代一个共享内存区对象时,只有四个成员含有信息。
struct stat {
...
mode_t st_mode; /* mode: S_I{RW}{USR,GRP,OTH} */
uid_t st_uid; /* user ID of owner */
gid_t st_gid; /* group ID of owner */
off_t st_size; /* size in bytes */
};
...
我们将在下一节给出使用这两个函数的例子。
不幸的是,Posix.1并没有指定一个新创建的共享内存区对象的初始内容。关于shm_open函数的说明只说:“(新创建的)共享内存区对象的大小应该为0”。关于ftruncate函数的说明指定,对于一个普通文件(不是共享内存区),“如果其大小被扩展,那么扩展部分应显得好像已用0填写过”。然而同样在关于ftruncate的说明中,却没有任何有关被扩展了的一个共享内存区对象新内容的陈述。Posix.1基本原理声称:“如果一个内存区对象被扩展,那么扩展部分内容全为0。”然而这只是基本原理,而不是正式标准。当作者在comp.std.unix新闻组上就此细节提出疑问时,得到的观点是有些厂家反对初始化为0的要求,因为这么做的开销很大。如果一个新扩展的共享内存区未被初始化为某个值(也就是说其内容在扩展前后没有改动),那么有可能成为一个安全漏洞。
现在开发一些简单的程序来操作Posix共享内存区。
13.4.1 shmcreate程序
图13-2给出的shmcreate程序以某个指定的名字和长度创建一个共享内存区对象。
图13-2 创建一个具有所指定大小的Posix共享内存区对象
图13-2(续)
19~22 shm_create创建所指定的共享内存区对象。如果指定了-e选项,那么若该对象已经存在则将出错。ftruncate设置该对象的长度,mmap则把它映射到调用进程的地址空间。
本程序随后终止。既然Posix共享内存区至少具有随内核的持续性,因此本程序的终止不会删除该共享内存区对象。
13.4.2 shmunlink程序
图13-3给出的简单程序只是调用shm_unlink从系统中删除一个共享内存区对象的名字。
图13-3 删除一个共享内存区对象的名字
13.4.3 shmwrite程序
图13-4给出了shmwrite程序,它往一个共享内存区对象中写入一个模式:0,1,2,…, 254,255,0,1,…。
l0~15 shmopen打开所指定的共享内存区对象,fstat获取其大小信息。使用mmap映射它之后close它的描述符。
16~18 把模式写入该共享内存区。
图13-4 打开一个共享内存区对象,填写一个数据模式
13.4.4 shmread程序
图13-5给出的shmread程序验证由shmwrite写入的模式。
图13-5 打开一个共享内存区对象,验证其数据模式
l0~15 打开所指定的共享内存区对象用于只读,使用fstat获取其大小信息,使用mmap把它映射到内存(用于只读目的),随后关闭其描述符。
16~19 验证由shmwrite写入的模式。
13.4.5 例子
在Digital Unix 4.0B下创建一个长度为123 456字节、名为/tmp/myshm的共享内存区对象。
alpha % shmcreate /tmp/myshm 123456
alpha % ls -l /tmp/myshm
-rw-r--r-- 1 rstevens system 123456 Dec 10 14:33 /tmp/myshm
alpha % od -c /tmp/myshm
0000000 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0
*
0361100
我们看到在文件系统中创建了一个同名文件。使用od程序可验证该对象的初始内容为0。(刚刚超过该文件最后一个字节位置的八进制字节偏移0361100,等于十进制的123 456。)
接着运行我们的shmwrite程序,然后使用od验证初始内容与预期的一致。
alpha % shmwrite /tmp/myshm
alpha % od -x /tmp/myshm | head -4
0000000 0100 0302 0504 0706 0908 0b0a 0d0c 0f0e
0000020 1110 1312 1514 1716 1918 1b1a 1d1c 1f1e
0000040 2120 2322 2524 2726 2928 2b2a 2d2c 2f2e
0000060 3130 3332 3534 3736 3938 3b3a 3d3c 3f3e
alpha % shmread /tmp/myshm
alpha % shmunlink /tmp/myshm
我们使用shmread验证该共享内存区对象的内容后删除其名字。
如果在Solaris 2.6下运行我们的shmcreate程序,我们看到在/tmp目录下创建了一个具有所指定大小的文件。
solaris % shmcreate –e /testshm 123
solaris % ls -1/tmp /.*testshm*
-rw-r—r-- 1 rstevens other1 123 Dec 10 14:40 /tmp/.SHMtestshm
13.4.6 例子
我们现在在图13-6中提供一个简单的例子程序,以展示同一共享内存区对象内存映射到不同进程的地址空间时,起始地址可以不一样。
10~14 创建一个其名字为命令行参数的共享内存区,把它的大小设置为一个整数的大小,然后打开文件/etc/motd。
15~30 fork后父子进程都调用mmap两次,但顺序不一样。父子进程分别输出每个内存映射区的起始地址。子进程接着睡眠5秒,父进程则在共享内存区中写入值777,子进程醒来后输出该值。
运行这个程序,我们发现所指定的共享内存区对象在父子进程中被内存映射到不同的起始地址。
solaris % test3 test3.data
parent: shm ptr = eee30000,motd ptr = eee20000
child: shm ptr = eee20000,motd ptr = eee30000
shared memory integer = 777
父进程把值777存入0xeee30000位置,子进程却从0xeee20000位置读出该值。父子进程中指针ptr1都指向同一共享内存区,即使每个指针在各自进程内有不同的值也不受影响。
图13-6 共享内存区在不同进程中可以出现在不同的地址
现在开发一个类似于12.3节中给出的例子,它由多个进程给存放在共享内存区中的某个计数器持续加1。我们把该计数器存放在一个共享内存区中,并用一个有名信号量来同步,不过不再需要父子进程关系了。既然Posix共享内存区对象和Posix有名信号量都是以名字来访问的,因此将给共享的计数器持续加1的各个进程间可以没有亲缘关系,不过它们都得知道该共享内存区和该信号量的IPC名字,并有访问这两个IPC对象的足够权限。
图13-7给出的服务器程序创建所指定的共享内存区对象,创建并初始化所指定的信号量,然后终止。
创建共享内存区对象
13~19 我们调用shm_unlink以提防所需共享内存区对象已经存在的情况,然后调用shm_open创建该对象。ftruncate将该对象的大小设置成我们的shmstruct结构的大小,该对象本身则随后由mmap映射到调用进程的地址空间。接着关闭该对象的描述符。
图13-7 创建并初始化共享内存区和信号量的程序
创建并初始化信号量
20~22 我们调用sem_unlink以提防所需信号量已经存在的情况,然后调用sem_open创建该有名信号量,并把它初始化为1。将给存放在所创建的共享内存区对象中的计数器加1的任何进程都会把该信号量用作一个互斥锁。接着关闭该信号量。
终止
23 进程终止。既然Posix共享内存区至少具有随内核的持续性,因此所创建的该对象将继续存在,直到它的所有打开着的引用都关闭(当本进程终止时,该对象不再有打开着的引用)并且该对象被显式地删除为止。
我们的程序必须给共享内存区和信号量使用不同的名字。操作系统并不保证给IPC名字加上点什么以区分消息队列、信号量和共享内存区。我们已看到Solaris给这三种IPC类型的名字分别加上.MQ、.SEM和.SHM的前缀,但是Digital Unix却没有这样做。
图13-8给出了我们的客户程序,它对存放在共享内存区中的计数器执行一定次数的加1操作,每次给该计数器加1时都事先获取保护它的信号量。
打开共享内存区
15~18 shm_open打开所指定的共享内存区对象,该对象必须存在(因为没有指定O_CREAT标志)。使用mmap把该内存区映射到调用进程的地址空间,然后关闭它的描述符。
打开信号量
19 打开所指定的有名信号量。
图13-8 给存放在共享内存区中的一个计数器加1的程序
获取信号量并给计数器持续加1
20~26 给存放在所打开共享内存区中的计数器执行由命令行参数指定次数的加1操作。每次加1前输出该计数器原来的值以及当前的进程ID,输出进程ID是因为我们将同时运行本程序的多个副本。
我们首先启动服务器,然后在后台运行客户程序的三个副本。
solaris % server1 shm1 sem1 创建并初始化共享内存区和信号量
solaris % client1 shm1 sem1 10000 & client1 shm1 sem1 10000 & \
client1 shm1 sem1 10000 &
[2] 17976 由shell输出的各个进程ID
[3] 17977
[4] 17978
pid 17977: 0 进程17977首先运行
pid 17977: 1
. . . 进程17977继续运行
pid 17977: 32
pid 17976: 33 内核切换上下文到进程17976
. . . 进程17976继续运行
pid 17976: 707
pid 17978: 708 内核切换上下文到进程17978
. . . 进程17978继续运行
pid 17978: 852
pid 17977: 853 内核切换上下文到进程17977
. . . 如此等等pid 17977: 29998
pid 17977: 29999 最终值输出,它是正确的
现在对我们的生产者-消费者例子作如下修改。服务器启动后创建一个共享内存区对象,各个客户进程就在其中放置消息。我们的服务器只是输出这些消息,不过可以一般化为做类似于syslog守护进程所做之事,该守护进程在UNPv1第13章中讲述。我们把其他进程称为客户,因为它们相对于我们的服务器呈现为客户,但是它们也可以是某种处理其他客户的服务器。举例来说,Telnet服务器在向syslog守护进程发送登记消息时就是后者的一个客户。
我们没有使用第二部分中讲述的某种消息传递技术,而是使用共享内存区来容纳消息。当然,这使得我们有必要在存入消息的各个客户和取走并输出消息的服务器之间采取某种形式的同步。图13-9展示了总体设计。
图13-9 多个客户通过共享内存区向一个服务器发送消息
这儿有多个生产者(客户)和单个消费者(服务器)。共享内存区既出现在服务器的地址空间,也出现在各个客户的地址空间。
图13-10是我们的cliserv2.h头文件,它定义了一个给出共享内存区对象布局的结构。
图13-10 定义共享内存区布局的头文件
基本的信号量和变量
5~8 mutex、nempty和nstored这三个Posix基于内存的信号量与10.6节里生产者-消费者例子中的同名信号量作用相同。变量nput是用于存放一个消息的下一个位置的下标(0、1、…、NMESG-1)。既然我们有多个生产者,该变量就必须存放在共享内存区中,并且只能在持有mutex期间访问。
溢出计数器
9~10 某个客户想发送一个消息,但是所有的消息槽位都被占用了,发生这种情况的可能性是存在的。但是如果该客户实际上同时又是某种类型的一个服务器(譬如说是一个FTP服务器或HTTP服务器),那么它可能不愿意等待服务器释放出一个槽位。因此,我们将把客户程序编写成发生这种情况时并不阻塞,而是给noverflow计数器加1。由于该溢出计数器也是在所有客户和服务器之间共享的,因此它也需要一个互斥锁,以免其值遭受破坏。
消息偏移和数据
11~12 数组msgoff含有针对msgdata数组的各个偏移,指出了每个消息的起始位置。这就是说,msgoff[0]为0,msgoff[1]为256(MESGSIZE的值),msgoff[2]为512,等等。
必须搞清楚在处理共享内存区时,我们只能使用像这样子的偏移(offset),因为共享内存区对象可映射到映射它的各个进程的不同物理地址。也就是说,对于同一个共享内存区对象,调用mmap的每个进程所得到的mmap返回值可能不同。由于这个原因,我们不能在共享内存区对象中使用指针(pointer),因为它们含有存放在这些对象内各变量的实际地址。
图13-11是我们的服务器程序,它等待某个客户往所指定的共享内存区中放置一个消息,然后输出这个消息。
创建共享内存区对象
10~16 首先调用shm_unlink删除可能仍然存在的共享内存区对象。接着使用shm_open创建这个对象,再使用mmap把它映射到调用进程的地址空间。然后关闭它的描述符。
初始化偏移量数组
17~19 把偏移量数组msgoff初始化为含有每个消息的位置偏移。
初始化信号量
20~24 初始化存放在共享内存区对象中的四个基于内存的信号量。每个sem_init调用的第二个参数都不为0,因为这些信号量将在进程间共享。
等待消息,然后输出
25~36 for循环的前半部分是标准的消费者算法:等待nstored变为大于0,等待mutex,处理数据,释放mutex,然后给nempty加1。
处理溢出
37~43 每次经由这个循环,我们还检查是否溢出。我们测试计数器noverflows的值是否不同于上一次的值,若是则输出并保存这个新值。注意,我们是在持有noverflowmutex信号量期间获取该计数器的值的,但在比较并输出它之前先释放了这个信号量。这么一来展示了如下的一般规则:我们应该把持有某个互斥锁期间执行的代码编写得操作总数尽量地少。
图13-11 从共享内存区中取得并输出消息的服务器程序
图13-12给出了我们的客户程序。
图13-12 在共享内存区中给服务器存放消息的客户程序
命令行参数
10~13 第一个命令行参数是共享内存区对象的名字,下一个是给服务器存放的消息数,最后一个是每次存放消息之间停顿的微秒数。通过启动本客户程序的多个副本并指定一个较小的停顿值,我们可以强行造成溢出,然后验证服务器能正确地处理它。
打开并映射共享内存区
14~18 在假设所指定的共享内存区对象已经存在的前提下,我们打开该对象,然后把它映射到当前进程的地址空间。随后关闭它的描述符。
存放消息
19~31 客户程序接着依循基本的生产者算法工作,不过我们把缓冲区中没有存放新消息的空间时生产者阻塞在其中的sem_wait(nempty)调用换成了不会阻塞的sem_trywait调用。如果nempty信号量的值为0,该函数就返回一个EAGAIN错误。我们检测出该错误后给溢出计数器加1。
sleep_us是来自APUE图C.9和图C.10的一个函数。它睡眠指定数目的微秒数,是通过调用select和poll来实现的。
32~37 我们在持有mutex信号量期间取得offset的值并给nput加1,但在接下去把新消息复制到共享内存区之前却释放了mutex。在持有该信号量期间,我们只应该执行那些必须被保护起来的操作。
我们首先在后台启动服务器,然后运行一个客户,给它指定50个待存放消息,每个消息的存放没有彼此间的停顿。
solaris % server2 serv2 &
[2] 27223
solaris % client2 serv2 50 0
index = 0: pid 27224: message 0
index = 1: pid 27224: message 1
index = 2: pid 27224: message 2
. . . 如此继续index = 15: pid 27224: message 47
index = 0: pid 27224: message 48
index = 1: pid 27224: message 49 没有消息丢失
但是当我们再次运行一个客户时,却看到了一些溢出现象:
solaris % client2 serv2 50 0
index = 2: pid 27228: message 0
index = 3: pid 27228: message 1
. . . 仍然正常index = 10: pid 27228: message 8
index = 11: pid 27228: message 9
noverflow = 25 服务器检测到有25个消息丢失index = 12: pid 27228: message 10
index = 13: pid 27228: message 11
. . . 消息12~22仍然正常index = 9: pid 27228: message 23
index = 10: pid 27228: message 24
这一次该客户呈现为存放了消息0~9,它们由服务器取走并输出。该客户继续运行,准备存放消息10~49,但是共享内存区中只有存放其中前15个消息的空间,于是剩余的25个消息(编号为25~49)因溢出而未被存放。
很明显,在本例子中通过让客户尽可能快地产生消息,而且每次存放消息之间没有停顿来达到溢出效果,不过这并不是一种典型的现实情形。然而本例子的目的只是展示客户产生的消息没有存放空间可用,但是客户又不想为此而阻塞的情况应如何处理。这种情况并不是只有共享内存区才有的,消息队列、管道和FIFO都可能发生同样情况。
不断提供数据,造成接收者忙不过来的情形并非只有本例子出现。UNPv1的8.13节就UDP数据报和UDP套接字接收缓冲区讨论了同样情形。TCPv3的18.2节讲述了接收者的缓冲区发生溢出时,Unix域数据报套接字是如何向发送者返回一个ENOBUFS错误的。在图13-12中,我们的客户(发送者)知道什么时候服务器的缓冲区溢出了,因此要是把这段代码放到某个供其
他程序调用的通用函数中,那么当服务器的缓冲区溢出时,该函数有可能向调用者返回一个错误。
Posix共享内存区构筑在上一章讲述的mmap函数之上。我们首先指定待打开共享内存区的Posix IPC名字来调用shm_open,取得一个描述符后使用mmap把它映射到内存。其结果类似于内存映射文件,不过共享内存区对象不必作为一个文件来实现。
既然共享内存区对象是由描述符来表示的,它们的大小就使用ftruncate来设置,有关某个已存在对象的信息(保护位、用户ID、组ID及大小)由fstat返回。
在讨论Posix消息队列和Posix信号量时,我们分别在5.8节和10.15节提供了它们基于内存映射I/O的实现。我们不对Posix共享内存区提供这样的实现,因为实在太简单。如果愿意以内存映射一个文件的方式(这在Solaris和Digital Unix上都有实现)来实现Posix共享内存区这种IPC形式,那么shm_open是通过调用open实现的,shm_unlink是通过调用unlink实现的。
13.1 把图12-16和图12-19修改成访问Posix共享内存区而不是内存映射文件,并验证它们的运行结果与原来访问内存映射文件的程序相同。
13.2 在图13-4和图13-5的for循环中,用于步进访问数组元素的C表达式为*ptr++。改用ptr[i]更为可取吗?
System V共享内存区在概念上类似于Posix共享内存区。代之以调用shm_open后调用mmap的是,先调用shmget,再调用shmat。
对于每个共享内存区,内核维护如下的信息结构,它定义在<sys/shm.h>头文件中:
struct shmid_ds {
struct ipc_perm shm_perm; /* operation permission struct */
size_t shm_segsz; /* segment size */
pid_t shm_lpid; /* pid of last operation */
pid_t shm_cpid; /* creator pid */
shmatt_t shm_nattch; /* current # attached */
shmat_t shm_cnattch; /* in-core # attached */
time_t shm_atime; /* last attach time */
time_t shm_dtime; /* last detach time */
time_t shm_ctime; /* last change time of this structure */
};
我们已在3.3节描述过其中的ipc_perm结构,它含有本共享内存区的访问权限。
shmget函数创建一个新的共享内存区,或者访问一个已存在的共享内存区。
#include <sys/shm.h>
int shmget(key_t key,size_t size,int oflag);
返回:若成功则为共享内存区对象,若出错则为−1
返回值是一个称为共享内存区标识符(shared memory identifier)的整数,其他三个shmXXX函数就用它来指代这个内存区。
key既可以是ftok的返回值,也可以是IPC_PRIVATE,我们已在3.2节讨论过。
size以字节为单位指定内存区的大小。当实际操作为创建一个新的共享内存区时,必须指定一个不为0的size值。如果实际操作为访问一个已存在的共享内存区,那么size应为0。
oflag是图3-6中所示读写权限值的组合。它还可以与IPC_CREAT或IPC_CREAT | IPC_EXCL按位或,如随图3-4所作的讨论。
当实际操作为创建一个新的共享内存区时,该内存区被初始化为size字节的0。
注意,shmget创建或打开一个共享内存区,但并没有给调用进程提供访问该内存区的手段。这是shmat函数的目的,我们将接下去讲述它。
由shmget创建或打开一个共享内存区后,通过调用shmat把它附接到调用进程的地址空间。
#include <sys/shm.h>
void *shmat(int shmid,const void *shmaddr,int flag);
返回:若成功则为映射区的起始地址,若出错则为−1
其中shmid是由shmget返回的标识符。shmat的返回值是所指定的共享内存区在调用进程内的起始地址。确定这个地址的规则如下。
如果shmaddr是一个空指针,那么系统替调用者选择地址。这是推荐的(也是可移植性最好的)方法。
如果shmaddr一是一个非空指针,那么返回地址取决于调用者是否给flag参数指定了SHM_RND值:
如果没有指定SHM_RND,那么相应的共享内存区附接到由shmaddr参数指定的地址;如果指定了SHM_RND,那么相应的共享内存区附接到由shmaddr参数指定的地址向下舍入一个SHMLBA常值。LBA代表“低端边界地址(lower boundary address)”。
默认情况下,只要调用进程具有某个共享内存区的读写权限,它附接该内存区后就能够同时读写该内存区。flag参数中也可以指定SHM_RDONLY值,它限定只读访问。
当一个进程完成某个共享内存区的使用时,它可调用shmdt断接这个内存区。
#include <sys/shm.h>
int shmdt(const void *shmaddr);
返回:若成功则为0,若出错则为−1
当一个进程终止时,它当前附接着的所有共享内存区都自动断接掉。
注意本函数调用并不删除所指定的共享内存区。这个删除工作通过以IPC_RMID命令调用shmctl完成,我们将在下一节讲述它。
shmctl提供了对一个共享内存区的多种操作。
#include <sys/shm.h>
int shmctl(int shmid,int cmd,struct shmid_ds *buff);
该函数提供了三个命令。
返回:若成功则为0,若出错则为−1
IPC_RMID 从系统中删除由shmid标识的共享内存区并拆除它。 [3]
IPC_SE.给所指定的共享内存区设置其shmid_ds结构的以下三个成员:shm_perm.uid、shm_perm.gid和shm_perm.mode,它们的值来自buff参数指向的结构中的相应成员。shm_ctime的值也用当前时间替换。
IPC_STAT (通过buff参数)向调用者返回所指定共享内存区当前的shmid_ds结构。
我们现在开发一些对System V共享内存区进行操作的简单程序。
14.6.1 shmget程序
图14-1给出的shmget程序使用指定的路径名和长度创建一个共享内存区。19 shmget创建由用户指定其名字和大小的共享内存区。作为命令行参数传递进来的路径名由ftok映射成一个System V IPC键。如果指定了-e选项,那么一旦该内存区已存在就会出错。如果我们知道该内存区已存在,那么在命令行上的长度参数必须指定为0。20 shmat把该内存区附接到当前进程的地址空间。本程序然后终止,不过既然System V共享内存区至少具有随内核的持续性,那么这不会删除该共享内存区。
图14-1 创建一个指定大小的System V共享内存区
14.6.2 shmrmid程序
图14-2给出的简单程序只是以一个IPC_RMID命令调用shmctl,以便从系统中删除一个共享内存区。
图14-2 删除一个System V共享内存区
14.6.3 shmwrite程序
图14-3给出了shmwrite程序,它往一个共享内存区中写入一个模式:0,1,2,…,254, 255,0,1,…。
图14-3 打开一个共享内存区,填入一个数据模式
10~12 使用shmget打开所指定的共享内存区后由shmat把它附接到当前进程的地址空间。其大小通过以一个IPC_STAT命令调用shmctl取得。
13~15 往该共享内存区中写入给定的模式。
14.6.4 shmread程序
图14-4给出的shmread程序会验证由shmwrite写入的模式。
图14-4 打开一个共享内存区,验证其数据模式
10~12 打开并附接所指定的共享内存区。其大小通过以一个IPC_STAT命令调用shmctl获取。13~16 验证由shmwrite写入的模式。
14.6.5 例子
在Solaris 2.6下创建一个大小为1234字节的共享内存区。用于标识该内存区的路径名(也就是传递给ftok的路径名)是我们的shmget可执行文件的路径名。对于一个给定的应用,使用服务器的可执行文件路径名往往能够提供一个唯一的标识符。
solaris % shmget shmget 1234
solaris % ipcs -bmo
IPC status from <running system> as of Thu Jan 8 13:17:06 1998
T ID KEY MODE OWNER GROUP NATTCH SEGSZ
m 1 0x0000f12a --rw-r--r-- rstevens other1 0 1234 Shared Memory:
我们运行ipcs程序以验证相应的共享内存区已经创建出来。注意它的附接数(存放在该内存区的shmid_ds结构的shm_nattch成员中)为0,跟我们预期的一致。
接着运行我们的shmwrite程序,以把该共享内存区的内容设置成给定的模式。然后用shmread验证该共享内存区的内容,并删除其标识符。
solaris % shmwrite shmget
solaris % shmread shmget
solaris % shmrmid shmget
solaris % ipcs -bmo
IPC status from <running system> as of Thu Jan 8 13:17:06 1998
T ID KEY MODE OWNER GROUP NATTCH SEGSZ
Shared Memory:
我们运行ipcs来验证该共享内存区确实已被删除。
当把服务器可执行文件的名字用作ftok的参数来标识某种形式的System V IPC时,通常应使用绝对路径名(例如/usr/bin/myserverd),而不是像本例子那样使用一个相对路径名(shmget)。我们能在本节的例子中使用相对路径名的原因是,所有程序都是从含有服务器可执行文件的目录中运行的。我们知道ftok使用文件的索引节点来构成IPC标识符(见图3-2),而一个给定文件是使用一个绝对路径名还是一个相对路径名来引用对于其索引节点并没有影响。
跟System V消息队列和System V信号量一样,System V共享内存区也存在特定的系统限制(3.8节)。图14-5给出了本书所用两种不同实现的这些限制值。其中第一栏是含有当前限制值的内核变量的传统System V名字。
图14-5 System V共享内存区的典型系统限制
例子
图14-6中的程序可确定图14-5中给出的四个限制值。
图14-6 确定共享内存区的系统限制
图14-6(续)
在Digital Unix 4.0B下运行这个程序的结果为:
alpha % limits
127 identifiers open at once
32 shared memory segments attached at once
minimum size of shared memory segment = 1
maximum size of shared memory segment = 4194304
图14-5指出128个标识符的限制,但我们的程序只能创建127个,其原因在于有一个系统守护进程早已创建了一个共享内存区。
System V共享内存区在概念上与Posix共享内存区类似。最常用的函数调用有以下几个。
shmget:获取一个标识符。
shmat:把一个共享内存区附接到调用进程的地址空间。
以一个IPC_STAT命令调用shmctl:获取一个已存在共享内存区的大小。
以一个IPC_RMID命令调用shmctl:删除一个共享内存区对象。
两者的差别之一是,Posix共享内存区对象的大小可在任何时刻通过调用ftruncate修改(如习题13.1中所展示的那样),而System V共享内存区对象的大小是在调用shmget创建时固定下来的。
14.1 图6-8是对图6-6的修改,它接受的用于指定队列的是标识符而不是路径名。我们已展示有了这种标识符,就足以访问一个System V消息队列(假设我们有足够权限)。对图14-4作类似的修改,以展示同样的特性也适用于System V共享内存区。
[1]. 确切地说,从观察程序运行的用户来看,缓冲模式的标准输出妨碍了父子进程动态输出的及时反映。——译者注
[2]. 意思是不像调用read和write执行I/O时那样由内核直接参与I/O的完成,而是由内核在背后通过操纵页表等方法间接参与,这样就用户进程看来,I/O不再涉及系统调用。——译者注
[3]. 删除一个共享内存区指的是使其标识符失效,这样以后针对该标识符的shmat、shmdt和shmctl函数调用必定失败。拆除一个共享内存区指的是释放或回收与它对应的数据结构,包括删除存放在其上的数据。拆除操作要到该共享内存区的引用计数变为0时才进行。另外,当某个shmdt调用发现所指定的共享内存区的引用计数变为0时也顺便拆除它,这就是shmctl的IPC_RMID命令先于最后一个shmdt调用发出时会发生的情形。——译者注