守护进程常常用作服务器进程。确实,我们可以称图13-2中的syslogd进程为服务器进程,用户进程(客户进程)用UNIX域数据报套接字向其发送消息。
一般而言,服务器进程等待客户进程与其联系,提出某种类型的服务要求。图 13-2 中,由syslogd服务器进程提供的服务是将一条出错消息记录到日志文件中。
图13-2中,客户进程和服务器进程之间的通信是单向的。客户进程向服务器进程发送服务请求,服务器进程则不向客户进程回送任何消息。在下面有关进程通信的几章中,我们将见到大量客户进程和服务器进程之间双向通信的实例。客户进程向服务器进程发送请求,服务器进程则向客户进程回送应答。
在服务器进程中调用fork然后exec另一个程序来向客户进程提供服务是很常见的。这些服务器进程通常管理着多个文件描述符:通信端点、配置文件、日志文件和类似的文件。最好的情况下,让子进程中的这些文件描述符保持打开状态并无大碍,因为它们很可能不会被在子进程中执行的程序所使用,尤其是那些与服务器端无关的程序。最坏情况下,保持它们的打开状态会导致安全问题——被执行的程序可能有一些恶意行为,如更改服务器端配置文件或欺骗客户端程序使其认为正在与服务器端通信,从而获取未授权的信息。
解决此问题的一个简单方法是对所有被执行程序不需要的文件描述符设置执行时关闭(close-on-exec)标志。图13-9展示了一个可以用来在服务器端进程中执行上述工作的函数。
图13-9 设置执行时关闭标志
在大多数UNIX系统中,守护进程是一直运行的。为了初始化我们自己的进程,使之作为守护进程运行,需要一些审慎的思索并理解第9章中说明的进程之间的关系。本章开发了一个可由守护进程调用的能对其自身正确初始化的函数。
因为守护进程通常没有控制终端,所以本章还讨论了守护进程记录出错消息的几种方法。我们讨论了在大多数UNIX系统中,守护进程遵循的若干惯例,给出了几个如何实现某些惯例的实例。
13.1 从图13-2可以推测出,直接调用openlog或第一次调用syslog都可以初始化syslog设施,此时一定要打开用于 UNIX 域数据报套接字的特殊设备文件/dev/log。如果调用openlog前,用户进程(守护进程)先调用了chroot,结果会怎么样?
13.2 回顾13.2节中ps 输出的示例。唯一一个不是会话首进程的用户层守护进程是rsyslogd进程。请解释为什么rsyslogd守护进程不是会话首进程。
13.3 列出你系统中所有有效的守护进程,并说明它们各自的功能。
13.4 编写一段程序调用图13-1中daemonize函数。调用该函数后,它已成为守护进程,再调用getlogin(见8.15节)查看该进程是否有登录名。将结果打印到一个文件中。
本章涵盖众多概念和函数,我们把它们统统都放到高级I/O下讨论:非阻塞I/O、记录锁、I/O 多路转接(select 和 poll 函数)、异步 I/O、readv 和 writev 函数以及存储映射 I/O (mmap)。第15章和第17章中的进程间通信以及以后各章中的很多实例都要使用本章所描述的概念和函数。
10.5节中曾将系统调用分成两类:“低速”系统调用和其他。低速系统调用是可能会使进程永远阻塞的一类系统调用,包括:
•如果某些文件类型(如读管道、终端设备和网络设备)的数据并不存在,读操作可能会使调用者永远阻塞;
•如果数据不能被相同的文件类型立即接受(如管道中无空间、网络流控制),写操作可能会使调用者永远阻塞;
•在某种条件发生之前打开某些文件类型可能会发生阻塞(如要打开一个终端设备,需要先等待与之连接的调制解调器应答,又如若以只写模式打开FIFO,那么在没有其他进程已用读模式打开该FIFO时也要等待);
•对已经加上强制性记录锁的文件进行读写;
•某些ioctl操作;
•某些进程间通信函数(见第15章)。
我们也曾说过,虽然读写磁盘文件会暂时阻塞调用者,但并不能将与磁盘I/O有关的系统调用视为“低速”。
非阻塞I/O使我们可以发出open、read和write这样的I/O操作,并使这些操作不会永远阻塞。如果这种操作不能完成,则调用立即出错返回,表示该操作如继续执行将阻塞。
对于一个给定的描述符,有两种为其指定非阻塞I/O的方法。
(1)如果调用open获得描述符,则可指定O_NONBLOCK标志(见3.3节)。
(2)对于已经打开的一个描述符,则可调用fcntl,由该函数打开 O_NONBLOCK 文件状态标志(见3.14节)。图3-12中的函数可用来为一个描述符打开任一文件状态标志。
System V的早期版本使用标志O_NDELAY指定非阻塞方式。在这些System V版本中,如果无数据可读,则read返回0。而UNIX系统又常将read的返回值0解释为文件结束,两者有所混淆。POSIX.1提供了一个非阻塞标志,它的名字和语义都与O_NDELAY不同。确实,在System V的早期版本中,当从read得到返回值0时,我们并不知道该调用是阻塞了还是遇到了文件尾端。POSIX.1要求,对于一个非阻塞的描述符如果无数据可读,则read返回−1,errno被设置为EAGAIN。System V派生的某些平台既支持较旧的O_NDELAY,又支持POSIX.1的O_NONBLOCK,但在本书的实例中只使用POSIX.1规定的特征。较旧的O_NDELAY只是为了向后兼容,不应在新应用程序中使用。
4.3BSD为fcntl提供了FNDELAY标志,其语义也稍有区别。它不只影响描述符的文件状态标志,还将终端设备或套接字的标志更改成非阻塞的,因此不仅影响共享同一文件表项的用户,而且对终端或套接字的所有用户起作用(4.3BSD 非阻塞 I/O 只对终端和套接字起作用)。另外,如果对一个非阻塞描述符的操作不能无阻塞地完成,那么4.3BSD返回EWOULDBLOCK。现今,基于BSD的系统提供POSIX.1的O_NONBLOCK标志,并且将EWOULDBLOCK定义为与POSIX.1的EAGAIN相同。这些系统提供与其他POSIX兼容系统相一致的非阻塞语义:文件状态标志的更改影响同一文件表项的所有用户,但与通过其他文件表项对同一设备的访问无关。
实例
图14-1中的程序是一个非阻塞I/O的实例,它从标准输入读500 000字节,并试图将它们写到标准输出上。该程序先将标准输出设置为非阻塞的,然后用for循环进行输出,每次write调用的结果都在标准错误上打印。函数clr_fl类似于图3-12中的set_fl。这个新函数清除1个或多个标志位。
图14-1 长的非阻塞write
若标准输出是普通文件,则可以期望write只执行一次。
$ ls -l /etc/services 打印文件长度
-rw-r--r-- 1 root 677959 Jun 23 2009 /etc/services
$ ./a.out < /etc/services > temp.file 先试一个普通文件
read 500000 bytes
nwrite = 500000, errno = 0 一次写
$ ls -l temp.file 检验输出文件长度
-rw-rw-r-- 1 sar 500000 Apr 1 13:03 temp.file
但是,若标准输出是终端,则期望write有时返回小于500 000的一个数字,有时返回错误。下面是运行结果:
$ ./a.out < /etc/services 2>stderr.out 终端至输出
大量输出至终端……
$ cat stderr.out
read 500000 bytes
nwrite = 999, errno = 0
nwrite = -1, errno = 35
nwrite = -1, errno = 35
nwrite = -1, errno = 35
nwrite = -1, errno = 35
nwrite = 1001, errno = 0
nwrite = -1, errno = 35
nwrite = 1002, errno = 0
nwrite = 1004, errno = 0
nwrite = 1003, errno = 0
nwrite = 1003, errno = 0
nwrite = 1005, errno = 0
nwrite = -1, errno = 35 61个此类错误
┇
nwrite = 1006, errno = 0
nwrite = 1004, errno = 0
nwrite = 1005, errno = 0
nwrite = 1006, errno = 0
nwrite = -1, errno = 35 108个此类错误
┇
nwrite = 1006, errno = 0
nwrite = 1005, errno = 0
nwrite = 1005, errno = 0
nwrite = -1, errno = 35 681个此类错误
┇
等等
nwrite = 347, errno = 0
在该系统上,errno 值35 对应的是EAGAIN。终端驱动程序一次能接受的数据量随系统而变。具体结果还会因登录系统时所使用的方式的不同而不同:在系统控制台上登录、在硬接线的终端上登录或用伪终端在网络连接上登录。如果你在终端上运行一个窗口系统,那么也是经由伪终端设备与系统交互。
在此实例中,程序发出了9 000多个write调用,但是只有500个真正输出了数据,其余的都只返回了错误。这种形式的循环称为轮询,在多用户系统上用它会浪费CPU时间。14.4节将介绍非阻塞描述符的I/O多路转接,这是进行这种操作的一种比较有效的方法。
有时,可以将应用程序设计成使用多线程的(见第11章),从而避免使用非阻塞I/O。如若我们能在其他线程中继续进行,则可以允许单个线程在I/O调用中阻塞。这种方法有时能简化应用程序的设计(见第21章),但是,线程间同步的开销有时却可能增加复杂性,于是导致得不偿失的后果。
当两个人同时编辑一个文件时,其后果将如何呢?在大多数UNIX系统中,该文件的最后状态取决于写该文件的最后一个进程。但是对于有些应用程序,如数据库,进程有时需要确保它正在单独写一个文件。为了向进程提供这种功能,商用UNIX系统提供了记录锁机制。(第20章包含了使用记录锁的数据库函数库。)
记录锁(record locking)的功能是:当第一个进程正在读或修改文件的某个部分时,使用记录锁可以阻止其他进程修改同一文件区。对于 UNIX 系统而言,“记录”这个词是一种误用,因为 UNIX 系统内核根本没有使用文件记录这种概念。一个更适合的术语可能是字节范围锁(byte-range locking),因为它锁定的只是文件中的一个区域(也可能是整个文件)。
1.历史
对早期UNIX系统的其中一个批评是它们不能用来运行数据库系统,其原因是这些系统不支持对部分文件加锁。在UNIX系统寻找进入商用计算环境的途径时,很多系统开发小组以各种不同方式增加了对记录锁的支持。
早期的伯克利版本只支持flock函数。该函数只能对整个文件加锁,不能对文件中的一部分加锁。
SVR3通过fcntl函数增加了记录锁功能。在此基础上构造了lockf函数,它提供了一个简化的接口。这些函数允许调用者对一个文件中任意字节数的区域加锁,长至整个文件,短至文件中的一个字节。
POSIX.1标准的基础是fcntl方法。图14-2列出了各种系统提供的不同形式的记录锁。注意,Single UNIX Specification在其XSI扩展中包括了lockf。
图14-2 各种UNIX系统支持的记录锁形式
本节最后部分将说明建议性锁和强制性锁之间的区别。本书只介绍POSIX.1的fcntl锁。
记录锁是 1980 年由 John Bass 最早添加到 V7 上的。内核中相应的系统调用入口项是名为locking的函数。此函数提供了强制性记录锁功能,它被用在很多System III版本中。Xenix系统采用了此函数,某些基于Intel的System V派生版本,如OpenServer 5,在Xenix兼容库中仍旧支持该函数。
2.fcntl记录锁
3.14节中已经给出了fcntl函数的原型,为了叙说方便,这里再重复一次。
#include <fcnt1.h>
int fcnt1(int fd, int cmd, .../* struct flock *flockptr */);
返回值:若成功,依赖于cmd(见下),否则,返回−1
对于记录锁,cmd是F_GETLK、F_SETLK或F_SETLKW。第三个参数(我们将调用flockptr)是一个指向flock结构的指针。
struct flock {
short l_type; /* F_RDLCK, F_WRLCK, or F_UNLCK */
short l_whence; /* SEEK_SET, SEEK_CUR, or SEEK_END */
off_t l_start; /* offset in bytes, relative to l_whence */
off_t l_len; /* length, in bytes; 0 means lock to EOF */
pid_t l_pid; /* returned with F_GETLK */
};
对flock结构说明如下。
•所希望的锁类型:F_RDLCK(共享读锁)、F_WRLCK(独占性写锁)或 F_UNLCK(解锁一个区域)。
•要加锁或解锁区域的起始字节偏移量(l_start和l_whence)。
•区域的字节长度(l_len)。
•进程的ID(l_pid)持有的锁能阻塞当前进程(仅由F_GETLK返回)。
关于加锁或解锁区域的说明还要注意下列几项规则。
•指定区域起始偏移量的两个元素与lseek函数(见3.6节)中最后两个参数类似。l_whence可选用的值是SEEK_SET、SEEK_CUR或SEEK_END。
•锁可以在当前文件尾端处开始或者越过尾端处开始,但是不能在文件起始位置之前开始。
•如若l_len 为0,则表示锁的范围可以扩展到最大可能偏移量。这意味着不管向该文件中追加写了多少数据,它们都可以处于锁的范围内(不必猜测会有多少字节被追加写到了文件之后),而且起始位置可以是文件中的任意一个位置。
•为了对整个文件加锁,我们设置l_start和l_whence指向文件的起始位置,并且指定长度(l_len)为0。(有多种方法可以指定文件起始处,但常用的方法是将l_start指定为0,l_whence指定为SEEK_SET。)
上面提到了两种类型的锁:共享读锁(l_type为L_RDLCK)和独占性写锁(L_WRLCK)。基本规则是:任意多个进程在一个给定的字节上可以有一把共享的读锁,但是在一个给定字节上只能有一个进程有一把独占写锁。进一步而言,如果在一个给定字节上已经有一把或多把读锁,则不能在该字节上再加写锁;如果在一个字节上已经有一把独占性写锁,则不能再对它加任何读锁。在图14-3中示出了这些兼容性规则。
图14-3 不同类型锁彼此之间的兼容性
上面说明的兼容性规则适用于不同进程提出的锁请求,并不适用于单个进程提出的多个锁请求。如果一个进程对一个文件区间已经有了一把锁,后来该进程又企图在同一文件区间再加一把锁,那么新锁将替换已有锁。因此,若一进程在某文件的16~32 字节区间有一把写锁,然后又试图在 16~32 字节区间加一把读锁,那么该请求将成功执行,原来的写锁会被替换为读锁。
加读锁时,该描述符必须是读打开。加写锁时,该描述符必须是写打开。
下面说明一下fcntl函数的3种命令。
F_GETLK 判断由flockptr所描述的锁是否会被另外一把锁所排斥(阻塞)。如果存在一把锁,它阻止创建由flockptr所描述的锁,则该现有锁的信息将重写flockptr指向的信息。如果不存在这种情况,则除了将l_type设置为F_UNLCK之外, flockptr所指向结构中的其他信息保持不变。
F_SETLK 设置由 flockptr 所描述的锁。如果我们试图获得一把读锁(l_type 为F_RDLCK)或写锁(l_type为F_WRLCK),而兼容性规则阻止系统给我们这把锁,那么fcntl会立即出错返回,此时errno设置为EACCES或EAGAIN。
虽然POSIX.1 允许实现返回这两种出错代码中的任何一种,但本书说明的4种实现在锁请求不能得到满足时,都返回EAGAIN。
此命令也用来清除由flockptr指定的锁(l_type为F_UNLCK)。
F_SETLKW 这个命令是F_SETLK的阻塞版本(命令名中的W表示等待(wait))。如果所请求的读锁或写锁因另一个进程当前已经对所请求区域的某部分进行了加锁而不能被授予,那么调用进程会被置为休眠。如果请求创建的锁已经可用,或者休眠由信号中断,则该进程被唤醒。
应当了解,用F_GETLK测试能否建立一把锁,然后用F_SETLK或F_SETLKW企图建立那把锁,这两者不是一个原子操作。因此不能保证在这两次fcntl调用之间不会有另一个进程插入并建立一把相同的锁。如果不希望在等待锁变为可用时产生阻塞,就必须处理由F_SETLK返回的可能的出错。
注意,POSIX.1 并没有说明在下列情况下将发生什么:一个进程在某个文件的一个区间上设置了一把读锁,第二个进程在试图对同一文件区间加一把写锁时阻塞,然后第三个进程则试图在同一文件区间上得到另一把读锁。如果第三个进程只是因为读区间已有一把读锁,而被允许在该区间放置另一把读锁,那么这种实现就可能会使希望加写锁的进程饿死。因此,当对同一区间加另一把读锁的请求到达时,提出加写锁而阻塞的进程需等待的时间延长了。如果加读锁的请求来得很频繁,使得该文件区间始终存在一把或几把读锁,那么欲加写锁的进程就将等待很长时间。
在设置或释放文件上的一把锁时,系统按要求组合或分裂相邻区。例如,若第 100~199 字节是加锁的区,需解锁第 150 字节,则内核将维持两把锁,一把用于第 100~149 字节,另一把用于第151~199字节。图14-4说明了这种情况下的字节范围锁。
图14-4 文件字节范围锁
假定我们又对第150字节加锁,那么系统将会再把3个相邻的加锁区合并成一个区(第100~199字节)。其结果如图14-4中的第一个图所示,又跟开始的时候一样了。
实例:请求和释放一把锁
为了避免每次分配flock结构,然后又填入各项信息,可以用图14-5所示的程序中的函数lock_reg来处理所有这些细节。
图14-5 加锁或解锁一个文件区域的函数
因为大多数锁调用是加锁或解锁一个文件区域(命令F_GETLK很少使用),故通常使用下列5个宏中的一个,这5个宏都定义在apue.h中(见附录B)。
#define read_lock(fd,offset,whence,len) \
lock_reg((fd), F_SETLK, F_RDLCK, (offset), (whence), (len))
#define readw_lock(fd,offset,whence,len) \
lock_reg((fd), F_SETLKW, F_RDLCK, (offset), (whence), (len))
#define write_lock(fd,offset,whence,len) \
lock_reg((fd), F_SETLK, F_WRLCK, (offset), (whence), (len))
#define writew_lock(fd,offset,whence,len) \
lock_reg((fd), F_SETLKW, F_WRLCK, (offset), (whence), (len))
#define un_lock(fd,offset,whence,len) \
lock_reg((fd), F_SETLK, F_UNLCK, (offset), (whence), (len))
我们有目的地用与lseek函数同样的顺序定义了这些宏中的前3个参数。
实例:测试一把锁
图14-6中定义了一个函数lock_test,我们将用它测试一把锁。
图14-6 测试一个锁条件的函数
如果存在一把锁,它阻塞由参数指定的锁请求,则此函数返回持有这把现有锁的进程的进程ID,否则此函数返回0。通常用下面两个宏来调用此函数(它们也定义在apue.h中)。
#define is_read_lockable(fd, offset, whence, len) \
(lock_test((fd), F_RDLCK, (offset), (whence), (len)) == 0)
#define is_write_lockable(fd, offset, whence, len) \
(lock_test((fd), F_WRLCK, (offset), (whence), (len)) == 0)
注意,进程不能使用 lock_test 函数测试它自己是否在文件的某一部分持有一把锁。F_GETLK 命令的定义说明,返回信息指示是否有现有的锁阻止调用进程设置它自己的锁。因为F_SETLK和F_SETLKW命令总是替换调用进程现有的锁(若已存在),所以调用进程决不会阻塞在自己持有的锁上,于是,F_GETLK命令决不会报告调用进程自己持有的锁。
实例:死锁
如果两个进程相互等待对方持有并且不释放(锁定)的资源时,则这两个进程就处于死锁状态。如果一个进程已经控制了文件中的一个加锁区域,然后它又试图对另一个进程控制的区域加锁,那么它就会休眠,在这种情况下,有发生死锁的可能性。
图14-7所示的程序给出了一个死锁的例子。子进程对第0字节加锁,父进程对第1字节加锁。然后,它们中的每一个又试图对对方已经加锁的字节加锁。在该程序中使用了8.9节中介绍的父进程和子进程同步例程(TELL_xxx和WAIT_xxx),以便每个进程能够等待另一个进程获得它设置的第一把锁。
图14-7 死锁检测实例
运行图14-7中的程序得到:
$ ./a.out
parent: got the lock, byte 1
child: got the lock, byte 0
parent: writew_lock error: Resource deadlock avoided
child: got the lock, byte 1
检测到死锁时,内核必须选择一个进程接收出错返回。在本实例中,选择了父进程,但这是一个实现细节。在某些系统上,子进程总是接到出错信息,在另一些系统上,父进程总是接到出错信息。在某些系统上,当试图使用多把锁时,有时是子进程接到出错信息,有时则是父进程接到出错信息。
3.锁的隐含继承和释放
关于记录锁的自动继承和释放有3条规则。
(1)锁与进程和文件两者相关联。这有两重含义:第一重很明显,当一个进程终止时,它所建立的锁全部释放;第二重则不太明显,无论一个描述符何时关闭,该进程通过这一描述符引用的文件上的任何一把锁都会释放(这些锁都是该进程设置的)。这就意味着,如果执行下列4步:
fd1 = open(pathname, ...);
read_lock(fd1, ...);
fd2 = dup(fd1);
close(fd2);
则在close(fd2)后,在fd1上设置的锁被释放。如果将dup替换为open,其效果也一样:
fd1 = open(pathname, ...);
read_lock(fd1, ...);
fd2 = open(pathname, ...)
close(fd2);
(2)由fork产生的子进程不继承父进程所设置的锁。这意味着,若一个进程得到一把锁,然后调用 fork,那么对于父进程获得的锁而言,子进程被视为另一个进程。对于通过 fork 从父进程处继承过来的描述符,子进程需要调用 fcntl 才能获得它自己的锁。这个约束是有道理的,因为锁的作用是阻止多个进程同时写同一个文件。如果子进程通过fork继承父进程的锁,则父进程和子进程就可以同时写同一个文件。
(3)在执行exec后,新程序可以继承原执行程序的锁。但是注意,如果对一个文件描述符设置了执行时关闭标志,那么当作为exec的一部分关闭该文件描述符时,将释放相应文件的所有锁。
4.FreeBSD实现
先简要地观察FreeBSD实现中使用的数据结构。这会帮助我们进一步理解记录锁的自动继承和释放的第一条规则:锁与进程和文件两者相关联。
考虑一个进程,它执行下列语句(忽略出错返回)。
fd1 = open(pathname, ...);
write_lock(fd1, 0, SEEK_SET, 1); /* parent write locks byte 0 */
if ((pid = fork()) > 0) { /* parent */
fd2 = dup(fd1);
fd3 = open(pathname, ...);
} else if (pid == 0) {
read_lock(fd1, 1, SEEK_SET, 1); /* child read locks byte 1 */
}
pause();
图14-8显示了父进程和子进程暂停(执行pause())后的数据结构情况。
图14-8 关于记录锁的FreeBSD数据结构
前面已经给出了open、fork以及dup调用后的数据结构(见图3-9和图8-2)。有了记录锁后,在原来的这些图上新加了lockf结构,它们由i节点结构开始相互链接起来。每个lockf结构描述了一个给定进程的一个加锁区域(由偏移量和长度定义的)。图中显示了两个lockf结构,一个是由父进程调用write_lock形成的,另一个则是由子进程调用read_lock形成的。每一个结构都包含了相应的进程ID。
在父进程中,关闭fd1、fd2或fd3中的任意一个都将释放由父进程设置的写锁。在关闭这3个描述符中的任意一个时,内核会从该描述符所关联的i节点开始,逐个检查lockf链接表中的各项,并释放由调用进程持有的各把锁。内核并不清楚(也不关心)父进程是用这3个描述中的哪一个来设置这把锁的。
实例
在图13-6所示的程序中,我们了解到,守护进程可用一把文件锁来保证只有该守护进程的唯一副本在运行。图14-9展示了lockfile函数的实现,守护进程可用该函数在文件上加写锁。
图14-9 在文件整体上加一把写锁
另一种方法是用write_lock函数定义lockfile函数。
#define lockfile(fd) write_lock((fd), 0, SEEK_SET, 0)
5.在文件尾端加锁
在对相对于文件尾端的字节范围加锁或解锁时需要特别小心。大多数实现按照 l_whence的SEEK_CUR或SEEK_END值,用l_start以及文件当前位置或当前长度得到绝对文件偏移量。但是,常常需要相对于文件的当前长度指定一把锁,但又不能调用fstat来得到当前文件长度,因为我们在该文件上没有锁。(在 fstat和锁调用之间,可能会有另一个进程改变该文件长度。)
考虑以下代码序列:
writew_lock(fd, 0, SEEK_END, 0);
write(fd, buf, 1);
un_lock(fd, 0, SEEK_END);
write(fd, buf, 1);
该代码序列所做的可能并不是你所期望的。它得到一把写锁,该写锁从当前文件尾端起,包括以后可能追加写到该文件的任何数据。假定,该文件偏移量处于文件尾端时,执行第一个write,这个操作将文件延伸了1个字节,而该字节将被加锁。跟随其后的是解锁操作,其作用是对以后追加写到文件上的数据不再加锁。但在其之前刚追加写的一个字节则保留加锁状态。当执行第二个写时,文件尾端又延伸了1个字节,但该字节并未加锁。由此代码序列造成的文件锁状态如图14-10所示。
图14-10 文件区域锁
当对文件的一部分加锁时,内核将指定的偏移量变换成绝对文件偏移量。另外,除了指定一个绝对偏移量(SEEK_SET)之外,fcntl还允许我们相对于文件中的某个点指定该偏移量,这个点是指当前偏移量(SEEK_CUR)或文件尾端(SEEK_END)。当前偏移量和文件尾端可能会不断变化,而这种变化又不应影响现有锁的状态,所以内核必须独立于当前文件偏移量或文件尾端而记住锁。
如果想解除的锁中包括第一次write 所写的1 个字节,那么应指定长度为−1。负的长度值表示在指定偏移量之前的字节数。
6.建议性锁和强制性锁
考虑数据库访问例程库。如果该库中所有函数都以一致的方法处理记录锁,则称使用这些函数访问数据库的进程集为合作进程(cooperating process)。如果这些函数是唯一地用来访问数据库的函数,那么它们使用建议性锁是可行的。但是建议性锁并不能阻止对数据库文件有写权限的任何其他进程写这个数据库文件。不使用数据库访问例程库协同一致的方法来访问数据库的进程是非合作进程。
强制性锁会让内核检查每一个 open、read 和 write,验证调用进程是否违背了正在访问的文件上的某一把锁。强制性锁有时也称为强迫方式锁(enforcement-mode locking)。
从图14-2中可以看出,Linux 3.2.0和Solaris 10提供强制性记录锁,而FreeBSD 8.0和Mac OS X 10.6.8则不提供。强制性记录锁不是Single UNIX Specification的组成部分。在Linux中,如果用户想要使用强制性锁,则需要在各个文件系统基础上用mount命令的-o mand选项来打开该机制。
对一个特定文件打开其设置组ID位、关闭其组执行位便开启了对该文件的强制性锁机制(回忆图4-12)。因为当组执行位关闭时,设置组ID位不再有意义,所以SVR3的设计者借用两者的这种组合来指定对一个文件的锁是强制性的而非建议性的。
如果一个进程试图读(read)或写(write)一个强制性锁起作用的文件,而欲读、写的部分又由其他进程加上了锁,此时会发生什么呢?对这一问题的回答取决于3方面的因素:操作类型(read或write)、其他进程持有的锁的类型(读锁或写锁)以及read或write的描述符是阻塞还是非阻塞的。图14-11列出了8种可能性。
图14-11 强制性锁对其他进程的read和write的影响
除了图14-11中的read和write函数,另一个进程持有的强制性锁也会对open函数产生影响。通常,即使正在打开的文件具有强制性记录锁,该open也会成功。随后的read或write依从于图14-11中所示的规则。但是,如果欲打开的文件具有强制性记录锁(读锁或写锁),而且open调用中的标志指定为O_TRUNC或O_CREAT,则不论是否指定O_NONBLOCK,open都立即出错返回,errno设置为EAGAIN。
只有Solaris对O_CREAT标志处理为出错。当打开一个具强制性锁的文件时,Linux允许指定O_CREAT标志。对O_TRUNC标志产生open出错是有意义的,因为对于一个文件来讲,若另一个进程持有它的读锁或写锁,那么它就不能被截短为0。但是对O_CREAT标志在返回时设置出错就没什么意义了,因为该标志表示,只有在该文件不存在时才创建,但由于另一个进程持有该文件的记录锁,所以该文件肯定是存在的。
这种open的锁冲突处理方式可能会导致令人惊异的结果。在开发本节习题的时候,我们曾编写过一个测试程序,它打开一个文件(其模式指定为强制性锁),对该文件整体设置一把读锁,然后休眠一段时间。(回忆图 14-11,读锁应当阻止其他进程写该文件。)在这段休眠时间内,用某些典型的UNIX系统程序和操作符对该文件进行处理,发现下列情况。
•可用ed编辑器对该文件进行编辑操作,而且编辑结果可以写回磁盘!强制性记录锁根本不起作用。用某些UNIX系统版本提供的系统调用跟踪特性,对ed操作进行跟踪分析发现,ed将新内容写到一个临时文件中,然后删除原文件,最后将临时文件名改为原文件名。强制性锁机制对unlink函数没有影响,于是这一切就发生了。
在FreeBSD 8.0和Solaris 10中,用truss(1)命令可以得到一个进程的系统调用跟踪信息。Linux 3.2.0出于相同的目的提供了strace(1)命令。Mac OS X 10.6.8提供了dtruss(1m)命令来追踪系统调用,但该命令的使用需要超级用户的权限。
•不能用vi 编辑器编辑该文件。vi 可以读该文件的内容,但是如果试图将新的数据写到该文件中,就会出错返回(EAGAIN)。如果试图将新数据追加写到该文件中,则 write阻塞。vi的这种行为与我们所希望的一样。
•使用Korn shell的>和>>操作符重写或追加写该文件,会产生出误信息“cannot create”。
•在Bourne shell下使用>操作符也会出错,但是使用>>操作符时只阻塞,在解除强制性锁后会继续进行处理。(这两种shell在执行追加写操作时之所以会产生的差异,是因为Korn shell以O_CREAT和O_APPEND标志打开文件,而上面已提及指定O_CREAT会产生出错返回。但是, Bourne shell在该文件已存在时并不指定O_CREAT,所以open成功,而下一个write则阻塞。)产生的结果随所用操作系统版本的不同而不同。从这样一个习题中可见,在使用强制性锁时还需有所警惕。从ed实例可以看到,强制性锁是可以设法避开的。
一个恶意用户可以使用强制性记录锁,对大家都可读的文件加一把读锁,这样就能阻止任何人写该文件(当然,该文件应当是强制性锁机制起作用的,这可能要求该用户能够更改该文件的权限位)。考虑一个数据库文件,它是大家都可读的,并且是强制性锁机制起作用的。如果一个恶意用户要对整个这个文件持有一把读锁,其他进程就不能再写该文件。
实例
图14-12中的程序可以用于确定一个系统是否支持强制性锁机制。
图14-12 确定是否支持强制性锁
此程序首先创建一个文件,并使强制性锁机制对其起作用。然后程序分出一个父进程和一个子进程。父进程对整个文件设置一把写锁,子进程则先将该文件的描述符设置为非阻塞的,然后企图对该文件设置一把读锁,我们期望这会出错返回,并希望看到系统返回是 EACCES 或EAGAIN。接着,子进程将文件读、写位置调整到文件起点,并试图读(read)该文件。如果系统提供强制性锁机制,则 read 应返回 EACCES 或 EAGAIN(因为该描述符是非阻塞的),否则read返回所读的数据。在Solaris 10上运行此程序(该系统支持强制性锁机制),得到:
$ ./a.out temp.lock
read_lock of already-locked region returns 11
read failed (mandatory locking works): Resource temporarily unavailable
查看系统头文件或intro(2)手册页,可以看到errno值11对应于EAGAIN。若在FreeBSD 8.0运行此程序,则得到:
$ ./a.out temp.lock
read_lock of already_locked region returns 35
read OK (no mandatory locking), buf = ab
其中,errno值35对应于EAGAIN。该系统不支持强制性锁。
实例
让我们回到本节的第一个问题:当两个人同时编辑同一个文件时将会怎样呢?一般的 UNIX系统文本编辑器并不使用记录锁,所以对此问题的回答仍然是:该文件的最后结果取决于写该文件的最后一个进程。
某些版本的vi编辑器使用建议性记录锁。即使我们使用这种版本的vi编辑器,它仍然不能阻止其他用户使用另一个没有使用建议性记录锁的编辑器。
若系统提供强制性记录锁,那么我们可以修改自己常用的编辑器来使用它(如果我们有该编辑器的源代码)。如果没有该编辑器的源代码,那么可以试一试下述方法。编写一个vi的前端程序。该程序立即调用fork,然后父进程只等待子进程完成。子进程打开在命令行中指定的文件,使强制性锁起作用,对整个文件设置一把写锁,然后执行vi。在vi运行时,该文件是加了写锁的,所以其他用户不能修改它。当vi结束时,父进程从wait返回,自编的前端程序结束。
虽然可以编写这种类型的小型前端程序,但它却不起作用。问题出在大多数编辑器读它们的输入文件,然后关闭它。只要引用被编辑文件的描述符关闭了,那么加在该文件上的锁就被释放了。这意味着,在编辑器读了该文件的内容后,随即关闭了该文件,那么锁也就不存在了。这个前端程序中没有任何方法可以阻止这一点。
在第 20 章中,我们将使用数据库函数库中的记录锁来提供多个进程的并发访问。我们还将提供一些时间测量,以观察记录锁对进程的影响。
当从一个描述符读,然后又写到另一个描述符时,可以在下列形式的循环中使用阻塞I/O:
while ((n=read(STDIN_FILENO, buf, BUFSIZ)) > 0)
if (write(STDOUT_FILENO, buf, n) != n)
err_sys("write error");
这种形式的阻塞I/O到处可见。但是如果必须从两个描述符读,又将如何呢?在这种情况下,我们不能在任一个描述符上进行阻塞读(read),否则可能会因为被阻塞在一个描述符的读操作上而导致另一个描述符即使有数据也无法处理。所以为了处理这种情况需要另一种不同的技术。
让我们观察telnet(1)命令的结构。该程序从终端(标准输入)读,将所得数据写到网络连接上,同时从网络连接读,将所得数据写到终端上(标准输出)。在网络连接的另一端,telnetd守护进程读用户键入的命令,并将所读到的送给 shell,这如同用户登录到远程机器上一样。telnetd 守护进程将执行用户键入命令而产生的输出通过 telnet 命令送回给用户,并显示在用户终端上。图14-13显示了这种工作情景。
图14-13 telnet程序概观
telnet 进程有两个输入,两个输出。我们不能对两个输入中的任一个使用阻塞 read,因为我们不知道到底哪一个输入会得到数据。
处理这种特殊问题的一种方法是,将一个进程变成两个进程(用fork),每个进程处理一条数据通路。图14-14中显示了这种安排。(System V的uucp通信包提供了cu(1)命令,其结构与此相似。)
图14-14 使用两个进程实现telnet程序
如果使用两个进程,则可使每个进程都执行阻塞read。但是这也产生了问题:操作什么时候终止?如果子进程接收到文件结束符(telnetd守护进程使网络连接断开),那么该子进程终止,然后父进程接收到 SIGCHLD 信号。但是,如果父进程终止(用户在终端上键入了文件结束符),那么父进程应通知子进程停止。为此可以使用一个信号(如SIGUSR1),但这使程序变得更加复杂。
我们可以不使用两个进程,而是用一个进程中的两个线程。虽然这避免了终止的复杂性,但却要求处理两个线程之间的同步,在复杂性方面这可能会得不偿失。
另一个方法是仍旧使用一个进程执行该程序,但使用非阻塞I/O读取数据。其基本思想是:将两个输入描述符都设置为非阻塞的,对第一个描述符发一个 read。如果该输入上有数据,则读数据并处理它。如果无数据可读,则该调用立即返回。然后对第二个描述符作同样的处理。在此之后,等待一定的时间(可能是若干秒),然后再尝试从第一个描述符读。这种形式的循环称为轮询。这种方法的不足之处是浪费CPU时间。大多数时间实际上是无数据可读,因此执行read系统调用浪费了时间。在每次循环后要等多长时间再执行下一轮循环也很难确定。虽然轮询技术在支持非阻塞I/O的所有系统上都可使用,但是在多任务系统中应当避免使用这种方法。
还有一种技术称为异步I/O(asynchronous I/O)。利用这种技术,进程告诉内核:当描述符准备好可以进行I/O时,用一个信号通知它。这种技术有两个问题。首先,尽管一些系统提供了各自的受限形式的异步I/O,但POSIX采纳了另外一套标准化接口,所以可移植性成为一个问题(以前,POSIX异步I/O是Single UNIX Specification中是可选设施,但现在,这些接口在SUSv4中是必需的)。System V 提供了 SIGPOLL 信号来支持受限形式的异步 I/O,但是仅当描述符引用STREAMS设备时,此信号才起作用。BSD有一个类似的信号SIGIO,但也有类似的限制:仅当描述符引用终端设备或网络时它才能起作用。
这种技术的第二个问题是,这种信号对每个进程而言只有1个(SIGPOLL或SIGIO)。如果使该信号对两个描述符都起作用(在我们正在讨论的实例中,从两个描述符读),那么进程在接到此信号时将无法判别是哪一个描述符准备好了。尽管POSIX.1异步I/O接口允许选择哪个信号作为通知,但能用的信号数量仍远小于潜在的打开文件描述符的数量。为了确定是哪一个描述符准备好了,仍需将这两个描述符都设置为非阻塞的,并顺序尝试执行I/O。我们将在14.5节讨论异步I/O。
一种比较好的技术是使用I/O多路转接(I/O multiplexing)。为了使用这种技术,先构造一张我们感兴趣的描述符(通常都不止一个)的列表,然后调用一个函数,直到这些描述符中的一个已准备好进行I/O时,该函数才返回。poll、pselect和select这3个函数使我们能够执行I/O多路转接。在从这些函数返回时,进程会被告知哪些描述符已准备好可以进行I/O。
POSIX指定,为了在程序中使用select,必须包括<sys/select.h>。但较老的系统还要求包括<sys/types.h>、<sys/time.h>和<unistd.h>。查看select手册页可以弄清楚你的系统都支持什么。
I/O多路转接在4.2BSD中是用select函数提供的。虽然该函数主要用于终端I/O和网络I/O,但它对其他描述符同样是起作用的。SVR3在增加STREAMS机制时增加了poll函数。但在SVR4之前,poll只对STREAMS设备起作用。SVR4支持对任意描述符起作用的poll。
在所有POSIX兼容的平台上,select函数使我们可以执行I/O多路转接。传给select的参数告诉内核:
•我们所关心的描述符;
•对于每个描述符我们所关心的条件(是否想从一个给定的描述符读,是否想写一个给定的描述符,是否关心一个给定描述符的异常条件);
•愿意等待多长时间(可以永远等待、等待一个固定的时间或者根本不等待)。
从select返回时,内核告诉我们:
•已准备好的描述符的总数量;
•对于读、写或异常这3个条件中的每一个,哪些描述符已准备好。
使用这种返回信息,就可调用相应的I/O 函数(一般是read 或write),并且确知该函数不会阻塞。
#include <sys/select.h>
int select(int maxfdp1, fd_set *restrict readfds,
fd_set *restrict writefds, fd_set *restrict exceptfds,
struct timeval *restrict tvptr);
返回值:准备就绪的描述符数目;若超时,返回0;若出错,返回−1
先来说明最后一个参数,它指定愿意等待的时间长度,单位为秒和微秒(回忆 4.20 节)。有以下3种情况。
tvptr == NULL
永远等待。如果捕捉到一个信号则中断此无限期等待。当所指定的描述符中的一个已准备好或捕捉到一个信号则返回。如果捕捉到一个信号,则select返回-1,errno设置为EINTR。
tvptr->tv_sec == 0 && tvptr->tv_usec == 0
根本不等待。测试所有指定的描述符并立即返回。这是轮询系统找到多个描述符状态而不阻塞select函数的方法。
tvptr->tv_sec != 0 || tvptr->tv_usec != 0
等待指定的秒数和微秒数。当指定的描述符之一已准备好,或当指定的时间值已经超过时立即返回。如果在超时到期时还没有一个描述符准备好,则返回值是 0。(如果系统不提供微秒级的精度,则tvptr->tv_usec值取整到最近的支持值。)与第一种情况一样,这种等待可被捕捉到的信号中断。
POSIX.1允许实现修改timeval结构中的值,所以在select返回后,你不能指望该结构仍旧保持调用select之前它所包含的值。FreeBSD 8.0、Mac OS X 10.6.8和Solaris 10都保持该结构中的值不变。但是,若在超时时间尚未到期时,select就返回,那么Linux 3.2.0将用剩余时间值更新该结构。
中间3个参数readfds、writefds和exceptfds是指向描述符集的指针。这3个描述符集说明了我们关心的可读、可写或处于异常条件的描述符集合。每个描述符集存储在一个fd_set数据类型中。这个数据类型是由实现选择的,它可以为每一个可能的描述符保持一位。我们可以认为它只是一个很大的字节数组,如图14-15所示。
图14-15 对select指定读、写和异常条件描述符
对于fd_set数据类型,唯一可以进行的处理是:分配一个这种类型的变量,将这种类型的一个变量值赋给同类型的另一个变量,或对这种类型的变量使用下列4个函数中的一个。
#include <sys/select.h>
int FD_ISSET(int fd, fd_set *fdset);
返回值:若fd在描述符集中,返回非0值;否则,返回0
void FD_CLR(int fd, fd_set *fdset);
void FD_SET(int fd, fd_set *fdset);
void FD_ZERO(fd_set *fdset);
这些接口可实现为宏或函数。调用FD_ZERO将一个fd_set变量的所有位设置为0。要开启描述符集中的一位,可以调用FD_SET。调用 FD_CLR可以清除一位。最后,可以调用FD_ISSET测试描述符集中的一个指定位是否已打开。
在声明了一个描述符集之后,必须用FD_ZERO将这个描述符集置为0,然后在其中设置我们关心的各个描述符的位。具体操作如下所示:
fd_set rset;
int fd;
FD_ZERO(&rset);
FD_SET(fd, &rset);
FD_SET(STDIN_FILENO, &rset);
从select返回时,可以用FD_ISSET测试该集中的一个给定位是否仍处于打开状态:
if (FD_ISSET(fd, &rset)) {
┇
}
select的中间3个参数(指向描述符集的指针)中的任意一个(或全部)可以是空指针,这表示对相应条件并不关心。如果所有3个指针都是NULL,则select提供了比sleep更精确的定时器。(回忆10.19节,sleep等待整数秒,而select的等待时间则可以小于1秒,其实际精度取决于系统时钟。)习题14.5给出了这样一个函数。
select第一个参数maxfdp1的意思是“最大文件描述符编号值加1”。考虑所有3个描述符集,在3个描述符集中找出最大描述符编号值,然后加1,这就是第一个参数值。也可将第一个参数设置为FD_SETSIZE,这是<sys/select.h>中的一个常量,它指定最大描述符数(经常是1 024),但是对大多数应用程序而言,此值太大了。确实,大多数应用程序只使用3~10个描述符(某些应用程序需要更多的描述符,但这种UNIX程序并不典型)。通过指定我们所关注的最大描述符,内核就只需在此范围内寻找打开的位,而不必在3个描述符集中的数百个没有使用的位内搜索。
例如,图14-16所示的两个描述符集的情况就好像是执行了下述操作:
fd_set readset, writeset;
FD_ZERO(&readset);
FD_ZERO(&writeset);
FD_SET(0, &readset);
FD_SET(3, &readset);
FD_SET(1, &writeset);
FD_SET(2, &writeset);
select(4, &readset, &writeset, NULL, NULL);
因为描述符编号从0开始,所以要在最大描述符编号值上加1。第一个参数实际上是要检查的描述符数(从描述符0开始)。
select有3个可能的返回值。
图14-16 select的样本描述符集
(1)返回值-1表示出错。这是可能发生的,例如,在所指定的描述符一个都没准备好时捕捉到一个信号。在此种情况下,一个描述符集都不修改。
(2)返回值0表示没有描述符准备好。若指定的描述符一个都没准备好,指定的时间就过了,那么就会发生这种情况。此时,所有描述符集都会置0。
(3)一个正返回值说明了已经准备好的描述符数。该值是3个描述符集中已准备好的描述符数之和,所以如果同一描述符已准备好读和写,那么在返回值中会对其计两次数。在这种情况下, 3个描述符集中仍旧打开的位对应于已准备好的描述符。
对于“准备好”的含义要作一些更具体的说明。
•若对读集(readfds)中的一个描述符进行的read操作不会阻塞,则认为此描述符是准备好的。
•若对写集(writefds)中的一个描述符进行的write操作不会阻塞,则认为此描述符是准备好的。
•若对异常条件集(exceptfds)中的一个描述符有一个未决异常条件,则认为此描述符是准备好的。现在,异常条件包括:在网络连接上到达带外的数据,或者在处于数据包模式的伪终端上发生了某些条件。(Stevens[1990]的15.10节中描述了后一种条件。)
•对于读、写和异常条件,普通文件的文件描述符总是返回准备好。
一个描述符阻塞与否并不影响select是否阻塞,理解这一点很重要。也就是说,如果希望读一个非阻塞描述符,并且以超时值为5秒调用select,则select最多阻塞5 s。相类似,如果指定一个无限的超时值,则在该描述符数据准备好,或捕捉到一个信号之前,select会一直阻塞。
如果在一个描述符上碰到了文件尾端,则select会认为该描述符是可读的。然后调用read,它返回0,这是UNIX系统指示到达文件尾端的方法。(很多人错误地认为,当到达文件尾端时, select会指示一个异常条件。)
POSIX.1也定义了一个select的变体,称为pselect。
#include <sys/select.h>
int pselect(int maxfdp1, fd_set *restrict readfds,
fd_set *restrict writefds, fd_set *restrict exceptfds,
const struct timespec *restrict tsptr,
const sigset_t *restrict sigmask);
返回值:准备就绪的描述符数目;若超时,返回0;若出错,返回−1
除下列几点外,pselect与select相同。
•select的超时值用timeval结构指定,但pselect使用timespec结构(回忆4.2节中timespec结构的定义)。timespec结构以秒和纳秒表示超时值,而非秒和微秒。如果平台支持这样的时间精度,那么timespec就能提供更精准的超时时间。
•pselect的超时值被声明为const,这保证了调用pselect不会改变此值。
•pselect 可使用可选信号屏蔽字。若 sigmask 为 NULL,那么在与信号有关的方面, pselect 的运行状况和 select 相同。否则,sigmask 指向一信号屏蔽字,在调用pselect时,以原子操作的方式安装该信号屏蔽字。在返回时,恢复以前的信号屏蔽字。
poll函数类似于select,但是程序员接口有所不同。虽然poll函数是System V引入进来支持STREAMS子系统的,但是poll函数可用于任何类型的文件描述符。
#include <poll.h>
int poll(struct pollfd fdarray[], nfds_t nfds, int timeout);
返回值:准备就绪的描述符数目;若超时,返回0;若出错,返回-1
与select不同,poll不是为每个条件(可读性、可写性和异常条件)构造一个描述符集,而是构造一个pollfd结构的数组,每个数组元素指定一个描述符编号以及我们对该描述符感兴趣的条件。
struct pollfd {
int fd; /* file descriptor to check, or < 0 to ignore */
short events; /* events of interest on fd */
short revents; /* events that occurred on fd */
};
fdarray数组中的元素数由nfds指定。
由于历史原因,在如何声明 nfds 参数方面有几种不同的方式。SVR3 将 nfds 的类型指定为unsigned long,这似乎是太大了。在SVR4手册[AT&T 1990d]中,poll原型的第二个参数的数据类型为size_t(见图2-21中的基本系统数据类型)。但在<poll.h>包含的实际原型中,第二个参数的数据类型仍指定为unsigned long。Single UNIX Specification定义了新类型nfds_t,该类型允许实现选择对其合适的类型并且隐藏了应用细节。注意,因为返回值表示数组中满足事件的项数,所以这种类型必须大得足以保存一个整数。
对应于 SVR4 的 SVID[AT&T 1989]上显示,poll 的第一个参数是 struct pollfd fdarray[],而SVR4手册页[AT&T 1990d]上则显示该参数为struct pollfd*fdarray。在C语言中,这两种声明是等价的。我们使用第一种声明是为了重申fdarray指向的是一个结构数组,而不是指向单个结构的指针。
应将每个数组元素的events成员设置为图14-17中所示值的一个或几个,通过这些值告诉内核我们关心的是每个描述符的哪些事件。返回时,revents 成员由内核设置,用于说明每个描述符发生了哪些事件。(注意,poll没有更改events成员。这与select不同,select修改其参数以指示哪一个描述符已准备好了。)
图14-17中的前4行测试的是可读性,接下来的3行测试的是可写性,最后3行测试的是异常条件。最后3行是由内核在返回时设置的。即使在events字段中没有指定这3个值,如果相应条件发生,在revents中也会返回它们。
有些poll事件的名字中包含BAND,它指的是STREAMS当中的优先级波段。想要了解关于STREAMS和优先级波段的更多信息,可以查看Rago[1993]。
图14-17 poll的events和revents标志
当一个描述符被挂断(POLLHUP)后,就不能再写该描述符,但是有可能仍然可以从该描述符读取到数据。
poll的最后一个参数指定的是我们愿意等待多长时间。如同select一样,有3种不同的情形。
timeout == -1
永远等待。(某些系统在<stropts.h>中定义了常量INFTIM,其值通常是-1。)当所指定的描述符中的一个已准备好,或捕捉到一个信号时返回。如果捕捉到一个信号,则poll返回-1,errno设置为EINTR。
timeout == 0
不等待。测试所有描述符并立即返回。这是一种轮询系统的方法,可以找到多个描述符的状态而不阻塞poll函数。
timeout > 0
等待timeout毫秒。当指定的描述符之一已准备好,或timeout到期时立即返回。如果timeout到期时还没有一个描述符准备好,则返回值是0。(如果系统不提供毫秒级精度,则timeout值取整到最近的支持值。)
理解文件尾端与挂断之间的区别是很重要的。如果我们正从终端输入数据,并键入文件结束符,那么就会打开POLLIN,于是我们就可以读文件结束指示(read返回0)。revents中的POLLHUP没有打开。如果正在读调制解调器,并且电话线已挂断,我们将接到POLLHUP通知。
与select一样,一个描述符是否阻塞不会影响poll是否阻塞。
select和poll的可中断性
中断的系统调用的自动重启是由4.2BSD引入的(见10.5节),但当时select函数是不重启的。这种特性在大多数系统中一直延续了下来,即使指定了SA_RESTART选项也是如此。但是,在SVR4上,如果指定了SA_RESTART,那么select和poll也是自动重启的。为了在将软件移植到SVR4派生的系统上时阻止这一点,如果信号有可能会中断select或poll,就要使用signal_intr函数(见图10-19)。
本书说明的各种实现在接到一信号时都不重启动poll和select,即便使用了SA_RESTART标志也是如此。
使用上一节说明的select和poll可以实现异步形式的通知。关于描述符的状态,系统并不主动告诉我们任何信息,我们需要进行查询(调用select或poll)。如在第10章中所述,信号机构提供了一种以异步形式通知某种事件已发生的方法。由BSD和System V派生的所有系统都提供了某种形式的异步I/O,使用一个信号(在System V中是SIGPOLL,在BSD中是SIGIO)通知进程,对某个描述符所关心的某个事件已经发生。我们在前面的章节中提到过,这些形式的异步I/O是受限制的:它们并不能用在所有的文件类型上,而且只能使用一个信号。如果要对一个以上的描述符进行异步I/O,那么在进程接收到该信号时并不知道这一信号对应于哪一个描述符。
SUSv4中将通用的异步I/O机制从实时扩展部分调整到基本规范部分。这种机制解决了这些陈旧的异步I/O设施存在的局限性。
在我们了解使用异步I/O的不同方法之前,需要先讨论一下成本。在用异步I/O的时候,要通过选择来灵活处理多个并发操作,这会使应用程序的设计复杂化。更简单的做法可能是使用多线程,使用同步模型来编写程序,并让这些线程以异步的方式运行。
使用POSIX异步I/O接口,会带来下列麻烦。
•每个异步操作有 3 处可能产生错误的地方:一处在操作提交的部分,一处在操作本身的结果,还有一处在用于决定异步操作状态的函数中。
•与POSIX异步I/O接口的传统方法相比,它们本身涉及大量的额外设置和处理规则。
事实上,并不能把非异步I/O函数称作“同步”的,因为尽管它们相对于程序流来说是同步的,但相对于I/O来说并非如此。回忆第3章中关于同步写的讨论。当从write函数的调用返回时,写的数据是持久的,我们称这个写操作为“同步”的。也不能依靠把传统的调用归类为“标准”的I/O调用来区别传统的I/O函数和异步I/O函数,因为这样会使它们和标准I/O库中的函数调用相混淆。为了避免产生这种混淆,本节中我们把read和write函数归类为“传统”的I/O函数。
•从错误中恢复可能会比较困难。举例来说,如果提交了多个异步写操作,其中一个失败了,下一步我们应该怎么做?如果这些写操作是相关的,那么可能还需要撤销所有成功的写操作。
在System V中,异步 I/O是STREAMS系统的一部分,它只对STREAMS设备和STREAMS管道起作用。System V的异步I/O信号是SIGPOLL。
为了对一个STREAMS设备启动异步I/O,需要调用ioctl,将它的第二个参数(request)设置成I_SETSIG。第三个参数是由图14-18中的一个或多个常量构成的整型值。这些常量是在<stropts.h>中定义的。
与STREAMS机制相关的接口在SUSv4中已被标记为弃用,所以这里不讨论它们的任何细节。关于STREAMS的信息详见Rago[1993]。
除了调用ioctl指定产生SIGPOLL信号的条件以外,还应为该信号建立信号处理程序。回忆图10-1,对于SIGPOLL的默认动作是终止该进程,所以应当在调用ioctl之前建立信号处理程序。
图14-18 产生SIGPOLL信号的条件
在BSD派生的系统中,异步I/O是信号SIGIO和SIGURG的组合。SIGIO是通用异步I/O信号,SIGURG则只用来通知进程网络连接上的带外数据已经到达。
为了接收SIGIO信号,需执行以下3步。
(1)调用signal或sigaction为SIGIO信号建立信号处理程序。
(2)以命令F_SETOWN(见3.14节)调用fcntl来设置进程ID或进程组ID,用于接收对于该描述符的信号。
(3)以命令F_SETFL调用fcntl设置O_ASYNC文件状态标志(见图3-10),使在该描述符上可以进行异步I/O。
第3步仅能对指向终端或网络的描述符执行,这是BSD异步I/O设施的一个基本限制。
对于SIGURG信号,只需执行第1步和第2步。该信号仅对引用支持带外数据的网络连接描述符而产生,如TCP连接。
POSIX异步I/O接口为对不同类型的文件进行异步I/O提供了一套一致的方法。这些接口来自实时草案标准,该标准是Single UNIX Specification的可选项。在SUSv4中,这些接口被移到了基本部分中,所以现在所有的平台都被要求支持这些接口。
这些异步I/O接口使用AIO控制块来描述I/O操作。aiocb结构定义了AIO控制块。该结构至少包括下面这些字段(具体的实现可能还包含有额外的字段):
struct aiocb {
int aio_fildes; /* file descriptor */
off_t aio_offset; /* file offset for I/O */
volatile void *aio_buf; /* buffer for I/O */
size_t aio_nbytes; /* number of bytes to transfer */
int aio_reqprio; /* priority */
struct sigevent aio_sigevent; /* signal information */
int aio_lio_opcode; /* operation for list I/O */
};
aio_fields 字段表示被打开用来读或写的文件描述符。读或写操作从 aio_offset 指定的偏移量开始。对于读操作,数据会复制到缓冲区中,该缓冲区从 aio_buf 指定的地址开始。对于写操作,数据会从这个缓冲区中复制出来。aio_nbytes字段包含了要读或写的字节数。
注意,异步I/O操作必须显式地指定偏移量。异步I/O接口并不影响由操作系统维护的文件偏移量。只要不在同一个进程里把异步I/O函数和传统I/O函数混在一起用在同一个文件上,就不会导致什么问题。同时值得注意的是,如果使用异步I/O接口向一个以追加模式(使用O_APPEND)打开的文件中写入数据,AIO控制块中的aio_offset字段会被系统忽略。
其他字段和传统I/O函数中的不一致。应用程序使用aio_reqprio字段为异步I/O请求提示顺序。然而,系统对于该顺序只有有限的控制能力,因此不一定能遵循该提示。aio_lio_opcode字段只能用于基于列表的异步I/O,我们在稍后再讨论它。aio_sigevent字段控制,在I/O事件完成后,如何通知应用程序。这个字段通过sigevent结构来描述。
struct sigevent {
int sigev_notify; /* notify type */
int sigev_signo; /* signal number */
union sigval sigev_value; /* notify argument */
void (*sigev_notify_function)(union sigval); /* notify function */
pthread_attr_t *sigev_notify_attributes; /* notify attrs */
};
sigev_notify字段控制通知的类型。取值可能是以下3个中的一个。
SIGEV_NONE 异步I/O请求完成后,不通知进程。
SIGEV_SIGNAL 异步I/O请求完成后,产生由sigev_signo字段指定的信号。如果应用程序已选择捕捉信号,且在建立信号处理程序的时候指定了 SA_SIGINFO 标志,那么该信号将被入队(如果实现支持排队信号)。信号处理程序会传送给一个siginfo结构,该结构的si_value字段被设置为sigev_value (如果使用了SA_SIGINFO标志)。
SIGEV_THREAD 当异步I/O请求完成时,由sigev_notify_function字段指定的函数被调用。sigev_value字段被传入作为它的唯一参数。除非sigev_notify_attributes 字段被设定为pthread 属性结构的地址,且该结构指定了一个另外的线程属性,否则该函数将在分离状态下的一个单独的线程中执行。
在进行异步I/O之前需要先初始化AIO控制块,调用aio_read函数来进行异步读操作,或调用aio_write函数来进行异步写操作。
#include <aio.h>
int aio_read(struct aiocb *aiocb);
int aio_write(struct aiocb *aiocb);
两个函数的返回值:若成功,返回0;若出错,返回−1
当这些函数返回成功时,异步I/O请求便已经被操作系统放入等待处理的队列中了。这些返回值与实际I/O操作的结果没有任何关系。I/O操作在等待时,必须注意确保AIO控制块和数据库缓冲区保持稳定;它们下面对应的内存必须始终是合法的,除非I/O操作完成,否则不能被复用。
要想强制所有等待中的异步操作不等待而写入持久化的存储中,可以设立一个 AIO 控制块并调用aio_fsync函数。
#include <aio.h>
int aio_fsync(int op, struct aiocb *aiocb);
返回值:若成功,返回0;若出错,返回−1
AIO控制块中的aio_fildes字段指定了其异步写操作被同步的文件。如果op参数设定为O_DSYNC,那么操作执行起来就会像调用了fdatasync一样。否则,如果op参数设定为O_SYNC,那么操作执行起来就会像调用了fsync一样。
像aio_read和aio_write函数一样,在安排了同步时,aio_fsync操作返回。在异步同步操作完成之前,数据不会被持久化。AIO 控制块控制我们如何被通知,就像 aio_read 和aio_write函数一样。
为了获知一个异步读、写或者同步操作的完成状态,需要调用aio_error函数。
#include <aio.h>
int aio_error(const struct aiocb *aiocb);
返回值:(见下)
返回值为下面4种情况中的一种。
0 异步操作成功完成。需要调用aio_return函数获取操作返回值。
对aio_error的调用失败。这种情况下,errno会告诉我们为什么。−1
EINPROGRESS 异步读、写或同步操作仍在等待。
其他情况 其他任何返回值是相关的异步操作失败返回的错误码。
如果异步操作成功,可以调用aio_return函数来获取异步操作的返回值。
#include <aio.h>
ssize_t aio_return(const struct aiocb *aiocb);
返回值:(见下)
直到异步操作完成之前,都需要小心不要调用aio_return函数。操作完成之前的结果是未定义的。还需要小心对每个异步操作只调用一次aio_return。一旦调用了该函数,操作系统就可以释放掉包含了I/O操作返回值的记录。
如果aio_return函数本身失败,会返回−1,并设置errno。其他情况下,它将返回异步操作的结果,即会返回read、write或者fsync在被成功调用时可能返回的结果。
执行I/O操作时,如果还有其他事务要处理而不想被I/O操作阻塞,就可以使用异步I/O。然而,如果在完成了所有事务时,还有异步操作未完成时,可以调用aio_suspend函数来阻塞进程,直到操作完成。
#include <aio.h>
int aio_suspend(const struct aiocb *const list[], int nent,
const struct timespec *timeout);
返回值:若成功,返回0;若出错,返回−1
aio_suspend 可能会返回三种情况中的一种。如果我们被一个信号中断,它将会返回-1,并将errno设置为EINTR。如果在没有任何I/O操作完成的情况下,阻塞的时间超过了函数中可选的 timeout 参数所指定的时间限制,那么 aio_suspend 将返回-1,并将 errno 设置为EAGAIN(不想设置任何时间限制的话,可以把空指针传给timeout参数)。如果有任何I/O操作完成,aio_suspend将返回0。如果在我们调用aio_suspend操作时,所有的异步I/O操作都已完成,那么aio_suspend将在不阻塞的情况下直接返回。
list参数是一个指向AIO控制块数组的指针,nent参数表明了数组中的条目数。数组中的空指针会被跳过,其他条目都必须指向已用于初始化异步I/O操作的AIO控制块。
当还有我们不想再完成的等待中的异步I/O操作时,可以尝试使用aio_cancel函数来取消它们。
#include <aio.h>
int aio_cancel(int fd, struct aiocb *aiocb);
返回值:(见下)
fd参数指定了那个未完成的异步I/O操作的文件描述符。如果aiocb参数为NULL,系统将会尝试取消所有该文件上未完成的异步I/O操作。其他情况下,系统将尝试取消由AIO控制块描述的单个异步I/O操作。我们之所以说系统“尝试”取消操作,是因为无法保证系统能够取消正在进程中的任何操作。
aio_cancel函数可能会返回以下4个值中的一个。
AIO_ALLDONE 所有操作在尝试取消它们之前已经完成。
AIO_CANCELED 所有要求的操作已被取消。
AIO_NOTCANCELED 至少有一个要求的操作没有被取消。
-1 对aio_cancel的调用失败,错误码将被存储在errno中。
如果异步I/O操作被成功取消,对相应的AIO控制块调用aio_error函数将会返回错误ECANCELED。如果操作不能被取消,那么相应的AIO控制块不会因为对aio_cancel的调用而被修改。
还有一个函数也被包含在异步I/O接口当中,尽管它既能以同步的方式来使用,又能以异步的方
式来使用,这个函数就是lio_listio。该函数提交一系列由一个AIO控制块列表描述的I/O请求。
#include <aio.h>
int lio_listio(int mode, struct aiocb *restrict const list[restrict],
int nent, struct sigevent *restrict sigev);
返回值:若成功,返回0;若出错,返回−1
mode参数决定了I/O是否真的是异步的。如果该参数被设定为LIO_WAIT,lio_listio函数将在所有由列表指定的I/O 操作完成后返回。在这种情况下,sigev参数将被忽略。如果mode参数被设定为LIO_NOWAIT,lio_listio函数将在I/O请求入队后立即返回。进程将在所有I/O操作完成后,按照sigev参数指定的,被异步地通知。如果不想被通知,可以把sigev设定为NULL。注意,每个AIO控制块本身也可能启用了在各自操作完成时的异步通知。被sigev参数指定的异步通知是在此之外另加的,并且只会在所有的I/O操作完成后发送。
list参数指向AIO控制块列表,该列表指定了要运行的I/O操作的。nent参数指定了数组中的元素个数。AIO控制块列表可以包含NULL指针,这些条目将被忽略。
在每一个AIO控制块中,aio_lio_opcode字段指定了该操作是一个读操作(LIO_READ)、写操作(LIO_WRITE),还是将被忽略的空操作(LIO_NOP)。读操作会按照对应的 AIO 控制块被传给了aio_read函数来处理。类似地,写操作会按照对应的AIO控制块被传给了aio_write函数来处理。
实现会限制我们不想完成的异步 I/O 操作的数量。这些限制都是运行时不变量,其总结如图14-19所示。
可以通过调用 sysconf 函数并把 name 参数设置为_SC_IO_LISTIO_MAX 来设定 AIO_LISTIO_MAX的值。类似地,可以通过调用sysconf并把name参数设置为_SC_AIO_MAX来设定 AIO_MAX 的值,通过调用 sysconf 并把其参数设置为_SC_AIO_PRIO_DELTA_MAX 来设定AIO_PRIO_DELTA_MAX的值。
图14-19 POSIX.1中的异步I/O运行时不变量的值
引入POSIX异步操作I/O接口的初衷是为实时应用提供一种方法,避免在执行I/O操作时阻塞进程。接下来就让我们来看一个使用这些接口的例子。
实例
虽然我们不会在本文中讨论实时编程,但因为 POSIX 异步 I/O 接口现在是 Single UNIX Specification的基本部分,所以我们要了解一下怎么使用它们。为了对比异步I/O接口和相应的传统I/O接口,我们来研究一个任务,将一个文件从一种格式翻译成另一种格式。
图14-20中展示的程序,使用20世纪80年代流行的USENET新闻系统中使用的ROT-13算法,翻译文件,该算法原本用于将文本中的带有侵犯性的或者含有剧透和笑话笑点部分的文本模糊化。该算法将文本中的英文字符a~z和A~Z分别循环向右偏移13个字母位移,但不改变其他字符。
图14-20 用ROT-13翻译一个文件
程序中的I/O部分是很直接的:从输入文件中读取一个块,翻译之,然后再把这个块写到输出文件中。重复该步骤直到遇到文件尾端,read返回0。图14-21中的程序展示了如何使用等价的异步I/O函数做同样的任务。
图14-21 用ROT-13和异步I/O翻译一个文件
注意,我们使用了8个缓冲区,因此可以有最多8个异步I/O请求处于等待状态。令人惊讶的是,实际上这可能会降低性能,因为如果读操作是以无序的方式提交给文件系统的,操作系统提前读的算法便会失效。
在检查操作的返回值之前,必须确认操作已经完成。当aio_error返回的值既非EINPROGRESS亦非−1时,表明操作完成。除了这些值之外,如果返回值是0以外的任何值,说明操作失败了。一旦检查过这些情况,便可以安全地调用aio_return来获取I/O操作的返回值了。
只要还有事情要做,就可以提交异步I/O操作。当存在未使用的AIO控制块时,可以提交一个异步读操作。读操作完成后,翻译缓冲区中的内容并将它提交给一个异步写请求。当所有AIO控制块都在使用中时,通过调用aio_suspend等待操作完成。
在把一个块写入输出文件时,我们保留了在从输入文件读取数据时的偏移量。因而写的顺序并不重要。这一策略仅在输入文件中每个字符和输出文件中对应的字符的偏移量相同的情况下适用,我们在输出文件中既没有添加字符也没有删除字符。
这个实例中并没有使用异步通知,因为使用同步编程模型更加简单。如果在I/O操作进行时还有别的事情要做,那么额外的工作可以包含在for循环当中。然而,如果需要阻止这些额外的工作延迟翻译文件的任务,那么就需要组织下代码使用异步通知。多任务情况下,决定程序如何建构之前需要先考虑各个任务的优先级。
readv和writev函数用于在一次函数调用中读、写多个非连续缓冲区。有时也将这两个函数称为散布读(scatter read)和聚集写(gather write)。
#include <sys/uio.h>
ssize_t readv(int fd, const struct iovec *iov, int iovcnt);
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);
两个函数的返回值:已读或已写的字节数;若出错,返回−1
这两个函数的第二个参数是指向iovec结构数组的一个指针:
struct iovec {
void *iov_base; /* starting address of buffer */
size_t iov_len; /* size of buffer */
};
iov数组中的元素数由iovcnt指定,其最大值受限于IOV_MAX(回忆图2-11)。图14-22显示了这两个函数的参数和iovec结构之间的关系。
writev 函数从缓冲区中聚集输出数据的顺序是:iov[0]、iov[1]直至 iov[iovcnt-1]。writev返回输出的字节总数,通常应等于所有缓冲区长度之和。
图14-22 readv和writev的iovec结构
readv 函数则将读入的数据按上述同样顺序散布到缓冲区中。readv 总是先填满一个缓冲区,然后再填写下一个。readv返回读到的字节总数。如果遇到文件尾端,已无数据可读,则返回0。
这两个函数始于4.2BSD,后来,SVR4也提供它们。在Single UNIX Specification的XSI扩展中包括了这两个函数。
实例
在 20.8 节的_db_writeidx 函数中,需将两个缓冲区中的内容连续地写到一个文件中。第二个缓冲区是调用者传递过来的一个参数,第一个缓冲区是我们创建的,它包含了第二个缓冲的长度以及文件中其他信息的文件偏移量。有以下3种方法可以实现这一要求。
(1)调用两次write,每个缓冲区一次。
(2)分配一个大到足以包含两个缓冲区的新缓冲区。将两个缓冲区的内容复制到新缓冲区中。然后对这个新缓冲区调用一次write。
(3)调用writev输出两个缓冲区。
20.8节的解决方案使用了writev,但是将它与另外两种方法进行比较,对我们是很有启发的。图14-23显示了上面所述3种方法的结果。
图14-23 比较writev和其他技术所得的时间结果
用于测量的测试程序输出一个100字节的头文件,接着又输出200字节的数据。这样做1 048 576次,产生了一个300 MB的文件。该测试程序有3个版本—针对图14-23中的每一种测量技术编写了一个版本。使用times(见8.17节)测得它们在写操作前、后各使用的用户CPU时间、系统CPU时间和时钟时间。这3个时间的单位都是秒。
正如我们所预料的,调用两次write的系统时间比调用一次write或writev的长,这与图3-6的结果类似。
接着要注意的是,在缓冲区复制后跟随一个write所用的CPU时间(用户时间加系统时间)要少于调用一次writev所耗费的CPU时间。对于单一write的情况,我们先将用户层次的两个缓冲区复制到一个分段缓冲区(staging buffer),然后在调用write时内核将该分段缓冲区中的数据复制到其内部缓冲区。对于writev的情况,因为内核只需将数据直接复制进其分段缓冲区,所以复制工作应当会少一些。但是,对于这种少量数据,使用writev的固定成本大于收益。随着需复制数据的增加,程序中复制缓冲区的成本也会增多,此时,writev 这种替代方法将更具吸引力。
不要依据图14-23中的数字对Linux和Mac OS X之间的相对性能作过多的推断。这两种计算机有很大差别:它们有不同的处理器结构、不同数量的 RAM 以及不同速度的磁盘。为了在操作系统之间进行公平的比较,需要对每一种操作系统都使用相同的硬件。
总之,应当用尽量少的系统调用次数来完成任务。如果我们只写少量的数据,将会发现自己复制数据然后使用一次write会比用writev更合算。但也可能发现,我们管理自己的分段缓冲区会增加程序额外的复杂性成本,所以从性能成本的角度来看不合算。
管道、FIFO以及某些设备(特别是终端和网络)有下列两种性质。
(1)一次read操作所返回的数据可能少于所要求的数据,即使还没达到文件尾端也可能是这样。这不是一个错误,应当继续读该设备。
(2)一次write操作的返回值也可能少于指定输出的字节数。这可能是由某个因素造成的,例如,内核输出缓冲区变满。这也不是错误,应当继续写余下的数据。(通常,只有非阻塞描述符,或捕捉到一个信号时,才发生这种write的中途返回。)
在读、写磁盘文件时从未见到过这种情况,除非文件系统用完了空间,或者接近了配额限制,不能将要求写的数据全部写出。
通常,在读、写一个管道、网络设备或终端时,需要考虑这些特性。下面两个函数 readn和writen的功能分别是读、写指定的N字节数据,并处理返回值可能小于要求值的情况。这两个函数只是按需多次调用read和write直至读、写了N字节数据。
#include "apue.h"
ssize_t readn(int fd, void *buf, size_t nbytes);
ssize_t writen(int fd, void *buf, size_t nbytes);
两个函数的返回值:读、写的字节数;若出错,返回−1
类似于本书很多实例所使用的出错处理例程,我们定义这两个函数的目的是便于在后面实例中使用。readn和writen函数并不是哪个标准的组成部分。
在要将数据写到上面提到的文件类型上时,就可调用 writen,但是仅当事先就知道要接收数据的数量时,才调用readn。图14-24包含了readn和writen的实现,在后面的实例中,我们还会用到。
图14-24 readn和writen函数
注意,若在已经读、写了一些数据之后出错,则这两个函数返回的是已传输的数据量,而非错误。与此类似,在读时,如达到文件尾端,而且在此之前已成功地读了一些数据,但尚未满足所要求的量,则readn返回已复制到调用者缓冲区中的字节数。
存储映射I/O(memory-mapped I/O)能将一个磁盘文件映射到存储空间中的一个缓冲区上,于是,当从缓冲区中取数据时,就相当于读文件中的相应字节。与此类似,将数据存入缓冲区时,相应字节就自动写入文件。这样,就可以在不使用read和write的情况下执行I/O。
存储映射I/O伴随虚拟存储系统已经用了很多年。1981年,4.1BSD以其vread和vwrite函数提供了一种不同形式的存储映射I/O。4.2BSD中删除了这两个函数,试图替换成mmap函数。
但是4.2BSD实际上并没有包含mmap函数(原因见McKusick等[1996]中2.5节的描述)。Gingell、Moran和Shannon[1987]描述了mmap的一种实现。SUSv4把mmap函数从可选项规范中移到了基础规范中。所有的遵循POSIX的系统都需要支持它。
为了使用这种功能,应首先告诉内核将一个给定的文件映射到一个存储区域中。这是由mmap函数实现的。
#include <sys/mman.h>
void *mmap(void *addr, size_t len, int prot, int flag, int fd, off_t off);
返回值:若成功,返回映射区的起始地址;若出错,返回MAP_FAILED
addr参数用于指定映射存储区的起始地址。通常将其设置为0,这表示由系统选择该映射区的起始地址。此函数的返回值是该映射区的起始地址。
fd参数是指定要被映射文件的描述符。在文件映射到地址空间之前,必须先打开该文件。len参数是映射的字节数,off是要映射字节在文件中的起始偏移量(有关off值的一些限制将在后面说明)。
prot参数指定了映射存储区的保护要求,如图14-25所示。
图14-25 映射存储区的保护要求
可将prot参数指定为PROT_NONE,也可指定为PROT_READ、PROT_WRITE和PROT_EXEC的任意组合的按位或。对指定映射存储区的保护要求不能超过文件open模式访问权限。例如,若该文件是只读打开的,那么对映射存储区就不能指定PROT_WRITE。
在说明flag参数之前,先看一下存储映射文件的基本情况。图14-26显示了一个存储映射文件。(见图7-6中所示的典型进程的存储器安排。)在此图中,“起始地址”是mmap的返回值。映射存储区位于堆和栈之间:这属于实现细节,各种实现之间可能不同。
下面是flag参数影响映射存储区的多种属性。
MAP_FIXED 返回值必须等于addr。因为这不利于可移植性,所以不鼓励使用此标志。如果未指定此标志,而且addr非0,则内核只把addr视为在何处设置映射区的一种建议,但是不保证会使用所要求的地址。将addr指定为0可获得最大可移植性。
在遵循POSIX的系统中,对MAP_FIXED标志的支持是可选择的,但遵循XSI的系统则要求支持MAP_FIXED。
MAP_SHARED 这一标志描述了本进程对映射区所进行的存储操作的配置。此标志指定存储操作修改映射文件,也就是,存储操作相当于对该文件的 write。必须指定本标志或下一个标志(MAP_PRIVATE),但不能同时指定两者。
MAP_PRIVATE 本标志说明,对映射区的存储操作导致创建该映射文件的一个私有副本。所有后来对该映射区的引用都是引用该副本。(此标志的一种用途是用于调试程序,它将程序文件的正文部分映射至存储区,但允许用户修改其中的指令。任何修改只影响程序文件的副本,而不影响原文件。)
图14-26 存储映射文件的例子
每种实现都可能还有另外一些 MAP_xxx 标志值,它们是那种实现所特有的。详细情况请参见你所使用系统的mmap(2)手册页。
off的值和addr的值(如果指定了MAP_FIXED)通常被要求是系统虚拟存储页长度的倍数。虚拟存储页长可用带参数_SC_PAGESIZE或_SC_PAGE_SIZE的sysconf函数(见2.5.4节)得到。因为off和addr常常指定为0,所以这种要求一般并不重要。
这一要求通常是由系统实现强加的。尽管Single UNIX Specification不再要求满足该条件,但是所有本书中讲到的除了FreeBSD 8.0以外的所有平台都满足了这一要求。FreeBSD 8.0允许我们使用任意的地址对齐和偏移对齐,只要对齐匹配即可。
既然映射文件的起始偏移量受系统虚拟存储页长度的限制,那么如果映射区的长度不是页长的整数倍时,会怎么样呢?假定文件长为 12 字节,系统页长为 512 字节,则系统通常提供 512字节的映射区,其中后500字节被设置为0。可以修改后面的这500字节,但任何变动都不会在文件中反映出来。于是,不能用mmap将数据添加到文件中。我们必须先加长该文件,如后面的图14-27中的程序所示。
与映射区相关的信号有SIGSEGV和SIGBUS。信号 SIGSEGV通常用于指示进程试图访问对它不可用的存储区。如果映射存储区被mmap指定成了只读的,那么进程试图将数据存入这个映射存储区的时候,也会产生此信号。如果映射区的某个部分在访问时已不存在,则产生 SIGBUS信号。例如,假设用文件长度映射了一个文件,但在引用该映射区之前,另一个进程已将该文件截断。此时,如果进程试图访问对应于该文件已截去部分的映射区,将会接收到SIGBUS信号。
子进程能通过fork继承存储映射区(因为子进程复制父进程地址空间,而存储映射区是该地址空间中的一部分),但是由于同样的原因,新程序则不能通过exec继承存储映射区。
调用mprotect可以更改一个现有映射的权限。
#include <sys/mman.h>
int mprotect(void *addr, size_t len, int prot);
返回值:若成功,返回0;若出错,返回-1
prot的合法值与mmap中prot参数的一样(见图14-25)。请注意,地址参数addr的值必须是系统页长的整数倍。
如果修改的页是通过MAP_SHARED标志映射到地址空间的,那么修改并不会立即写回到文件中。相反,何时写回脏页由内核的守护进程决定,决定的依据是系统负载和用来限制在系统失败事件中的数据损失的配置参数。因此,如果只修改了一页中的一个字节,当修改被写回到文件中时,整个页都会被写回。
如果共享映射中的页已修改,那么可以调用 msync 将该页冲洗到被映射的文件中。msync函数类似于fsync(见3.13节),但作用于存储映射区。
#include <sys/mman.h>
int msync(void *addr, size_t len, int flags);
返回值:若成功,返回0;若出错,返回-1
如果映射是私有的,那么不修改被映射的文件。与其他存储映射函数一样,地址必须与页边界对齐。
flags参数使我们对如何冲洗存储区有某种程度的控制。可以指定 MS_ASYNC 标志来简单地调试要写的页。如果希望在返回之前等待写操作完成,则可指定 MS_SYNC 标志。一定要指定MS_ASYNC和MS_SYNC中的一个。
MS_INVALIDATE是一个可选标志,允许我们通知操作系统丢弃那些与底层存储器没有同步的页。若使用了此标志,某些实现将丢弃指定范围中的所有页,但这种行为并不是必需的。
msync函数包含在Single UNIX Specification的XSI选项中。因此,所有UNIX系统必须支持它。
当进程终止时,会自动解除存储映射区的映射,或者直接调用munmap函数也可以解除映射区。关闭映射存储区时使用的文件描述符并不解除映射区。
#include <sys/mman.h>
int munmap(void *addr, size_t len);
返回值:若成功,返回0;若出错,返回−1
munmap并不影响被映射的对象,也就是说,调用munmap并不会使映射区的内容写到磁盘文件上。对于MAP_SHARED区磁盘文件的更新,会在我们将数据写到存储映射区后的某个时刻,按内核虚拟存储算法自动进行。在存储区解除映射后,对MAP_PRIVATE存储区的修改会被丢弃。
实例
图14-27中的程序用存储映射I/O复制文件(类似于cp(1)命令)。
图14-27 用存储映射I/O复制文件
该程序首先打开两个文件,然后调用fstat得到输入文件的长度。在为输入文件调用mmap和设置输出文件长度时都需使用输入文件长度。可以调用ftruncate设置输出文件的长度。如果不设置输出文件的长度,则对输出文件调用mmap也可以,但是对相关存储区的第一次引用会产生SIGBUS信号。
然后对每个文件调用mmap,将文件映射到内存,最后调用memcpy将输入缓冲区的内容复制到输出缓冲区。为了限制使用内存的量,我们每次最多复制 1 GB 的数据(如果系统没有足够的内存,可能无法把一个很大的文件中的所有内容都映射到内存中)。在映射文件中的后一部分数据之前,我们需要解除前一部分数据的映射。
在从输入缓冲区(src)取数据字节时,内核自动读输入文件;在将数据存入输出缓冲区(dst)时,内核自动将数据写到输出文件中。
数据被写到文件的确切时间依赖于系统的页管理算法。某些系统设置了守护进程,在系统运行期间,它慢条斯理地将改写过的页写到磁盘上。如果想要确保数据安全地写到文件中,则需在进程终止前以MS_SYNC标志调用msync。
将存储区映射复制与用read和write进行的复制(缓冲区长度为8 192)相比较,得到图14-28中所示的结果。其中,时间单位是秒,被复制文件的长度是300 MB。注意,我们并没有在退出前将数据同步到磁盘。
图14-28 read/write与mmap/memcpy比较的时间结果
在Linux 3.2.0和Solaris 10中,两种方法的总的CPU时间(用户时间+系统时间)几乎是相同的。在Solaris中,使用mmap和memcpy复制,与使用read和write相比,花费了更多的用户时间,但却减少了系统时间。在Linux中,用户时间的结果很相似,但是用read和write消耗的系统时间要比使用mmap和memcpy略好一些。这两种版本的方法是殊途同归的。
二者的主要区别在于,与mmap和memcpy相比,read和write执行了更多的系统调用,并做了更多的复制。read和write将数据从内核缓冲区中复制到应用缓冲区(read),然后再把数据从应用缓冲区复制到内核缓冲区(write)。而mmap和memcpy则直接把数据从映射到地址空间的一个内核缓冲区复制到另一个内核缓冲区。当引用尚不存在的内存页时,这样的复制过程就会作为处理页错误的结果而出现(每次错页读发生一次错误,每次错页写发生一次错误)。如果系统调用和额外的复制操作的开销和页错误的开销不同,那么这两种方法中就会有一种比另一种表现更好。
在Linux 3.2.0中,相对于运行时间,两种版本的程序在时钟时间上显示出了巨大的差异:使用read和write的版本完成任务比使用mmap和memcpy的版本快了4倍。然而在Solaris 10中,使用mmap和memcpy的版本比使用read和write的版本要快。既然二者的CPU时间几乎是相同的,为何它们的时钟时间差异却如此之大呢?一种可能是,在一种版本中需要较长的时间来等待I/O完成。这个等待时间并没有计算在CPU的处理时间中。另一种可能是,某些系统处理的时间可能并没有在程序中计算,比如系统守护进程把页写到磁盘中的操作。由于需要为读和写分配页,系统的守护进程会帮助我们准备可用的页。如果页的写操作是随机的而非连续的,那么把它们写入磁盘所需要的时间会更长,因此在页可以被用来复用之前所需等待的时间也会更长。
有的系统将一个普通文件复制到另一个普通文件中时,存储映射I/O可能会比较快。但是有一些限制,例如,不能用这种技术在某些设备之间(如网络设备或终端设备)进行复制,并且在对被复制的文件进行映射后,也要注意该文件的长度是否改变。尽管如此,某些应用程序仍然能得益于存储映射 I/O,因为它处理的是存储空间而不是读、写一个文件,所以常常可以简化算法。从存储映射I/O中得益的一个例子是对帧缓冲设备的操作,该设备引用位图式显示(bit-mapped display)。
Krieger、Stumm和Unrau[1992]描述了一个使用存储映射I/O的标准I/O库(见第5章)。
15.9节还会提到存储映射I/O,其中还举了一个例子,说明如何使用存储映射I/O在两个相关进程间提供共享存储区。
本章描述了很多高级I/O功能,其中有许多将用在后面章节的实例中。
•非阻塞I/O——发一个I/O操作,不使其阻塞。
•记录锁(在第20章中有一个实例,该实例会对此进行更详细的讨论)。
• I/O多路转接—select和poll函数(在后面的很多实例中会用到这两个函数)。
• readv和writev函数(在后面的很多实例中也会用到这两个函数)。
•存储映射I/O(mmap)。
14.1 编写一个测试程序说明你所用系统在下列情况下的运行情况:一个进程在试图对一个文件的某个范围加写锁的时候阻塞,之后其他进程又提出了一些相关的加读锁请求。试图加写锁的进程会不会因此而饿死?
14.2 查看你所用系统的头文件,并研究select和4个FD_宏的实现。
14.3 系统头文件通常对 fd_set 数据类型可以处理的最大描述符数有一个内置的限制,假设需要将描述符数增加到2 048,该如何实现?
14.4 比较处理信号量集的函数(见10.11节)和处理fd_set描述符集的函数,并比较这两类函数在你系统上的实现。
14.5 用select或poll实现一个与sleep类似的函数sleep_us,不同之处是要等待指定的若干微秒。比较这个函数和BSD中的usleep函数。
14.6 是否可以利用建议性记录锁来实现图 10-24 中的函数 TELL_WAIT、TELL_PARENT、TELL_CHILD、WAIT_PARENT以及WAIT_CHILD?如果可以,编写这些函数并测试其功能。14.7 用非阻塞写来确定管道的容量。将其值与第2章的PIPE_BUF值进行比较。
14.8 重写图14-21中的程序来制作一个过滤器:从标准输入中读入并向标准输出写,但是要使用异步I/O接口。为了使之能正常工作,你都需要修改些什么?记住,无论你的标准输出被连接到终端、管道还是一个普通文件,都应该得到相同的结果。
14.9 回忆图14-23,在你的系统上找到一个损益平衡点,从此点开始,使用writev将快于你自己使用单个write复制数据。
14.10 运行图14-27中的程序复制一个文件,检查输入文件的上一次访问时间是否更新了?
14.11 在图14-27的程序中,在调用mmap后调用close关闭输入文件,以验证关闭描述符不会使内存映射I/O失效。
第8章说明了进程控制原语,并且观察了如何调用多个进程。但是这些进程之间交换信息的唯一途径就是传送打开的文件,可以经由fork或exec来传送,也可以通过文件系统来传送。本章将说明进程之间相互通信的其他技术—进程间通信(InterProcess Communication,IPC)。
过去,UNIX系统IPC是各种进程通信方式的统称,但是,这些通信方式中极少有能在所有UNIX系统实现中进行移植的。随着POSIX和The Open Group(以前是X/Open)标准化的推进和影响的扩大,情况已得到改善,但差别仍然存在。图15-1摘要列出了本书讨论的4种实现所支持的不同形式的IPC。
图15-1 UNIX系统IPC摘要
注意,虽然Single UNIX Specification(“SUS”列)要求的是半双工管道,但允许实现支持全双工管道。即使应用程序在编写时假定基础操作系统只支持半双工管道,支持全双工管道的实现也能用这种应用程序正常工作。图中使用“(全)”表示用全双工管道支持半双工管道的实现。
在图15-1中,我们在支持基本功能的位置处标注了一个黑点。对于全双工管道,如果该特征是经由UNIX域套接字(UNIX domain socket,见17.2节)支持的,则在相应列中标注“UDS”。某些实现用管道和UNIX域套接字来支持该特征,所以这些位置上标有“•、UDS”。
IPC接口作为POSIX.1实时扩展的一部分,也是Single UNIX Specification中的选项。在SUSv4中,信号量接口从可选规范移到了基本规范中。
虽然命名全双工管道作为被挂载的基于STREAMS的管道使用,但是Single UNIX Specification将它标记成弃用的。
尽管Linux中OpenSS7项目的“Linux Fast-STREAMS”包支持STREAMS,但是这个包最近都没有更新。从2008年以来最新的包版本只到内核版本2.6.26。
图15-1中前10种IPC形式通常限于同一台主机的两个进程之间的IPC。最后两行(套接字和STREAMS)是仅有的支持不同主机上两个进程之间IPC的两种形式。
我们将与IPC有关的讨论分成3章。本章讨论经典的IPC:管道、FIFO、消息队列、信号量以及共享存储。下一章讨论使用套接字机制的网络IPC。第17章说明IPC的某些高级特征。
管道是UNIX系统IPC的最古老形式,所有UNIX系统都提供此种通信机制。管道有以下两种局限性。
(1)历史上,它们是半双工的(即数据只能在一个方向上流动)。现在,某些系统提供全双工管道,但是为了最佳的可移植性,我们决不应预先假定系统支持全双工管道。
(2)管道只能在具有公共祖先的两个进程之间使用。通常,一个管道由一个进程创建,在进程调用fork之后,这个管道就能在父进程和子进程之间使用了。
我们将会看到FIFO(见15.5节)没有第二种局限性,UNIX域套接字(见17.2节)没有这两种局限性。
尽管有这两种局限性,半双工管道仍是最常用的IPC形式。每当在管道中键入一个命令序列,让 shell 执行时,shell 都会为每一条命令单独创建一个进程,然后用管道将前一条命令进程的标准输出与后一条命令的标准输入相连接。
管道是通过调用pipe函数创建的。
#include <unistd.h>
int pipe(int fd[2]);
返回值:若成功,返回0,若出错,返回-1
经由参数 fd 返回两个文件描述符:fd[0]为读而打开,fd[1]为写而打开。fd[1]的输出是fd[0]的输入。
最初在4.3BSD和4.4BSD中,管道是用UNIX域套接字实现的。虽然UNIX域套接字默认是全双工的,但这些操作系统阻碍了用于管道的套接字,以至于这些管道只能以半双工模式操作。
POSIX.1允许实现支持全双工管道。对于这些实现,fd[0]和fd[1]以读/写方式打开。
图15-2中给出了两种描绘半双工管道的方法。左图显示管道的两端在一个进程中相互连接,右图则强调数据需要通过内核在管道中流动。
fstat函数(见4.2节)对管道的每一端都返回一个FIFO类型的文件描述符。可以用S_ISFIFO宏来测试管道。
POSIX.1规定stat结构的st_size成员对于管道是未定义的。但是当fstat函数应用于管道读端的文件描述符时,很多系统在st_size中存储管道中可用于读的字节数。但是,这是不可移植的。
图15-2 描绘半双工管道的两种方法
单个进程中的管道几乎没有任何用处。通常,进程会先调用pipe,接着调用fork,从而创建从父进程到子进程的IPC通道,反之亦然。图15-3显示了这种情况。
图15-3 fork之后的半双工管道
fork 之后做什么取决于我们想要的数据流的方向。对于从父进程到子进程的管道,父进程关闭管道的读端(fd[0]),子进程关闭写端(fd[1])。图15-4显示了在此之后描述符的状态结果。
图15-4 从父进程到子进程的管道
对于一个从子进程到父进程的管道,父进程关闭fd[1],子进程关闭fd[0]。
当管道的一端被关闭后,下列两条规则起作用。
(1)当读(read)一个写端已被关闭的管道时,在所有数据都被读取后,read返回0,表示文件结束。(从技术上来讲,如果管道的写端还有进程,就不会产生文件的结束。可以复制一个管道的描述符,使得有多个进程对它具有写打开文件描述符。但是,通常一个管道只有一个读进程和一个写进程。下一节介绍FIFO时,会看到对于单个的FIFO常常有多个写进程。)
(2)如果写(write)一个读端已被关闭的管道,则产生信号SIGPIPE。如果忽略该信号或者捕捉该信号并从其处理程序返回,则write返回−1,errno设置为EPIPE。
在写管道(或 FIFO)时,常量 PIPE_BUF 规定了内核的管道缓冲区大小。如果对管道调用write,而且要求写的字节数小于等于 PIPE_BUF,则此操作不会与其他进程对同一管道(或FIFO)的 write 操作交叉进行。但是,若有多个进程同时写一个管道(或 FIFO),而且我们要求写的字节数超过PIPE_BUF,那么我们所写的数据可能会与其他进程所写的数据相互交叉。用pathconf或fpathconf函数(见图2-12)可以确定PIPE_BUF的值。
实例
图 15-5 程序创建了一个从父进程到子进程的管道,并且父进程经由该管道向子进程传送数据。
图15-5 经由管道从父进程向子进程传送数据
注意,这里的管道方向和图15-4中的是一致的。
在上面的例子中,直接对管道描述符调用了read和write。更有趣的是将管道描述符复制到了标准输入或标准输出上。通常,子进程会在此之后执行另一个程序,该程序或者从标准输入(已创建的管道)读数据,或者将数据写至其标准输出(该管道)。
实例
试着编写一个程序,其功能是每次一页地显示已产生的输出。已经有很多UNIX系统公用程序具有分页功能,因此无需再构造一个新的分页程序,只要调用用户最喜爱的分页程序就可以了。为了避免先将所有数据写到一个临时文件中,然后再调用系统中有关程序显示该文件,我们希望通过管道将输出直接送到分页程序。为此,先创建一个管道,fork 一个子进程,使子进程的标准输入成为管道的读端,然后调用exec,执行用的分页程序。图15-6中的程序显示了如何实现这些操作。(本例要求在命令行中有一个参数指定要显示的文件的名称。通常,这种类型的程序要求在终端上显示的数据已经在存储器中了。)
图15-6 将文件复制到分页程序
在调用fork之前,先创建一个管道。调用fork之后,父进程关闭其读端,子进程关闭其写端。然后子进程调用 dup2,使其标准输入成为管道的读端。当执行分页程序时,其标准输入将是管道的读端。
将一个描述符复制到另一个上(在子进程中,fd[0]复制到标准输入),在复制之前应当比较该描述符的值是否已经具有所希望的值。如果该描述符已经具有所希望的值,并且调用了dup2和close,那么该描述符的副本将关闭。(回忆3.12节中所述,当dup2中的两个参数值相等时的操作。)在本程序中,如果shell没有打开标准输入,那么程序开始处的fopen应已使用描述符0,也就是最小未使用的描述符,所以fd[0]决不会等于标准输入。尽管如此,无论何时调用dup2和 close 将一个描述符复制到另一个上,作为一种保护性的编程措施,都要先将两个描述符进行比较。
请注意,我们是如何尝试使用环境变量 PAGER 获得用户分页程序名称的。如果这种操作没有成功,则使用系统默认值。这是环境变量的常见用法。
实例
回忆8.9节中的5个函数:TELL_WAIT、TELL_PARENT、TELL_CHILD、WAIT_PARENT和WAIT_CHILD。图10-24中提供了一个使用信号的实现。图15-7则提供了一个使用管道的实现。
图15-7 让父进程和子进程同步的例程
如图15-8中所示,我们在调用fork之前创建了两个管道。父进程在调用TELL_CHILD时,经由上一个管道写一个字符“p”,子进程在调用TELL_PARENT时,经由下一个管道写一个字符“c”。相应的WAIT_XXX函数调用read读一个字符,没有读到字符时则阻塞(休眠等待)。
图15-8 用两个管道实现父进程和子进程同步
注意,每一个管道都有一个额外的读取进程,这没有关系。也就是说,除了子进程从pfd1[0]读取,父进程也有上一个管道的读端。因为父进程并没有执行对该管道的读操作,所以这不会影响我们。
常见的操作是创建一个连接到另一个进程的管道,然后读其输出或向其输入端发送数据,为此,标准I/O库提供了两个函数popen和pclose。这两个函数实现的操作是:创建一个管道, fork一个子进程,关闭未使用的管道端,执行一个shell运行命令,然后等待命令终止。
#include <stdio.h>
FILE *popen(const char *cmdstring, const char *type);
返回值:若成功,返回文件指针;若出错,返回NULL
int pclose(FILE *fp);
返回值:若成功,返回cmdstring的终止状态;若出错,返回-1
函数popen先执行fork,然后调用exec执行cmdstring,并且返回一个标准I/O文件指针。如果type是"r",则文件指针连接到cmdstring的标准输出(见图15-9)。
如果type是"w",则文件指针连接到cmdstring的标准输入,如图15-10所示。
图15-9 执行fp = popen
(cmdstring, "r")的结果
图15-10 执行fp = popen
(cmdstring, "w")的结果
有一种方法可以帮助我们记住popen的最后一个参数及其作用,这就是与fopen进行类比。如果type是"r",则返回的文件指针是可读的,如果type是"w",则是可写的。
pclose函数关闭标准I/O流,等待命令终止,然后返回shell的终止状态。(我们曾在8.6节中描述过终止状态,8.13 节描述的 system 函数也返回终止状态。)如果 shell 不能被执行,则pclose返回的终止状态与shell已执行exit(127)一样。
cmdstring由Bourne shell以下列方式执行:
sh -c cmdstring
这表示shell将扩展cmdstring中的任何特殊字符。例如,可以使用:
fp = popen("ls *.c" , "r");
或者
fp = popen("cmd 2>&1" , "r");
实例
用popen重写图15-6中的程序,其结果如图15-11所示。
图15-11 用popen向分页程序传送文件
使用popen减少了需要编写的代码量。
shell命令${PAGER:-more}的意思是:如果shell变量PAGER已经定义,且其值非空,则使用其值,否则使用字符串more。
实例:函数popen和pclose
图15-12中的程序是我们编写的popen和pclose。
图15-12 popen函数和pclose函数
虽然 popen 的核心部分与本章中前面用过的代码类似,但是增加了很多需要考虑的细节。首先,每次调用popen时,应当记住所创建的子进程的进程ID,以及其文件描述符或FILE指针。我们选择在数组childpid中保存子进程ID,并用文件描述符作为其下标。于是,当以FILE指针作为参数调用pclose时,调用标准I/O函数fileno得到文件描述符,然后取得子进程ID,并用其作为参数调用waitpid。因为一个进程可能调用popen多次,所以在动态分配childpid数组时(第一次调用popen时),其数组长度应当是最大文件描述符数,于是该数组中可以存放与最大文件描述符数相同的子进程ID数。
注意,图2-17中的open_max函数可以返回打开文件的最大个数的近似值,如果这个值与系统不相关的话。注意不要使用那种其值大于(或等于)open_max函数返回值的管道文件描述符。对于 popen,如果 open_max 函数返回的值恰巧非常小,那我们会关闭管道文件描述符并将 errno设置为EMFILE,以此表明这里的很多文件描述符是打开的,最后返回−1。对于pclose,如果对应于文件指针参数的描述符比所期望的大,则将errno设置为EINVAL,并返回−1。
调用pipe和fork,然后为popen函数中的每个进程复制合适的描述符,这个过程和我们在本章前面所做的相类似。
POSIX.1要求popen关闭那些以前调用popen打开的、现在仍然在子进程中打开着的I/O流。为此,在子进程中从头逐个检查childpid数组的各个元素,关闭仍旧打开着的描述符。
若pclose的调用者已经为信号SIGCHLD设置了一个信号处理程序,则pclose中的waitpid调用将返回一个错误EINTR。因为允许调用者捕捉此信号(或者任何其他可能中断waitpid调用的信号),所以当waitpid被一个捕捉到的信号中断时,我们只是再次调用waitpid。
注意,如果应用程序调用waitpid,并且获得了popen创建的子进程的退出状态,那么我们会在应用程序调用pclose时调用waitpid,如果发现子进程已不再存在,将返回−1,将errno设置为ECHILD。这正是这种情况下POSIX.1所要求的。
如果一个信号中断了wait,pclose的一些早期版本会返回错误EINTR。pclose的一些早期版本在wait期间,会阻塞或忽略信号SIGINT、SIGQUIT和SIGHUP。这是POSIX.1所不允许的。
注意,popen决不应由设置用户ID或设置组ID程序调用。当它执行命令时,popen等同于:
execl("/bin/sh", "sh", "-c", command, NULL);
它在从调用者继承的环境中执行shell,并由shell解释执行command。一个恶意用户可以操控这种环境,使得shell能以设置ID文件模式所授予的提升了的权限以及非预期的方式执行命令。
popen特别适用于执行简单的过滤器程序,它变换运行命令的输入或输出。当命令希望构造它自己的管道时,就是这种情形。
实例
考虑一个应用程序,它向标准输出写一个提示,然后从标准输入读1行。使用popen,可以在应用程序和输入之间插入一个程序以便对输入进行变换处理。图15-13显示了这种情况下的进程安排。
图15-13 用popen对输入进行变换处理
对输入进行的变换可能是路径名扩充,或者是提供一种历史机制(记住以前输入的命令)。
图15-14是一个简单的用于演示这个操作的过滤程序。它将标准输入复制到标准输出,在复制时将大写字符变换为小写字符。在写完换行符之后,
要仔细冲洗(用fflush)标准输出,这样做的理由将在下一节介绍协同进程时讨论。
图15-14 将大写字符变换成小写字符的过滤程序
将这个过滤程序编译成可执行文件myuclc,然后图15-15的程序会用popen调用它。
图15-15 调用大写/小写过滤程序读取命令
因为标准输出通常是行缓冲的,而提示并不包含换行符,所以在写了提示之后,需要调用fflush。
UNIX系统过滤程序从标准输入读取数据,向标准输出写数据。几个过滤程序通常在shell管道中线性连接。当一个过滤程序既产生某个过滤程序的输入,又读取该过滤程序的输出时,它就变成了协同进程(coprocess)。
Korn shell提供了协同进程[Bolsky and Korn 1995]。Bourne shell、Bourne-again shell和C shell并没有提供将进程连接成协同进程的方法。协同进程通常在shell的后台运行,其标准输入和标准输出通过管道连接到另一个程序。虽然初始化一个协同进程,并将其输入和输出连接到另一个进程的shell语法是十分奇特的(详细情况见Bolsky和Korn[1995]中的第62~63页),但是协同进程的工作方式在C程序中也是非常有用的。
popen 只提供连接到另一个进程的标准输入或标准输出的一个单向管道,而协同进程则有连接到另一个进程的两个单向管道:一个接到其标准输入,另一个则来自其标准输出。我们想将数据写到其标准输入,经其处理后,再从其标准输出读取数据。
实例
让我们通过一个实例来观察协同进程。进程创建两个管道:一个是协同进程的标准输入,另一个是协同进程的标准输出。图15-16显示了这种安排。
图15-16 通过写协同进程的标准输入和读取它的标准输出来驱动协同进程
图15-17中的程序是一个简单的协同进程,它从其标准输入读取两个数,计算它们的和,然后将和写至其标准输出。(协同进程通常会做较此更有意义的工作。设计本实例的目的是帮助了解将进程连接起来所需的各种管道设施。)
图15-17 将两个数相加的简单过滤程序
对此程序进行编译,将其可执行目标代码存入名为add2的文件。
图15-18中的程序从其标准输入读取两个数之后调用add2协同进程,并将协同进程送来的值写到其标准输出。
图15-18 驱动add2过滤程序的程序
这个程序创建了两个管道,父进程、子进程各自关闭它们不需使用的管道端。必须使用两个管道:一个用作协同进程的标准输入,另一个则用作它的标准输出。然后,子进程调用dup2使管道描述符移至其标准输入和标准输出,最后调用了execl。
若编译和运行图15-18中的程序,它会按预期工作。此外,若图15-18中的程序在等待输入的时候杀死了add2协同进程,然后又输入两个数,那么程序对没有读进程的管道进行写操作时,会调用信号处理程序(见习题15.4)。
实例
在协同进程add2(见图15-17)中,我们故意使用了底层I/O(UNIX系统调用):read和write。如果使用标准I/O来改写该协同进程,会怎么样呢?图15-19所示的程序就是改写后的版本。
图15-19 将两个数相加的过滤程序,使用标准I/O
若图15-18中的程序调用这个新的协同进程,则它不再工作。问题出在默认的标准I/O缓冲机制上。当调用图15-19中的程序时,对标准输入的第一个fgets引起标准I/O库分配一个缓冲区,并选择缓冲的类型。因为标准输入是一个管道,所以标准I/O库默认是全缓冲的。标准输出也是如此。当add2从其标准输入读取而发生阻塞时,图15-18中的程序从管道读时也发生阻塞,于是产生了死锁。
这里,可以对将要运行的这一协同进程加以控制。我们可以修改图 15-19 中的程序,在while循环之前加上下面4行:
if (setvbuf(stdin, NULL, _IOLBF, 0) != 0)
err_sys("setvbuf error");
if (setvbuf(stdout, NULL, _IOLBF, 0)!= 0)
err_sys("setvbuf error");
这些代码行使得:当有一行可用时,fgets 就返回;当输出一个换行符时,printf 立即执行fflush操作。对setvbuf进行的这些显式调用使得图15-19中的程序能正常工作了。
如果不能修改管道输出的目标程序,则需使用其他技术。例如,如果在程序中使用 awk(1)作为协同进程(代替add2程序),则下列命令行不能工作:
#!/bin/awk/ -f
{ print $1 + $2 }
不能工作的原因还是标准I/O的缓冲机制问题。但是在这种情况下,无法改变awk的工作方式(除非有awk的源代码)。我们不能修改awk的可执行代码,于是也就不能更改处理其标准I/O缓冲的方式。
对这种问题的一般解决方法是使被调用(在本例中是awk)的协同进程认为它的标准输入和输出都被连接到了一个终端。这使得协同进程中的标准I/O例程对这两个I/O流进行行缓冲,这类似于前面所做的显式调用setvbuf。第19章将用伪终端实现这种方法。
FIFO有时被称为命名管道。未命名的管道只能在两个相关的进程之间使用,而且这两个相关的进程还要有一个共同的创建了它们的祖先进程。但是,通过FIFO,不相关的进程也能交换数据。
第14章中已经提及FIFO是一种文件类型。通过stat结构(见4.2节)的st_mode成员的编码可以知道文件是否是FIFO类型。可以用S_ISFIFO宏对此进行测试。
创建FIFO类似于创建文件。确实,FIFO的路径名存在于文件系统中。
#include <sys/stat.h>
int mkfifo(const char *path, mode_t mode);
int mkfifoat(int fd, const char *path, mode_t mode);
两个函数的返回值:若成功,返回0;若出错,返回−1
mkfifo函数中mode参数的规格说明与open函数中mode的相同(见3.3节)。新FIFO的用户和组的所有权规则与4.6节所述的相同。
mkfifoat函数和mkfifo函数相似,但是mkfifoat函数可以被用来在fd文件描述符表示的目录相关的位置创建一个FIFO。像其他*at函数一样,这里有3种情形:
(1)如果path参数指定的是绝对路径名,则fd参数会被忽略掉,并且mkfifoat函数的行为和mkfifo类似。
(2)如果path参数指定的是相对路径名,则fd参数是一个打开目录的有效文件描述符,路径名和目录有关。
(3)如果path参数指定的是相对路径名,并且fd参数有一个特殊值AT_FDCWD,则路径名以当前目录开始,mkfifoat和mkfifo类似。
当我们用mkfifo或者mkfifoat创建FIFO时,要用open来打开它。确实,正常的文件I/O函数(如close、read、write和unlink)都需要FIFO。
应用程序可以用mknod和mknodat函数创建FIFO。因为POSIX.1原先并没有包括mknod函数,所以mkfifo是专门为POSIX.1设计的。mknod和mknodat函数现在已包括在POSIX.1的XSI扩展中。
POSIX.1也包括了对mkfifo(1)命令的支持。本书讨论的4种平台都提供此命令。因此,可以用一条shell命令创建一个FIFO,然后用一般的shell I/O重定向对其进行访问。
当open一个FIFO时,非阻塞标志(O_NONBLOCK)会产生下列影响。
•在一般情况下(没有指定O_NONBLOCK),只读 open要阻塞到某个其他进程为写而打开这个FIFO为止。类似地,只写open要阻塞到某个其他进程为读而打开它为止。
•如果指定了 O_NONBLOCK,则只读 open 立即返回。但是,如果没有进程为读而打开一个FIFO,那么只写open将返回−1,并将errno设置成ENXIO。
类似于管道,若 write 一个尚无进程为读而打开的 FIFO,则产生信号 SIGPIPE。若某个FIFO的最后一个写进程关闭了该FIFO,则将为该FIFO的读进程产生一个文件结束标志。
一个给定的 FIFO 有多个写进程是常见的。这就意味着,如果不希望多个进程所写的数据交叉,则必须考虑原子写操作。和管道一样,常量PIPE_BUF说明了可被原子地写到FIFO的最大数据量。
FIFO有以下两种用途。
(1)shell命令使用FIFO将数据从一条管道传送到另一条时,无需创建中间临时文件。
(2)客户进程-服务器进程应用程序中,FIFO 用作汇聚点,在客户进程和服务器进程二者之间传递数据。
我们各用一个实例来说明这两种用途。
实例:用FIFO复制输出流
FIFO可用于复制一系列sell命令中的输出流。这就防止了将数据写向中间磁盘文件(类似于使用管道来避免中间磁盘文件)。但是不同的是,管道只能用于两个进程之间的线性连接,而FIFO是有名字的,因此它可用于非线性连接。
考虑这样一个过程,它需要对一个经过过滤的输入流进行两次处理。图15-20显示了这种安排。
图15-20 对一个经过过滤的输入流进行两次处理的过程
使用FIFO和UNIX程序tee(1)就可以实现这样的过程而无需使用临时文件。(tee 程序将其标准输入同时复制到其标准输出以及其命令行中命名的文件中。)
mkfifo fifo1
prog3 < fifo1 &
prog1 < infile | tee fifo1 | prog2
创建FIFO,然后在后台启动prog3,从FIFO读数据。然后启动progl,用tee将其输出发送到FIFO和prog2。图15-21显示了进程安排。
图15-21 使用FIFO和tee将一个流发送到两个不同的进程
实例:使用FIFO进行客户进程-服务器进程通信
FIFO 的另一个用途是在客户进程和服务器进程之间传送数据。如果有一个服务器进程,它与很多客户进程有关,每个客户进程都可将其请求写到一个该服务器进程创建的众所周知的FIFO中(“众所周知”的意思是:所有需与服务器进程联系的客户进程都知道该FIFO的路径名)。图15-22显示了这种安排。
图15-22 客户进程用FIFO向服务器进程发送请求
因为该 FIFO 有多个写进程,所以客户进程发送给服务器进程的请求的长度要小于PIPE_BUF字节。这样就能避免客户进程的多次写之间的交叉。
在这种类型的客户进程-服务器进程通信中使用FIFO的问题是:服务器进程如何将回答送回各个客户进程。不能使用单个FIFO,因为客户进程不可能知道何时去读它们的响应以及何时响应其他客户进程。一种解决方法是,每个客户进程都在其请求中包含它的进程ID。然后服务器进程为每个客户进程创建一个FIFO,所使用的路径名是以客户进程的进程ID为基础的。例如,服务器进程可以用名字/tmp/serv1.XXXXX创建FIFO,其中XXXXX被替换成客户进程的进程ID。图15-23显示了这种安排。
图15-23 用FIFO进行客户进程-服务器进程通信
虽然这种安排可以工作,但服务器进程不能判断一个客户进程是否崩溃终止,这就使得客户进程专用FIFO会遗留在文件系统中。另外,服务器进程还必须得捕捉SIGPIPE信号,因为客户进程在发送一个请求后有可能没有读取响应就终止了,于是留下一个只有写进程(服务器进程)而无读进程的客户进程专用FIFO。
按照图15-23中的安排,如果服务器进程以只读方式打开众所周知的FIFO(因为它只需读该FIFO),则每当客户进程个数从1变成0时,服务器进程就将在FIFO中读到(read)一个文件结束标志。为使服务器进程免于处理这种情况,一种常用的技巧是使服务器进程以读-写方式打开该众所周知的FIFO(见习题15.10)。
有3种称作XSI IPC的IPC:消息队列、信号量以及共享存储器。它们之间有很多相似之处。本节先介绍它们相类似的特征,后面几节将说明这些IPC各自的特殊功能。
XSI IPC函数是紧密地基于System V的IPC函数的。这3种类型的XSI IPC源自于1970年的一种称为“Columbus UNIX”的AT&T内部版本。后来它们被添加到System V上。由于XSI IPC不使用文件系统命名空间,而是构造了它们自己的命名空间,为此常常受到批评。
每个内核中的 IPC 结构(消息队列、信号量或共享存储段)都用一个非负整数的标识符(identifier)加以引用。例如,要向一个消息队列发送消息或者从一个消息队列取消息,只需要知道其队列标识符。与文件描述符不同,IPC标识符不是小的整数。当一个IPC结构被创建,然后又被删除时,与这种结构相关的标识符连续加1,直至达到一个整型数的最大正值,然后又回转到0。
标识符是IPC对象的内部名。为使多个合作进程能够在同一IPC对象上汇聚,需要提供一个外部命名方案。为此,每个IPC对象都与一个键(key)相关联,将这个键作为该对象的外部名。
无论何时创建IPC结构(通过调用msgget、semget或shmget创建),都应指定一个键。这个键的数据类型是基本系统数据类型key_t,通常在头文件<sys/types.h>中被定义为长整型。这个键由内核变换成标识符。
有多种方法使客户进程和服务器进程在同一IPC结构上汇聚。
(1)服务器进程可以指定键IPC_PRIVATE创建一个新IPC结构,将返回的标识符存放在某处(如一个文件)以便客户进程取用。键IPC_PRIVATE保证服务器进程创建一个新IPC结构。这种技术的缺点是:文件系统操作需要服务器进程将整型标识符写到文件中,此后客户进程又要读这个文件取得此标识符。
IPC_PRIVATE键也可用于父进程子关系。父进程指定IPC_PRIVATE创建一个新IPC结构,所返回的标识符可供fork后的子进程使用。接着,子进程又可将此标识符作为exec函数的一个参数传给一个新程序。
(2)可以在一个公用头文件中定义一个客户进程和服务器进程都认可的键。然后服务器进程指定此键创建一个新的IPC结构。这种方法的问题是该键可能已与一个IPC结构相结合,在此情况下,get函数(msgget、semget或shmget)出错返回。服务器进程必须处理这一错误,删除已存在的IPC结构,然后试着再创建它。
(3)客户进程和服务器进程认同一个路径名和项目ID(项目ID是0~255之间的字符值),接着,调用函数ftok将这两个值变换为一个键。然后在方法(2)中使用此键。ftok提供的唯一服务就是由一个路径名和项目ID产生一个键。
#include <sys/ipc.h>
key_t ftok(const char *path, int id);
返回值:若成功,返回键;若出错,返回(key_t)−1
path参数必须引用一个现有的文件。当产生键时,只使用id参数的低8位。
ftok创建的键通常是用下列方式构成的:按给定的路径名取得其stat结构(见4.2节)中的部分st_dev和st_ino字段,然后再将它们与项目ID组合起来。如果两个路径名引用的是两个不同的文件,那么ftok通常会为这两个路径名返回不同的键。但是,因为i节点编号和键通常都存放在长整型中,所以创建键时可能会丢失信息。这意味着,对于不同文件的两个路径名,如果使用同一项目ID,那么可能产生相同的键。
3个get函数(msgget、semget和shmget)都有两个类似的参数:一个key和一个整型flag。在创建新的IPC结构(通常由服务器进程创建)时,如果key是IPC_PRIVATE或者和当前某种类型的IPC结构无关,则需要指明flag的IPC_CREAT标志位。为了引用一个现有队列(通常由客户进程创建),key必须等于队列创建时指明的key的值,并且IPC_CREAT必须不被指明。
注意,决不能指定 IPC_PRIVATE 作为键来引用一个现有队列,因为这个特殊的键值总是用于创建一个新队列。为了引用一个用 IPC_PRIVATE 键创建的现有队列,一定要知道这个相关的标识符,然后在其他 IPC 调用中(如 msgsnd、msgrcv)使用该标识符,这样可以绕过get函数。
如果希望创建一个新的IPC结构,而且要确保没有引用具有同一标识符的一个现有IPC结构,那么必须在flag中同时指定IPC_CREAT和IPC_EXCL位。这样做了以后,如果IPC结构已经存在就会造成出错,返回EEXIST(这与指定了O_CREAT和O_EXCL标志的open相类似)。
XSI IPC为每一个IPC结构关联了一个ipc_perm结构。该结构规定了权限和所有者,它至少包括下列成员:
struct ipc_perm {
uid_t uid; /* owner's effective user id */
gid_t gid; /* owner's effective group id */
uid_t cuid; /* creator's effective user id */
gid_t cgid; /* creator's effective group id */
mode_t mode; /* access modes */
};
┇
每个实现会包括另外一些成员。如欲了解你所用系统中它的完整定义,请参见<sys/ipc.h>。
在创建IPC结构时,对所有字段都赋初值。以后,可以调用msgctl、semctl或shmctl修改uid、gid和mode字段。为了修改这些值,调用进程必须是IPC结构的创建者或超级用户。修改这些字段类似于对文件调用chown和chmod。
mode字段的值类似于图4-6中所示的值,但是对于任何IPC结构都不存在执行权限。另外,消息队列和共享存储使用术语“读”和“写”,而信号量则用术语“读”和“更改”(alter)。图15-24显示了每种IPC的6种权限。
图15-24 XSI IPC权限
某些实现定义了表示每种权限的符号常量,但是这些常量并不包括在Single UNIX Specification中。
所有3种形式的XSI IPC都有内置限制。大多数限制可以通过重新配置内核来改变。在对这3种形式的IPC中的每一种进行描述时,我们都会指出它的限制。
在报告和修改限制方面,每种平台都有自己的方法。FreeBSD 8.0、Linux 3.2.0和Mac OS X 10.6.8提供了sysctl命令来观察和修改内核配置参数。在Solaris 10中,可以用prctl命令来改变内核IPC的限制。
在Linux中,可以运行ipcs –l来显示IPC相关的限制。在FreeBSD中,等效的命令是ipcs-T。在Solaris中,可以通过运行sysdef –y来找到可调节参数。
XSI IPC 的一个基本问题是:IPC 结构是在系统范围内起作用的,没有引用计数。例如,如果进程创建了一个消息队列,并且在该队列中放入了几则消息,然后终止,那么该消息队列及其内容不会被删除。它们会一直留在系统中直至发生下列动作为止:由某个进程调用 msgrcv 或msgctl读消息或删除消息队列;或某个进程执行ipcrm(1)命令删除消息队列;或正在自举的系统删除消息队列。将此与管道相比,当最后一个引用管道的进程终止时,管道就被完全地删除了。对于FIFO而言,在最后一个引用FIFO的进程终止时,虽然FIFO的名字仍保留在系统中,直至被显式地删除,但是留在FIFO中的数据已被删除了。
XSI IPC的另一个问题是:这些IPC结构在文件系统中没有名字。我们不能用第3章和第4章中所述的函数来访问它们或修改它们的属性。为了支持这些IPC对象,内核中增加了十几个全新的系统调用(msgget、semop、shmat等)。我们不能用ls命令查看IPC对象,不能用rm命令删除它们,也不能用chmod命令修改它们的访问权限。于是,又增加了两个新命令ipcs(1)和ipcrm(1)。
因为这些形式的 IPC 不使用文件描述符,所以不能对它们使用多路转接 I/O 函数(select和poll)。这使得它很难一次使用一个以上这样的IPC结构,或者在文件或设备I/O中使用这样的IPC结构。例如,如果没有某种形式的忙等循环(busy-wait loop),就不能使一个服务器进程等待将要放在两个消息队列中任意一个中的消息。
Andrade、Carges和Kovach[1989]对使用System V IPC构建的一个事务处理系统进行了综述。他们认为System V IPC使用的命名空间(标识符)是一个优点,而不是前面所说的问题,理由是使用标识符使一个进程只要使用单个函数调用(msgsnd)就能将一个消息发送到一个队列,而其他形式的IPC则通常还要调用open、write和close。这种说法是错误的。为了避免使用键和调用 msgget,客户进程总要以某种方式获得服务器进程队列的标识符。分派给特定队列的标识符取决于在创建该队列时,有多少消息队列已经存在,也取决于自内核自举以来,内核中将分配给新队列的表项已经使用了多少次。这是一个动态值,无法猜到或事先存放在一个头文件中。正如15.6.1节所述,至少服务器进程应将分配给队列的标识符写到一个文件中以便客户进程读取。
这些作者列举的消息队列的其他优点是:它们是可靠的、流控制的以及面向记录的;它们可以用非先进先出次序处理。图15-25对这些不同形式IPC的某些特征进行了比较。
图15-25 不同形式IPC之间的特征比较
(我们将在第 16 章中描述流和数据报套接字,在 17.2 节中描述 UNIX 域套接字。)图 15-25中的“无连接”指的是无需先调用某种形式的打开函数就能发送消息的能力。如前所述,因为需要有某种技术来获得队列标识符,所以我们并不认为消息队列是无连接的。因为所有这些形式的IPC 被限制在一台主机上,所以它们都是可靠的。当消息通过网络传送时,就要考虑丢失消息的可能性。“流控制”的意思是:如果系统资源(缓冲区)短缺,或者如果接收进程不能再接收更多消息,则发送进程就要休眠。当流控制条件消失时,发送进程应自动唤醒。
图 15-25 中没有显示的一个特征是:IPC 设施能否自动地为每个客户进程创建一个到服务器进程的唯一连接。第17章将说明UNIX流套接字可以提供这种能力。下面3节将对3种形式的XSI IPC进行详细的描述。
消息队列是消息的链接表,存储在内核中,由消息队列标识符标识。在本节中,我们把消息队列简称为队列,其标识符简称为队列ID。
Single UNIX Specification的消息传送选项中包括一种替代的IPC消息队列接口,该接口来源于POSIX实时扩展。本书不讨论这个接口。
msgget 用于创建一个新队列或打开一个现有队列。msgsnd 将新消息添加到队列尾端。每个消息包含一个正的长整型类型的字段、一个非负的长度以及实际数据字节数(对应于长度),所有这些都在将消息添加到队列时,传送给 msgsnd。msgrcv 用于从队列中取消息。我们并不一定要以先进先出次序取消息,也可以按消息的类型字段取消息。
每个队列都有一个msqid_ds结构与其相关联:
struct msqid_ds {
struct ipc_perm msg_perm; /* see Section 15.6.2 */
msgqnum_t msg_qnum; /* # of messages on queue */
msglen_t msg_qbytes; /* max # of bytes on queue */
pid_t msg_lspid; /* pid of last msgsnd() */
pid_t msg_lrpid; /* pid of last msgrcv() */
time_t msg_stime; /* last-msgsnd() time */
time_t msg_rtime; /* last-msgrcv() time */
time_t msg_ctime; /* last-change time */
┇
};
此结构定义了队列的当前状态。结构中所示的各成员是由Single UNIX Specification定义的。具体实现可能包括标准中没有定义的另一些字段。
图15-26列出了影响消息队列的系统限制。“导出的”表示这种限制来源于其他限制。例如,在Linux系统中,最大消息数是根据最大队列数和队列中所允许的最大数据量来决定的。其中最大队列数还要根据系统上安装的RAM 的数量来决定。注意,队列的最大字节数限制进一步限制了队列中将要存储的消息的最大长度。
调用的第一个函数通常是msgget,其功能是打开一个现有队列或创建一个新队列。
图15-26 影响消息队列的系统限制
#include <sys/msg.h>
int msgget(key_t key, int flag);
返回值:若成功,返回消息队列ID;若出错,返回−1
15.6.1 节说明了将key变换成一个标识符的规则,并且讨论了是创建一个新队列还是引用一个现有队列。在创建新队列时,要初始化msqid-ds结构的下列成员。
•ipc-perm结构按15.6.2节中所述进行初始化。该结构中的mode成员按flag中的相应权限位设置。这些权限用图15-24中的值指定。
•msg_qnum、msg_lspid、msg_lrpid、msg_stime和msg_rtime都设置为0。
•msg_ctime设置为当前时间。
•msg_qbytes设置为系统限制值。
若执行成功,msgget返回非负队列ID。此后,该值就可被用于其他3个消息队列函数。
msgctl函数对队列执行多种操作。它和另外两个与信号量及共享存储有关的函数(semctl和shmctl)都是XSI IPC的类似于ioctl的函数(亦即垃圾桶函数)。
#include <sys/msg.h>
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
返回值:若成功,返回0;若出错,返回−1
cmd参数指定对msqid指定的队列要执行的命令。
IPC_STAT 取此队列的msqid_ds结构,并将它存放在buf指向的结构中。
IPC_SET 将字段 msg_perm.uid、msg_perm.gid、msg_perm.mode 和 msg_qbytes从buf指向的结构复制到与这个队列相关的msqid_ds结构中。此命令只能由下列两种进程执行:一种是其有效用户ID等于msg_perm.cuid或msg_perm.uid,另一种是具有超级用户特权的进程。只有超级用户才能增加msg_qbytes的值。
IPC_RMID 从系统中删除该消息队列以及仍在该队列中的所有数据。这种删除立即生效。仍在使用这一消息队列的其他进程在它们下一次试图对此队列进行操作时,将得到EIDRM错误。此命令只能由下列两种进程执行:一种是其有效用户ID等于msg_perm.cuid或msg_perm.uid;另一种是具有超级用户特权的进程。
这3条命令(IPC_STAT、IPC_SET和IPC_RMID)也可用于信号量和共享存储。
调用msgsnd将数据放到消息队列中。
#include <sys/msg.h>
int msgsnd(int msqid, const void *ptr, size_t nbytes, int flag);
返回值:若成功,返回0;若出错,返回−1
正如前面提及的,每个消息都由3部分组成:一个正的长整型类型的字段、一个非负的长度(nbytes)以及实际数据字节数(对应于长度)。消息总是放在队列尾端。
ptr参数指向一个长整型数,它包含了正的整型消息类型,其后紧接着的是消息数据(若nbytes是0,则无消息数据)。若发送的最长消息是512字节的,则可定义下列结构:
struct mymesg {
long mtype; /* positive message type */
char mtext[512]; /* message data, of length nbytes */
};
ptr就是一个指向mymesg结构的指针。接收者可以使用消息类型以非先进先出的次序取消息。
某些平台既支持32位环境,又支持64位环境。这影响到长整型和指针的大小。例如,在64位SPARC系统中,Solaris允许32位应用程序和64位应用程序同时存在。如果一个32位应用程序要经由管道或套接字与一个64位应用程序交换此结构,就会出问题。因为在32位应用程序中,长整型的大小是4字节,而在64位应用程序中,长整型的大小是8字节。这意味着,32位应用程序期望mtext字段在结构起始地址后的第4个字节处开始,而64位应用程序则期望mtext字段在结构起始地址后的第8个字节处开始。在这种情况下,64位应用程序的mtype字段的一部分会被32位应用程序视为mtext字段的组成部分,而32位应用程序的mtext字段的前4个字节会被64位应用程序解释为mtype字段的组成部分。
但是,XSI消息队列就不会发生这种问题。Solaris实现的IPC系统调用的32位版本和64位版本具有不同的入口点。这些系统调用知道如何处理32位应用程序与64位应用程序的通信操作,并对类型字段做了特殊处理以避免它干扰消息的数据部分。唯一的潜在问题是,当64位应用程序向32位应用程序发送消息时,如果它在8字节类型字段中设置的值大于32位应用程序中4字节类型字段可表示的值,那么32位应用程序在其mtype字段中得到的将是一个截短了的类型值。
参数flag的值可以指定为IPC_NOWAIT。这类似于文件I/O的非阻塞I/O标志(见14.2节)。若消息队列已满(或者是队列中的消息总数等于系统限制值,或队列中的字节总数等于系统限制值),则指定IPC_NOWAIT使得msgsnd立即出错返回EAGAIN。如果没有指定IPC_NOWAIT,则进程会一直阻塞到:有空间可以容纳要发送的消息;或者从系统中删除了此队列;或者捕捉到一个信号,并从信号处理程序返回。在第二种情况下,会返回EIDRM错误(“标识符被删除”)。最后一种情况则返回EINTR错误。
注意,对删除消息队列的处理不是很完善。因为每个消息队列没有维护引用计数器(打开文件有这种计数器),所以在队列被删除以后,仍在使用这一队列的进程在下次对队列进行操作时会出错返回。信号量机构也以同样方式处理其删除。相反,删除一个文件时,要等到使用该文件的最后一个进程关闭了它的文件描述符以后,才能删除文件中的内容。
当msgsnd返回成功时,消息队列相关的msqid_ds结构会随之更新,表明调用的进程ID (msg_lspid)、调用的时间(msg_stime)以及队列中新增的消息(msg_qnum)。
msgrcv从队列中取用消息。
#include <sys/msg.h>
ssize_t msgrcv(int msqid, void *ptr, size_t nbytes, long type, int flag);
返回值:若成功,返回消息数据部分的长度;若出错,返回-1
和msgsnd一样,ptr参数指向一个长整型数(其中存储的是返回的消息类型),其后跟随的是存储实际消息数据的缓冲区。nbytes 指定数据缓冲区的长度。若返回的消息长度大于 nbytes,而且在flag中设置了MSG_NOERROR位,则该消息会被截断(在这种情况下,没有通知告诉我们消息截断了,消息被截去的部分被丢弃)。如果没有设置这一标志,而消息又太长,则出错返回E2BIG(消息仍留在队列中)。
参数type可以指定想要哪一种消息。
type == 0 返回队列中的第一个消息。
type > 0 返回队列中消息类型为type的第一个消息。
type < 0 返回队列中消息类型值小于等于 type 绝对值的消息,如果这种消息有若干个,则取类型值最小的消息。
type值非0用于以非先进先出次序读消息。例如,若应用程序对消息赋予优先权,那么type就可以是优先权值。如果一个消息队列由多个客户进程和一个服务器进程使用,那么type字段可以用来包含客户进程的进程ID(只要进程ID可以存放在长整型中)。
可以将flag值指定为IPC_NOWAIT,使操作不阻塞,这样,如果没有所指定类型的消息可用,则msgrcv返回−1,error设置为ENOMSG。如果没有指定IPC_NOWAIT,则进程会一直阻塞到有了指定类型的消息可用,或者从系统中删除了此队列(返回−1,error设置为EIDRM),或 者捕捉到一个信号并从信号处理程序返回(这会导致msgrcv返回−1,errno设置为EINTR)。
msgrcv成功执行时,内核会更新与该消息队列相关联的msgid_ds结构,以指示调用者的进程ID(msg_lrpid)和调用时间(msg_rtime),并指示队列中的消息数减少了1个(msg_qnum)。
实例:消息队列与全双工管道的时间比较
如若需要客户进程和服务器进程之间的双向数据流,可以使用消息队列或全双工管道。(回忆图15-1,通过 UNIX域套接字机制,见17.2节,可以使全双工管道可用,而某些平台通过pipe函数提供全双工管道。)
图15-27显示了在Solaris上3种技术在时间方面的比较,这3种技术是:消息队列、全双工(STREAMS)管道和UNIX域套接字。测试程序先创建IPC通道,调用fork,然后从父进程向子进程发送约200 MB数据。数据发送的方式是:对于消息队列,调用100 000次msgsnd,每个消息长度为2 000字节;对于全双工管道和UNIX域套接字,调用100 000次write,每次写2 000字节。时间都以秒为单位。
图15-27 在Solaris上3种IPC的时间比较
从这些数字中可见,消息队列原来的实施目的是提供高于一般速度的 IPC,但现在与其他形式的 IPC 相比,在速度方面已经没有什么差别了。(在原来实施消息队列时,可用的其他形式的IPC就只有半双工管道这一种。)考虑到使用消息队列时遇到的问题(见15.6.4节),我们得出的结论是,在新的应用程序中不应当再使用它们。
信号量与已经介绍过的 IPC 机构(管道、FIFO 以及消息列队)不同。它是一个计数器,用于为多个进程提供对共享数据对象的访问。
Single UNIX Specification包括了另外一套信号量接口,该接口原来是实时扩展的一部分。我们将在15.10节讨论这种接口。
为了获得共享资源,进程需要执行下列操作。
(1)测试控制该资源的信号量。
(2)若此信号量的值为正,则进程可以使用该资源。在这种情况下,进程会将信号量值减1,表示它使用了一个资源单位。
(3)否则,若此信号量的值为 0,则进程进入休眠状态,直至信号量值大于 0。进程被唤醒后,它返回至步骤(1)。
当进程不再使用由一个信号量控制的共享资源时,该信号量值增 1。如果有进程正在休眠等待此信号量,则唤醒它们。
为了正确地实现信号量,信号量值的测试及减1操作应当是原子操作。为此,信号量通常是在内核中实现的。
常用的信号量形式被称为二元信号量(binary semaphore)。它控制单个资源,其初始值为1。但是,一般而言,信号量的初值可以是任意一个正值,该值表明有多少个共享资源单位可供共享应用。
遗憾的是,XSI信号量与此相比要复杂得多。以下3种特性造成了这种不必要的复杂性。
(1)信号量并非是单个非负值,而必需定义为含有一个或多个信号量值的集合。当创建信号量时,要指定集合中信号量值的数量。
(2)信号量的创建(semget)是独立于它的初始化(semctl)的。这是一个致命的缺点,因为不能原子地创建一个信号量集合,并且对该集合中的各个信号量值赋初值。
(3)即使没有进程正在使用各种形式的XSI IPC,它们仍然是存在的。有的程序在终止时并没有释放已经分配给它的信号量,所以我们不得不为这种程序担心。后面将要说明的 undo 功能就是处理这种情况的。
内核为每个信号量集合维护着一个semid_ds结构:
struct semid_ds {
struct ipc_perm sem_perm; /* see Section 15.6.2 */
unsigned short sem_nsems; /* # of semaphores in set */
time_t sem_otime; /* last-semop() time */
time_t sem_ctime; /* last-change time */
};
┇
Single UNIX Specification定义了上面所示的各字段,但是具体实现可在semid_ds结构中定义添加的成员。
每个信号量由一个无名结构表示,它至少包含下列成员:
struct {
unsigned short semval; /* semaphore value, always >= 0 */
pid_t sempid; /* pid for last operation */
unsigned short semncnt; /* # processes awaiting semval>curval */
unsigned short semzcnt; /* # processes awaiting semval==0 */
┇
};
图15-28列出了影响信号量集合的系统限制。
图15-28 影响信号量的系统限制
当我们想使用XSI信号量时,首先需要通过调用函数semget来获得一个信号量ID。
#include <sys/sem.h>
int semget(key_t key, int nsems, int flag);
返回值:若成功,返回信号量ID;若出错,返回−1
15.6.1节说明了将key变换为标识符的规则,讨论了是创建一个新集合,还是引用一个现有集合。创建一个新集合时,要对semid_ds结构的下列成员赋初值。
•按15.6.2节中所述,初始化ipc_perm结构。该结构中的mode成员被设置为flag中的相应权限位。这些权限是用图15-24中的值设置的。
•sem_otime设置为0。
•sem_ctime设置为当前时间。
•sem_nsems设置为nsems。
nsems是该集合中的信号量数。如果是创建新集合(一般在服务器进程中),则必须指定nsems。如果是引用现有集合(一个客户进程),则将nsems指定为0。
semctl函数包含了多种信号量操作。
#include <sys/sem.h>
int semctl(int semid, int semnum, int cmd, ... /* union semun arg */);
返回值:(见下)
第4个参数是可选的,是否使用取决于所请求的命令,如果使用该参数,则其类型是semun,它是多个命令特定参数的联合(union):
union semun {
int val; /* for SETVAL */
struct semid_ds *buf; /* for IPC_STAT and IPC_SET */
unsigned short *array; /* for GETALL and SETALL */
};
注意,这个选项参数是一个联合,而非指向联合的指针。
通常应用程序必须定义semun联合。然而,在FreeBSD 8.0中,semun已经由<sys/sem.h>为我们定义好了。
cmd参数指定下列10种命令中的一种,这些命令是运行在semid指定的信号量集合上的。其中有5种命令是针对一个特定的信号量值的,它们用semnum指定该信号量集合中的一个成员。semnum值在0和nsems−1之间,包括0和nsems−1。
IPC_STAT 对此集合取semid_ds结构,并存储在由arg.buf指向的结构中。
IPC_SET 按arg.buf指向的结构中的值,设置与此集合相关的结构中的sem_perm.uid、sem_perm.gid和sem_perm.mode字段。此命令只能由两种进程执行:一种是其有效用户ID等于sem_perm.cuid或sem_perm.uid的进程;另一种是具有超级用户特权的进程。
IPC_RMID 从系统中删除该信号量集合。这种删除是立即发生的。删除时仍在使用此信号量集合的其他进程,在它们下次试图对此信号量集合进行操作时,将出错返回EIDRM。此命令只能由两种进程执行:一种是其有效用户ID等于sem_perm.cuid或sem_perm.uid的进程;另一种是具有超级用户特权的进程。
GETVAL 返回成员semnum的semval值。
SETVAL 设置成员semnum的semval值。该值由arg.val指定。
GETPID 返回成员semnum的sempid值。
GETNCNT 返回成员semnum的semncnt值。
GETZCNT 返回成员semnum的semzcnt值。
GETALL 取该集合中所有的信号量值。这些值存储在arg.array指向的数组中。
SETALL 将该集合中所有的信号量值设置成arg.array指向的数组中的值。
对于除GETALL以外的所有GET命令,semctl函数都返回相应值。对于其他命令,若成功则返回值为0,若出错,则设置errno并返回−1。
函数semop自动执行信号量集合上的操作数组。
#include <sys/sem.h>
int semop(int semid, struct sembuf semoparray[], size_t nops);
返回值:若成功,返回0;若出错,返回−1
参数semoparray是一个指针,它指向一个由sembuf结构表示的信号量操作数组:
struct sembuf {
unsigned short sem_num; /* member # in set (0, 1, ..., nsems-1 */
short sem_op; /* operation(negative, 0,or pasitive */)
short sem_flg; /* IPC_NOWAIT, SEM_UNDO */
};
参数nops规定该数组中操作的数量(元素数)。
对集合中每个成员的操作由相应的 sem_op 值规定。此值可以是负值、0或正值。(下面的讨论将提到信号量的“undo”标志。此标志对应于相应的sem_flg成员的SEM_UNDO位。)
(1)最易于处理的情况是 sem_op 为正值。这对应于进程释放的占用的资源数。sem_op 值会加到信号量的值上。如果指定了undo标志,则也从该进程的此信号量调整值中减去sem_op。
(2)若sem_op为负值,则表示要获取由该信号量控制的资源。
如若该信号量的值大于等于 sem_op 的绝对值(具有所需的资源),则从信号量值中减去 sem_op的绝对值。这能保证信号量的结果值大于等于0。如果指定了 undo 标志,则 sem_op 的绝对值也加到该进程的此信号量调整值上。
如果信号量值小于sem_op的绝对值(资源不能满足要求),则适用下列条件。
a.若指定了IPC_NOWAIT,则semop出错返回EAGAIN。
b.若未指定IPC_NOWAIT,则该信号量的semncnt值加1(因为调用进程将进入休眠状态),然后调用进程被挂起直至下列事件之一发生。
i.此信号量值变成大于等于sem_op的绝对值(即某个进程已释放了某些资源)。此信号量的semncnt值减1(因为已结束等待),并且从信号量值中减去sem_op的绝对值。
如果指定了undo标志,则sem_op的绝对值也加到该进程的此信号量调整值上。
ii.从系统中删除了此信号量。在这种情况下,函数出错返回EIDRM。
iii.进程捕捉到一个信号,并从信号处理程序返回,在这种情况下,此信号量的semncnt值减1(因为调用进程不再等待),并且函数出错返回EINTR。
(3)若sem_op为0,这表示调用进程希望等待到该信号量值变成0。
如果信号量值当前是0,则此函数立即返回。
如果信号量值非0,则适用下列条件。
a.若指定了 IPC_NOWAIT,则出错返回EAGAIN。
b.若未指定 IPC_NOWAIT,则该信号量的 semzcnt 值加 1(因为调用进程将进入休眠状态),然后调用进程被挂起,直至下列的一个事件发生。
i.此信号量值变成0。此信号量的semzcnt值减1(因为调用进程已结束等待)。
ii.从系统中删除了此信号量。在这种情况下,函数出错返回EIDRM。
iii.进程捕捉到一个信号,并从信号处理程序返回。在这种情况下,此信号量的semzcnt值减1(因为调用进程不再等待),并且函数出错返回EINTR。
semop函数具有原子性,它或者执行数组中的所有操作,或者一个也不做。
exit时的信号量调整
正如前面提到的,如果在进程终止时,它占用了经由信号量分配的资源,那么就会成为一个问题。无论何时只要为信号量操作指定了SEM_UNDO标志,然后分配资源(sem_op值小于0),那么内核就会记住对于该特定信号量,分配给调用进程多少资源(sem_op的绝对值)。当该进程终止时,不论自愿或者不自愿,内核都将检验该进程是否还有尚未处理的信号量调整值,如果有,则按调整值对相应信号量值进行处理。
如果用带SETVAL或SETALL命令的semctl设置一个信号量的值,则在所有进程中,该信号量的调整值都将设置为0。
实例:信号量、记录锁和互斥量的时间比较
如果在多个进程间共享一个资源,则可使用这3种技术中的一种来协调访问。我们可以使用映射到两个进程地址空间中的信号量、记录锁或者互斥量。对这3种技术两两之间在时间上的差别进行比较是有益的。
若使用信号量,则先创建一个包含一个成员的信号量集合,然后将该信号量值初始化为 1。为了分配资源,以 sem_op 为−1调用 semop。为了释放资源,以sem_op为+1调用semop。对每个操作都指定SEM_UNDO,以处理在未释放资源条件下进程终止的情况。
若使用记录锁,则先创建一个空文件,并且用该文件的第一个字节(无需存在)作为锁字节。为了分配资源,先对该字节获得一个写锁。释放该资源时,则对该字节解锁。记录锁的性质确保了当一个锁的持有者进程终止时,内核会自动释放该锁。
若使用互斥量,需要所有的进程将相同的文件映射到它们的地址空间里,并且使用 PTHREAD_PROCESS_SHARED互斥量属性在文件的相同偏移处初始化互斥量。为了分配资源,我们对互斥量加锁。为了释放锁,我们解锁互斥量。如果一个进程没有释放互斥量而终止,恢复将是非常困难的,除非我们使用鲁棒互斥量(回忆12.4.1节中讨论的pthread_mutex_consistent函数)。
图15-29显示了在Linux上,使用这3种不同技术进行锁操作所需的时间。在每一种情况下,资源都被分配、释放1 000 000次。这同时由3个不同的进程执行。图15-29中所示的时间是3个进程的总计,单位是秒。
图15-29 Linux上锁替代技术的时间比较
在Linux上,记录锁比信号量快,但是共享存储中的互斥量的性能比信号量和记录锁的都要优越。如果我们能单一资源加锁,并且不需要XSI信号量的所有花哨功能,那么记录锁将比信号量要好。原因是它使用起来更简单、速度更快(在这个平台上),当进程终止时系统会管理遗留下来的锁。尽管对于这种平台来说,在共享存储中使用互斥量是一个更快的选择,但是我们依然喜欢使用记录锁,除非要特别考虑性能。这样做有两个原因。首先,在多个进程间共享的内存中使用互斥量来恢复一个终止的进程更难。其次,进程共享的互斥量属性还没有得到普遍支持。在Single UNIX Specification的老版本中,这是可选的。尽管在SUSv4中依然是可选的,但是现在,所有遵循XSI的实现都要求使用它。
在本书讨论的4个平台中,只有Linux 3.2.0和Solaris 10当前支持进程共享的互斥量属性。
共享存储允许两个或多个进程共享一个给定的存储区。因为数据不需要在客户进程和服务器进程之间复制,所以这是最快的一种 IPC。使用共享存储时要掌握的唯一窍门是,在多个进程之间同步访问一个给定的存储区。若服务器进程正在将数据放入共享存储区,则在它做完这一操作之前,客户进程不应当去取这些数据。通常,信号量用于同步共享存储访问。(不过正如前节最后部分所述,也可以用记录锁或互斥量。)
Single UNIX Specification在其共享存储对象选项中包括了访问共享存储的替代接口,这些接口源于实时扩展。本书不讨论这些接口。
我们已经看到了共享存储的一种形式,就是在多个进程将同一个文件映射到它们的地址空间的时候。XSI 共享存储和内存映射的文件的不同之处在于,前者没有相关的文件。XSI 共享存储段是内存的匿名段。
内核为每个共享存储段维护着一个结构,该结构至少要为每个共享存储段包含以下成员:
struct shmid_ds {
struct ipc_perm shm_perm; /* see Section 15.6.2 */
size_t shm_segsz; /* size of segment in bytes */
pid_t shm_lpid; /* pid of last shmop() */
pid_t shm_cpid; /* pid of creator */
shmatt_t shm_nattch; /* number of current attaches */
time_t shm_atime; /* last-attach time */
time_t shm_dtime; /* last-detach time */
time_t shm_ctime; /* last-change time */
┇
};
(按照支持共享存储段的需要,每种实现会增加其他结构成员。)
shmatt_t类型定义为无符号整型,它至少与unsigned short一样大。图15-30列出了影响共享存储的系统限制。
图15-30 影响共享存储的系统限制
调用的第一个函数通常是shmget,它获得一个共享存储标识符。
#include <sys/shm.h>
int shmget(key_t key, size_t size, int flag);
返回值:若成功,返回共享存储ID;若出错,返回−1
15.6.1 节说明了将key变换成一个标识符的规则,以及是创建一个新共享存储段,还是引用一个现有的共享存储段。当创建一个新段时,初始化shmid_ds结构的下列成员。
•ipc_perm结构按15.6.2节中所述进行初始化。该结构中的mode按flag中的相应权限位设置。这些权限用图15-24中的值指定。
•shm_lpid、shm_nattach、shm_atime和shm_dtime都设置为0。
•shm_ctime设置为当前时间。
•shm_segsz设置为请求的size。
参数size是该共享存储段的长度,以字节为单位。实现通常将其向上取为系统页长的整倍数。但是,若应用指定的size值并非系统页长的整倍数,那么最后一页的余下部分是不可使用的。如果正在创建一个新段(通常在服务器进程中),则必须指定其size。如果正在引用一个现存的段(一个客户进程),则将size指定为0。当创建一个新段时,段内的内容初始化为0。
shmctl函数对共享存储段执行多种操作。
#include <sys/shm.h>
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
返回值:若成功,返回0;若出错,返回−1
cmd参数指定下列5种命令中的一种,使其在shmid指定的段上执行。
IPC_STAT 取此段的shmid_ds结构,并将它存储在由buf指向的结构中。
IPC_SET 按buf指向的结构中的值设置与此共享存储段相关的shmid_ds 结构中的下列3个字段:shm_perm.uid、shm_perm.gid和shm_perm.mode。此命令只能由下列两种进程执行:一种是其有效用户ID等于shm_perm.cuid或shm_perm.uid的进程;另一种是具有超级用户特权的进程。
IPC_RMID 从系统中删除该共享存储段。因为每个共享存储段维护着一个连接计数(shmid_ds结构中的shm_nattch字段),所以除非使用该段的最后一个进程终止或与该段分离,否则不会实际上删除该存储段。不管此段是否仍在使用,该段标识符都会被立即删除,所以不能再用 shmat 与该段连接。此命令只能由下列两种进程执行:一种是其有效用户 ID 等于 shm_perm.cuid 或shm_perm.uid的进程;另一种是具有超级用户特权的进程。
Linux和Solaris提供了另外两种命令,但它们并非Single UNIX Specification的组成部分。
SHM_LOCK 在内存中对共享存储段加锁。此命令只能由超级用户执行。
SHM_UNLOCK 解锁共享存储段。此命令只能由超级用户执行。
一旦创建了一个共享存储段,进程就可调用shmat将其连接到它的地址空间中。
#include <sys/shm.h>
void *shmat(int shmid, const void *addr, int flag);
返回值:若成功,返回指向共享存储段的指针;若出错,返回-1
共享存储段连接到调用进程的哪个地址上与addr参数以及flag中是否指定SHM_RND位有关。
•如果addr为0,则此段连接到由内核选择的第一个可用地址上。这是推荐的使用方式。
•如果addr非0,并且没有指定SHM_RND,则此段连接到addr所指定的地址上。
•如果addr非0,并且指定了SHM_RND,则此段连接到(addr−(addr mod SHMLBA))所表示的地址上。SHM_RND命令的意思是“取整”。SHMLBA的意思是“低边界地址倍数”,它总是2的乘方。该算式是将地址向下取最近1个SHMLBA的倍数。
除非只计划在一种硬件上运行应用程序(这在当今是不大可能的),否则不应指定共享存储段所连接到的地址。而是应当指定addr为0,以便由系统选择地址。
如果在flag中指定了SHM_RDONLY位,则以只读方式连接此段,否则以读写方式连接此段。
shmat的返回值是该段所连接的实际地址,如果出错则返回−1。如果shmat成功执行,那么内核将使与该共享存储段相关的shmid_ds结构中的shm_nattch计数器值加1。
当对共享存储段的操作已经结束时,则调用 shmdt 与该段分离。注意,这并不从系统中删除其标识符以及其相关的数据结构。该标识符仍然存在,直至某个进程(一般是服务器进程)带IPC_RMID命令的调用shmctl特地删除它为止。
#include <sys/shm.h>
int shmdt(const void *addr);
返回值:若成功,返回0;若出错,返回-1
addr参数是以前调用shmat时的返回值。如果成功,shmdt将使相关shmid_ds结构中的shm_nattch计数器值减1。
实例
内核将以地址0连接的共享存储段放在什么位置上与系统密切相关。图15-31中的程序打印了一些特定系统存放各种类型的数据的位置信息。
图15-31 打印各种类型的数据存放的位置
在一个基于Intel的64位Linux系统上运行此程序,其输出如下:
$ ./a.out
array[] from 0x6020c0 to 0x60bd00
stack around 0x7fff957b146c
malloced from 0x9e3010 to 0x9fb6b0
shared memory attached from 0x7fba578ab000 to 0x7fba578c36a0
图15-32显示了这种情况,这与图7-6中所示的典型存储区布局类似。注意,共享存储段紧靠在栈之下。
回忆一下mmap函数(见14.8节),它可将一个文件的若干部分映射至进程地址空间。这在概念上类似于用shmat XSI IPC函数连接一个共享存储段。两者之间的主要区别是,用mmap映射的存储段是与文件相关联的,而XSI共享存储段则并无这种关联。
图15-32 在基于Intel的Linux系统上的存储区布局
实例:/dev/zero的存储映射
共享存储可由两个不相关的进程使用。但是,如果进程是相关的,则某些实现提供了一种不同的技术。
下面说明的技术用于FreeBSD 8.0、Linux 3.2.0和Solaris 10。Mac OS X 10.6.8当前并不支持将字符设备映射至进程地址空间。
在读设备/dev/zero时,该设备是0字节的无限资源。它也接收写向它的任何数据,但又忽略这些数据。我们对此设备作为 IPC 的兴趣在于,当对其进行存储映射时,它具有一些特殊性质。
•创建一个未命名的存储区,其长度是mmap的第二个参数,将其向上取整为系统的最近页长。
•存储区都初始化为0。
•如果多个进程的共同祖先进程对mmap指定了MAP_SHARED标志,则这些进程可共享此存储区。
图15-33中的程序是使用此特殊设备的一个例子。
图15-33 在父进程、子进程之间使用/dev/zero的存储映射I/O的IPC
该程序打开此/dev/zero设备,然后指定长整型的长度调用mmap。注意,一旦存储区映射成功,我们就要关闭(close)此设备。然后,进程创建一个子进程。因为在调用mmap时指定了 MAP_SHARED,所以一个进程写到存储映射区的数据可被另一进程见到。(如果已指定MAP_PRIVATE,则此程序不能工作。)
然后,父进程、子进程交替运行,它们使用 8.9 节中的同步函数各自对共享存储映射区中的长整型数加1。存储映射区由mmap初始化为0。父进程先对它进行增1操作,使其成为1,然后子进程对其进行增1操作,使其成为2,然后父进程使其成为3,依此类推。注意,当在update函数中对长整型值增1时,因为增加的是其值,而不是指针,所以必须使用括号。
以上述方式使用/dev/zero 的优点是:在调用 mmap 创建映射区之前,无需存在一个实际文件。映射/dev/zero 自动创建一个指定长度的映射区。这种技术的缺点是:它只在两个相关进程之间起作用。但在相关进程之间使用线程可能更为简单有效(见第11章和第12章)。注意,无论使用哪一种技术,都需对共享数据进行同步访问。
实例:匿名存储映射
很多实现提供了一种类似于/dev/zero 的设施,称为匿名存储映射。为了使用这种功能,要在调用mmap时指定MAP_ANON标志,并将文件描述符指定为−1。结果得到的区域是匿名的(因为它并不通过一个文件描述符与一个路径名相结合),并且创建了一个可与后代进程共享的存储区。
本书讨论的 4 种平台都支持匿名存储映射设施。但是注意,Linux 为此设备定义了 MAP_ANONYMOUS标志,并将MAP_ANON标志定义为与它相同的值以改善应用的可移植性。
为使图 15-33 中的程序应用这个设施,我们对它做了 3 处修改:(a)删除了/dev/zero 的open语句,(b)删除了fd的close语句,(c)将mmap调用修改如下:
if ((area = mmap(0, SIZE, PROT_READ | PROT_WRITE,
MAP_ANON | MAP_SHARED, -1, 0)) == MAP_FAILED)
此调用指定了MAP_ANON标志,并将文件描述符设置为−1。图15-33中的程序的其余部分没变。
最后两个实例说明了在多个无关进程之间如何使用共享存储段。如果在两个无关进程之间要使用共享存储段,那么有两种替代的方法。一种是应用程序使用XSI共享存储函数,另一种是使用mmap将同一文件映射至它们的地址空间,为此使用MAP_SHARED标志。