IPC是进程间通信(interprocess communication)的简称。传统上该术语描述的是运行在某个操作系统之上的不同进程间各种消息传递(message passing)的方式。本书还讲述多种形式的同步(synchronization),因为像共享内存区这样的较新式的通信需要某种形式的同步参与运作。
在Unix操作系统过去30年的演变史中,消息传递历经了如下几个发展阶段。
管道(pipe,第4章)是第一个广泛使用的IPC形式,既可在程序中使用,也可从shell中使用。管道的问题在于它们只能在具有共同祖先(指父子进程关系)的进程间使用,不过该问题已随有名管道(named pipe)即FIFO(第4章)的引入而解决了。
System V消息队列(System V message queue,第6章)是在20世纪80年代早期加到System V内核中的。它们可用在同一主机上有亲缘关系或无亲缘关系的进程之间。尽管称呼它们时仍冠以“System V”前缀,当今多数版本的Unix却不论自己是否源自System V都支持它们。
在谈论Unix进程时,有亲缘关系(related)的说法意味着所论及的进程具有某个共同的祖先。说得更明白点,这些有亲缘关系的进程是从该祖先进程经过一次或多次fork派生来的。一个常见的例子是在某个进程调用fork两次,派生出两个子进程。我们说这两个子进程是有亲缘关系的。同样,每个子进程与其父进程也是有亲缘关系的。考虑到IPC,父进程可以在调用fork前建立某种形式的IPC(例如管道或消息队列),因为它知道随后派生的两个子进程将穿越fork继承该IPC对象。我们随图1-6详细讨论各种IPC对象的继承性。我们还得注意,从理论上说,所有Unix进程与init进程都有亲缘关系,它是在系统自举时启动所有初始化进程的祖先进程。然而从实践上说,进程亲缘关系开始于一个登录shell(称为一个会话)以及由该shell派生的所有进程。APUE的第9章详细讨论会话和进程亲缘关系。
本书将全文使用缩进的插入式注解(如此处所示)来说明实现上的细节、历史上的观点以及其他琐事。
Posix消息队列(Posix消息队列,第5章)是由Posix实时标准(1003.1b-1993,将在1.7节详细讨论)加入的。它们可用在同一主机上有亲缘关系和无亲缘关系的进程之间。
远程过程调用(Remote Procedure Call,RPC,第五部分)出现在20世纪80年代中期,它是从一个系统(客户主机)上某个程序调用另一个系统(服务器主机)上某个函数的一种方法,是作为显式网络编程的一种替换方法开发的。既然客户和服务器之间通常传递一些信息(被调用函数的参数与返回值),而且RPC可用在同一主机上的客户和服务器之间,因此可认为RPC是另一种形式的消息传递。
看一看由Unix提供的各种同步形式的演变同样颇有教益。
需要某种同步形式(往往是为了防止多个进程同时修改同一文件)的早期程序使用了文件系统的诡秘特性,我们将在9.8节讨论其中的一些。
记录上锁(record locking,第9章)是在20世纪80年代早期加到Unix内核中的,然后在1988年由Posix.1标准化的。
System V信号量(System V semaphore,第11章)是在System V消息队列加入System V内核的同时(20世纪80年代早期)伴随System V共享内存区(System V shared memory)加入的。当今多数版本的Unix都支持它们。
Posix信号量(Posix semaphore,第10章)和Posix共享内存区(Posix shared memory,第13章)也由Posix实时标准(1003.1b-1993)加入。
互斥锁(mutex)和条件变量(condition variable,第7章)是由Posix线程标准(1003.1c-1995)定义的两种同步形式。尽管往往用于线程间的同步,它们也能提供不同进程间的同步。
读写锁(read-write lock,第8章)是另一种形式的同步。它们还没有被Posix标准化,不过也许不久后会被标准化。
按照传统的Unix编程模型,我们在一个系统上运行多个进程,每个进程都有各自的地址空间。Unix进程间的信息共享可以有多种方式。图1-1对此作了总结。
图1-1 Unix进程间共享信息的三种方式
(1)左边的两个进程共享存留于文件系统中某个文件上的某些信息。为访问这些信息,每个进程都得穿越内核(例如read、write、lseek等)。当一个文件有待更新时,某种形式的同步是必要的,这样既可保护多个写入者,防止相互串扰,也可保护一个或多个读出者,防止写入者的干扰。
(2)中间的两个进程共享驻留于内核中的某些信息。管道是这种共享类型的一个例子, System V消息队列和System V信号量也是。现在访问共享信息的每次操作涉及对内核的一次系统调用。
(3)右边的两个进程有一个双方都能访问的共享内存区。每个进程一旦设置好该共享内存区,就能根本不涉及内核而访问其中的数据。共享该内存区的进程需要某种形式的同步。
注意没有任何东西限制任何IPC技术只能使用两个进程。我们讲述的技术适用于任意数目的进程。在图1-1中只展示两个进程是为了简单起见。
线程
虽然Unix系统中进程的概念已使用了很久,一个给定进程内多个线程(thread)的概念却相对较新。Posix.1线程标准(称为“Pthreads”)是于1995年通过的。从IPC角度看来,一个给定进程内的所有线程共享同样的全局变量(也就是说共享内存区的概念对这种模型来说是内在的)。然而我们必须关注的是各个线程间对全局数据的同步访问。同步尽管不是一种明确的IPC形式,但它确实伴随许多形式的IPC使用,以控制对某些共享数据的访问。
本书中我们讲述进程间的IPC和线程间的IPC。我们假设有一个线程环境,并作类似如下形式的陈述:“如果管道为空,调用线程就阻塞在它的read调用上,直到某个线程往该管道写入数据。”要是你的系统不支持线程,那你可以将该句子中的“线程”替换成“进程”,从而提供“阻塞在对空管道的read调用上”的经典Unix定义。然而在支持线程的系统上,只有对空管道调用read的那个线程阻塞,同一进程中的其余线程才可以继续执行。向该空管道写数据的工作既可以由同一进程中的另一个线程去做,也可以由另一个进程中的某个线程去做。
附录B汇总了线程的某些特征以及全书都用到的5个基本的Pthread函数。
我们可以把任意类型的IPC的持续性(persistence)定义成该类型的一个对象一直存在多长时间。图1-2展示了三种类型的持续性。
图1-2 IPC对象的持续性
(1)随进程持续的(process-persistent)IPC对象一直存在到打开着该对象的最后一个进程关闭该对象为止。例如管道和FIFO就是这种对象。
(2)随内核持续的(kernel-persistent)IPC对象一直存在到内核重新自举或显式删除该对象为止。例如System V的消息队列、信号量和共享内存区就是此类对象。Posix的消息队列、信号量和共享内存区必须至少是随内核持续的,但也可以是随文件系统持续的,具体取决于实现。
(3)随文件系统持续的(filesystem-persistent)IPC对象一直存在到显式删除该对象为止。即使内核重新自举了,该对象还是保持其值。Posix消息队列、信号量和共享内存区如果是使用映射文件实现的(不是必需条件),那么它们就是随文件系统持续的。
在定义一个IPC对象的持续性时我们必须小心,因为它并不总是像看起来的那样。例如管道内的数据是在内核中维护的,但管道具备的是随进程的持续性而不是随内核的持续性:最后一个将某个管道打开着用于读的进程关闭该管道后,内核将丢弃所有的数据并删除该管道。类似地,尽管FIFO在文件系统中有名字,它们也只是具备随进程的持续性,因为最后一个将某个FIFO打开着的进程关闭该FIFO后,FIFO中的数据都被丢弃。
图1-3汇总了将在本书中讲述的各种类型IPC对象的持续性。
图1-3 各种类型IPC对象的持续性
注意该列表中没有任何类型的IPC具备随文件系统的持续性,但是我们说过有三种类型的Posix IPC可能会具备该持续性,这取决于它们的实现。显然,向一个文件写入数据提供了随文件系统的持续性,但这通常不作为一种IPC形式使用。多数形式的IPC并没有在系统重新自举后继续存在的打算,因为进程不可能跨越重新自举继续存活。对于一种给定形式的IPC,要求它具备随文件系统的持续性可能会使其性能降级,而IPC的一个基本的设计目标是高性能。
当两个或多个无亲缘关系的进程使用某种类型的IPC对象来彼此交换信息时,该IPC对象必须有一个某种形式的名字(name)或标识符(identifier),这样其中一个进程(往往是服务器)可以创建该IPC对象,其余进程则可以指定同一个IPC对象。
管道没有名字(因此不能用于无亲缘关系的进程间),但是FIFO有一个在文件系统中的Unix路径名作为其标识符(因此可用于无亲缘关系的进程间)。在以后各章具体讲述其他形式的IPC时,我们将使用另外的命名约定。对于一种给定的IPC类型,其可能的名字的集合称为它的名字空间(name space)。名字空间非常重要,因为对于除普通管道以外的所有形式的IPC来说,名字是客户与服务器彼此连接以交换消息的手段。
图1-4汇总了不同形式的IPC所用的命名约定。
我们还指出哪些形式的IPC是由1996年版的Posix.1和Unix 98标准化的,这两个标准本身则在1.7节详细讨论。为了比较的目的,我们还包含了三种类型的套接字,它们在UNPv1中具体讲述。注意套接字API(应用程序编程接口)是由Posix.1g工作组标准化的,最终应该成为某个未来的Posix.1标准的一部分。
图1-4 各种形式IPC的名字空间
尽管Posix.1标准化了信号量,它们仍然是可选的特性。图1-5汇总了Posix.1和Unix 98对各种IPC特性的说明。每种特性有强制、未定义和可选三种选择。对于可选的特性,我们指出了其中每种特性受支持时(通常在<unistd.h>头文件中)定义的常值的名字,例如_POSIX_THREADS。注意,Unix 98是Posix.1的超集。
图1-5 各种形式IPC的可用性
我们需要理解fork、exec和_exit函数对于所讨论的各种形式的IPC的影响(_exit是由exit调用的一个函数)。图1-6对此作了总结。
图1-6 调用fork、exec和_exit对于IPC的影响
表中多数特性将在以后的章节中讲述,不过我们需要强调几点。首先,考虑到无名同步变量(互斥锁、条件变量、读写锁、基于内存的信号量),从一个具有多个线程的进程中调用fork将变得混乱不堪。[Butenhof 1997]的6.1节提供了其中的细节。我们在表中只是简单地注明:如果这些变量驻留在共享内存区中,而且创建时设置了进程间共享属性,那么对于能访问该共享内存区的任意进程来说,其任意线程能继续访问这些变量。其次,System V IPC的三种形式没有打开或关闭的说法。我们将从图6-8和习题11.1和习题14.1中看出,访问这三种形式的IPC所需知道的只是一个标识符,因此知道该标识符的任何进程都能访问它们,尽管信号量和共享内存区可附带提出某种特殊处理要求。
在现实程序中,我们必须检查每个函数调用是否返回错误。由于碰到错误时终止程序执行是个惯例,因此我们可以通过定义包裹函数(wrapper function)来缩短程序的长度。包裹函数执行实际的函数调用,测试其返回值,并在碰到错误时终止进程。我们使用的命名约定是将函数名第一个字母改为大写字母,例如:
Sem_post(ptr);
图1-7定义了这个包裹函数。
图1-7 sem_post函数的包裹函数
每当你遇到一个以大写字母打头的函数名时,它就是我们所说的包裹函数。它调用一个名字相同但以相应小写字母开头的实际函数。当碰到错误时,包裹函数总是在输出一个出错消息后终止。
我们在讲解书中提供的源代码时,所指代的总是被调用的最低层函数(例如sem_post),而不是包裹函数(例如Sem_post)。类似地,书后的索引也总是指代被调用的最低层函数,而不是指代包裹函数。
刚刚展示的源代码格式全书都在使用。每一非空行都被编号。代码的正文说明部分的左边标有起始与结束的行号。有的段落开始处含有一个醒目的简短标题,概述本段代码的内容。
源代码片段起始与结束处的水平划线标出了该片段所在源代码文件名,本例中就是lib目录下的wrapunix.c文件。既然本书所有例子的源代码都可免费获得(见前言),你就可以凭这个文件名找到相应的文件。阅读本书的过程中,编译、运行并修改这些程序是学习进程间通信概念的好方法。
尽管包裹函数不见得如何节省代码量,当在第7章中讨论线程时,我们会发现线程函数出错时并不设置标准的Unix errno变量;相反,本该设置errno的值改由线程函数作为其返回值返回调用者。这意味着我们每次调用任意一个线程函数时,都得分配一个变量来保存函数返回值,然后在调用我们的err_sys函数(图C-4)前,把errno设置成所保存的值。为避免源代码中到处出现花括弧,我们可以使用C语言的逗号运算符,把给errno赋值与调用err_sys组合成单个语句,如下所示:
int n;
if ( (n = pthread_mutex_lock(&ndone_mutex))!= 0)
errno = n,err_sys("pthread_mutex_lock error");
另一种办法是定义一个新的出错处理函数,它需要的另一个参数是系统的错误号 [1] 。但是我们可以将这段代码简化得更容易些:
Pthread_mutex_lock(&ndone_mutex);
其前提是定义自己的包裹函数,如图1-8所示。
图1-8 给pthread_mutex_lock定义的包裹函数
仔细推敲编码,我们可改用宏代替函数,从而稍稍提高运行效率,不过即使有过的话,包裹函数也很少是程序性能的瓶颈所在。
选择将函数名的第一个字母大写是一种较折中的方法。还有许多其他方法:例如用e作为函数名的前缀(如[Kernighan and Pike 1984]第184页所示),或者用_e作为函数名的后缀等。同样提供确实在调用某个其他函数的可视化指示,我们的方法看来是最少分散人们的注意力的。
这种技巧还有助于检查那些其错误返回值通常被忽略的函数,例如close和pthread_mutex_lock。
本书后面的例子中我们将普遍使用包裹函数,除非必须检查某个确定的错误并处理它(而不是终止进程)。我们并不给出所有包裹函数的源代码,但它们是免费可得的(见前言)。
Unix errno值
每当在一个Unix函数中发生错误时,全局变量errno将被设置成一个指示错误类型的正数,函数本身则通常返回-1。我们的err_sys函数检查errno的值并输出相应的出错消息,例如, errno的值等于EAGAIN时的出错消息为“Resource temporarily unavailable”(资源暂时不可用)。
errno的值只在某个函数发生错误时设置。如果该函数不返回错误,errno的值就无定义。所有正的错误值都是常值,具有以E打头的全部为大写字母的名字,通常定义在头文件<sys/errno.h>中。没有值为0的错误。
在多线程环境中,每个线程必须有自己的errno变量。提供一个局限于线程的errno变量的隐式请求是自动处理的,不过通常需要告诉编译器所编译的程序是可重入的。给编译器指定类似-D_REENTRANT或-D_POSIX_C_SOURCE=199506L这样的命令行选项是较典型的方法。<errno.h>头文件往往把errno定义成一个宏,当常值_REENTRANT有定义时,该宏就扩展成一个函数,由它访问errno变量的某个局限于线程的副本。
全书使用类似“mq_send函数返回EMSGSIZE错误”的用语来简略地表示这样的意思:该函数返回一个错误(典型情况是返回值为-1),并且在errno中设置了指定的常值。
有关Unix标准化的大多数活动是由Posix和Open Group做的。
1.7.1 Posix
Posix是“可移植操作系统接口”(Portable Operating System Interface)的首字母缩写。它并不是一个单一标准,而是一个由电气与电子工程师学会即IEEE开发的一系列标准。Posix标准还是由ISO(国际标准化组织)和IEC(国际电工委员会)采纳的国际标准,这两个组织合称为ISO/IEC。Posix标准经历了以下若干代。
IEEE Std 1003.1-1988(共317页)是第一个Posix标准。它说明进入类Unix内核的C语言接口,涉及下列领域:进程原语(fork、exec、信号、定时器)、进程环境(用户ID、进程组)、文件与目录(所有I/O函数)、终端I/O、系统数据库(口令文件和用户组文件)、tar与cpio归档格式。
第一个Posix标准是出现于1986年称为“IEEEIX”的试用版本。Posix这个名字是由Richard Stallman建议使用的。
IEEE Std 1003.1-1990(共356页)是下一个Posix标准,它也是国际标准ISO/IEC 9945-1:1990。从1988年版本到1990年版本只做了少量的修改。新添的副标题为“Part 1: System Application Program Interface (API)[C Language]”,指示本标准为C语言API。
IEEE Std 1003.2-1992出版成两卷本,共约1300页,其副标题为“Part2: Shell and Utilities”。这一部分定义了shell(基于System V的Bourne shell)和大约100个实用程序(即通常从shell启动执行的程序,包括awk、basename、vi和yacc等)。本书称这个标准为Posix.2。
IEEE Std 1003.1b-1993(共590页)先前称为IEEE P1003.4。这是对1003.1-1990标准的更新,添加了由P1003.4工作组开发的实时扩展:文件同步、异步I/O、信号量、内存管理(mmap和共享内存区)、执行调度、时钟与定时器、消息队列。
IEEE Std 1003.1,1996年版[IEEE 1996]包括1003.1-1990(基本API)、1003.lb-1993(实时扩展)、1003.1c-1995 (Pthreads)和1003.1i-1995(对1003.1b的技术性修正)。这个标准也称为ISO/IEC 9945-1: 1996。其中增加了三章线程内容以及有关线程同步(互斥锁和条件变量)、线程调度和同步调度的额外各节。本书称这个标准为Posix.1。
743页中有四分之一强的篇幅是标题为“Rationale and Notes”(原理与注解)的附录。这些原理含有历史性信息以及某些特性必须加入或删除的理由,它们通常跟正式标准一样有教益。
遗憾的是IEEE标准在因特网上不是免费可得的。其订购信息在[IEEE 1996]的参考文献说明中给出。
注意信号量在实时标准中定义,它与在Pthreads标准中定义的互斥锁和条件变量相分离,这足以解释它们的API中存在的某些差异。
最后注意读写锁(尚)不属于任何Posix标准。我们将在笫8章中详细讨论。
将来某个时候印制的新版本的IEEE Std 1003.1应包括P1003.lg标准,它是我们在UNPvl中讲述的网络编程API(套接字和XTI)。
1996年版的Posix.1标准的前言中声称ISO/IEC 9945由下面三个部分构成。
Part 1: System application program interface (API)[C Language](第一部分:系统应用程序接口(API)[C语言])。
Part 2: Shell and utilities(第二部分:Shell和实用程序)。
Part 3: System administration(第三部分:系统管理)(正在开发中)。
第一部分和第二部分就是我们所称的Posix.1和Posix.2。
Posix标准化工作仍将继续,任何论述到它的书籍都在跟踪这项工作。从http://www.pasc.org/standing/sdll.html可获得各种Posix标准的最新状态。
1.7.2 Open Group
Open Group是由X/Open公司(1984年成立)和开放软件基金会(OSF,1988年成立)于1996年合并而成的组织。它是由厂家、业界最终用户、政府部门和学术机构组成的国际组织。它们的标准经历了以下若干代。
X/Open公司于1989年出版了“X/Open Portability Guide”(《X/Open移植性指南》)第3期(XPG3)。
第4期于1992年出版,这一期的第2版于1994年出版。这个最终版本也称为“Spec 1170”,其中魔数1170是系统接口数(926个)、头文件数(70个)和命令数(174个)的总和。这组规范的最终名字是“X/Open Single Unix Specification”(X/Open单一Unix规范),也称为“Unix 95”。
1997年3月单一Unix规范的第2版发表。符合这个规范的产品可称为“Unix 98”,这也是本书提到这个规范所用的名称。Unix 98所需的接口数从1170个增加到1434个,然而,适用于工作站的接口数却猛增到3030个,因为它包含CDE(公共桌面环境,Common Desktop Environment),而CDE又反过来要求有X Windows系统和Motif用户接口。其详情见[Josey 1997]和http://www.UNIX-systems.org/version2。
单一Unix规范的许多文档可在因特网上从这个站点免费取得。
1.7.3 Unix版本和移植性
当今大多数Unix系统符合Posix.1和Posix.2的某个版本。我们使用限定词“某个”是因为Posix每次更新(例如1993年增加实时扩展,1996年增加Pthreads内容),厂家都得花一两年(甚至更长的时间)去实现并加入最近的更新内容。
从历史上看,多数Unix系统或者源自Berkeley,或者源自System V,不过这些差别在慢慢消失,因为大多数厂家已开始采用Posix标准。仍然存在的主要差别在于系统管理,这是一个目前还没有任何Posix标准可循的领域。
运行本书中大多数例子的平台是Solaris 2.6和Digital Unix 4.0B。其原因在于写到此处时(1997年末到1998年初),只有这两种Unix系统支持System V IPC、Posix IPC及Posix线程。
为分析各种特性,全书主要使用了三种交互模式。
(1)文件服务器:客户-服务器应用程序,客户向服务器发送一个路径名,服务器把该文件的内容返回给客户。
(2)生产者-消费者:一个或多个线程或进程(生产者)把数据放到一个共享缓冲区中,另有一个或多个线程或进程(消费者)对该共享缓冲区中的数据进行操作。
(3)序列号持续增1:一个或多个线程或进程给一个共享的序列号持续增1。该序列号有时在一个共享文件中,有时在共享内存区中。
第一个例子分析各种形式的消息传递,另外两个例子则分析各种类型的同步和共享内存区。
为了提供本书所涵盖的不同主题的索引,图1-9、图1-10和图1-11汇总了我们开发的程序及它们的源代码所在的起始图号和页码。
图1-9 不同版本的文件服务器客户-服务器例子
图1-10 不同版本的生产者-消费者例子
图1-11 不同版本的序列号持续增1例子
IPC传统上是Unix中一个杂乱不堪的领域。虽然有了各种各样的解决办法,但没有一个是完美的。我们的讨论分成4个主要领域:
(1)消息传递(管道、FIFO、消息队列);
(2)同步(互斥锁、条件变量、读写锁、信号量);
(3)共享内存区(匿名共享内存区、有名共享内存区);
(4)过程调用(Solaris门、Sun RPC)。
我们考虑单个进程中多个线程间的IPC以及多个进程间的IPC。
各种类型IPC的持续性可以是随进程持续的、随内核持续的或随文件系统持续的,这取决于IPC对象存在时间的长短。在为给定的应用选择所用的IPC类型时,我们必须清楚相应IPC对象的持续性。
各种类型IPC的另一个特性是名字空间,也就是使用IPC对象的进程和线程标识各个IPC对象的方式。各种类型的IPC有些没有名字(管道、互斥锁、条件变量、读写锁),有些具有在文件系统中的名字(FIFO),有些具有将在第2章中讲述的Posix IPC名字,有些则具有其他类型的名字(将在第3章中讲述的System V IPC键或标识符)。典型做法是:服务器以某个名字创建一个IPC对象,客户则使用该名字访问同一个IPC对象。
本书中所有源代码使用1.6节中讲述的包裹函数来缩短篇幅,同时达到检查每个函数调用是否返回错误的目的。我们的包裹函数都以一个大写字母开头。
IEEE Posix标准一直是多数厂家努力遵循的标准,其中Posix.1定义了访问Unix的基本C接口,Posix.2定义了标准命令。然而商业标准也在迅速地吸纳并扩展Posix标准,著名的有Open Group的Unix标准,例如Unix 98。
1.1 图1-1中我们展示了两个进程访问单个文件的情形。如果这两个进程都只是往该文件的末尾添加新的数据(譬如说这是一个日志文件),那么需要什么类型的同步?
1.2 查看一下你的系统中的<error.h>头文件是如何定义errno变量的。
1.3 在图1-5上标注出你使用的Unix系统所支持的特性。
以下三种类型的IPC合称为“Posix IPC”:
Posix消息队列(第5章);
Posix信号量(第10章);
Posix共享内存区(第13章)。
Posix IPC在访问它们的函数和描述它们的信息上有一些类似点。本章讲述所有这些共同属性:用于标识的路径名、打开或创建时指定的标志以及访问权限。
图2-1汇总了所有Posix IPC函数。
图2-1 Posix IPC函数汇总
在图1-4中我们指出,三种类型的Posix IPC都使用“Posix IPC名字”进行标识。mq_open、sem_open和shm_open这三个函数的第一个参数就是这样的一个名字,它可能是某个文件系统中的一个真正的路径名,也可能不是。Posix.1是这么描述Posix IPC名字的。
它必须符合已有的路径名规则(必须最多由PATH_MAX个字节构成,包括结尾的空字节)。
如果它以斜杠符开头,那么对这些函数的不同调用将访问同一个队列。如果它不以斜杠符开头,那么效果取决于实现。
名字中额外的斜杠符的解释由实现定义。
因此,为便于移植起见,Posix IPC名字必须以一个斜杠符打头,并且不能再含有任何其他斜杠符。遗憾的是这些规则还不够,仍会出现移植性问题。
Solaris 2.6要求有打头的斜杠符,但是不允许有另外的斜杠符。假设要创建的是一个消息队列,创建函数将在/tmp中创建三个以.MQ开头的文件。例如,如果给mq_open的参数为/queue.1234,那么这三个文件分别为/tmp/.MQDqueue.1234、/tmp/.MQLqueue.1234和/tmp/.MQPqueue.1234。Digita.Uni.4.0B则在文件系统中创建所指定的路径名。
当我们指定一个只有单个斜杠符(作为首字符)的名字时,移植性问题就发生了:我们必须在根目录中具有写权限。例如,/tmp.1234符合Posix规则,在Solaris下也可行,但是Digital Unix却会试图创建这个文件,这时除非我们有在根目录中的写权限,否则这样的尝试将失败。如果我们指定一个/tmp/test.1234这样的名字,那么在以该名字创建一个真正文件的所有系统上都将成功(前提是/tmp目录存在,而且我们在该目录中有写权限,对于多数Unix系统来说,这是正常情况),在Solaris下则失败。
为避免这些移植性问题,我们应该把Posix IPC名字的#define行放在一个便于修改的头文件中,这样应用程序转移到另一个系统上时,只需修改这个头文件。
这是一个标准试图变得相当通用(本例子中,实时标准试图允许消息队列、信号量和共享内存区都在现有的Unix内核中实现,而且在独立的无盘系统上也能工作),结果标准的具体实现却变得不可移植的个例之一。在Posix中,这种现象称为“造成不标准的标准方式”(a standard way of being nonstandard)。
Posix.1定义了三个宏:
S_TYPEISMQ(buf)
S_TYPEISSEM(buf)
S_TYPEISSHM(buf)
它们的单个参数是指向某个stat结构的指针,其内容由fstat、lstat或stat这三个函数填入。如果所指定的IPC对象(消息队列、信号量或共享内存区对象)是作为一种独特的文件类型实现的,而且参数所指向的stat结构访问这样的文件类型,那么这三个宏计算出一个非零值。否则,计算出的值为0。
不幸的是,这三个宏没有多大用处,因为无法保证这三种类型的IPC使用一种独特的文件类型实现。举例来说,在Solaris 2.6下,这三个宏的计算结果总是0。
测试某个文件是否为给定文件类型的所有其他宏的名字都以S_IS开头,而且它们的单个参数是某个stat结构的st_mode成员。由于上面三个新宏的参数不同于其他宏,因此它们的名字改为以S_TYPEIS开头。
px_ipc_name函数
解决上述移植性问题的另一种办法是自己定义一个名为px_ipc_name的函数,它为定位Posix IPC名字而添加上正确的前缀目录。
#include "unpipc.h"
char *px_ipc_name(const char *name);
均返回:若成功则为非空指针,若出错则为NULL
本书中我们给自己定义的非标准系统函数都使用这样的版式:围绕函数原型和返回值的方框是虚框。开头包含的头文件通常是我们的unpipc.h(图C-l)。
name参数中不能有任何斜杠符。例如,调用
px_ipc_name("test1")
在Solaris 2.6下返回一个指向字符串/testl的指针,在Digital Unix 4.0B下返回一个指向字符串/tmp/test1的指针。存放结果字符串的内存空间是动态分配的,并可通过调用free释放。另外,环境变量PX_IPC_NAME能够覆盖默认目录。
图2-2给出了该函数的实现。
图2-2 我们的px_ipc_name函数
这也许是你第一次碰到snprintf函数。许多现有代码调用的是sprintf,但是sprintf不检查目标缓冲区是否溢出,不过snprintf要求其第二个参数是目标缓冲区的大小,因此可确保缓冲区不溢出。提供能有意溢出一个程序的sprintf缓冲区的输入数据是黑客们已使用很多年的一种攻破系统的方法。
snprintf不是标准ANSI C的一部分,但这个标准的修订版C9X正在考虑。 [2] 不过,许多厂家提供的标准C函数库含有这个函数。我们在本书中使用snprintf,如果你的系统不提供这个函数,那就使用我们自己的通过调用sprintf实现的版本。
mq_open、sem_open和shm_open这三个创建或打开一个IPC对象的函数,它们的名为oflag的第二个参数指定怎样打开所请求的对象。这与标准open函数的第二个参数类似。图2-3给出了可组合构成该参数的各种常值。
前3行指定怎样打开对象:只读、只写或读写。消息队列能以其中任何一种模式打开,信号量的打开不指定任何模式(任意信号量操作,都需要读写访问权),共享内存区对象则不能以只写模式打开。
图2-3 打开或创建Posix IPC对象所用的各种oflag常值
图2-3中余下4行标志是可选的。
O_CREAT 若不存在则创建由函数第一个参数所指定名字的消息队列、信号量或共享内存区对象(同时检查O_EXCL标志,我们不久将要说明)。
创建一个新的消息队列、信号量或共享内存区对象时,至少需要另外一个称为mode的参数。该参数指定权限位,它是由图2-4中所示常值按位或形成的。
图2-4 创建新的IPC对象所用的mode常值
这些常值定义在<sys/stat.h>头文件中。所指定的权限位受当前进程的文件模式创建掩码(file mode creation mask)修正,而该掩码可通过调用umask函数(APUE第83~85页 [3] )或使用shell的umask命令来设置。
跟新创建的文件一样,当创建一个新的消息队列、信号量或共享内存区对象时,其用户ID被置为当前进程的有效用户ID。信号量或共享内存区对象的组ID被置为当前进程的有效组ID或某个系统默认组ID。新消息队列对象的组ID则被置为当前进程的有效组ID(APUE第77~78页 [4] 详细讨论了用户ID和组ID。)
这三种Posix IPC类型在设置组ID上存在的差异多少有点奇怪。由open新创建的文件的组ID或者是当前进程的有效组ID,或者是该文件所在目录的组ID,但是IPC函数不能假定系统为IPC对象创建了一个在文件系统中的路径名。
O_EXCL 如果该标志和O_CREAT一起指定,那么IPC函数只在所指定名字的消息队列、信号量或共享内存区对象不存在时才创建新的对象。如果该对象已经存在,而且指定了O_CREAT|O_EXCL,那么返回一个EEXIST错误。
考虑到其他进程的存在,检查所指定名字的消息队列、信号量或共享内存区
对象的存在与否和创建它(如果它不存在)这两步必须是原子的(atomic)。
我们将在3.4节看到适用于System V IPC的两个类似标志。
O_NONBLOCK 该标志使得一个消息队列在队列为空时的读或队列填满时的写不被阻塞。我们将在5.4节随mq_receive和mq_send这两个函数详细讨论该标志。
O_TRUNC 如果以读写模式打开了一个已存在的共享内存区对象,那么该标志将使得该对象的长度被截成0。
图2-5展示了打开一个IPC对象的真正逻辑流程。我们将在2.4节通过访问权限的测试说明该图。图2-6是展示图2-5中逻辑的另一种形式。
图2-5 打开或创建一个IPC对象的逻辑
图2-6 创建或打开一个IPC对象的逻辑
注意图2-6指定了O_CREAT标志但没有指定O_EXCL标志的中间那行,我们无法得到一个指示以判别是创建了一个新对象,还是在引用一个已存在的对象。
新的消息队列、有名信号量或共享内存区对象是由其oflag参数中含有O_CREAT标志的mq_open、sem_open或shm_open函数创建的。如图2-4所示,权限位与这些IPC类型的每个对象相关联,就像它们与每个Unix文件相关联一样。
当同样由这三个函数打开一个已存在的消息队列、信号量或共享内存区对象时(或者未指定O_CREAT,或者指定了O_CREAT但没有指定O_EXCL,同时对象已经存在),将基于如下信息执行权限测试:
(1)创建时赋予该IPC对象的权限位;
(2)所请求的访问类型(O_RDONLY、O_WRONLY或O_RDWR);
(3)调用进程的有效用户ID、有效组ID以及各个辅助组ID(若支持的话)。
大多数Unix内核按如下步骤执行权限测试。
(1)如果当前进程的有效用户ID为0(超级用户),那就允许访问。
(2)在当前进程的有效用户ID等于该IPC对象的属主ID的前提下,如果相应的用户访问权限位已设置,那就允许访问,否则拒绝访问。
这里相应的访问权限位的意思是:如果当前进程为读访问而打开该IPC对象,那么用户读权限位必须设置;如果当前进程为写访问而打开该IPC对象,那么用户写权限位必须设置。
(3)在当前进程的有效组ID或它的某个辅助组ID等于该IPC对象的组ID的前提下,如果相应的组访问权限位已设置,那就允许访问,否则拒绝访问。
(4)如果相应的其他用户访问权限位已设置,那就允许访问,否则拒绝访问。
这4个步骤是按所列的顺序尝试的。因此,如果当前进程拥有该IPC对象(第2步),那么访问权的授予与拒绝只依赖于用户访问权限——组访问权限绝不会考虑。类似地,如果当前进程不拥有该IPC对象,但它属于某个合适的组,那么访问权的授予与拒绝只依赖于组访问权限——其他用户访问权限绝不会考虑。
我们从图2-3中指出,sem_open不使用O_RDONLY、O_WRONLY或O_RDWR标志。然而在10.2节我们将指出,某些Unix实现采用O_RDWR,因为只要使用一个信号量,都涉及读写该信号量的值。
三种类型的Posix IPC——消息队列、信号量、共享内存区——都是用路径名标识的。但是这些路径名既可以是文件系统中的实际路径名,也可以不是,而这点不一致性会导致一个移植性问题。全书采用的解决办法是使用我们自己的px_ipc_name函数。
当创建或打开一个IPC对象时,我们指定一组类似于open函数所用的标志。创建一个新的IPC对象时,我们必须给这个新对象指定访问权限,所用的是同样由open函数使用的S_xxx常值(见图2-4)。当打开一个已存在的IPC对象时,所执行的权限测试与打开一个已存在的文件时一样。
2.1 使用Posix IPC的程序,其SUID与SGID位(APUE的4.4节)是如何影响2.4节中所述的权限测试的?
2.2 当一个程序打开一个Posix IPC对象时,它怎样才能判定是创建了一个新对象还是在引用一个已有的对象?
以下三种类型的IPC合称为System V IPC:
System V消息队列(第6章);
System V信号量(第11章);
System V共享内存区(第14章)。
这个称谓作为这三种IPC机制的通称是因为它们源自System V Unix。System V IPC在访问它们的函数和内核为它们维护的信息上享有许多类似点。本章讲述所有这些共同属性。
图3-1汇总了所有System V IPC函数。
图3-1 System V IPC函数汇总
System V IPC函数的设计与开发信息难以找到。[Rochkind 1985]提供了下述信息:System V消息队列、信号量和共享内存区是20世纪70年代后期在俄亥俄州哥伦布市的一个贝尔实验室分支机构开发的,他们开发了一个内部Unix版本,(顺理成章地)称为“Columbus Unix”或简称“CB Unix”。CB Unix用于“操作支持系统”(Operation Support System),即自动完成电话公司的管理和记录保存工作的事务处理系统。System V IPC大约于1983年随System V加入到商用Unix系统中。
图1-4中注明,三种类型的System V IPC使用key_t值作为它们的名字。头文件<sys/types.h>把key_t这个数据类型定义为一个整数,它通常是一个至少32位的整数。这些整数值通常是由ftok函数赋予的。
函数ftok把一个已存在的路径名和一个整数标识符转换成一个key_t值,称为IPC键。
#include <sys/ipc.h>
key_t ftok(const char *pathname,int id);
返回:若成功则为IPC键,若出错则为-1
该函数把从pathname导出的信息与id的低序8位组合成一个整数IPC键。
该函数假定对于使用System V IPC的某个给定应用来说,客户和服务器同意使用对该应用有一定意义的pathname。它可以是服务器守护程序的路径名、服务器使用的某个公共数据文件的路径名或者系统上的某个其他路径名。如果客户和服务器之间只需单个IPC通道,那么可以使用譬如说值为1的id。如果需要多个IPC通道,譬如说从客户到服务器一个通道,从服务器到客户又一个通道,那么作为一个例子,一个通道可使用值为1的id,另一个通道可使用值为2的id。客户和服务器一旦在pathname和id上达成一致,双方就都能调用ftok函数把pathname和id转换成同一个IPC键。
ftok的典型实现调用stat函数,然后组合以下三个值。
(1)pathname所在的文件系统的信息(stat结构的st_dev成员)。
(2)该文件在本文件系统内的索引节点号(stat结构的st_ino成员)。
(3)id的低序8位(不能为0)。 [5]
这三个值的组合通常会产生一个32位键。不能保证两个不同的路径名与同一个id的组合产生不同的键,因为上面所列三个条目(文件系统标识符、索引节点、id)中的信息位数可能大于一个整数的信息位数(见习题3.5)。
索引节点绝不会是0,因此大多数实现把IPC_PRIVATE(将在3.4节讲述)定义为0。
如果pathname不存在,或者对于调用进程不可访问,ftok就返回−1。注意,路径名用于产生键的文件不能是在服务器存活期间由服务器反复创建并删除的文件,因为该文件每次创建时由系统赋予的索引节点号很可能不一样,于是对下一个调用者来说,由ftok返回的键也可能不同。
例子
图3-2中的程序取一个作为命令行参数的路径名,调用stat,调用ftok,然后输出stat结构的st_dev和st_ino成员以及得出的IPC键。这三个值是以十六进制输出的,这样我们可从这两个值以及id值0x57很容易地看出IPC键是如何构造的。
图3-2 获取并输出文件系统信息和IPC键
在Solaris 2.6下执行该程序的结果如下:
solaris % ftok /etc/system
st_dev: 800018,st_ino: 4a1b,key: 57018a1b
solaris % ftok /usr/tmp
st_dev: 800015,st_ino: 10b78,key: 57015b78
solaris % ftok /home/rstevens/Mail.out
st_dev: 80001f,st_ino: 3b03,key: 5701fb03
很明显,id在IPC键的高序8位,st_dev的低序12位IPC在键的接下来12位,st_ino的低序12位则在IPC键的低序12位。
我们展示本例子的目的不是让大家依据这种信息组合方式构造出IPC键,而是让大家看看一个实现是如何组合pathname和id的。其他实现可能以不同的方式组合。
FreeBSD使用id的低8位、st_dev的低8位以及st_ino的低16位。
注意由ftok完成的映射是单向的,因为st_dev和st_ino中某些位未被使用。这就是说,我们不能从一个给定的键确定创建它时所用的路径名。
内核给每个IPC对象维护一个信息结构,其内容跟内核给文件维护的信息类似。
struct ipc_perm {
uid_t uid; /* owner's user id */
gid_t gid; /* owner's group id */
uid_t cuid; /* creator's user id */
gid_t cgid; /* creator's group id */
mode_t mode; /* read-write permissions */
ulong_t seq; /* slot usage sequence number */
key_t key; /* IPC key */
};
该结构以及System V IPC函数使用的较为明显的常值定义在<sys/ipc.h>头文件中。我们将在本章讨论该结构的所有成员。
创建或打开一个IPC对象的三个getXXX函数(见图3-1)的第一个参数key是类型为key_t的IPC键,返回值identifier是一个整数标识符。该标识符不同于ftok函数的id参数,我们不久就会看到。对于key值,应用程序有两种选择。
(1)调用ftok,给它传递pathname和id。
(2)指定key为IPC_PRIVATE,这将保证会创建一个新的、唯一的IPC对象。
图3-3展示有关步骤的顺序。
图3-3 从IPC键生成IPC标识符
所有三个getXXX函数(见图3-1)都有一个名为oflag的参数,它指定IPC对象的读写权限位(ipc_perm结构的mode成员),并选择是创建一个新的IPC对象还是访问一个已存在的IPC对象。这种选择的规则如下。
指定key为IPC_PRIVATE能保证创建一个唯一的IPC对象。没有一对id和pathname的组合会导致ftok产生IPC_PRIVATE这个键值。
设置oflag参数的IPC_CREAT位但不设置它的IPC_EXCL位时,如果所指定键的IPC对象不存在,那就创建一个新的对象,否则返回该对象。
同时设置oflag的IPC_CREAT和IPC_EXCL位时,如果所指定键的IPC对象不存在,那就创建一个新的对象,否则返回一个EEXIST错误,因为该对象已存在。
对IPC对象来说,IPC_CREAT和IPC_EXCL的组合跟open函数的O_CREAT和O_EXCL的组合类似。
设置IPC_EXCL位但不设置IPC_CREAT位是没有意义的。
图3-4展示了打开一个IPC对象的逻辑流程。图3-5是展示图3-4所示逻辑的另一种形式。
图3-4 创建或打开一个IPC对象的逻辑
图3-5 创建或打开一个IPC通道的逻辑
注意图3-5中间只有IPC_CREAT而没有IPC_EXCL标志的那一行,我们得不到一个指示以判别是创建了一个新对象,还是在引用一个已存在的对象。大多数应用程序中,由服务器创建IPC对象并指定IPC_CREAT标志(如果它不关心该对象是否存在)或IPC_CREAT | IPC_EXCL标志(如果它需要检查该对象是否已经存在)。客户则不指定其中任何一个标志(它们假定服务器已经创建了该对象)。
System V IPC定义了自己的IPC_xxx常值,而不像标准open函数以及Posix IPC函数那样使用O_CREAT和O_EXCL常值(参见图2-3)。
还要注意的是,System V IPC函数把它们的IPC_xxx常值跟权限位(将在下一节讲述)组合到单个oflag参数中。open函数以及Posix IPC函数有一个名为oflag的参数,用以指定各种O_xxx标志,另有一个名为mode的参数,用以指定权限位。
每当使用某个getXXX函数(指定IPC_CREAT标志)创建一个新的IPC对象时,以下信息就保存到该对象的ipc_perm结构中(3.3节)。
(1)oflag参数中某些位初始化ipc_perm结构的mode成员。图3-6展示了System V三种不同IPC机制的权限位(记号>>3的意思是将值右移3位)。
图3-6 IPC读写权限的mode值
(2)cuid和cgid成员分别设置为调用进程的有效用户ID和有效组ID。这两个成员合称为创建者ID(creator ID)。
(3)ipc_perm结构的uid和gid成员也分别设置为调用进程的有效用户ID和有效组ID。这两个成员合称为属主ID(owner ID)。
尽管一个进程可通过调用相应IPC机制ctlXXX函数(所用命令为IPC_SET)修改属主ID,创建者ID却从不改变。这三个ctlXXX函数还允许一个进程修改某个IPC对象的mode成员。
多数实现在<sys/msg.h>、<sys/sem.h>和<sys/shm.h>这三个头文件中定义图3-6中所示的6个常值:MSG_R、MSG_W、SEM_R、SEM_A、SHM_R、SHM_W。不过Unix 98没有这样的要求。SEM_A中的后缀A代表“alter”(改变)。
这三个ctlXXX函数不使用通常的文件模式创建掩码。消息队列、信号量或共享内存区对象的权限准确地设置成由这些函数所指定的值。
Posix IPC并不允许一个IPC对象的创建者改变该对象的属主。Posix IPC中没有类似于IPC_SET命令的操作。然而如果Posix IPC名字存储在文件系统中,那么超级用户可使用chown命令改变其属主。
每当有一个进程访问某个IPC对象时,IPC就执行两级检查,该IPC对象被打开时(getXXX函数)执行一次,以后每次使用该对象时执行一次。
(1)每当有一个进程以某个getXXX函数建立访问某个已存在IPC对象的通道时,IPC就执行一次初始检查,验证调用者的oflag参数没有指定不在该对象ipc_perm结构mode成员中的任何访问位。这就是图3-4中底部的方框。举例来说,一个服务器进程可以把它的输入消息队列的mode成员设置成关掉组成员读和其他用户读这两个权限位。任何进程调用针对该消息队列的msgget函数时,如果所指定的oflag参数包含这两位,那么该函数都将返回一个错误。然而由getXXX函数完成的这种测试并没有多大用处。它隐含假定调用者知道自己属于哪个权限范畴——用户、组成员或其他用户。如果创建者特意关掉了某些权限位,而调用者却指定了这些位,那么getXXX函数将检测出这个错误。然而任何进程都能够完全绕过这种检查,其办法是在得知该IPC对象已存在后,简单地指定一个值为0的oflag参数即可。
(2)每次IPC操作都对使用该操作的进程执行一次权限测试。举例来说,每当有一个进程试图使用msgsnd函数往某个消息队列放置一个消息时,msgsnd函数将以下面所列的顺序执行(多个)测试。一旦某个测试赋予了访问权,其后的测试就不再执行。
a)超级用户总是赋予访问权。
b)如果当前进程的有效用户ID等于该IPC对象的uid值或cuid值,而且相应的访问位在该IPC对象的mode成员中是打开的,那么赋予访问权。这儿“相应的访问位”的意思是,如果调用者想要在该IPC对象上执行一个读操作(例如从某个消息队列接收一个消息),那么读位必须设置,如果想要执行一个写操作,那么写位必须设置。
c)如果当前进程的有效组ID等于该IPC对象的gid值或cgid值,而且相应的访问位在该IPC对象的mode成员中是打开的,那么赋予访问权。
d)如果上面的测试没有一个为真,那么相应的“其他用户”访问位在该IPC对象的mode成员中必须是打开的才能赋予访问权。
ipc_perm结构(3.3节)还含有一个名为seq的变量,它是一个槽位使用情况序列号。该变量是一个由内核为系统中每个潜在的IPC对象维护的计数器。每当删除一个IPC对象时,内核就递增相应的槽位号,若溢出则循环回0。
我们在本节讲述的是普通SVR4实现。Unix 98没有强制使用该实现技巧。
该计数器的存在有两个原因。首先,考虑由内核维护的用于打开文件的文件描述符。它们是些小整数,只在单个进程内有意义,也就是它们是进程特定的值。如果我们试图从譬如说文件描述符4读,那么这种尝试只有该进程已在该描述符上打开了一个文件后才会奏效。它对于可能在另外一个与本进程无亲缘关系的进程中打开在文件描述符4上的文件来说,根本没有意义。然而,System V IPC标识符却是系统范围的,而不是特定于进程的。
我们从某个get函数(msgget、semget和shmget)获得一个IPC标识符(类似于文件描述符)。这些标识符也是整数,不过它们的意义适用于所有进程。举例来说,如果有两个无亲缘关系的进程(一个是客户,一个是服务器)使用单个消息队列,那么由msgget函数返回的该消息队列的标识符在双方进程中必须是同一个整数值,这样双方才能访问同一个消息队列。这种特性意味着某个行为不端的进程可能尝试从另外某个应用的消息队列中读消息,办法是尝试不同的小整数标识符,以期待找出一个当前在使用的允许大家读访问的消息队列。要是这些标识符的取值是小整数(像文件描述符那样),那么找到一个有效标识符的可能性约为1:50(假设每个进程最多有约50个描述符)。
为避免这样的问题,这些IPC机制的设计者们把标识符值的可能范围扩大到包含所有整数,而不是仅仅包含小整数。这种扩大是这么实现的:每次重用一个IPC表项时,把返回给调用进程的标识符值增加一个IPC表项数。举例来说,如果系统配置成最多50个消息队列,那么内核中的第一个消息队列表项首次被使用时,返回给进程的标识符值为0。该消息队列被删除,从而第一个表项得以重用后,所返回的标识符为50。再下一次重用时,该标识符变为100,如此等等。既然seq变量通常作为一个无符号长整数实现(见3.3节所示的ipc_perm结构),那么该表项只有在被重用85 899 346(232 /50,假设长整数为32位)次后才循环回0。
递增槽位使用情况序列号的另一个原因是为了避免短时间内重用System V IPC标识符。这有助于确保过早终止的服务器重新启动后不会重用标识符。
作为这种特性的一个例子,图3-7中的程序输出由msgget返回的前10个标识符值。
图3-7 连续输出由内核赋予的消息队列标识符10次
每次循环由msgget创建一个消息队列,然后由使用IPC_RMID命令的msgctl删除该队列。常值SVMSG_MODE定义在我们的unpipc.h头文件中(图C-1),它给我们的System V消息队列指定默认权限位。该程序的输出如下:
solaris % slot
msqid = 0
msqid = 50
msqid = 100
msqid = 150
msqid = 200
msqid = 250
msqid = 300
msqid = 350
msqid = 400
msqid = 450
如果再次运行该程序,我们就能看出槽位使用情况序列号是一个跨进程保持的内核变量。
solaris % slot
msqid = 500
msqid = 550
msqid = 600
msqid = 650
msqid = 700
msqid = 750
msqid = 800
msqid = 850
msqid = 900
msqid = 950
由于System V IPC的三种类型不是以文件系统中的路径名标识的,因此使用标准的ls和rm程序无法看到它们,也无法删除它们。不过实现了这些类型IPC的任何系统都提供两个特殊的程序:ipcs和ipcrm。ipcs输出有关System V IPC特性的各种信息,ipcrm则删除一个System V消息队列、信号量集或共享内存区。前者支持约十来个命令行选项,它们决定报告哪种类型的IPC以及输出哪些信息,后者支持6个命令行选项。所有这些选项的详细信息可查阅它们的手册页面。
System V IPC不是Posix中的内容,因此这两个命令也未被Posix.2标准化。不过它们是Unix 98的内容。
System V IPC的多数实现有内在的内核限制,例如消息队列的最大数目、每个信号量集的最大信号量数,等等。我们将在图6-25、图11-8和图14-5中给出这些限制的某些典型值。这些限制通常起源于最初的System V实现。
[Bach 1986]的11.2节和[Goodheart and Cox 1994]的第8章都讲述了消息队列、信号量和共享内存区的实现。某些限制就在那儿说明。
不幸的是,这些对象的大小被内核限制得往往太小,这是因为其中许多限制起源于在某个小地址空间系统(16位PDP-11)上完成的最初实现。然而万幸的是,多数系统允许管理员部分或完全修改这些默认限制,但是不同风格的Unix所需的步骤也不一样。多数系统要求在修改完值后重新自举运行中的内核。尽管如此,某些实现仍然给其中一些限制使用16位整数,这在无形之中提供了一个难以突破的硬限制。
举例来说,Solaris 2.6有20个这些限制。它们的当前值可使用sysdef命令输出,不过如果相应的内核模块尚未加载(也就是说尚未使用IPC机制),那么所输出的值为0。它们的值可通过在/etc/system文件中加入如下语句来修改,而/etc/system是自举内核时读入的。
set msgsys:msginfo_msgseg = value
set msgsys:msginfo_msgssz = value
set msgsys:msginfo_msgtql = value
set msgsys:msginfo_msgmap = value
set msgsys:msginfo_msgmax = value
set msgsys:msginfo_msgmnb = value
set msgsys:msginfo_msgmni = value
set semsys:seminfo_semopm = value
set semsys:seminfo_semume = value
set semsys:seminfo_semaem = value
set semsys:seminfo_semmap = value
set semsys:seminfo_semvmx = value
set semsys:seminfo_semmsl = value
set semsys:seminfo_semmni = value
set semsys:seminfo_semmns = value
set semsys:seminfo_semmnu = value
set shmsys:shminfo_shmmin = value
set shmsys:shminfo_shmseg = value
set shmsys:shminfo_shmmax = value
set shmsys:shminfo_shmmni = value
等号左边名字中最后6个字符就是列在图6-25、图11-8和图14-5中的变量名。
至于Digital Unix 4.0B,sysconfig程序可用于查询或修改许多内核参数和限制。下面是使用-q选项时该程序的输出,它就ipc子系统查询内核以输出当前限制值。我们已省略掉了与System V IPC机制无关的一些行。
alpha % /sbin/sysconfig -q ipc
ipc:
msg-max = 8192
msg-mnb = 16384
msg-mni = 64
msg-tql = 40
shm-max = 4194304
shm-min = 1
shm-mni = 128
shm-seg = 32
sem-mni = 16
sem-msl = 25
sem-opm = 10
sem-ume = 10
sem-vmx = 32767
sem-aem = 16384
num-of-sems = 60
这些参数的默认值可通过在/etc/sysconfigtab文件中指定不同的值来修改,不过该文件应使用sysconfigdb程序维护。该文件是在系统自举时读入的。
msgget、semget和shmget这三个函数的第一个参数是一个System V IPC键。这些键通常是使用系统的ftok函数从某个路径名创建出的。键还可以是IPC_PRIVATE这个特殊值。这三个函数创建一个新的IPC对象或打开一个已存在的IPC对象,并返回一个System V IPC标识符:接下去用于给其余IPC函数标识该对象的一个整数。这些整数不是特定于进程的标识符(像描述符那样),而是系统范围的标识符。这些标识符还由内核在一段时间后重用。
与每个System V IPC对象相关联的是一个ipc_perm结构,它含有诸如属主的用户ID、组ID、读写权限等信息。Posix IPC和System V IPC的差别之一是,这些信息对于System V IPC对象总是可用的(通过以IPC_STAT命令参数调用三个ctlXXX函数中的某一个),但是对于Posix IPC对象来说,能否访问这些信息要看具体实现。如果Posix IPC对象存放在文件系统中,而且我们知道它们在文件系统中的名字,那么使用现有的文件系统工具就能访问到与ipc_perm结构的内容相同的信息。
在创建一个新的System V IPC对象或打开一个已存在的对象时,可给getXXX函数指定两个标志(IPC_CREAT和IPC_EXCL),外加9个权限位。
毫无疑问,使用System V IPC的最大问题在于多数实现在这些对象的大小上施加了人为的内核限制,这些限制可追溯到它们历史上的最初实现。这就是说,较多使用System V IPC的多数应用需要系统管理员修改这些内核限制,然而不同风格的Unix完成这些修改工作的步骤也不一样。
3.1 粗读一下6.5节的msgctl函数,把图3-7中的程序修改成除输出所赋予的标识符外,还输出ipc_perm结构的seq成员。
3.2 运行图3-7中的程序两次后立即运行一个创建两个消息队列的程序。假设内核从自举以来没有任何其他应用程序使用过其他消息队列,那么由内核返回的作为消息队列标识符的两个值是什么?
3.3 我们在3.5节中指出System V IPC getXXX函数不使用文件模式创建掩码。编写一个测试程序,由它创建一个FIFO(使用4.6节中所述的mkfifo函数)和一个System V消息队列,给它们指定的权限都是666(八进制)。比较创建成的FIFO和消息队列二者的权限。在运行该程序前,确保你的shell umask为非零值。
3.4 如果一个服务器想要为其客户创建一个唯一的消息队列,那么采用哪种方法更恰当——使用某个常值路径名(譬如说该服务器的可执行文件)作为ftok的一个参数呢,还是使用IPC_PRIVATE?
3.5 修改图3-2使其只输出IPC键和路径名。运行find程序输出你的系统上的所有路径名,并将这些路径名逐个作为刚修改过的程序的命令行参数运行之。有多少路径名映射到同一键值上?
3.6 如果你的系统支持sar程序(“系统行为报告程序”),那么运行如下命令:
sar -m 5 6
该命令输出每秒钟的消息队列操作数和信号量操作数,采样频率为5秒钟一次,共6次。
[1]. 意思是用新的出错处理函数代替原来的err_sys,这样对线程函数的调用可直接作为新的出错处理函数中增设的参数用,即err_sys_new("pthread_mutex_lock error",pthread_mutex_lock(&ndone_mutex))。——译者注
[2]. snprinf现在已经是C99标准的函数。——编者注
[3]. 此处为APUE第1版英文原版书页码,第2版为第97~100页,第2版中文版为第80~82页。——编者注
[4]. 同样为第1版英文原版书中页码,第2版为第16~17页,第2版中文版为第12~13页。——编者注
[5]. Unix 98现在宣称:当ftok的id参数的低序8位为0时,该函数的行为是未指定的。查看一番后作者发现Solaris和Digital Unix中关于ftok的手册页面也作了同样的声明。作者不知道这是什么时候加上的,而且作者于1991年编写的“System V Interface Definition”中也没有这样的声明。AIX甚至走得更远,若id为0则返回一个错误。实际上ftok的三种不同实现——System V Release 2、GNU libc和BSD/OS——没有一个要求id为非零:它们只是在id的低序8位中作逻辑或,而不管它的值。