第二部分 消息传递

第4章 管道和FIFO

4.1 概述

管道是最初的Unix IPC形式,可追溯到1973年的Unix第3版[Salus 1994]。尽管对于许多操作来说很有用,但它们的根本局限在于没有名字,从而只能由有亲缘关系的进程使用。这一点随FIFO的加入在System III Unix(1982年)中得以改正。FIFO有时称为有名管道(named pipe)。管道和FIFO都是使用通常的read和write函数访问的。

技术上讲,自从可以在进程间传递描述符(在本书15.8节和UNPvl的14.7节中讲述)后,管道也能用于无亲缘关系的进程间。然而现实中,管道通常用于具有共同祖先的进程间。

本章讲述管道和FIFO的创建与使用。我们使用一个简单的文件服务器例子,同时查看一些客户-服务器程序设计问题:IPC通道需要量、迭代服务器与并发服务器、字节流与消息接口。

4.2 一个简单的客户-服务器例子

图4-1所示的客户-服务器例子在本章和第6章中都要用到,我们用它来分析说明管道、FIFO和System V消息队列。

图4-1 客户-服务器例子

图中客户从标准输入(stdin)读进一个路径名,并把它写入IPC通道。服务器从该IPC通道读出这个路径名,并尝试打开其文件来读。如果服务器能打开该文件,它就读出其中的内容,并写入(可能另一个)IPC通道,以作为对客户的响应;否则,它就响应以一个出错消息。客户随后从该IPC通道读出响应,并把它写到标准输出(stdout)。如果服务器无法读该文件,那么客户读出的响应将是一个出错消息。否则,客户读出的响应就是该文件的内容。客户和服务器之间的虚线表示IPC通道。

4.3 管道

所有式样的Unix都提供管道。它由pipe函数创建,提供一个单路(单向)数据流。

#include <unistd.h>

int pipe(int fd[2]);

返回:若成功则为0,若出错则为-1

该函数返回两个文件描述符:fd[0]和fd[1]。前者打开来读,后者打开来写。

有些版本的Unix(例如SVR4)提供全双工管道,也就是说这些管道的两端都是既可用于读,也可用于写。创建一个全双工IPC管道的另一种方法是使用UNPvl的14.3节中讲述的socketpair函数,它在大多数现行Unix系统上都能工作。然而管道的最常见用途是用在各种shell中,这种情况下半双工管道足够了。

Posix.1和Unix 98只要求半双工管道,本章中我们也这么假设。

宏S_ISFIFO可用于确定一个描述符或文件是管道还是FIFO。它的唯一参数是stat结构的st_mode成员,计算结果或者为真(非零值),或者为假(0)。对于管道来说,这个stat结构是由fstat函数填写的。对于FIFO来说,这个结构是由fstat、lstat或stat函数填写的。

图4-2展示了单个进程中管道的模样。

图4-2 单个进程中的管道

尽管管道是由单个进程创建的,它却很少在单个进程内使用(我们将在图5-14中给出在单个进程内使用管道的一个例子)。管道的典型用途是以下述方式为两个不同进程(一个是父进程,一个是子进程)提供进程间的通信手段。首先,由一个进程(它将成为父进程)创建一个管道后调用fork派生一个自身的副本,如图4-3所示。

图4-3 单个进程内的管道,刚刚fork后

接着,父进程关闭这个管道的读出端,子进程关闭同一管道的写入端。这就在父子进程间提供了一个单向数据流,如图4-4所示。

图4-4 两个进程间的管道

who | sort | lp

我们在某个Unix shell中输入一个像下面这样的命令时:

该shell将执行上述步骤创建三个进程和其间的两个管道。它还把每个管道的读出端复制到相应进程的标准输入,把每个管道的写入端复制到相应进程的标准输出。图4-5展示了这样的管道线。

图4-5 某个shell管道线中三个进程间的管道

到此为止所示的所有管道都是半双工的即单向的,只提供一个方向的数据流。当需要一个双向数据流时,我们必须创建两个管道,每个方向一个。实际步骤如下:

(1)创建管道1(fd1[0]和fd1[1])和管道2(fd2[0]和fd2[1]);

(2)fork;

(3)父进程关闭管道1的读出端(fd1[0]);

(4)父进程关闭管道2的写入端(fd2[1]);

(5)子进程关闭管道1的写入端(fd1[1]);

(6)子进程关闭管道2的读出端(fd2[0])。

图4-8给出了执行这些步骤的代码。它产生如图4-6所示的管道布局。

例子

现在使用管道实现4.2节中描述的客户—服务器例子。main函数创建两个管道并用fork生成一个子进程。客户然后作为父进程运行,服务器则作为子进程运行。第一个管道用于从客户向服务器发送路径名,第二个管道用于从服务器向客户发送该文件的内容(或者一个出错消息)。这样设置后的布局如图4-7所示。

图4-6 提供一个双向数据流的两个管道

图4-7 使用两个管道实现图4-1

注意图4-7所示的两个管道直接连接着两个进程,然而实际上它们都是通过内核运作的,如图4-6所示。因此,从客户到服务器以及从服务器到客户的所有数据都穿越了用户-内核接口两次:一次是在写入管道时,另一次是在从管道读出时。

图4-8给出了这个例子的main函数。

图4-8 使用两个管道的客户-服务器程序main函数

创建管道,fork

8~19 创建两个管道,然后执行随图4-6列出的6个步骤。父进程调用client函数(图4-9),子进程调用server函数(图4-10)。

为子进程waitpid

20 服务器(子进程)在往管道写入最终数据后调用exit首先终止。它随后变成了一个僵尸进程(zombie):自身已终止、但其父进程仍在运行且尚未等待该子进程的进程。当子进程终止时,内核还给其父进程产生一个SIGCHLD信号,不过父进程没有捕获这个信号,而该信号的默认行为就是忽略。此后不久,父进程的client函数在从管道读入最终数据后返回。父进程随后调用waitpid取得已终止子进程(僵尸进程)的终止状态。要是父进程没有调用waitpid,而是直接终止,那么子进程将成为托孤给init进程的孤儿进程,内核将为此向init进程发送另外一个SIGCHLD信号,init进程随后将取得该僵尸进程的终止状态。

client函数如图4-9所示。

图4-9 使用两个管道的客户-服务器程序client函数

从标准输入读进路径名

8~14 从标准输入读进路径名后,删除其中由fgets存入的换行符,再写入管道。

从管道复制到标准输出

15~17 客户随后读出由服务器写入管道的全部内容,并写到标准输出。正常情况下它是所请求文件的内容,但是如果服务器打不开所指定的路径名,那它将返回一个出错消息。

server函数如图4-10所示。

从管道读出路径名

8~11 从管道读出由客户写入的路径名,并以空字节作为其结尾。注意,对一个管道的read只要该管道中存在一些数据就会马上返回,它不必等待达到所请求的字节数(本例中为MAXLINE)。

图4-10 使用两个管道的客户-服务器程序server函数

打开文件,处理错误

12~17 打开所请求的文件来读,若出错则通过管道返回给客户一个出错消息串。我们调用strerror函数以返回对应于errno的出错消息串。(UNPvl第690~691页详细讨论了strerror函数 [1] 。)

把文件复制到管道

18~23 如果open成功,就将该文件的内容复制到管道中。

下面的例子给出了路径名正确及发生错误时该程序的输出。

solaris % mainpipe

/etc/inet/ntp.conf           一个由两行文本构成的文件

multicastclient 224.0.1.1

driftfile /etc/inet/ntp.drift

solaris % mainpipe

/etc/shadow               一个我们不能读的文件

/etc/shadow: can't open,Permission denied

solaris % mainpipe

/no/such/file              一个不存在的文件

/no/such/file: can't open,No such file or directory

4.4 全双工管道

上一节中我们提到,某些系统提供全双工管道:SVR4的pipe函数以及许多内核都提供的socketpair函数。那么全双工管道到底提供什么呢?首先,我们可以如图4-11所示考虑一个半双工管道,它是对图4-2的修改,省略了其中的进程。

图4-11 半双工管道

全双工管道可能实现成如图4-12所示。它隐含的意思是:整个管道只存在一个缓冲区,(在任意一个描述符上)写入管道的任何数据都添加到该缓冲区末尾,(在任意一个描述符上)从管道读出的都是取自该缓冲区开头的数据。

图4-12 全双工管道的一个可能实现(不正确)

这种实现所存在的问题在像图A-29这样的程序中变得很明显。我们需要双向通信,但所需的是两个独立的数据流,每个方向一个。若不是这样,当一个进程往该全双工管道写入数据,过后再对该管道调用read时,有可能读回刚写入的数据。

图4-13展示了全双工管道的真正实现。

图4-13 全双工管道的真正实现

这儿的全双工管道是由两个半双工管道构成的。写入fd[1]的数据只能从fd[0]读出,写入fd[0]的数据只能从fd[1]读出。

图4-14中的程序表明可使用单个全双工管道完成双向通信。

图4-14 测试全双工管道的双向通信能力

我们创建一个全双工管道后调用fork。父进程往该管道写入字符p,然后从中读出一个字符。子进程先睡眠3秒,从该管道读出一个字符后,往它写入字符c。子进程中的睡眠是为了让父进程的read调用先于子进程的read调用执行,从而查看父进程是否读回自己刚写的字符。

在提供全双工管道的Solaris 2.6下运行该程序,我们观察到了所期望的行为。

solaris % fduplex

child read p

parent read c

字符p穿越图4-13中所示顶部的半双工管道,字符c则穿越底部的半双工管道。父进程并没有读回自己写入管道的数据(字符p)。

在默认提供半双工管道的Digital Unix 4.0B下运行该程序,我们看到了半双工管道的预期行为。编译时如果指定不同的选项,那它也能像SVR4那样提供全双工管道。

alpha % fduplex

read error: Bad file number

alpha % child read p

write error: Bad file number

父进程写入字符p,它由子进程读出,但此后父进程在试图从fd[1] read时中止,子进程则在试图往fd[0] write时中止(回想图4-11)。由read返回的错误是EBADF,意思是描述符fd[1]不是打开来读的。类似地,write返回同样的错误,表示描述符fd[1]不是打开来写的。

4.5 popen和pclose函数

作为另一个关于管道的例子,标准I/O函数库提供了popen函数,它创建一个管道并启动另外一个进程,该进程要么从该管道读出标准输入,要么往该管道写入标准输出。

#include <stdio.h>

FILE *popen(const char *command,const char *type);

返回:若成功则为文件指针,若出错则为NULL

int pclose(FILE *stream);

返回:若成功则为shell的终止状态,若出错则为-1

其中command是一个shell命令行。它是由sh程序(通常为Bourne shell)处理的,因此PATH环境变量可用于定位command。popen在调用进程和所指定的命令之间创建一个管道。由popen返回的值是一个标准I/O FILE指针,该指针或者用于输入,或者用于输出,具体取决于字符串type。

如果type为r,那么调用进程读进command的标准输出。

如果type为w,那么调用进程写到command的标准输入。

pclose函数关闭由popen创建的标准I/O流(stream),等待其中的命令终止,然后返回shell的终止状态。

APUE的14.3节 [2] 提供了popen和pclose的一个实现。

例子

图4-15给出了我们的客户-服务器例子的另一个实现,它使用popen函数和Unix的cat程序。8~17 跟图4-9一样,路径名从标准输入读出。随后构建一个命令并把它传递给popen。来自shell或cat程序的输出被复制到标准输出。

图4-15 使用popen的客户-服务器程序

这个实现与图4-8中的实现的差别之一是:现在我们依赖于由系统的cat程序产生的出错消息,而这些消息往往不足以说明具体错误。例如在Solaris 2.6下,当试图读一个我们没有读权限的文件时,将得到如下的错误:

solaris % cat /etc/shadow

cat: cannot open /etc/shadow

但是在BSD/OS 3.1下,当试图读一下类似的文件时,将得到一个更清晰的错误消息:

bsdi % cat /etc/master.passwd

cat: /etc/master.passwd: cannot open [Permission denied]

还要认识到在上面的例子中,popen调用是成功的,但是其后的fgets只是在首次被调用时返回一个文件结束符。cat程序将它的出错消息写到标准错误输出,但popen不对标准错误输出作任何特殊的处理——只有标准输出才被重定向到由它创建的管道。

4.6 FIFO

管道没有名字,因此它们的最大劣势是只能用于有一个共同祖先进程的各个进程之间。我们无法在无亲缘关系的两个进程间创建一个管道并将它用作IPC通道(不考虑描述符传递)。

FIFO指代先进先出(first in,first out),Unix中的FIFO类似于管道。它是一个单向(半双工)数据流。不同于管道的是,每个FIFO有一个路径名与之关联,从而允许无亲缘关系的进程访问同一个FIFO。FIFO也称为有名管道(named pipe)。

FIFO由mkfifo函数创建。

#include <sys/types.h>

#include <sys/stat.h>

int mkfifo(const char *pathname,mode_t mode);

返回:若成功则为0,若出错则为-1

其中pathname是一个普通的Unix路径名,它是该FIFO的名字。

mode参数指定文件权限位,类似于open的第二个参数。图2-4给出了定义在<sys/stat.h>头文件中的6个常值,用于给一个FIFO指定权限位。

mkfifo函数已隐含指定O_CREAT | O_EXCL。也就是说,它要么创建一个新的FIFO,要么返回一个EEXIST错误(如果所指定名字的FIFO已经存在)。如果不希望创建一个新的FIFO,那就改为调用open而不是mkfifo。要打开一个已存在的FIFO或创建一个新的FIFO,应先调用mkfifo,再检查它是否返回EEXIST错误,若返回该错误则改为调用open。

mkfifo命令也能创建FIFO。可以从shell脚本或命令行中使用它。

在创建出一个FIFO后,它必须或者打开来读,或者打开来写,所用的可以是open函数,也可以是某个标准I/O打开函数,例如fopen。FIFO不能打开来既读又写,因为它是半双工的。

对管道或FIFO的write总是往末尾添加数据,对它们的read则总是从开头返回数据。如果对管道或FIFO调用lseek,那就返回ESPIPE错误。

4.6.1 例子

现在重新编写图4-8中的客户-服务器程序,这次改用两个FIFO代替两个管道。client和server函数保持不变,所有变动都在main函数上,它如图4-16所示。

创建两个FIFO

10~16 在/tmp文件系统中创建两个FIFO。这两个FIFO事先存在与否无关紧要。常值FILE_MODE在我们的unpipc.h头文件(图C-l)中定义如下。

#define FILE_MODE (S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH)

/* default permissions for new files */

这允许用户读、用户写、组成员读和其他用户读。这些权限位会被当前进程的文件模式创建掩码修正。

fork

17~27 调用fork后,子进程调用我们的server函数(图4-10),父进程调用我们的client函数(图4-9)。在执行这些调用前,父进程打开第一个FIFO来写,打开第二个FIFO来读,子进程打开第一个FIFO来读,打开第二个FIFO来写。这与我们的管道例子类似,图4-17展示了这个布局。

这个FIFO例子比起之前的管道例子变动如下。

创建并打开一个管道只需调用pipe。创建并打开一个FIFO则需在调用mkfifo后再调用open。

管道在所有进程最终都关闭它之后自动消失。FIFO的名字则只有通过调用unlink才从文件系统中删除。

FIFO需要额外调用的好处是:FIFO在文件系统中有一个名字,该名字允许某个进程创建一个FIFO,与它无亲缘关系的另一个进程来打开这个FIFO。对于管道来说,这是不可能的。

图4-16 使用两个FIFO的客户-服务器程序main函数

图4-17 使用两个FIFO的客户-服务器例子

没有正确使用FIFO的程序会发生微妙的问题。考虑图4-16:如果我们对换父进程中两个open调用的顺序,该程序就不工作。其原因在于,如果当前尚没有任何进程打开某个FIFO来写,那么打开该FIFO来读的进程将阻塞。对换父进程中两个open调用的顺序后,父子进程将都打开同一个FIFO来读,然而当时并没有任何进程已打开该文件来写,于是父子进程都阻塞。这种现象称为死锁(deadlock)。我们将在下一节讨论这种情形。

4.6.2 例子:无亲缘关系的客户与服务器

图4-16中客户和服务器仍然是有亲缘关系的进程。不过我们可以把这个例子重新编写成客户与服务器无亲缘关系的状态。图4-18给出了服务器程序,它与图4-16的服务器部分差不多一样。

图4-18 独立服务器程序main函数

头文件fifo.h如图4-19所示,它提供了程序中两个FIFO名字的定义,客户和服务器都得知道它们。

图4-19 客户程序和服务器程序都包含的fifo.h头文件

图4-20给出了客户程序,它与图4-16的客户部分差不多一样。注意最后删除所用FIFO的是客户而不是服务器,因为对这些FIFO执行最终操作的是客户。

内核为管道和FIFO维护一个访问计数器,它的值是访问同一个管道或FIFO的打开着的描述符的个数。有了访问计数器后,客户或服务器就能成功地调用unlink。尽管该函数从文件系统中删除了所指定的路径名,先前已经打开该路径名、目前仍打开着的描述符却不受影响。

然而对于其他形式的IPC来说(例如System V消息队列),这样的计数器并不存在,因此要是服务器在向某个消息队列写入自己的最终消息后删除了该队列,那么当客户尝试读出这个最终消息时,该队列可能已消失了。

图4-20 独立客户程序main函数

为运行这对客户和服务器,先在后台启动服务器:

% server_file &

再启动客户。另一种办法是只启动客户,服务器则由客户通过调用fork和exec来激活。客户还可以把所用的两个FIFO的名字作为命令行参数通过exec函数传递给服务器,从而不必将它们编写到一个头文件中。不过这种情形会使得服务器成为客户的一个子进程,这么一来管道就够用了。

4.7 管道和FIFO的额外属性

我们需要就管道和FIFO的打开、读出和写入更为详细地描述它们的某些属性。首先,一个描述符能以两种方式设置成非阻塞。

(1)调用open时可指定O_NONBLOCK标志。例如图4-20中第一个open调用可以是:

writefd = Open(FIFO1,O_WRONLY | O_NONBLOCK,0);

(2)如果一个描述符已经打开,那么可以调用fcntl以启用O_NONBLOCK标志。对于管道来说,必须使用这种技术,因为管道没有open调用,在pipe调用中也无法指定O_NONBLOCK标志。使用fcntl时,我们先使用F_GETFL命令取得当前文件状态标志,将它与O_NONBLOCK标志按位或后,再使用F_SETFL命令存储这些文件状态标志:

int   flags;

if ( (flags = fcntl(fd,F_GETFL,0))< 0)

err_sys("F_GETFL error");

flags |= O_NONBLOCK;

if (fcntl(fd,F_SETFL,flags)< 0)

err_sys("F_SETFL error");

留心你可能会碰到的简单地设置所需标志的代码,因为这样的代码在设置所需标志的同时清除了所有其他可能存在的文件状态标志:

/* wrong way to set nonblocking */

if (fcntl(fd,F_SETFL,O_NONBLOCK)< 0)

err_sys("F_SETFL error");

图4-21给出了非阻塞标志对打开一个FIFO的影响、对从一个空管道或空FIFO读出数据的影响以及对往一个管道或FIFO写入数据的影响。

图4-21 O_NONBLOCK标志对管道和FIFO的影响

下面是关于管道或FIFO的读出与写入的若干额外规则。

如果请求读出的数据量多于管道或FIFO中当前可用数据量,那么只返回这些可用的数据。我们必须准备好处理来自read的小于所请求数目的返回值。

如果请求写入的数据的字节数小于或等于PIPE_BUF(一个Posix限制值,将在4.11节详细讨论),那么write操作保证是原子的。这意味着,如果有两个进程差不多同时往同一个管道或FIFO写,那么或者先写入来自第一个进程的所有数据,再写入来自第二个进程的所有数据,或者颠倒过来。系统不会相互混杂来自这两个进程的数据。然而,如果请求写入的数据的字节数大于PIPE_BUF,那么write操作不能保证是原子的。

Posix.1要求PIPE_BUF至少为512字节。常见的值处于从BSD/0S 3.1的1024到Solaris 2.6的5120之间。4.11节有一个程序可输出这个值。

O_NONBLOCK标志的设置对write操作的原子性没有影响——原子性完全是由所请求字节数是否小于等于PIPE_BUF决定的。然而当一个管道或FIFO设置成非阻塞时,来自write的返回值取决于待写的字节数以及该管道或FIFO中当前可用空间的大小。如果待写的字节数小于等于PIPE_BUF:

a.如果该管道或FIFO中有足以存放所请求字节数的空间,那么所有数据字节都写入。

b.如果该管道或FIFO中没有足以存放所请求字节数的空间,那么立即返回一个EAGAIN错误。既然设置了O_NONBLOCK标志,调用进程就不希望自己被投入睡眠中。但是内核无法在接受部分数据的同时仍保证write操作的原子性,于是它必须返回一个错误,告诉调用进程以后再试。

如果待写的字节数大于PIPE_BUF:

a.如果该管道或FIFO中至少有1字节空间,那么内核写入该管道或FIFO能容纳数目的数据字节,该数目同时作为来自write的返回值。

b.如果该管道或FIFO已满,那么立即返回一个EAGAIN错误。

如果向一个没有为读打开着的管道或FIFO写入,那么内核将产生一个SIGPIPE信号:a.如果调用进程既没有捕获也没有忽略该SIGPIPE信号,所采取的默认行为就是终止该进程。

b.如果调用进程忽略了该SIGPIPE信号,或者捕获了该信号并从其信号处理程序中返回,那么write返回一个EPIPE错误。

SIGPIPE被认为是一个同步信号,也就是说,这是一个由特定的线程(调用write的线程)引起的信号。然而处理这个信号最容易的办法是忽略它(把它的处理办法设置成SIG-IGN),让write返回一个EPIPE错误。应用程序应该无遗漏地检测由write返回的错误,而检测某个进程被SIGPIPE终止却困难得多。如果该信号未被捕获,我们就得从shell中查看被终止进程的终止状态,以确定该进程是否被某个信号所杀死以及具体是被哪个信号杀死的。UNPvl的5.13节详细讨论SIGPIPE。

4.8 单个服务器,多个客户

FIFO的真正优势表现在服务器可以是一个长期运行的进程(例如守护进程,如UNPvl第12章所述),而且与其客户可以无亲缘关系。作为服务器的守护进程以某个众所周知的路径名创建一个FIFO,并打开该FIFO来读。此后某个时刻启动的客户打开该FIFO来写,并将其命令或给守护进程的其他任何东西通过该FIFO发送出去。使用FIFO很容易实现这种形式的单向通信(从客户到服务器),但是如果守护进程需要向客户发送回一些东西,事情就困难了。图4-22是我们随例子使用的技巧。

图4-22 单个服务器,多个客户

服务器以一个众所周知的路径名(本例中为/tmp/fifo.serv)创建一个FIFO,它将从这个FIFO读入客户的请求。每个客户在启动时创建自己的FIFO,所用的路径名含有自己的进程ID。每个客户把自己的请求写入服务器的众所周知FIFO中,该请求含有客户的进程ID以及一个路径名,具有该路径名的文件就是客户希望服务器打开并发回的文件。

图4-23给出了服务器程序。

创建众所周知FIFO,打开来读,打开来写

10~15 创建服务器的众所周知FIFO,就算它已经存在也没问题。接着打开该管道两次,一次只读,一次只写。readfifo描述符用于读出到达该FIFO的每个客户请求,dummyfd描述符则从来不用。打开该FIFO来写的原因可从图4-21中看出。要是我们不这么做,那么每当有一个客户终止时,该FIFO就变空,于是服务器的read返回0,表示是一个文件结束符。我们将不得不close该FIFO,并以O_RDONLY标志再次调用open,不过该调用会一直阻塞到下一个客户请求到达为止。然而如果我们总是有一个该FIFO的描述符打开着用于写,那么当不再有客户存在时,服务器的read一定不会返回0以指示读到一个文件结束符。相反,服务器只是阻塞在read调用中,等待下一个客户请求。于是这个技巧简化了我们的服务器代码,减少了为其众所周知FIFO调用open的次数。

图4-23 处理多个客户请求的FIFO服务器程序

服务器启动时,它的第一个open(使用O_RDONLY标志)将阻塞到第一个客户只写打开服务器的FIFO为止(回想图4-21)。它的第二个open(使用O_WRONLY标志)则立即返回,因为该FIFO已经打开着用于读了。

读出客户请求

16 每个客户请求是由进程ID、一个空格再加路径名构成的单行。我们使用自己的readline函数(见UNPvl第79页)。

分析客户请求

17~26 删除通常由readline返回的换行符。该换行符只有在这样两种情况下才会拉掉:遇到它之前缓冲区已填满,或者输入的最后一行不是以换行符终止。由strchr函数返回的赋给ptr的指针指向客户请求行中的空格,ptr增1后即指向后跟的路径名的首字符。客户的FIFO的路径名是根据其进程ID构造的,服务器以只写的方式打开该FIFO。

打开客户请求的文件,将它发送到客户的FIFO

27~44 服务器的剩余部分类似于图4-10中的server函数。它打开客户请求的文件,若失败则通过客户的FIFO向客户返回一个出错消息。若open成功则把文件的内容复制到客户的FIFO中。完成后close客户的FIFO的服务器端,以使得客户的read返回0(文件结束符)。服务器不删除客户的FIFO,这个工作由客户在读出来自服务器的文件结束符后完成。

客户程序在图4-24中给出。

图4-24 与图4-23中服务器程序协同工作的客户程序

创建FIFO

10~14 客户的FIFO是使用以进程ID作为其最后一部分的路径名创建的。

构建客户请求行

15~21 客户的请求由其进程ID、一个空格、一个路径名和一个换行符构成,其中的路径名指代请求服务器发送给本客户的文件。这个请求行在字符数组buff中构建,路径名则从标准输入读入。

打开服务器的FIFO,写入请求

22~24 打开服务器的FIFO后往其中写入请求。如果本客户是自服务器启动以来第一个打开该FIFO的客户,那么这儿的open将把服务器从它的open调用(使用O_RDONLY标志)中解阻塞出来。

读出来自服务器的文件内容或出错消息

25~31 从本客户的FIFO中读出服务器的应答,并写到标准输出。随后关闭并删除该FIFO。

我们可以在一个窗口中启动服务器,在另一个窗口中运行客户,它们将如期地工作。下面给出的只是客户的交互例子。

solaris % mainclient

/etc/shadow                  一个我们不能读的文件

/etc/shadow: can't open,Permission denied

solaris % mainclient

/etc/inet/ntp.conf              一个由2行文本构成的文件

multicastclient 224.0.1.1

driftfile /etc/inet/ntp.drift

我们还可以从shell中与服务器交互,因为FIFO在文件系统中有名字。

solaris % Pid=$$               本shell的进程ID

solaris % mkfifo /tmp/fifo.$Pid       创建客户的FIFO

solaris % echo "$Pid /etc/inet/ntp.conf" > /tmp/fifo.serv

solaris % cat < /tmp/fifo.$Pid       读出服务器的应答

multicastclient 224.0.1.1

driftfile /etc/inet/ntp.drift

solaris % rm /tmp/fifo.$Pid

我们用一个shell命令(echo)把客户(本shell)的进程ID和所请求的路径名发送给服务器,用另一个命令(cat)读出服务器的应答。这两个命令之间可相隔任意长度的时间。这么一来,表面上看先由服务器往客户的FIFO中写文件,再由客户执行cat命令从该FIFO中读出数据,这样的表象可能会使我们认为即使没有进程打开着客户的FIFO,数据也会以某种方式存留于该FIFO中。但事情并不是这样,真正的规则是:当对一个管道或FIFO的最终close发生时,该管道或FIFO中的任何残余数据都被丢弃。在我们的shell例子中,服务器读出客户的请求行后,会阻塞在对客户的FIFO的open调用中,因为客户(即我们的shell)还没有打开该FIFO来读(回想图4-21)。服务器对该FIFO的open调用一直阻塞到我们在以后某个时候执行cat命令为止,该命令打开这个FIFO来读,服务器的open调用随之返回。这种时间顺序关系还会导致拒绝服务(denial-of-service)型攻击,我们将在下一节讨论。

使用shell还允许简单测试服务器的出错处理。我们可以简单地向服务器发送一行不带进程ID的请求,也可以向它发送一行所带进程ID在/tmp目录中没有对应的FIFO的请求。举例来说,如果在启动服务器后输入如下行:

solaris % cat > /tmp/fifo.serv

/no/process/id

999999 /invalid/process/id

那么服务器的输出(在另一个窗口中)如下:

solaris % server

bogus request: /no/process/id

cannot open: /tmp/fifo.999999

4.8.1 FIFO write的原子性

本节介绍的简单客户-服务器程序还能让我们体会到管道和FIFO的write操作的原子性相当重要。假设有两个客户差不多同时向服务器发送请求。第一个客户的请求行如下:

1234 /etc/inet/ntp.conf

第二个客户的请求行如下:

9876 /etc/passwd

假设每个客户给自己的请求行执行单个write函数调用,而且每个请求行都小于或等于PIPE_BUF(这样假设是合理的,因为PIPE_BUF通常在1024和5120之间,而路径名往往限制成最多1024字节),那么系统保证该FIFO中的数据或者是

1234 /etc/inet/ntp.conf

9876 /etc/passwd

或者是

9876 /etc/passwd

1234 /etc/inet/ntp.conf

该FIFO中的数据不会像是下面的模样:

1234 /etc/inet9876 /etc/passwd

/ntp.conf

4.8.2 FIFO与NFS的关系

FIFO是一种只能在单台主机上使用的IPC形式。尽管在文件系统中有名字,它们也只能用在本地文件系统上,而不能用在通过NFS安装的文件系统上。

solaris % mkfifo /nfs/bsdi/usr/rstevens/fifo.temp

mkfifo: I/O error

上面的例子中,文件系统/nfs/bsdi/usr是主机bsdi上的/usr文件系统。

有些系统(例如BSD/OS)确实允许在通过NFS安装的文件系统上创建FIFO,但是数据无法在这样的两个系统间通过这些FIFO传递。这种情形下,FIFO只是用作同一主机上客户和服务器之间位于文件系统中的集结点。即使在不同主机上的某两个进程都能通过NFS打开同一个FIFO,它们也不能通过该FIFO从一个进程向另一个进程发送数据。

4.9 对比迭代服务器与并发服务器

上一节的简单客户-服务器程序例子中,服务器是一个迭代服务器(iterative server)。它逐一处理客户请求,而且是在完全处理每个客户的请求后再接待下一个客户。举例来说,如果有两个客户几乎同时向服务器发送一个请求——第一个客户请求一个10M字节的文件,服务器把它发送给该客户需花(譬如说)10秒,第二个客户请求一个10字节的文件——那么第二个客户必须等待至少10秒,以让第一个客户的请求被处理完。

另一种设计是并发服务器(concurrent server)。Unix下最常见的并发服务器类型称为每个客户一个子进程(one-child-per-client)服务器,每当有一个客户请求到达时,这种服务器就让主进程调用fork派生出一个新的子进程。该新子进程处理相应的客户请求,直到完成为止,而Unix的多程序运行特性提供了所有不同进程间的并发性。UNPvl第27章还详细讨论了其他并发服务器设计技巧。

创建一个子进程池,让池中某个空闲子进程为一个新的客户服务。

为每个客户创建一个线程。

创建一个线程池,让池中某个空闲线程为一个新的客户服务。

尽管UNPvl中的讨论是针对网络服务器的,同样的技巧也适用于IPC服务器,差别只是IPC服务器的客户总是跟服务器运行在同一主机上。

拒绝服务型攻击

我们已提及迭代服务器的一个问题——某些客户必须等待比预期要长的时间,因为它们被排在请求处理时间较长的其他客户之后——然而还存在另一个问题。回想上一节中从shell完成与服务器交互的例子,我们讨论了当客户还没有打开自己的FIFO时(客户的打开操作要到我们执行cat命令时才发生),服务器是怎样阻塞在对该FIFO的open调用上的。这意味着某个恶意的客户可以让服务器处于停顿状态,办法是给它发送一个请求行,但从来不打开自己的FIFO来读。这称为拒绝服务(DoS)型攻击。为避免这种攻击,在编写任何服务器程序的迭代部分时必须小心,要留意服务器可能在哪儿阻塞以及可能阻塞多久。处理这种问题的方法之一是在特定操作上设置一个超时时钟,但是把服务器程序编写成并发服务器而不是迭代服务器通常更为简单,这么一来,上述类型的拒绝服务型攻击只影响一个子进程,而不会影响主服务器。即使采用并发服务器,拒绝服务型攻击仍可能发生:一个恶意的客户可能发送大量的独立请求,导致服务器达到它的子进程数限制,从而使得后续的fork失败。

4.10 字节流与消息

到此为止所给出的使用管道和FIFO的例子都使用字节流I/O模型,这是Unix的原生I/O模型。这种模型不存在记录边界,也就是说读写操作根本不检查数据。举例来说,从某个FIFO中读出100个字节的进程无法判定往该FIFO中写入这100个字节的进程执行了单个100字节的写操作、5个20字节的写操作、2个50字节的写操作还是另外某种总共为100字节的写操作的组合。一个进程往该FIFO中写入55个字节后,另一个进程再写入45字节,这样的情况同样是可能的。这样的数据是一个字节流(byte stream),系统不对它作解释。如果需要某种解释,写进程和读进程就得先验 [3] 地同意这种解释,并亲自去做。

有时候应用希望对所传送的数据加上某种结构。当数据由长度可变消息构成,并且读出者必须知道这些消息的边界以判定何时已读出单个消息时,这种需求可能发生。下面三种技巧经常用于这个目的。

(1)带内特殊终止序列:许多Unix应用程序使用换行符来分隔每个消息。写进程会给每个消息添加一个换行符,读进程则每次读出一行。图4-23和图4-24中的客户程序和服务器程序就用这种方法分隔各个客户请求。这种技巧一般要求数据中任何出现分隔符处都作转义处理(也就是说以某种方式把它们标志成数据,而不是作为分隔符)。

许多因特网应用程序(FTP、SMTP、HTTP、NNTP)使用由一个回车符后跟一个换行符构成的双字符序列(CR/LF)来分隔文本记录。

(2)显式长度:每个记录前冠以它的长度。我们将马上使用这种技巧。当用在TCP上时,Sun RPC也使用这种技巧。这种技巧的优势之一是不再需要通过转义出现在数据中的分隔符,因为接收者不必扫描整个数据以寻找每个记录的结束位置。

(3)每次连接一个记录:应用通过关闭与其对端的连接(网络应用时为TCP连接,IPC应用时为IPC连接)来指示一个记录的结束。这要求为每个记录创建一个新连接,HTTP 1.0就使用了这一技术。

标准I/O函数库也能用于读或写一个管道或FIFO。既然打开一个管道的唯一方法是使用pipe函数(它返回的是文件描述符),为创建一个新的标准I/O流(stream),必须使用标准I/O函数fdopen以将该标准I/O流与由pipe返回的某个已打开描述符相关联。

也可以构建更为结构化的消息,这种能力是由Posix消息队列和System V消息队列提供的。我们将看到每个消息有一个长度和一个优先级(System V称后者为“类型”)。长度和优先级是由发送者指定的,消息被读出后,这两者都返回给读出者。每个消息是一个记录(record),类似于UDP数据报(UNPvl)。

我们也能给一个管道或FIFO增加些结构。在图4-25所示的mesg.h头文件中,我们定义了一个消息。

图4-25 我们的mymesg结构及相关定义

每个消息有一个mesg_type,我们把它定义成一个值必须大于0的整数。我们现在暂时忽略这个类型成员,到第6章中讲述System V消息队列时再讨论它。每个消息还有一个长度,我们允许它为0。我们使用mymesg结构在每个消息前冠以它的长度,而没有使用换行符来分隔消息。早些时候我们提到过这种设计方法的两个好处:接收者不必扫描所收到的每个字节以找出消息的结束位置,即使分隔符(换行符)出现在消息中也不必将它转义。

图4-26展示了mymesg结构的图示以及我们如何随管道、FIFO和System V消息队列使用它。

我们定义两个函数分别发送和接收消息。图4-27给出了我们的mesg_send函数,图4-28给出了我们的mesg_recv函数。

图4-26 我们的mymesg结构

图4-27 mesg_send函数

图4-28 mesg_recv函数

现在读出每个消息需调用两个read,一个读出消息长度,另一个读出真正的消息(如果长度大于0的话)。

细心的读者可能注意到mesg_recv会检查所有可能的错误是否发生,一旦发现则马上终止。然而为一致性起见,我们还是定义了一个名为Mesg_recv的包裹函数,并在我们的程序中调用它。

现在把我们的客户函数和服务器函数改为使用mesg_send和mesg_recv函数。图4-29给出了新的客户函数。

图4-29 我们的使用消息的client函数

读出路径名,发送给服务器

8~16 从标准输入读出路径名,然后使用mesg_send将其发送给服务器。

读出来自服务器的文件内容或出错消息

17~19 客户在一个循环中调用mesg_recv,读出服务器发送回的所有东西。按照约定,mesg_recv返回一个值为0的长度表示已到达来自服务器的数据的结尾。我们将看到服务器将在发送给客户的每个消息中都包含换行符,因此空行也会有一个值为1的消息长度。

图4-30给出了新的服务器函数。

从IPC通道读出路径名,打开文件

8~18 读出来自客户的路径名。尽管这儿给mesg_type赋值为1看来无用(它将被图4-28中的mesg_recv覆写),在使用System V消息队列时(图6-10),我们仍然会调用本函数,那时是需要这样的赋值的(例如图6-13)。标准I/O函数fopen打开该路径名的文件,这与图4-10中调用Unix I/O函数open获得访问该文件的一个描述符不一样。这儿我们调用标准I/O函数库的原因是为了调用fgets逐行地读出该文件,然后把每一行作为一个消息发送给客户。

将文件复制给客户

19~26 如果fopen调用成功,就使用fgets读出该文件并发送给客户,每个消息一行。一个长度为0的消息表示已到达文件尾。

在使用管道或FIFO时,也可以通过关闭IPC通道来通知对端已到达输入文件的结尾。不过我们通过发送回一个长度为0的消息来达到同样目的,因为之后还会遇到没有文件结束符概念的其他类型的IPC。

图4-30 我们的使用消息的server函数

调用我们的client和server函数的main函数根本不变。我们既可使用管道版本(图4-8),也可使用FIFO版本(图4-16)。

4.11 管道和FIFO限制

系统加于管道和FIFO的唯一限制为:

OPEN_MAX 一个进程在任意时刻打开的最大描述符数(Posix要求至少为16);

PIPE_BUF 可原子地写往一个管道或FIFO的最大数据量(我们在4.7节讲述过,Posix要求至少为512)。

我们马上会看到OPEN_MAX的值可通过调用sysconf函数查询。它通常可通过执行ulimit命令(Bourne shell或KornShell,我们马上会看到)或limit命令(C shell)从shell中修改。它也可通过调用setrlimit函数(在APUE的7.11节中详细讲述)从一个进程中修改。

PIPE_BUF的值通常定义在<limits.h>头文件中,但是Posix认为它是一个路径名变量(pathname variable)。这意味着它的值可以随所指定的路径名而变化(只对FIFO而言,因为管道没有名字),因为不同的路径名可以落在不同的文件系统上,而这些文件系统可能有不同的特征。于是PIPE_BUF的值可在运行时通过调用pathconf或fpathconf取得。图4-31给出了输出这两个限制值的一个例子程序。

图4-31 在运行时确定PIPE_BUF和OPEN_MAX的值

下面是一些例子,指定了不同的文件系统。

solaris % pipeconf /          Solaris 2.6默认值

PIPE_BUF = 5120,OPEN_MAX = 64

solaris % pipeconf /home

PIPE_BUF = 5120,OPEN_MAX = 64

solaris % pipeconf /tmp

PIPE_BUF = 5120,OPEN_MAX = 64

alpha % pipeconf /           Digital Unix 4.0B默认值

PIPE_BUF = 4096,OPEN_MAX = 4096

alpha % pipeconf /usr

PIPE_BUF = 4096,OPEN_MAX = 4096

下面给出在Solaris下如何使用KornShell修改OPEN_MAX的值。

solaris % ulimit -nS          输出最大描述符数,软限制64

solaris % ulimit -nH          输出最大描述符数,硬限制

1024

solaris % ulimit –ns 512        设置软限制为512

solaris % pipeconf /          验证该变动已发生

PIPE_BUF = 5120,OPEN_MAX = 512

尽管能够修改FIFO的PIPE_BUF的值,这取决于路径名所存放的底层文件系统,但实际应该很少这么做。

APUE的第2章描述了fpathconf、pathconf和sysconf函数,这些函数提供了有关特定内核限制的运行时信息。Posix.1定义了以_PC_开头的12个常值和以_SC_开头的52个常值。Digital Unix 4.0B和Solaris 2.6都对后者作了扩充,定义了约100个可使用sysconf查询的运行时常值。

Posix.2定义了getconf命令,它可以输出这些实现限制值中的大多数。例如:

alpha % getconf OPEN_MAX

4096

alpha % getconf PIPE_BUF /

4096

4.12 小结

管道和FIFO是许多应用程序的基本构建模块。管道普遍用于shell中,不过也可以从程序中使用,往往是用于从子进程向父进程回传信息。使用管道时涉及的某些代码(pipe、fork、close、exec和waitpid)可通过使用popen和pclose来避免,由它们处理具体细节并激活一个shell。

FIFO与管道类似,但它们是用mkfifo创建的,之后需用open打开。打开管道时必须小心,因为有许多规则(图4-21)制约着open的阻塞与否。

在使用管道和FIFO的前提下,我们查看了一些客户-服务器程序设计:一个服务器服务多个客户,服务器可以是迭代的,也可以是并发的。迭代服务器以串行方式每次处理一个客户请求,它们易遭受拒绝服务型攻击。并发服务器则让另外一个进程或线程处理每个客户请求。

管道和FIFO的特征之一是它们的数据是一个字节流,类似于TCP连接。把这种字节流分隔成各个记录的任何方法都得由应用程序来实现。我们将在接下去的两章中看到消息队列会提供记录边界,类似于UDP数据报。

习题

4.1 在从图4-3到图4-4的转换中,如果子进程没有执行close(fd[1]),那么会发生什么情况?

4.2 在4.6节描述mkfifo时,我们说过要打开一个已有的FIFO或创建一个新的FIFO,应该调用mkfifo,检查是否返回EEXIST错误,若是则调用open。如果把这个逻辑关系变换一下,先调用open,当不存在所期望的FIFO时再调用mkfifo,那么会发生什么情况?

4.3 在图4-15中调用popen时,如果其中执行命令的shell碰到错误,那么会发生什么情况?

4.4 把图4-23中针对服务器的FIFO的open去掉,验证一下这将导致当不再有客户存在时,服务器即终止。

4.5 在图4-23中我们指出,当服务器启动后,它阻塞在自己的第一个open调用中,直到客户的第一个open打开同一个FIFO用于写为止。我们怎样才能绕过这样的阻塞,使得两个open都立即返回,转而阻塞在首次调用readline上?

4.6 如果图4-24中的客户程序对换其两个open调用的顺序,那么会发生什么情况?

4.7 为什么在读进程关闭管道或FIFO之后给写进程产生一个信号,而不会在写进程关闭管道或FIFO之后给读进程产生一个信号?

4.8 编写一个小测试程序,确定fstat是否以stat结构的st_size成员的形式返回当前在某个FIFO中的数据字节数。

4.9 写一个小测试程序,以确定在为一个读出端已关闭的管道描述符选择可写条件时select返回的内容。

第5章 Posix消息队列

5.1 概述

消息队列可认为是一个消息链表。有足够写权限的线程可往队列中放置消息,有足够读权限的线程可从队列中取走消息。每个消息都是一个记录(回想我们在4.10节就字节流和消息的讨论),它由发送者赋予一个优先级。在某个进程往一个队列写入消息之前,并不需要另外某个进程在该队列上等待消息的到达。这跟管道和FIFO是相反的,对后两者来说,除非读出者已存在,否则先有写入者是没有意义的。

一个进程可以往某个队列写入一些消息,然后终止,再让另外一个进程在以后某个时刻读出这些消息。我们说过消息队列具有随内核的持续性(1.3节),这跟管道和FIFO不一样。我们在第4章中说过,当一个管道或FIFO的最后一次关闭发生时,仍在该管道或FIFO上的数据将被丢弃。

本章讲述Posix消息队列,第6章讲述System V消息队列。这两组函数间存在许多相似性,下面是主要的差别。

对Posix消息队列的读总是返回最高优先级的最早消息,对System V消息队列的读则可以返回任意指定优先级的消息。

当往一个空队列放置一个消息时,Posix消息队列允许产生一个信号或启动一个线程, System V消息队列则不提供类似机制。

队列中的每个消息具有如下属性:

一个无符号整数优先级(Posix)或一个长整数类型(System V);

消息的数据部分长度(可以为0);

数据本身(如果长度大于0)。

注意这些特征不同于管道和FIFO。后两者是字节流模型,没有消息边界,也没有与每个消息关联的类型。我们在4.10节就此讨论过,并给管道和FIFO增设了自己的消息接口。

图5-1展示了一个消息队列的可能布局。

图5-1 含有三个消息的某个Posix消息队列的可能布局

我们所设想的是一个链表,该链表的头中含有当前队列的两个属性:队列中允许的最大消息数以及每个消息的最大大小。我将在5.3节中详细讨论这两个属性。

从本章开始我们使用一种新的程序设计技巧,它在以后讨论消息队列、信号量和共享内存区的各章中也会用到。既然所有这些IPC对象至少具有随内核的持续性(回想1.3节),于是我们可以编写若干小程序来使用这些IPC机制,以便深入地认识它们,并深刻地了解它们的操作。例如,我们可编写一个程序来创建一个Posix消息队列,编写另一个程序来往某个Posix消息队列中加入一个消息,再编写另一个程序来从某个Posix消息队列中读出一个消息。通过以不同的优先级构造各个消息,我们可以看出mq_receive函数是怎样返回这些消息的。

5.2 mq_open、mq_close和mq_unlink函数

mq_open函数创建一个新的消息队列或打开一个已存在的消息队列。

#include <mqueue.h>

mqd_t mq_open(const char *name,int oflag,...

/* mode_t mode,struct mq_attr *attr */ );

返回:若成功则为消息队列描述符,若出错则为-1

我们已在2.2节描述过有关name参数的规则。

oflag参数是O_RDONLY、O_WRONLY或O_RDWR之一,可能按位或上O_CREAT、O_EXCL或O_NONBLOCK。我们已在2.3节讲述过所有这些标志。

当实际操作是创建一个新队列时(已指定O_CREAT标志,且所请求的消息队列尚未存在), mode和attr参数是需要的。我们在图2-4中给出了mode值。attr参数用于给新队列指定某些属性。如果它为空指针,那就使用默认属性。我们将在5.3节讨论这些属性。

mq_open的返回值称为消息队列描述符(mesage queue descriptor),但它不必是(而且很可能不是)像文件描述符或套接字描述符这样的短整数。这个值用作其余7个消息队列函数的第一个参数。

Solaris 2.6把mqd_t定义为void*,而Digital Unix 4.0B把它定义为int。在5.8节我们的Posix消息队列实现例子中,消息队列描述符是一个结构指针。称这些数据类型为描述符是一个不幸的错误。

已打开的消息队列是由mq_close关闭的。

#include <mqueue.h>

int mq_close(mqd_t mqdes);

返回:若成功则为0,若出错则为-1

其功能与关闭一个已打开文件的close函数类似:调用进程可以不再使用该描述符,但其消息队列并不从系统中删除。一个进程终止时,它的所有打开着的消息队列都关闭,就像调用了mq_close一样。

要从系统中删除用作mq_open第一个参数的某个name,必须调用mq_unlink。

#include <mqueue.h>

int mq_unlink(const char *name);

返回:若成功则为0,若出错则为-1

每个消息队列有一个保存其当前打开着描述符数的引用计数器(就像文件一样) [4] ,因而本函数能够实现类似于unlink函数删除一个文件的机制:当一个消息队列的引用计数仍大于0时,其name就能删除,但是该队列的析构(这与从系统中删除其名字不同)要到最后一个mq_close发生时才进行 [5]

Posix消息队列至少具备随内核的持续性(回想1.3节)。这就是说,即使当前没有进程打开着某个消息队列,该队列及其上的各个消息也将一直存在,直到调用mq_unlink并让它的引用计数达到0以删除该队列为止。

我们将看到,如果消息队列是使用内存映射文件(12.2节)实现的,那么它们具有随文件系统的持续性,但这不是必需的,因而不能指望。

5.2.1 例子:mqcreatel程序

既然Posix消息队列至少具有随内核的持续性,我们于是可以编写一组小程序来操纵这些队列,以提供认知它们的简易办法。图5-2中的程序创建一个消息队列,其名字是作为命令行参数指定的。

图5-2 指定排他性创建标志,创建一个消息队列

8~16 我们允许有一个指定排他性创建的-e选项。(关于getopt函数和它的Getopt包裹函数,我们将随图5-5详细讨论。)返回时,getopt在optind中存放下一个待处理参数的下标。

17 直接以来自命令行的IPC名字调用mq_open,而不去调用px_ipc_name函数(2.2节)。这样我们就能准确地看出实现是如何处理这些Posix IPC名字的。(本书中的所有简单测试程序都这么处理。)

下面是在Solaris 2.6下的输出。

solaris % mqcreate1 /temp.1234       第一次创建成功

-rw-rw-rw-  1 rstevens other1 132632 Oct 23 17:08 /tmp/.MQDtemp.1234

-rw-rw-rw-  1 rstevens other1    0 Oct 23 17:08 /tmp/.MQLtemp.1234

-rw-r--r--  1 rstevens other1    0 Oct 23 17:08 /tmp/.MQPtemp.1234

solaris % mqcreate1 -e /temp.1234     指定-e选项的第二次创建失败

solaris % ls -l /tmp/.*1234

mq_open error for /temp.1234: File exists

(我们称这个程序为mqcreatel,因为它将在我们说明属性后,在图5-5中得以改进。)第三个文件(/temp/.MQPtemp.1234)具有我们用FILE_MODE常值(用户可读可写,组成员和其他用户只读)指定的权限,另外两个文件的权限则不一样。我们猜测文件名中含有D的文件含有数据,含有L的文件是某种类型的锁,含有P的文件指定权限。

在Digital Unix 4.0B下,我们可看到所创建的是真正的路径名。

alpha % mqcreate1 /tmp/myq.1234

alpha % ls -l /tmp/myq.1234

-rw-r--r-- 1 rstevens system 11976 Oct 23 17:04 /tmp/myq.1234

alpha % mqcreate1 -e /tmp/myq.1234

mq_open error for /tmp/myq.1234: File exists

5.2.2 例子:mqunlink程序

图5-3中的mqunlink程序从系统中删除一个消息队列。

图5-3 mq_unlink一个消息队列

我们可使用该程序删除由mqcreate程序创建的消息队列。

solaris % mqunlink /temp.1234

我们早先给出的/tmp目录下的所有三个文件都被删除了。

5.3 mq_getattr和mq_setattr函数

每个消息队列有四个属性,mq_getattr返回所有这些属性,mq_setattr则设置其中某个属性。

#include <mqueue.h>

int mq_getattr(mqd_t mqdes,struct mq_attr *attr);

int mq_setattr(mqd_t mqdes,const struct mq_attr *attr,struct mq_attr *oattr);

均返回:若成功则为0,若出错则为-1

mq_attr结构含有以下属性。

struct mq_attr {

long mq_flags;  /* message queue flag: 0,O_NONBLOCK */

long mq_maxmsg; /* max number of messages allowed on queue */

long mq_msgsize; /* max size of a message (in bytes)*/

long mq_curmsgs; /* number of messages currently on queue */

};

指向某个mq_attr结构的指针可作为mq_open的第四个参数传递,从而允许我们在该函数的实际操作是创建一个新队列时,给它指定mq_maxmsg和mq_msgsize属性。mq_open忽略该结构的另外两个成员。

mq_getattr把所指定队列的当前属性填入由attr指向的结构。

mq_setattr给所指定队列设置属性,但是只使用由attr指向的mq_attr结构的mq_flags成员,以设置或清除非阻塞标志。该结构的另外三个成员被忽略:每个队列的最大消息数和每个消息的最大字节数只能在创建队列时设置,队列中的当前消息数则只能获取而不能设置。

另外,如果oattr指针非空,那么所指定队列的先前属性(mq_flags、mq_maxmsg和mq_msgsize)和当前状态(mq_curmsgs)将返回到由该指针指向的结构中。

5.3.1 例子:mqgetattr程序

图5-4中的程序打开一个指定的消息队列,并输出其属性。

图5-4 取得并输出某个消息队列的属性

我们可以创建一个消息队列并输出其默认属性。

solaris % mqcreate1 /hello.world

solaris % mqgetattr /hello.world

max #msgs = 128,max #bytes/msg = 1024,#currently on queue = 0

现在可以看出,在图5-2之后以默认属性创建一个队列的例子中,由ls列出的数据文件(/tmp/.MQDtemp.1234)的大小为128×1024+1560=132 632。1560个额外字节也许是开销信息:每个消息8字节,外加另外536个字节。

5.3.2 例子:mqcreate程序

我们可以对图5-2中的程序作修改,以允许指定所创建队列的最大消息数和每个消息的最大大小。我们不能只指定其中一个而不指定另一个,即两者都得指定(不过见习题5.1)。图5-5是修改后的程序。

图5-5 改进后的图5-2,允许指定属性

指定某个命令行选项需要一个参数,我们在getopt调用中给这些选项字节(m和z)指定了一个后跟的冒号。在处理这样的选项字符时,optarg指向其参数。

我们的Getopt包裹函数调用标准函数库中的getopt函数,并在getopt检测到错误时终止当前进程,这些错误包括:遇到一个没有包含在getopt第三个参数中的选项字母,或者遇到一个没有所需参数的选项字母(通过在getopt的第三个参数中的该选项字母之后跟一个冒号指示)。不论遇到哪种错误,getopt都将一个出错消息写到标准错误输出,然后返回一个错误,这个错误导致我们的Getopt包裹函数终止。例如,如下两个错误由getopt检测出。

solaris % mqcreate -z

mqcreate: option requires an argument -- z solaris % mqcreate -q

mqcreate: illegal option -- q

下面这个错误(没有指定所需的名字参数)则由我们的程序检测出。

solaris % mqcreate

usage: mqcreate [ -e ] [ -m maxmsg -z msgsize ] <name>

如果这两个新的命令行选项都没有指定,我们就给mq_open传递一个空指针作为最后一个参数,否则传递一个根据所指定命令行选项的参数构造的attr结构。

现在运行这个新版本的程序,指定一个最多有1024个消息的队列,每个消息最多有8192个字节。

solaris % mqcreate -e -m 1024 -z 8192 /foobar

solaris % ls -al /tmp/.*foobar

-rw-rw-rw- 1 rstevens other1 8397336 Oct 25 11:29 /tmp/.MQDfoobar

-rw-rw-rw-  1 rstevens other1    0 Oct 25 11:29 /tmp/.MQLfoobar

-rw-r--r--  1 rstevens other1    0 Oct 25 11:29 /tmp/.MQPfoobar

含有该队列之数据的文件(/tmp/.MQDfoobar)其大小能容纳最大数目的最长消息(1024× 8192= 8 388 608),剩余的8728字节开销允许每个消息占用8个字节(8×1024=8192),外加536个字节。

在Digital Unix 4.0B下执行同一程序的结果如下:

alpha % mqcreate -m 256 -z 2048 /tmp/bigq

alpha % ls -l /tmp/bigq

-rw-r--r-- 1 rstevens system 537288 Oct 25 15:38 /tmp/bigq

该系统上的实现看来能容纳最大数目的最长消息(256×2048=524 288),剩余的13 000字节开销允许每个消息占用48个字节(48×256 = 12 288),外加712个字节。

5.4 mq_send和mq_receive函数

这两个函数分别用于往一个队列中放置一个消息和从一个队列中取走一个消息。每个消息有一个优先级,它是一个小于MQ_PRIO_MAX的无符号整数。Posix要求这个上限至少为32。

Solaris 2.6的上限为32,Digital Unix 4.0B的上限为256。我们将随图5-8给出取得该上限值的方法。

mq_receive总是返回所指定队列中最高优先级的最早消息,而且该优先级能随该消息的内容及其长度一同返回。

mq_receive的操作不同于Systme V的msgrcv(6.4节)。System V消息有一个类似于优先级的类型字段,但使用msgrcv时,我们可以就返回哪一个消息指定三种不同的情形:所指定队列中最早的消息、具有某个特定类型的最早消息、其类型小于或等于某个值的最早消息。

#include <mqueue.h>

int mq_send(mqd_t mqdes,const char *ptr,size_t len,unsigned int prio);

返回:若成功则为0,若出错则为−1

ssize_t mq_receive(mqd_t mqdes,char *ptr,size_t len,unsigned int *priop);

返回:若成功则为消息中字节数,若出错则为−1

这两个函数的前三个参数分别与write和read的前三个参数类似。

把指向缓冲区的指针参数定义为char*看来是个错误。使用void*将与其他Posix.1函数更为一致。

mq_receive的len参数的值不能小于能加到所指定队列中的消息的最大大小(该队列mq_attr结构的mq_msgsize成员)。要是len小于该值,mq_receive就立即返回EMSGSIZE错误。

这意味着使用Posix消息队列的大多数应用程序必须在打开某个队列后调用mq_getattr确定最大消息大小,然后分配一个或多个那样大小的读缓冲区。通过要求每个缓冲区总是足以存放队列中的任意消息,mq_receive就不必返回消息是否大于缓冲区的通知。作为比较的例子,System V消息队列(6.4节)可能使用MSG_NOERROR标志,返回E2BIG错误,接收UDP数据报的recvmsg函数(UNPvl的13.5节)可能使用MSG_TRUNC标志。

mq_send的prio参数是待发送消息的优先级,其值必须小于MQ_PRIO_MAX。如果mq_receive的priop参数是一个非空指针,所返回消息的优先级就通过该指针存放。如果应用不必使用优先级不同的消息,那就给mq_send指定值为0的优先级,给mq_receive指定一个空指针作为其最后一个参数。

0字节长度的消息是允许的。这里重要的不是标准(即Posix.1)中说的内容,而是它的言外之意:没有地方可以禁止使用0字节长度的消息。mq_receive的返回值是所接收消息中的字节数(如果成功)或−1(如果出错),因此返回值为0表示返回长度为0的消息。

Posix消息队列和System V消息队列都不具备的一个特性是:向接收者准确地标识每条消息的发送者。这个信息在许多应用中可能有用。不幸的是,大多数IPC消息机制并不标识发送者。在15.5节中,我们将讲述门如何提供这个标识。UNPvl的14.8节讲述了使用Unix域套接字时,BSD/OS如何提供这个标识。APUE的15.3.1节 [6] 讲述了通过管道传递描述符时,SVR4如何通过同一管道传递发送者的标识。BSD/OS技术没有得到广泛实现,而SVR4技术尽管是Unix 98的一部分,却要求通过管道传递描述符,这通常比通过管道直接传递数据开销更大。我们不能让发送者随消息包含自己的标识(例如有效用户ID),因为难以认定发送者不说假话。尽管消息队列的访问权限决定了是否允许发送者往队列中放置消息,这仍然没有标识发送者。另外,尽管存在为每个发送者创建一个队列的可能性(对此我们将在6.8节就System V消息队列展开讨论),但对于大的应用来说,这种方法的可扩展性并不好。最后,要是消息队列函数完全是作为用户函数实现的(5.8节中的实现就是这样),根本没在内核中,那么我们不能信任伴随消息的任何发送者标识,因为它很容易伪造。

5.4.1 例子:mqsend程序

图5-6给出了往某个队列中增加一个消息的mqsend程序。

图5-6 mqsend程序

图5-6(续)

待发送消息的大小和优先级必须作为命令行参数指定。所用缓冲区使用calloc分配,该函数会把该缓冲区初始化为0。

5.4.2 例子:mqreceive程序

图5-7中的程序从某个队列中读入下一个消息。

图5-7 mqreceive程序

允许-n选项以指定非阻塞属性

14~17 命令行选项-n指定非阻塞属性,这样如果所指定队列中没有消息,mqreceive程序就返回一个错误。

打开队列并取得属性

21~25 调用mq_getattr打开队列并取得属性。我们需要确定最大消息大小,因为必须为调用mq_receive分配一个这样大小的缓冲区。最后输出所读出消息的大小及其属性。

既然n是一个size_t数据类型,而我们又不知道size_t是int还是long,那么要使用%ld格式化串将n类型强制转换成一个长整数。在64位系统上,int是32位整数,long和size_t则都是64位整数。

我们可使用这两个程序来查看优先级字段是如何使用的。

solaris % mqcreate /test1          创建并取得属性

solaris % mqgetattr /test1

max #msgs = 128,max #bytes/msg = 1024,#currently on queue = 0

solaris % mqsend /test1 100 99999     以无效的优先级发送

mq_send error: Invalid argument

solaris % mqsend /test1 100 6        100字节,优先级为6

solaris % mqsend /test1 50 18        50字节,优先级为18

solaris % mqsend /test1 33 18        50字节,优先级为18

solaris % mqreceive /test1

read 50 bytes,priority = 18        返回优先级最高的最早消息

solaris % mqreceive /test1

read 33 bytes,priority = 18

solaris % mqreceive /test1

read 100 bytes,priority = 6

solaris % mqreceive-n /test1        指定非阻塞属性,队列为空

mq_receive error: Resource temporarily unavailable

可以看出,mq_receive返回优先级最高的最早消息。

5.5 消息队列限制

我们已遇到任意给定队列的两个限制,它们都是在创建该队列时建立的:

mq_mqxmsg   队列中的最大消息数;

mq_msgsize  给定消息的最大字节数。

这两个值都没有内在的限制,尽管对于我们已查看过的两种实现(Solaris 2.6和Digital Unix 4.0B)来说,大小为这两个数之积再加少量开销的某个文件在文件系统中必须有容纳空间。基于队列大小的虚拟内存要求也可能存在(见习题5.5)。

消息队列的实现定义了另外两个限制:

MQ_OPEN_MAX 一个进程能够同时拥有的打开着消息队列的最大数目(Posix要求它至少为8);

MQ_PRIO_MAX 任意消息的最大优先级值加1(Posix要求它至少为32)。

这两个常值往往定义在<unistd.h>头文件中,也可以在运行时通过调用sysconf函数获取,如接下来的例子所示。

例子:mqsysconf程序

图5-8中的程序调用sysconf,输出消息队列的两个由实现定义的限制。

图5-8 调用sysconf获取消息队列限制

在我们的两个系统上执行该程序的结果如下:

solaris % mqsysconf

MQ_OPEN_MAX = 32,MQ_PRIO_MAX = 32

alpha % mqsysconf

MQ_OPEN_MAX = 64,MQ_PRIO_MAX = 256

5.6 mq_notify函数

第6章中讨论的System V消息队列的问题之一是无法通知一个进程何时在某个队列中放置了一个消息。我们可以阻塞在msgrcv调用中,但那将阻止我们在等待期间做其他任何事。如果给msgrcv指定非阻塞标志(IPC_NOWAIT),那么尽管不阻塞了,但必须持续调用该函数以确定何时有一个消息到达。我们说过这称为轮询(polling),是对CPU时间的一种浪费。我们需要一种方法,让系统告诉我们何时有一个消息放置到了先前为空的某个队列中。

本节和本章的剩余各节含有高级主题,你第一次阅读时可暂时跳过去。

Posix消息队列允许异步事件通知(asynchronous event notification),以告知何时有一个消息放置到了某个空消息队列中。这种通知有两种方式可供选择:

产生一个信号;

创建一个线程来执行一个指定的函数。

这种通知通过调用mq_notify建立。

#include <mqueue.h>

int mq_notify(mqd_t mqdes,const struct sigevent *notification);

返回:若成功则为0,若出错则为−1

该函数为指定队列建立或删除异步事件通知。sigevent结构是随Posix.1实时信号新加的,后者将在下一节详细讨论。该结构以及本章中引入的所有新的信号相关常值都定义在<signal.h>头文件中。

union sigval {

int    sival_int;         /* integer value */

void   *sival_ptr;         /* pointer value */

};struct sigevent {

int        sigev_notify;   /* SIGEV_{NONE,SIGNAL,THREAD} */

int        sigev_signo;    /* signal number if SIGEV_SIGNAL */

union sigval sigev_value; /* passed to signal handler or thread */

/* following two if SIGEV_THREAD */

void        (*sigev_notify_function)(union sigval);

pthread_attr_t  *sigev_notify_attributes;

};

我们马上给出以不同方法使用异步事件通知的几个例子,但在此前先给出一些普遍适用于该函数的若干规则。

(1)如果notification参数非空,那么当前进程希望在有一个消息到达所指定的先前为空的队列时得到通知。我们说“该进程被注册为接收该队列的通知”。

(2)如果notification参数为空指针,而且当前进程目前被注册为接收所指定队列的通知,那么已存在的注册将被撤销。

(3)任意时刻只有一个进程可以被注册为接收某个给定队列的通知。

(4)当有一个消息到达某个先前为空的队列,而且已有一个进程被注册为接收该队列的通知时,只有在没有任何线程阻塞在该队列的mq_receive调用中的前提下,通知才会发出。这就是说,在mq_reveive调用中的阻塞比任何通知的注册都优先。

(5)当该通知被发送给它的注册进程时,其注册即被撤销。该进程必须再次调用mq_notify以重新注册(如果想要的话)。

Unix信号最初的问题之一是:每当一个信号产生后,其行为就被复位成默认行为(APUE的10.4节)。信号处理程序调用的第一个函数通常是signal,用于重新建立处理程序。这么一来提供了一个短的时间窗口,它处于该信号的产生与当前进程重建其信号处理程序之间,这段时间内再次产生的同一信号可能终止当前进程。初看起来,mq_notify似乎有类似的问题,因为当前进程必须在每次通知发生后重新注册。然而消息队列不同于信号,因为在队列变空前通知不会再次发生。因此我们必须小心,保证在从队列中读出消息之前(而不是之后)重新注册。

5.6.1 例子:简单的信号通知

在深入探讨Posix实时信号和线程之前,我们可以编写一个简单的程序,当有一个消息放置到某个空队列中,该程序产生一个SIGUSR1信号。图5-9给出了这个程序,注意它含有一个我们不久后将详细讨论的错误。

声明全局变量

2~6 声明main函数和信号处理程序(sig_usr1)都使用的一些全局变量。

打开队列,取得属性,分配读缓冲区

12~15 打开通过命令行参数指定的消息队列,获取其属性,然后分配一个读缓冲区。

建立信号处理程序,启用通知

16~20 首先给SIGUSR1建立信号处理程序。在sigevent结构的sigev_notify成员中填入SIGEV_SIGNAL常值,其意思是当所指定队列由空变为非空时,我们希望有一个信号会产生。将sigev_signo成员设置成希望产生的信号后,调用mq_notify。

无限循环

21~22 main函数接下来是个无限睡眠在pause函数中的循环,该函数每次捕获一个信号时都会返回−1。

捕获信号,读出消息

25~33 我们的信号处理程序调用mq_notify以便为下一个事件重新注册,然后读出消息并输出其长度。本程序中我们忽略了接收到的消息的优先级。

图5-9 当有消息放置到某个空队列中时产生SIGUSR1(不正确版本)

sig_usr1结束处的return语句不是必要的,因为并没有返回值需返回,而且掉出该函数的末尾也是一个向调用者的隐式返回。然而作者总会在信号处理程序的末尾写上一个显式的return语句,目的是为了强调从这种函数的返回是特殊的。它可能会导致处理该信号的线程中某个函数调用过早返回(返回一个EINTR错误)。

我们现在从一个窗口运行这个程序:

solaris % mqcreate /test1          创建队列

solaris % mqnotifysig1 /test1        启动图5-9中的程序

从另外一个窗口中执行如下命令:

solaris % mqsend /test1 50 16        发送50字节优先级为16的消息

正如所料,程序mynotifysig1输出:“SIGUSR1 reveived,read 50 bytes”。

我们可以验证每次只有一个进程可被注册为接收通知,方法是从另一个窗口中启动该程序的另一个副本:

solaris % mqnotifysig1 /test1

mq_notify error: Device busy

这个出错消息对应EBUSY错误。

5.6.2 Posix信号:异步信号安全函数

图5-9中程序的问题是它从信号处理程序中调用mq_notify、mq_receive和printf。这些函数实际上都不可以从信号处理程序中调用。

Posix使用异步信号安全(async-signal-safe)这一术语描述可以从信号处理程序中调用的函数。图5-10列出了这些Posix函数以及由Unix 98加上的其他几个函数。

图5-10 异步信号安全的函数

没有列在该表中的函数不可以从信号处理程序中调用。注意所有标准I/O函数和pthread_XXX函数都没有列在其中。本书所涵盖的所有IPC函数中,只有sem_post、read和write列在其中(我们假定read和write可用于管道和FIFO)。

ANSI C列出了可以从信号处理程序中调用的四个函数:abort、exit、longjmp和signal。Unix 98没有把前三个函数列为异步信号安全函数。

5.6.3 例子:信号通知

避免从信号处理程序中调用任何函数的方法之一是:让处理程序仅仅设置一个全局标志,由某个线程检查该标志以确定何时接收到一个消息。图5-11展示了这种技巧,不过它含有另外一个错误,我们不久会讲到。

全局变量

2 既然信号处理程序执行的唯一操作是把mqflag置为非零,于是图5-9中的全局变量不必仍然是全局变量。降低全局变量的数目肯定是一种好技巧,当使用线程时尤为如此。

图5-11 信号处理程序只是给主线程设置一个标志(不正确版本)

打开消息队列

15~18 打开通过命令行参数指定的消息队列,获取其属性,然后分配一个读缓冲区。

初始化信号集

19~22 初始化三个信号集,并在newmask集中打开对应SIGUSR1的位。

建立信号处理程序,启用通知

23~27 给SIGUSR1建立一个信号处理程序,填写sigevent结构,调用mq_notify。

等待信号处理程序设置标志

28~32 调用sigprocmask阻塞SIGUSR1,并把当前信号掩码保存到oldmask中。随后在一个循环中测试全局变量mqflag,以等待信号处理程序将它设置成非零。只要它为0,我们就调用sigsuspend,它原子性地将调用线程投入睡眠,并把它的信号掩码复位成zeromask (没有一个信号被阻塞)。APUE的10.16节详细讨论sigsuspend以及为什么只能在SIGUSR1被阻塞时测试mqflag变量。每次sigsuspend返回时,SIGUSR1被重新阻塞。

重新注册并读出消息

33~36 当mqflag为非零时,重新注册并从指定队列中读出消息。随后给SIGUSR1解阻塞并返回for循环顶部。

我们提到过这种办法仍存在一个问题。考虑一下第一个消息被读出之前有两个消息到达的情形。我们可通过在mq_notify调用前增加一个sleep语句来模拟这种情形。这里的基本问题是:通知只是在有一个消息被放置到某个空队列上时才发出。如果在能够读出第一个消息前有两个消息到达,那么只有一个通知被发出:我们于是读出第一个消息,并调用sigsuspend等待另一个消息,而对应它的通知可能永远不会发出。在此期间,另一个消息已放置于该队列中等待读出,而我们却一直在忽略它。

5.6.4 例子:使用非阻塞mq_receive的信号通知

上述问题的解决办法是:当使用mq_notify产生信号时,总是以非阻塞模式读消息队列。图5-12给出了图5-11的一个修改版本,它以非阻塞模式读消息队列。

图5-12 使用信号通知读Posix消息队列

图5-12(续)

打开消息队列以非阻塞模式

15~18 第一个变动是在打开消息队列时指定O_NONBLOCK标志。

从队列中读出所有消息

34~38 另一个变动是在一个循环中调用mq_receive,处理队列中的每个消息。返回一个EAGAIN错误不表示有问题,它只是意味着暂时没有消息可读。

5.6.5 例子:使用sigwait代替信号处理程序的信号通知

上一个例子尽管正确,但效率还可以更高些。我们的程序通过调用sigsuspend阻塞,以等待某个消息的到达。当有一个消息被放置到某个空队列中时,该信号产生,主线程被阻止,信号处理程序执行并设置mqflag变量,主线程再次执行,发现mq_flag为非零,于是读出该消息。更为简易(并且可能更为高效)的办法之一是阻塞在某个函数中,仅仅等待该信号的递交,而不是让内核执行一个只为设置一个标志的信号处理程序。sigwait提供了这种能力。

#include <signal.h>

int sigwait(const sigset_t *set,int *sig);

返回:若成功则为0,若出错则为正的Exxx值

调用sigwait前,我们阻塞某个信号集。我们将这个信号集指定为set参数。sigwait然后一直阻塞到这些信号中有一个或多个待处理,这时它返回其中一个信号。该信号值通过指针sig存放,函数的返回值则为0。这个过程称为“同步地等待一个异步事件”:我们是在使用信号,但没有涉及异步信号处理程序。

图5-13给出了用到sigwait时mq_notify的使用。

图5-13 伴随sigwait使用mq_notify

初始化信号集并阻塞SIGUSR1

18~20 把某个信号集初始化成只含有SIGUSR1,然后用sigprocmask阻塞该信号。

等待信号

26~34 在sigwait调用中阻塞并等待该信号。SIGUSR1被递交后,重新注册通知并读出所有可用消息。

sigwait往往在多线程化的进程中使用。实际上,看一看它的函数原型,我们会发现其返回值或为0,或为某个Exxx错误,这与大多数Pthread函数一样。但是在多线程化的进程中不能使用sigprocmask,而必须调用pthread_sigmask,它只是改变调用线程的信号掩码。pthread_sigmask的参数与sigprocmask的相同。

sigwait存在两个变种:sigwaitinfo和sigtimedwait。sigwaitinfo还返回一个siginfo_t结构(将在下一节中定义),目的是用于可靠信号中。sigtimedwait也返回一个siginfo_t结构,并允许调用者指定一个时间限制。

大多数讨论线程的书(例如[Butenhof 1997])推荐在多线程化的进程中使用sigwait来处理所有信号,而绝不要使用异步信号处理程序。

5.6.6 例子:使用select的Posix消息队列

消息队列描述符(mqd_t变量)不是“普通”描述符,它不能用在select或poll(UNPv1第6章)中。然而我们可以伴随一个管道和mq_notify函数使用它们。(我们将在6.9节中随System V消息队列展示类似的技巧,那时涉及的是一个子进程和一个管道。)首先,从图5-10注意到write函数是异步信号安全的,因此可以从信号处理程序中调用它。图5-14给出了我们的程序。

图5-14 伴随管道使用信号通知

图5-14(续)

创建一个管道

21 创建一个管道,当接收到消息队列的异步事件通知时,信号处理程序就会往该管道中写入数据。这是一个管道用于信号处理程序中的例子。

调用select

27~40 初始化描述符集rset,每次循环时打开对应于pipefd[0](管道的读出端)的那一位。然后调用select只等待该描述符,不过在典型的应用中,这儿是多个描述符上的输入或输出复用的地方。当管道的读出端可读时,重新注册消息队列的通知,并读出所有可得的消息。

信号处理程序

43~48 我们的信号处理程序只是往管道write 1个字节。我们已提及这是一个异步信号安全的操作。

5.6.7 例子:启动线程

异步事件通知的另一种方式是把sigev_notify设置成SIGEV_THREAD,这会创建一个新的线程。该线程调用由sigev_notify_function指定的函数,所用的参数由sigev_value指定。新线程的线程属性由sigev_notify_attributes指定,要是默认属性合适的话,它可以是一个空指针。图5-15给出了使用这种技术的一个例子。

我们把给新线程的参数(sigev_value)指定成一个空指针,因此不会有任何东西传递给该线程的起始函数。我们能以参数的形式传递一个指向所处理消息队列描述符的指针,而不是把它声明为一个全局变量,不过新线程仍然需要消息队列属性和sigev结构(以便重新注册)。我们把给新线程的属性指定成一个空指针,因此使用的是系统默认属性。这样的新线程是作为脱离的线程创建的。

遗憾的是,本书例子所用的两个系统(Solaris 2.6和Digital Unix 4.0B)没有一个支持SIGEV_THREAD。这两个系统都要求sigev_notify或者为SIGEV_NONE,或者为SIGEV_SIGNAL。

图5-15 启动一个新线程的mq_notify

5.7 Posix实时信号

在过去几十年中,Unix信号经历了多次重大的演变。

(1)由Version 7 Unix(1978年)提供的信号模型是不可靠的。信号可能丢失,而且进程难以在执行临界代码段时关掉选中的若干信号。

(2)4.3BSD(1986年)增加了可靠的信号。

(3)System V Release 3.0(1986年)也增加了可靠的信号,不过不同于BSD模型。

(4)Posix.l(1990年)标准化了BSD可靠信号模型,APUE的第10章详细讲述了该模型。

(5)Posix.1(1996年)给Posix模型增加了实时信号。该工作起源于Posix.1b实时扩展(以前称为Posix.4)。

当今几乎每种Unix系统都提供Posix可靠信号,更新的系统正在逐步提供Posix实时信号。(在描述信号时,注意区分可靠和实时。)我们有必要较详细地讨论实时信号,因为已在上一节中使用过由这个扩展定义的一些结构(sigval结构和sigevent结构)。

信号可划分为两个大组。

(1)其值在SIGRTMIN和SIGRTMAX之间(包括两者在内)的实时信号。Posix要求至少提供RTSIG_MAX种实时信号,而该常值的最小值为8。

(2)所有其他信号:SIGALRM、SIGINT、SIGKILL等。

Solaris 2.6上普通Unix信号的值为1~37,8种实时信号的值为38~45。Digital Unix 4.0B上普通Unix信号的值为1~32,16种实时信号的值为33~48。这两种实现都把SIGRTMIN和SIGRTMAX定义为调用sysconf的宏,以允许在将来修改它们的值。

接下去我们关注接收某个信号的进程的sigaction调用中是否指定了新的SA_SIGINFO标志。这些差异带来了图5-16中所示的四种可能情形。

图5-16 Posix信号实时行为,取决于SA_SIGINFO

其中有三个框标以“实时行为未指定”,其含义是有些实现可能提供实时行为,有些实现可能不提供。如果需要实时行为,我们必须使用SIGRTMIN和SIGRTMAX之间的新的实时信号,而且在安装信号处理程序时必须给sigaction指定SA_SIGINFO标志。

术语实时行为(realtime behavior)隐含着如下特征。

信号是排队的。这就是说,如果同一信号产生了三次,它就递交三次。另外,一种给定信号的多次发生以先进先出(FIFO)顺序排队。我们不久将给出一个信号排队的例子。

对于不排队的信号来说,产生了三次的某种信号可能只递交一次。

当有多个SIGRTMIN到SIGRTMAX范围内的解阻塞信号排队时,值较小的信号先于值较大的信号递交。这就是说,SIGRTMIN比值为SIGRTMIN+1的信号“更为优先”,值为SIGRTMIN+1的信号比值为SIGRTMIN+2的信号“更为优先”,依此类推。

当某个非实时信号递交时,传递给它的信号处理程序的唯一参数是该信号的值。实时信号比其他信号携带更多的信息。通过设置SA_SIGINFO标志安装的任意实时信号的信号处理程序声明如下:

void func(int signo,siginfo_t *info,void *context);

其中signo是该信号的值,siginfo_t结构则定义如下:

typedef struct {

int       si_signo;   /* same value as signo argument */

int       si_code;   /* SI_{USER,QUEUE,TIMER,ASYNCIO,MEGEQ} */

union sigval  si_value;   /* integer or pointer value from sender */

} siginfo_t;

context参数所指向的内容依赖于实现。

技术上讲,非实时Posix信号的处理程序只用一个参数调用。许多系统有一个较早的使用三个参数的约定,适用于先于Posix实时标准的信号处理程序。

siginfo_t是使用typedef定义的具有以_t结尾的名字的唯一一个Posix结构。图5-17中我们把指向这些结构的指针声明为siginfo_t*,而不出现struct一词 [7]

一些新函数定义成使用实时信号工作。例如,sigqueue函数用于代替kill函数向某个进程发送一个信号,该新函数允许发送者随所发送信号传递一个sigval联合。

实时信号由下列Posix.1特性产生,它们由包含在传递给信号处理程序的siginfo_t结构中的si_code值来标识。

SI_ASYNCIO 信号由某个异步I/O请求的完成产生,这些异步I/O请求就是Posix的aio_XXX函数,我们不讲述。

SI_MESGQ 信号在有一个消息被放置到某个空消息队列中时产生,如5.6节中所述。

SI_QUEUE 信号由sigqueue函数发出。稍后我们将给出一个这样的例子。

SI_TIMER 信号由使用timer_settime函数设置的某个定时器的到时产生,我们不讲述。

SI_USER 信号由kill函数发出。

如果信号是由某个其他事件产生的,si_code就会被设置成不同于这里所列的某个值。siginfo_t结构的si_value成员的内容只在si_code为SI_ASYNCIO、SI_MESGQ、SI_QUEUE或SI_TIMER时才有效。

5.7.1 例子

图5-17是一个演示实时信号的简单程序。该程序调用fork,子进程阻塞三种实时信号,父进程随后发送9个信号(三种实时信号中每种3个),子进程接着解阻塞信号,我们于是看到每种信号各有多少个递交以及它们的先后递交顺序。

输出实时信号值

10 输出最小和最大实时信号值,以查看系统实现支持多少种实时信号。我们把这两个常值类型强制转换成一个整数,因为有些实现把这两个常值定义为调用sysconf的宏,例如:

#define   SIGRTMAX   (sysconf(_SC_RTSIG_MAX))

而sysconf返回一个长整数(见习题5.4)。

fork:子进程阻塞三种实时信号

11~17 派生一个子进程,由子进程调用sigprocmask阻塞我们将使用的三种实时信号:SIGRTMAX、SIGRTMAX-1、SIGRTMAX-2。

图5-17 演示实时信号的简单测试程序

建立信号处理程序

18~21 调用signal_rt函数(将在图5-18中给出),建立我们的sig_rt函数来作为这三种实时信号的处理程序。该函数设置SA_SIGINFO标志,再加上这三种信号都是实时信号,于是我们预期它们具备实时行为。该函数还设置执行信号处理程序期间需阻塞的信号掩码 [8]

等待父进程产生信号,然后解阻塞信号

22~25 等待6秒以允许父进程产生预定的9个信号。然后调用sigprocmask解阻塞那三种实时信号。该操作应允许所有排队的信号都被递交。子进程停顿另外3秒,以便信号处理程序调用printf 9次,然后终止。

父进程发送9个信号

27~36 父进程停顿3秒,以便子进程阻塞所有信号。父进程随后给那三种实时信号的第一种产生3个信号:i取这三种实时信号的值,对于每个i值,j取值为0、1和2。我们特意从最高的信号值开始产生信号,因为期待它们从最低的信号值开始递交。我们还伴随每个信号发送一个不同的整数值(sival_int),以验证一种给定信号的3次发生是按FIFO的顺序产生的。

信号处理程序

38~43 我们的信号处理程序只是输出所递交信号的有关信息。

我们从图5-10注意到printf不是异步信号安全的,因而不能从信号处理程序中调用。在这儿的小测试程序中,我们将它作为一个简单的诊断工具来调用。 [9]

我们首先在Solaris 2.6下运行该程序,但是发现它的输出与我们预期的不一致。

solaris % test1

SIGRTMIN = 38,SIGRTMAX = 45           提供8种实时信号

这儿停顿3秒

父进程现在发送9个信号

sent signal 45,val = 0 sent signal 45,val = 1

sent signal 45,val = 2

sent signal 44,val = 0

static volatile siginfo_t arrival[10];

static volatile int    nsig;

然后让信号处理程序在该数组中保存其info参数:

static void

sig_rt(int signo,siginfo_t *info,void *context)

{

arrival[nsig++] = *info; /* save info for child to print */

}

最后由子进程在终止前输出该数组中的信息:

sleep(3);   /* let all queued signals be delivered */

for (i = 0; i < nsig; i++){

printf("received signal #%d,code = %d,ival = %d\n",

arrival[i].si_signo,arrival[i].si_code,

arrival[i].si_value.sival_int);

}

exit(0);

sent signal 44,val = 1

sent signal 44,val = 2

sent signal 43,val = 0

sent signal 43,val = 1

sent signal 43,val = 2

solaris %                父进程终止,shell提示符输出

在子进程解阻塞信号前停顿3秒

received signal #45,code = -2,ival = 2    子进程捕获信号received signal #45,code = -2,ival = 1

received signal #45,code = -2,ival = 0

received signal #44,code = -2,ival = 2

received signal #44,code = -2,ival = 1

received signal #44,code = -2,ival = 0

received signal #43,code = -2,ival = 2

received signal #43,code = -2,ival = 1

received signal #43,code = -2,ival = 0

父进程发送的9个信号排了队,但是那三种信号是从值最大的信号开始产生的(而我们却期待值最小的信号最先产生)。对于一种给定信号,它的3个排了队的信号看来是以LIFO顺序而不是FIFO顺序递交的。值为−2的si_code对应SI_QUEUE。

接着改在Digital Unix 4.0B下运行该程序,我们看到了预期的结果。

alpha % test1

SIGRTMIN = 33,SIGRTMAX = 48      提供16种实时信号

这儿停顿3秒

sent signal 48,val = 0              父进程现在发送9个信号

sent signal 48,val = 1

sent signal 48,val = 2

sent signal 47,val = 0

sent signal 47,val = 1

sent signal 47,val = 2

sent signal 46,val = 0

sent signal 46,val = 1

sent signal 46,val = 2

alpha %                       父进程终止,shell提示符输出

在子进程解阻塞信号前停顿3秒

received signal #46,code = -1,ival = 0    子进程捕获信号received signal #46,code = -1,ival = 1

received signal #46,code = -1,ival = 2

received signal #47,code = -1,ival = 0

received signal #47,code = -1,ival = 1

received signal #47,code = -1,ival = 2

received signal #48,code = -1,ival = 0

received signal #48,code = -1,ival = 1

received signal #48,code = -1,ival = 2

父进程发送的9个信号排队后按我们期待的顺序递交:值最小的信号最先递交,对于一种给定信号,它的3次发生按FIFO顺序递交。

Solaris 2.6的实现看来存在缺陷。

5.7.2 signal_rt函数

我们在UNPv1第120页给出了自己的signal函数,它调用Posix sigaction函数建立一个提供可靠Posix语义的信号处理程序。现在我们把该函数修改成提供实时行为。我们称这个新函数为signal_rt,如图5-18所示。

图5-18 提供实时行为的signal_rt函数

使用typedef简化函数原型

1~3 在我们的unpipc.h头文件(图C-1)中,Sigfunc_rt定义如下:

typedef void Sigfunc_rt(int,siginfo_t *,void *);

我们在本节早先说过,这是在设置SA_SIGINFO标志的前提下安装的信号处理程序的函数原型。

指定处理程序函数

5~7 加入实时信号支持后,sigaction发生变化,即增加了新的sa_sigaction成员。

struct sigaction {

void (*sa_handler)(); /* SIG_DFL,SIG_IGN,or add of signal handler */

sigset_t sa_mask;     /* additional signals to block */

int    sa_flags;    /* signal options: SA_xxx */

void    (*sa_sigaction)(int,siginfo_t,void *);

/* addr of signal handler if SA_SIGINFO set */

};

规则如下。

如果在sa_flags成员中设置了SA_SIGINFO标志,那么sa_sigaction成员会指定信号处理函数的地址。

如果在sa_flags成员中没有设置SA_SIGINFO标志,那么sa_handler成员会指定信号处理函数的地址。

为给某个信号指定默认行为或忽略该信号,应把sa_handler设置为SIG_DFL或SIG_IGN,并且不设置SA_SIGINFO标志。

设置SA_SIGINFO

8~17 我们总是设置SA_SIGINFO标志,如果信号不是SIGALRM,那就再指定SA_RESTART标志。

5.8 使用内存映射I/O实现Posix消息队列

我们现在提供一个使用内存映射I/O以及Posix互斥锁和条件变量完成的Posix消息队列的实现。

我们在第7章中讨论互斥锁和条件变量,在第12章和第13章中讨论内存映射I/O。你可能希望跳过本节,阅读过所列各章后再返回来。

图5-19展示了我们用于实现Posix消息队列的各种数据结构的布局。该图中我们假设创建出的消息队列最多容纳4个消息,每个消息7个字节。

图5-19 使用内存映射文件实现Posix消息队列的各种数据结构的布局

图5-20给出了我们的mqueue.h头文件,它定义了本实现的基本结构。

图5-20 mqueue.h头文件

mqd_t数据类型

1 我们的消息队列描述符只是一个指向某个mq_info结构的指针。每次调用mq_open都会分配一个这种结构,其指针就返回给调用者。这一点再次强调了消息队列描述符不必像文件描述符那样是一个小整数——唯一的Posix要求是这种数据类型不能是一个数组类型。

mq_hdr结构

8~18 该结构出现在映射文件的开头,含有针对每个队列的所有信息。mq_attr结构的mq_flags成员没有用上,因为标志(唯一定义了的是非阻塞标志)必须以每次打开为基而不是以每个队列为基来维护。也就是说,标志在mq_info结构中维护。该结构的其余成员随它们在各种函数中的使用而说明。

现在开始注意,我们称之为索引(index)的任何东西(本结构的mqh_head和mqh_free成员,下一个结构的msg_next成员)都含有从映射文件头开始的字节索引。举例来说,Solaris 2.6下mq_hdr结构的大小为96字节,因此该首部之后第一个消息的索引为96。图5-19中的每个消息占据20字节(12字节的msg_hdr结构和8字节的消息数据),因此其余三个消息的索引分别为116、136和156,该映射文件的大小为176字节。这些索引用于维护映射文件中的两个链表:一个链表(mqh_head)含有当前在队列中的所有消息,另一个链表(mqh_free)含有队列中的所有空闲消息。我们不能给这些链表指针使用真正的内存指针(地址),因为同一映射文件在映射它的各个进程中可以在不同的内存地址开始(如图13-6所示)。

msg_hdr结构

19~25 该结构出现在映射文件中每个消息的开头。所有消息要么在消息链表中,要么在空闲链表中,msg_next成员含有本链表中下一个消息的索引(如果本消息为所在链表最后一个消息,那么下一个消息的索引为0)。msg_len是消息数据的真正长度,对于图5-19中的例子来说,它可以在0~7字节之间(包括0和7)。msg_prio是由mq_send的调用者赋予消息的优先级。

mq_info结构

26~32 每次打开一个队列时,mq_open会动态分配一个这种结构,它由mq_close释放。

mqi_hdr指向映射文件(由mmap返回的起始地址)。我们的实现中的基本数据类型mqd_t就是指向该结构的指针,该指针是mq_open的返回值。

一旦mq_info结构初始化,其mqi_magic成员便含有MQI_MAGIC,被传递以一个mqd_t指针的每个函数都检查该成员,以确信该指针确实指向一个mq_info结构。mqi_flags成员含有当前队列的本次打开实例的非阻塞标志。

MSGSIZE宏

33~34 为了对齐,我们希望映射文件中的每个消息从一个长整数边界开始。因此,如果每个消息的最大大小不是这样对齐的,我们就得给每个消息的数据部分增加1~3个填充字节,如图5-19所示。这里假设长整数的大小为4字节(对于Solaris 2.6来说是正确的),但是如果长整数的大小为8字节(Digit Unix 4.0上就是这样),那么填充字节数在1~7之间。

5.8.1 mq_open函数

图5-21给出了mq_open函数的第一部分,它创建一个新消息队列或打开一个已存在的消息队列。

处理可变长度参数表

29~32 本函数能够以两个或四个参数调用,这取决于是否指定了O_CREAT标志。当指定了该标志时,第三个参数的类型为mode_t,它是一个基本的系统数据类型,可以是任意类型的整数。我们遇到的问题是在BSD/OS上,它把该数据类型定义为unsigned short整数(占据16位)。既然该实现上的整数要占据32位,而且参数表中的所有短整数都被扩展成整数,那么C编译器会把这种类型的参数从16位扩展到32位。但是如果在va_arg调用中指定mode_t,那它将会在栈中走过16位后便指向下一个参数,然而本参数已被扩展为占据32位。为此我们必须定义自己的数据类型va_mode_t,它在BSD/OS下是整数,在其他系统下是类型mode_t。我们的unpipc.h头文件(图C-1)中的如下各行处理这个移植性问题:

#ifdef   __bsdi__

#define  va_mode_t  int

#define  va_mode_t  mode_t

#else

#endif

30 我们关掉mode变量中的用户执行位(S_IXUSR),其原因稍后解释。

图5-21 mq_open函数:第一部分

创建一个新消息队列

33~34 按照由调用者指定的名字创建一个普通文件,并打开它的用户执行位。

处理潜在的竞争状态

35~40 要是我们只是打开该文件,内存映射其内容,并在调用者指定O_CREAT标志的前提下初始化映射文件(如稍后所述),就会碰到一个竞争状态。一个消息队列只在调用者指定了O_CREAT标志并且该消息队列原本不存在时才会由mq_open初始化。这意味着我们需要某种方法来检测消息队列是否存在。为此我们总是在open由调用者给定的内存映射文件时指定O_EXCL标志。但是只有调用者指定了O_EXCL标志时,来自open的EEXIST出错返回才会成为出自mq_open的错误。否则,如果open返回一个EEXIST错误,那说明由调用者给定的文件已经存在,我们就向前跳到图5-23,仿佛未曾指定过O_CREAT标志。

可能出现竞争状态是因为使用一个内存映射文件代表一个消息队列需要两个步骤来初始化一个新的消息队列:首先必须使用open创建该文件,其次必须初始化该文件的内容(稍后描述)。如果有两个线程(可以在同一进程或不同进程中)几乎同时调用mq_open,问题就会发生。一个线程创建该文件,然后系统在该线程完成初始化之前切换到第二个线程。第二个线程检测到该文件已存在(使用O_EXCL标志open),于是立即尝试使用该消息队列。不过只有在第一个线程初始化消息队列后,它才能被使用。

我们使用该文件的用户执行位来指示该消息队列尚未初始化。该位只能由真正创建该文件的线程启用(使用O_EXCL标志检测是否由本线程创建了该文件),这个线程随后初始化该消息队列,并关掉用户执行位。我们在图10-43和图10-52中还将遇到类似的竞争状态。

检查属性

42~50 如果调用者给mq_open的最后一个参数指定了一个空指针,我们就使用图5-21开始处给出的默认属性:128个消息,每个消息1024字节。如果调用者指定了属性,我们就验证mq_maxmsg和mq_msgsize为正数。

mq_open函数的第二部分在图5-22中给出;它完成一个新队列的初始化。

图5-22 mq_open函数第二部分:完成新队列的初始化

图5-22(续)

设置文件大小

51~58 计算每个消息的大小,并向上舍入到下一个长整数大小的倍数。计算文件大小时会将在该文件开头分配的mq_hdr结构和在每个消息开头分配的msg_hdr结构所占的空间包括在内(图5-19)。使用lseek设置新创建文件的大小,然后往当前读写位置写入字节0。

只调用ftruncate(13.3节)会更容易些,但是我们不能保证它可用来增长一个文件的大小。

内存映射该文件

59~63 使用mmap内存映射该文件。

分配mq_info结构

64~66 我们给mq_open的每次调用分配一个mq_info结构。然后初始化该结构。

初始化mq_hdr结构

67~68 初始化mq_hdr结构。设置消息链表的头(mqh_head)为0,把队列中所有消息加到空闲链表(mgh_free)中。

初始化互斥锁和条件变量

88~102 既然只要知道一个Posix消息队列的名字并具有足够的权限,任何进程都能共享它,那么我们必须以PTHEAD_PROCESS_SHARED属性初始化互斥锁和条件变量。为此我们首先调用pthread_mutexattr_init初始化一个互斥锁属性结构,再调用pthread_mutexattr_setpshared在该结构中设置进程间共享属性,然后调用pthread_mutex_init初始化互斥锁。对于条件变量也完成几乎同样的步骤。我们要小心地摧毁初始化了的互斥锁属性或条件变量属性,即使发生错误也这样,因为调用pthread_mutexattr_init或pthread_condattr_init可能分配了内存空间(习题7.3)。

关掉用户执行位

103~107 一旦消息队列已初始化,我们就关掉用户执行位。这样指示消息队列已初始化完毕。

我们还close已内存映射的文件,因为映射到内存后就没有必要让它继续打开着(这样会占用一个描述符)。

图5-23给出了mq_open函数的最后一部分,它打开一个已存在的队列。

打开已存在的消息队列

109~115 我们是在O_CREAT标志未指定或O_CREAT指定了但消息队列已存在的条件下到达这里的。这两种情况下,我们将打开一个已存在的消息队列。我们open含有该消息队列的文件来读写,并使用mmap把该文件内存映射到当前进程的地址空间中。

就打开模式而言,我们的实现简化了。即使调用者指定了O_RDONLY,我们也必须给open和mmap指定读写访问,因为不可能从一个队列中读出一个消息而不改变该队列所在的内存映射文件。同样地,我们不可能往一个队列中写入一个消息而不读其内存映射文件。解决这个问题的方法之一是在mq_info结构中保存打开模式(O_RDONLY、O_WRONLY或O_RDWR),然后在各个函数中检查这个模式。例如,如果打开模式是O_WRONLY,mq_receive就应该失败。

验证消息队列已初始化

116~132 我们必须等待消息队列的初始化(以防多个线程几乎同时尝试创建同一个消息队列)。为此我们调用stat查看内存映射文件的权限(stat结构的st_mode成员)。如果用户执行位已关掉,那么消息队列已被初始化。

这段代码还处理另外一个可能的竞争状态。假设不同进程中的两个线程几乎同时打开同一个消息队列。第一个线程创建了由调用者给定的文件后阻塞在图5-22中的lseek调用中。第二个线程发现该文件已存在,于是跳转到exists标号处,在那儿它再次打开文件,然后阻塞。第一个线程再次运行,然而它在图5-22中的mmap调用失败(也许是因为它超过了自己的虚拟内存限制),于是跳转到err标号处unlink自己创建的文件。第二个线程继续运行,然而要是我们调用fstat而不是stat的话,该线程就可能在等待该文件初始化的for循环中超时。于是我们改为调用stat,而且如果它返回一个文件不存在的错误,同时O_CREAT标志已指定,那么我们跳转到again标号处(图5-21)以再次创建该文件。这种可能的竞争状态也是我们在open调用中检查ENOENT错误的原因。

图5-23 mq_open函数第三部分:打开已存在的队列

内存映射文件,分配并初始化mq_info结构

133~144 内存映射消息队列所在文件,然后关闭该文件的描述符。分配一个mq_info结构并将其初始化。返回值为指向这个新分配mq_info结构的指针。

处理错误

145~158 当在本函数中此前某处检测到一个错误时,程序将跳转到err标号处,同时errno已被设置成将由mq_open返回的值。这里我们要注意保证检测到错误后调用的用于清理的函数不影响将由本函数返回的errno值。

5.8.2 mq_close函数

图5-24给出了我们的mq_close函数。

图5-24 mq_close函数

取得指向各个结构的指针

10~16 验证参数的有效性后,取得指向内存映射区(mqhdr)和属性(在mq_hdr结构中)的指针。

注销调用进程

17~18 调用mq_notify注销队列的调用进程。如果该进程已注册,它就被注销,但是如果它未注册过,那也不返回错误。

撤销内存区映射,释放内存空间

19~25 给munmap计算待撤销内存映射文件的大小,释放mq_info结构所用的内存空间。为防止本函数的调用者在该内存区被malloc重用之前继续使用已关闭的消息队列描述符,我们把魔数设置为0,这样我们的消息队列函数以后将检测到该错误。

注意,如果进程没有调用mq_close就终止,那么进程终止时发生同样的操作:内存映射文件被撤销映射,内存空间被释放。

5.8.3 mq_unlink函数

图5-25给出了我们的mq_unlink函数,它会删除与我们定义的某个消息队列相关联的名字。它只是调用Unix unlink函数。

图5-25 mq_unlink函数

5.8.4 mq_getattr函数

图5-26给出了我们的mq_getattr函数,它返回调用者指定的队列的当前属性。

图5-26 mq_getattr函数

获取队列的互斥锁

17~20 在取得属性前,必须获取由调用者指定的消息队列的互斥锁,以免另外某个线程中途修改它们。

5.8.5 mq_setattr函数

图5-27给出了我们的mq_setattr函数,它给调用者指定的队列设置当前属性。

图5-27 mq_setattr函数

返回当前属性

22~27 如果第三个参数是一个非空指针,那就在修改属性前返回先前的属性和当前的状态。

修改mq_flags

28~31 可使用本函数修改的唯一属性是mq_flags,我们将它放置在mq_info结构中。

5.8.6 mq_notify函数

图5-28给出的mq_notify函数注册或注销所指定队列的调用进程。我们追踪当前已注册为接收出自某个队列的通知的进程,办法是将它的进程ID存放在对应该队列的mq_hdr结构的mqh_pid成员中。对于一个给定队列,每次只有一个进程可注册。当一个进程注册自身时,它还把通过函数参数指定的sigevent结构保存到mqh_event结构中。

图5-28 mq_notify函数

注销调用进程

20~24 如果第二个参数是个空指针,那么注销所指定队列的调用进程。可能让人奇怪的是,调用进程未曾被注册接收该队列的通知并未被指定为一种错误。

注册调用进程

25~34 如果某个进程已被注册,我们就通过向它发送信号0(称为空信号(null signal))以检查它是否仍然存在。这么做只是执行普通的出错检查,而不发送任何信号,会在该进程不存在时返回一个ESRCH错误。如果先前注册了的进程仍然存在,本函数就返回一个EBUSY错误。否则,保存调用进程的进程ID以及调用者的sigevent结构。

这里测试先前注册了的进程是否存在的方法是不完善的。该进程可能终止,而其进程ID却在以后某个时刻被重用。

5.8.7 mq_send函数

图5-29给出了我们的mq_send函数的前半部分。

图5-29 mq_send函数:前半部分

初始化

14~29 取得指向我们将使用的各个结构的指针,获取访问调用者指定队列的互斥锁。检查待发送消息,确定其大小没有超过该队列的最大消息大小。

检查队列是否为空,若合适则发送通知

30~38 如果是在往一个空队列中放置消息,我们就检查是否有某个进程被注册为接收出自该队列的通知,并检查是否有某个线程阻塞在mq_receive调用中。对于后面那种检查而言,我们将看到图5-31和图5-32中的mq_receive函数维护着一个用来存放阻塞在空队列上的线程数的计数器(mqh_nwait)。如果该计数器的值为非零,我们就不向已注册了的进程发送任何通知。我们只处理SIGEV_SIGNAL这种通知方式,其信号通过调用sigqueue发出。已注册了的进程随后被注销。

调用sigqueue发送信号导致向信号处理程序传递的siginfo_t结构(5.7节)中si_code成员的值为SI_QUEUE,这是不正确的,正确的值应为SI_MESGQ。从用户进程中产生正确的si_code取决于实现。[IEEE 1996]第433页提到,要从一个用户函数库中产生该信号,需用到进入信号产生机制的一个隐藏接口。

检查队列是否填满

39~48 如果调用者指定的队列已填满,但是O_NONBLOCK标志已设置,我们就返回一个EAGAIN错误。否则,我们等待在条件变量mqh_wait上,该条件变量由我们的mq_receive函数在从某个填满的队列中读出一个消息时发给信号。

就mq_send调用在被某个由其调用进程捕获的信号中断时返回一个EINTR错误而言,我们的实现简化了。问题在于当信号处理程序返回时,pthread_cond_wait并不返回一个错误:它可能返回一个为0的值(这看来是次虚假的唤醒),也可能根本不返回。绕过这一问题的方法确实存在,但每种方法都不简单。

图5-30给出了mq_send函数的后半部分。至此我们已知道调用者指定的队列中有写入新消息的空间。

取得待用空闲块的索引

50~52 既然在调用者指定的队列初始化时创建的空闲消息数等于mq_maxmsg,我们就不应该有在空闲链表为空的前提下mq_curmsgs小于mq_maxmsg的状态。

复制消息

53~56 nmsghdr含有所映射内存区中用于存放待写入消息的位置的地址。该消息的优先级和长度存放在它的msg_hdr结构中,其内容则从调用者空间复制。

把新消息置于链表中正确位置

57~74 我们的链表中各消息的顺序是从开始处(mqh_head)的最高优先级到结束处的最低优先级。当一个新消息加入调用者指定的队列中,并且一个或多个同样优先级的消息已在该队列中时,这个新消息就加在最后一个优先级相同的消息之后。使用这样的排序方式后,mq_receive总是返回链表中的第一个消息(它是该队列上优先级最高的最早的消息)。当我们沿链表行进时,pmsghdr将含有链表中上一个消息的地址,因为它的msg_next值将含有该新消息的索引。

我们的做法在该队列中有大量消息时可能较慢,因为每次往该队列中写入一个消息时都得遍历大量的链表项。可以再维护一个索引,让它记住各个可能优先级的最后一个消息的位置。

图5-30 mq_send函数:后半部分

唤醒阻塞在mq_receive中的任何线程

75~77 如果在往该队列放置新消息前该队列为空,我们就调用pthread_cond_signal唤醒可能阻塞在mq_receive中的任何线程。

78 给当前在该队列中的消息数mq_curmsgs加1。

5.8.8 mq_receive函数

图5-31给出了我们的mq_receive函数的前半部分,它设置所需的指针、获取互斥锁,并验证调用者的缓冲区足以容纳最大的可能消息。

检查队列是否为空

30~40 如果由调用者指定的队列为空,而且O_NONBLOCK标志已设置,那就返回一个EAGAIN错误。否则,给该队列的mqh_nwait计数器加1,它由图5-29中的mq_send函数检查,检查前提是该队列为空,而且某个进程已注册成接收该队列的通知。然后等待在条件变量上,它由图5-29中的mq_send发送信号。

图5-31 mq_receive函数:前半部分

跟mq_send的实现一样,就mq_receive调用在被某个由其调用进程捕获的信号中断时返回一个EINTR错误而言,我们的实现简化了。

图5-32给出了我们的mq_receive函数的后半部分。至此我们已知道由调用者指定的队列中有一个消息准备返回给调用者。

给调用者返回消息

43~51 msghdr指向调用者指定队列中第一个消息的msg_hdr,它是我们要返回的。由该消息占据的空间变为空闲链表的新头。

唤醒阻塞在mq_send中的任何线程

52~54 如果该队列在我们从中取走待读出消息前是填满的,我们就调用pthread_cond_signal,以防某个线程阻塞在mq_send调用中等待一个消息的空间。

图5-32 mq_receive函数:后半部分

5.9 小结

Posix消息队列比较简单:mq_open创建一个新队列或打开一个已存在的队列,mq_close关闭队列,mq_unlink则删除队列名。往一个队列中放置消息使用mq_send,从一个队列中读出消息使用mq_receive。队列属性的查询与设置使用mq_getattr和mq_setattr,函数mq_notify则允许我们注册一个信号或线程,它们在有一个消息被放置到某个空队列上时发送(信号)或激活(线程)。队列中的每个消息被赋予一个小整数优先级,mq_receive每次被调用时总是返回最高优先级的最早消息。

rnq_notify的使用给我们引入了Posix实时信号,它们在SIGRTMIN和SIGRTMAX之间。当设置SA_SIGINFO标志来安装这些信号的处理程序时,(1)这些信号是排队的,(2)排了队的信号是以FIFO顺序递交的,(3)给信号处理程序传递两个额外的参数。

最后,我们使用内存映射I/O以及一个Posix互斥锁和一个Posix条件变量,以约500行C代码实现了Posix消息队列的大多数特性。该实现展示了处理新队列的创建中存在的一个竞争状态,我们在第10章中实现Posix信号量时将遇到同样的竞争状态。

习题

5.1 在介绍图5-5时我们说过,当创建新队列时,如果mq_open的attr参数非空,那么mq_maxmsg和mq_msgsize两个成员都必须指定。怎么做才能允许我们只指定其中的一个成员,而未指定的那个成员则采用系统的默认值?

5.2 修改图5-9中的程序,使得所讨论的信号在递交之时并不调用mq_notify。然后往相应的队列发送两个消息,验证对应第二个消息的信号没有产生。为什么?

5.3 修改图5-9中的程序,使得所讨论的信号在递交之时并不从相应队列中读出消息。相反,处理程序只是调用mq_notify并输出接收到的信号。然后往该队列发送两个消息,验证对应第二个消息的信号没有产生。为什么?

5.4 在图5-17的第一个printf中,如果我们把那两个常值向整数的类型强制转换去掉,那么会发生什么情况?

5.5 如下修改图5-5中的程序:在调用mq_open之前,输出一个消息并sleep 30秒。在mq_open返回之后,输出另一个消息,sleep 30秒,然后调用mq_close。编译并运行该程序,指定一个较大的消息数(几十万个),最大消息大小则(譬如说)为10字节。其目的是创建一个大消息队列(数百万字节大小),然后查看消息队列的实现是否使用内存映射文件。在第一个30秒停顿期间,运行一个诸如ps这样的程序,看一看修改后程序的内存大小。mq_open返回后再做一遍。你能解释所发生的情况吗?

5.6 当mq_send的调用者指定一个0长度时,图5-30中的memcpy调用会发生什么?

5.7 此较消息队列和4.4节讲述的全双工管道。父子进程之间的双向通信需多少个消息队列?

5.8 图5-24中我们为什么不摧毁互斥锁和条件变量?

5.9 Posix说消息队列描述符不能是数组类型。为什么?

5.10 图5-14中的main函数在哪儿花费大部分时间?每次递交一个信号后发生什么?我们如何处理这种情形?

5.11 不是所有实现都支持互斥锁和条件变量的PTHREAD_PROCESS_SHARED属性。重新编写5.8节中Posix消息队列的实现,使用Posix信号量(第10章)代替互斥锁和条件变量。

5.12 把5.8节中Posix消息队列的实现扩展成支持SIGEV_THREAD。

第6章 System V消息队列

6.1 概述

System V消息队列使用消息队列标识符(message queue identifier)标识。具有足够特权(3.5节)的任何进程都可以往一个给定队列放置一个消息,具有足够特权的任何进程都可以从一个给定队列读出一个消息。跟Posix消息队列一样,在某个进程往一个队列中写入一个消息之前,不求另外某个进程正在等待该队列上一个消息的到达。

对于系统中的每个消息队列,内核维护一个定义在<sys/msg.h>头文件中的信息结构。

struct msqid_ds {

struct ipc_perm  msg_perm;  /* read_write perms: Section 3.3 */

struct msg    *msg_first;  /* ptr to first message on queue */

struct msg    *msg_last;  /* ptr to last message on queue */

msglen_t      msg_cbytes; /* current # bytes on queue */

msgqnum_t     msg_qnum;  /* current # of messages on queue */

msglen_t      msg_qbytes; /* max # of bytes allowed on queue */

pid_t        msg_lspid;  /* pid of last msgsnd()*/

pid_t        msg_lrpid;  /* pid of last msgrcv()*/

time_t       msg_stime;  /* time of last msgsnd()*/

time_t       msg_rtime;  /* time of last msgrcv()*/

time_t       msg_ctime;  /* time of last msgctl()

(that changed the above)*/

};

Unix 98不要求有msg_first、msg_last和msg_cbytes成员。然而普通的源自System V的实现中可找到这三个成员。很自然,将一个队列中的各个消息作为一个链表来维护的要求并不存在,但这却是msg_first和msg_last这两个成员所隐含的。就算提供了这两个指针,那么它们指向的是内核内存空间,对于应用来说基本上没有作用。

我们可以将内核中某个特定的消息队列画为一个消息链表,如图6-1所示。假设有一个具有三个消息的队列,消息长度分别为1字节、2字节和3字节,而且这些消息就是以这样的顺序写入该队列的。再假设这三个消息的类型(type)分别为100、200和300。

图6-1 内核中的System V消息队列结构

我们将在本章中查看操纵System V消息队列的函数,并使用消息队列实现4.2节中的文件服务器例子。

6.2 msgget函数

msgget函数用于创建一个新的消息队列或访问一个已存在的消息队列。

#include <sys/msg.h>

int msgget(key_t key,int oflag);

返回:若成功则为非负标识符,若出错则为-1

返回值是一个整数标识符,其他三个msg函数就用它来指代该队列。它是基于指定的key产生的,而key既可以是ftok的返回值,也可以是常值IPC_PRIVATE,如图3-3所示。

oflag是图3-6中所示的读写权限值的组合。它还可以与IPC_CREAT或IPC_CREAT|IPC_EXCL按位或,如随图3-4的讨论所述。

当创建一个新消息队列时,msqid_ds结构的如下成员被初始化。

msg_perm结构的uid和cuid成员被设置成当前进程的有效用户ID,gid和cgid成员被设置成当前进程的有效组ID。

oflag中的读写权限位存放在msg_perm.mode中。

msg_qnum、msg_lspid、msg_lrpid、msg_stime和msg_rtime被置为0。

msg_ctime被设置成当前时间。

msg_qbytes被设置成系统限制值。

6.3 msgsnd函数

使用msgget打开一个消息队列后,我们使用msgsnd往其上放置一个消息。

#include <sys/msg.h>

int msgsnd(int msqid,const void *ptr,size_t length,int flag);

返回:若成功则为0,若出错则为-1

其中msqid是由msgget返回的标识符。ptr是一个结构指针,该结构具有如下的模板,它定义在<sys/msg.h>中。

struct msgbuf {

long mtype;    /* message type,must be > 0 */

char mtext[1];  /* message data */

};

消息类型必须大于0,因为对于msgrcv函数来说,非正的消息类型用作特殊的指示器,我们将在下一节讲述。

msgbuf结构定义中的名字mtext不大确切,消息的数据部分并不局限于文本。任何形式的数据都是允许的,无论是二进制数据还是文本。内核根本不解释消息数据的内容。

我们使用“模板”的说法描述这个结构,因为ptr所指向的只是一个含有消息类型的长整数,消息本身则紧跟在它之后(如果消息长度大于0字节)。不过大多数应用并不使用ms_gbuf结构的这个定义,因为其数据量(1个字节)通常是不够的。一个消息中的数据量并不存在编译时限制(其系统限制则通常可由系统管理员修改),因此可不去声明一个数据量很大(比一个给定应用可能支持的数据还要大)的结构,而去定义一个上述的模板。大多数应用然后定义自己的消息结构,其数据部分根据应用的需要定义。

举例来说,如果某个应用需要交换由一个16位整数后跟一个8字节字符数组构成的消息,那它可以定义自己的结构如下:

#define MY_DATA 8

typedef struct my_msgbuf {

long   mtype;    /* message type */

int16_t mshort;   /* start of message data */

char   mchar[MY_DATA];

} Message;

msgsnd的length参数以字节为单位指定待发送消息的长度。这是位于长整数消息类型之后的用户自定义数据的长度。该长度可以是0。在刚刚给出的例子中,长度可以传递成sizeof (Message)- sizeof(long)。

flag参数既可以是0,也可以是IPC_NOWAIT。IPC_NOWAIT标志使得msgsnd调用非阻塞(nonblocking):如果没有存放新消息的可用空间,该函数就马上返回。这个条件可能发生的情形包括:

在指定的队列中已有太多的字节(对应该队列的msqid_ds结构中的msg_qbytes值);

在系统范围存在太多的消息。

如果这两个条件中有一个存在,而且IPC_NOWAIT标志已指定,msgsnd就返回一个EAGAIN错误。如果这两个条件中有一个存在,但是IPC_NOWAIT标志未指定,那么调用线程被投入睡眠,直到:

具备存放新消息的空间;

由msqid标识的消息队列从系统中删除(这种情况下返回一个EIDRM错误);

调用线程被某个捕获的信号所中断(这种情况下返回一个EINTR错误)。

6.4 msgrcv函数

使用msgrcv函数从某个消息队列中读出一个消息。

#include <sys/msg.h>

ssize_t msgrcv(int msqid,void *ptr,size_t length,long type,int flag);

返回:若成功则为读入缓冲区中数据的字节数,若出错则为-1

其中ptr参数指定所接收消息的存放位置。跟msgsnd一样,该指针指向紧挨在真正的消息数据之前返回的长整数类型字段(图4-26)。

length指定了由ptr指向的缓冲区中数据部分的大小。这是该函数能返回的最大数据量。该长度不包括长整数类型字段。

type指定希望从所给定的队列中读出什么样的消息。

如果type为0,那就返回该队列中的第一个消息。既然每个消息队列都是作为一个FIFO(先进先出)链表维护的,因此type为0指定返回该队列中最早的消息。

如果type大于0,那就返回其类型值为type的第一个消息。

如果type小于0,那就返回其类型值小于或等于type参数的绝对值的消息中类型值最小的第一个消息。

考虑图6-1中所示的消息队列例子,它含有三个消息:

第一个消息的类型为100,长度为1;

第二个消息的类型为200,长度为2;

最后一个消息的类型为300,长度为3。

图6-2展示了为不同的type值返回的消息的类型。

图6-2 由msgrcv给不同的type值返回的消息

msgrcv的flag参数指定所请求类型的消息不在所指定的队列中时该做何处理。在没有消息可得的情况下,如果设置了flag中的IPC_NOWAIT位,msgrcv函数就立即返回一个ENOMSG错误。否则,调用者被阻塞到下列某个事件发生为止:

(1)有一个所请求类型的消息可获取;

(2)由msqid标识的消息队列被从系统中删除(这种情况下返回一个EIDRM错误);

(3)调用线程被某个捕获的信号所中断(这种情况下返回一个EINTR错误)。

flag参数中另有一位可以指定:MSG_NOERROR。当所接收消息的真正数据部分大于length参数时,如果设置了该位,msgrcv函数就只是截短数据部分,而不返回错误。否则,ms_grcv返回一个E2BIG错误。

成功返回时,msgrcv返回的是所接收消息中数据的字节数。它不包括也通过ptr参数返回的长整数消息类型所需的几个字节。

6.5 msgctl函数

msgctl函数提供在一个消息队列上的各种控制操作。

#include <sys/msg.h>

int msgctl(int msqid,int cmd,struct msqid_ds *buff);

返回:若成功则为0,若出错则为-1

msgctl函数提供3个命令。

IPC_RMID 从系统中删除由msqid指定的消息队列。当前在该队列上的任何消息都被丢弃。我们已在图3-7中看到过这种操作的例子。对于该命令而言,msgctl函数的第三个参数被忽略。

IPC_SET 给所指定的消息队列设置其msqid_ds结构的以下4个成员:msg_perm.uid、msg_perm.gid、msg_perm.mode和msg_qbytes。它们的值来自由buff参数指向的结构中的相应成员。

IPC_STAT (通过buff参数)给调用者返回与所指定消息队列对应的当前msqid_ds结构。

例子

图6-3中的程序创建一个消息队列,往该队列中放置一个含有1字节数据的消息,发出msgctl的IPC_STAT命令,使用system函数执行ipcs命令,最后使用msgctl的IPC_RMID命令删除该队列。

图6-3 使用msgctl命令的例子

我们把1字节长度的消息写入所创建的队列中,这里只要使用定义在<sys/msg.h>中的标准msgbuf结构就行了。

执行该程序的结果如下:

solaris % ctl

read-write: 664,cbytes = 1,qnum = 1,qbytes = 4096

IPC status from <running system> as of Mon Oct 20 15:36:40 1997

T      ID    KEY     MODE     OWNER   GROUP

Message Queues:

q    1150  00000000 --rw-rw-r-- rstevens  other1

这与预期的一致。如3.2节所提,0是IPC_PRIVATE键的共同键值。执行本例子的系统上每个消息队列有4096字节的限制。既然我们写了一个1字节数据的消息,而且msg_cbytes的值为1,那么该限制显然只适用于消息的数据部分,而不包括与每个消息关联的长整数消息类型。

6.6 简单的程序

既然System V消息队列是随内核持续的,我们就可以编写一组小程序来操纵它们,以便观察效果。

6.6.1 msgcreate程序

图6-4给出了我们的msgcreate程序,它创建一个消息队列。

图6-4 创建一个System V消息队列

9~12 我们允许使用-e命令行选项来指定IPC_EXCL标志。

16 把必须由用户作为命令行参数提供的路径名作为参数传递给ftok。导出的键由msgget转换成一个标识符(见习题6.1)。

6.6.2 msgsnd程序

图6-5给出了我们的msgsnd程序,它把一个指定了长度和类型的消息放置到某个队列中。

图6-5 往一个System V消息队列中加一个消息

我们先分配一个指向通用msgbuf结构的指针,然后根据待发送消息的大小调用calloc分配真正的结构(例如输出缓冲区)。calloc函数还把所分配的缓冲区初始化为0。

6.6.3 msgrcv程序

图6-6给出了我们的msgrcv程序,它从一个队列中读出一个消息。-n命令行选项指定非阻塞,-t命令行选项指定msgrcv函数的type参数。

图6-6 从一个System V消息队列中读出一个消息

2 没有简单的方法用以确定一个消息的最大大小(我们将在6.10节讨论这个限制及其他限制),因此我们给它定义了自己的常值。

6.6.4 msgrmid程序

要删除一个消息队列,我们以IPC_RMID命令调用msgctl,如图6-7所示。

图6-7 删除一个System V消息队列

图6-7(续)

6.6.5 例子

现在使用刚刚给出的四个程序。首先创建一个消息队列并往其中写入三个消息。

solaris % msgcreate /tmp/no/such/file

ftok error for pathname "/tmp/no/such/file" and id 0: No such file or directory

solaris % touch /tmp/test1

solaris % msgcreate /tmp/test1

solaris % msgsnd /tmp/test1 1 100

solaris % msgsnd /tmp/test1 2 200

solaris % msgsnd /tmp/test1 3 300

solaris % ipcs -qo

IPC status from <running system> as of Sat Jan 10 11:25:45 1998

T      ID    KEY     MODE     OWNER   GROUP CBYTES QNUM

Message Quenes:

q     100  0x0000113e --rw-r--r-- rstevens  other1    6   3

我们首先使用一个不存在的路径名尝试创建一个消息队列。这个示例表明ftok的路径名参数必须存在。我们随后创建文件/tmp/test1,并使用该路径名创建一个消息队列。往该队列中放置三个消息:长度分别为1字节、2字节和3字节,类型分别为100、200和300(回想图6-1)。ipcs程序指出这三个消息总共构成该队列中的6个字节。

接着展示在不以FIFO顺序读出消息时msgrcv的type参数的使用。

solaris % msgrcv -t 200 /tmp/test1

read 2 bytes,type = 200

solaris % msgrcv -t -300 /tmp/test1

read 1 bytes,type = 100

solaris % msgrcv /tmp/test1

read 3 bytes,type = 300

solaris % msgrcv -n /tmp/test1

msgrcv error: No message of desired type

其中第一个例子请求读出类型为200的消息,第二个例子请求读出类型小于或等于300且是最小的消息,第三个例子请求读出该队列中的第一个消息。最后一次执行msgrcv程序用上了IPC_NOWAIT标志。

如果我们给msgrcv指定一个正的type参数,但是队列中不存在具有该类型的消息,那会发生什么?

solaris % ipcs -qo

IPC status from <running system> as of Sat Jan 10 11:37:01 1998

T      ID    KEY     MODE     OWNER   GROUP CBYTES QNUM

Message Quenes:

q     100  0x0000113e --rw-r--r-- rstevens  other1    0   0

solaris % msgsnd /tmp/test1 1 100

solaris % msgrcv -t 999 /tmp/test1

^?                       键入中断键终止程序执行

solaris % msgrcv -n -t 999 /tmp/test1

msgrcv error: No message of desired type

solaris % grep desired /usr/include/sys/errno.h

#define ENOMSG 35   /* No message of desired type */

solaris % msgrmid /tmp/test1

我们首先执行ipcs验证刚才的队列是空的,然后往其中放置一个长度为1字节、类型为100的消息。当请求读出一个类型为999的消息时,msgrcv程序阻塞(阻塞在msgrcv调用中),等待某个该类型的消息被放置到该队列中。我们用中断键终止该程序以中断其中的阻塞。接着在指定-n标志以防止阻塞的前提下重新执行msgrcv程序,结果看到返回ENOMSG错误。然后用我们的msgrmid程序从系统中删除该队列。我们也可以使用由系统提供的命令删除该队列。下面的命令指定的是消息队列标识符:

solaris % ipcrm -q 100

下面的命令指定的是消息队列键:

solaris % ipcrm -Q 0x113e

6.6.6 msgrcvid程序

要访问一个System V消息队列,调用msgget并不是必须的:我们只需知道该消息队列的标识符(使用ipcs极易得到),并拥有该队列的读权限。图6-8是图6-6中msgrcv程序的简化版本。

图6-8 只知道标识符时从一个System V消息队列中读

我们没有调用msgget,而是由调用者在命令行上指定消息队列标识符。下面是使用这种技巧的一个例子。

solaris % touch /tmp/testid

solaris % msgcreate /tmp/testid

solaris % msgsnd /tmp/testid 4 400

solaris % ipcs -qo

IPC status from <running system> as of Wed Mar 25 09:48:28 1998

T      ID    KEY     MODE     OWNER   GROUP CBYTES QNUM Message Queues:

q     150  0x0000118a --rw-r--r-- rstevens  other1    4   1

solaris % msgrcvid 150

read 4 bytes,type = 400

我们从ipcs的输出获得标识符为150,它就是我们的msgrcvid程序的命令行参数。

同样的特性也适用于System V信号量(习题11.1)和System V共享内存区(习题14.1)。

6.7 客户—服务器例子

现在我们把4.2节中的客户-服务器例子编写成使用两个消息队列。一个队列用于从客户到服务器的消息,另一个队列用于从服务器到客户的消息。

图6-9给出了我们svmsg.h头文件。其中包括了我们的标准头文件(unpipc.h),并定义了两个消息队列的键。

图6-9 使用消息队列的客户-服务器程序的头文件

图6-10给出了服务器程序的main函数。创建两个方向的消息队列,不过任何一个已经存在也没有关系,因为我们没有指定IPC_EXCL标志。server函数是图4-30中所示的版本,它调用的mesg_send和mesg_recv函数我们稍后给出。

图6-10 使用消息队列的服务器程序main函数

图6-11给出了客户程序的main函数。我们打开两个方向的消息队列,随后调用图4-29中的client函数。该函数调用我们接下去给出的mesg_send和mesg_recv函数。

client和server函数都使用图4-25中所示的消息格式。这两个函数还调用我们的mesg_send和mesg_recv函数。图4-27和图4-28给出的这两个函数的版本调用了write和read,这对于管道和FIFO是有用的,但对于消息队列则需要重新编写它们。图6-12和图6-13给出了它们的新版本。注意,这两个函数的前后两个版本需传递的参数不变,因为第一个整数参数既可以含有一个整数描述符(用于访问管道或FIFO),也可以含有一个整数消息队列标识符。

图6-11 使用消息队列的客户程序main函数

图6-12 用于消息队列的mesg_send函数

图6-13 用于消息队列的mesg_recv函数

6.8 复用消息

与一个队列中的每个消息相关联的类型字段提供了两个特性。

(1)类型字段可用于标识消息,从而允许多个进程在单个队列上复用(multiplex)消息。举例来说,类型字段的某个值用于标识从各个客户到服务器的消息,对于每个客户均为唯一的另外某个值用于标识从服务器到各个客户的消息。每个客户的进程ID自然可用作对于每个客户均为唯一的类型字段。

(2)类型字段可用作优先级字段。这允许接收者以不同于先进先出(FIFO)的某个顺序读出各个消息。使用管道或FIFO时,数据必须以写入的顺序读出。使用System V消息队列时,消息能够以任意顺序读出,只要跟与消息类型关联的值一致就行。而且我们可以指定IPC_NOWAIT标志调用msgrcv从某个队列中读出某个给定类型的任意消息,但是如果没有给定类型的消息存在,那就立即返回。

6.8.1 例子:每个应用一个队列

回想我们那个由一个服务器进程和单个客户进程构成的简单应用例子。使用管道和FIFO时,为在两个方向上交换数据需两个IPC通道,因为这两种类型的IPC是单向的。使用消息队列时,单个队列就够用,由每个消息的类型来标识该消息是从客户到服务器,还是从服务器到客户。

考虑更为复杂的情况:一个服务器带多个客户。这儿我们可以使用譬如说值为1的类型来指示从任意客户到服务器的消息。如果该客户将自己的进程ID作为消息的一部分传递,服务器就能把自己的消息发送给该客户,办法是把该客户的进程ID用作消息类型。每个客户然后把自己的进程ID指定为msgrcv的type参数。图6-14展示了单个消息队列是如何用于在多个客户和单个服务器之间复用消息的。

图6-14 在多个客户和单个服务器之间复用消息

当单个IPC通道同时由多个客户和单个服务器使用时,总是存在死锁的隐患。客户们可以填满消息队列(在本例子中),妨碍服务器发送应答。于是这些客户阻塞在msgsnd中,服务器也这样。可检测这种死锁的办法之一是,约定服务器对消息队列总是使用非阻塞写。

现在改用单个消息队列重新编写我们的客户-服务器例子程序,给每个方向的消息使用不同的消息类型。这些程序使用这样的约定:类型为1的消息是从客户到服务器的,所有其他消息有一个等于其客户进程ID的类型。该客户-服务器应用要求每个客户请求含有对应客户的进程ID以及所请求的路径名,这与我们在4.8节的做法类似。

图6-15给出了服务器程序的main函数。其中svmsg.h头文件在图6-9中给出。服务器只创建一个消息队列,如果它已经存在,那也没有关系。给server函数的两个参数所用的是同一个消息队列标识符。

图6-15 服务器程序main函数

server函数完成所有的服务器处理,在图6-16中给出。该函数是图4-23和图4-30的组合,其中图4-23是读出由一个进程ID和一个路径名构成的命令的FIFO服务器程序,图4-30则使用了我们的mesg_send和mesg_recv函数。注意,由某个客户发送的进程ID用作由服务器发送给该客户的所有消息的消息类型。这个server函数还是一个调用一次后永不返回的无限循环,每次循环读出一个客户请求并发回应答。这是个迭代服务器,如4.9节中讨论的那样。

图6-16 server函数

图6-16(续)

图6-17给出了客户程序的main函数。客户打开唯一的消息队列,它必须已由服务器创建。

图6-17 客户程序main函数

图6-18所示的client函数为我们的客户完成所有处理。该函数是图4-24和图4-29的组合,其中图4-24发送一个进程ID后跟一个路径名,图4-29使用了我们的mesg_send和mesg_recv函数。注意从mesg_recv请求的消息类型等于当前客户的进程ID。

图6-18 client函数

图6-18(续)

我们的client和server函数都使用图6-12和图6-13中的mesg_send和mesg_recv函数。

6.8.2 例子:每个客户一个队列

现在把前面的例子改成给去往服务器的所有客户请求使用一个队列,给每个客户使用一个队列接收去往各个客户的服务器应答。图6-19展示了这样的设计。

图6-19 每个服务器一个队列,每个客户一个队列

服务器的队列有一个对客户来说众所周知的键,但是各个客户以IPC_PRIVATE键创建各自的队列。这里并未随请求传递本进程ID,而是由每个客户把自己的私用队列的标识符传递给服务器,服务器把自己的应答发送到由客户指出的队列中。我们还以并发服务器模型编写这个服务器程序,给每个客户fork一次。

这种设计的潜在问题之一发生在某个客户中途死亡时,这种情况下它的私用队列中可能永远残留消息(或者至少到内核重新自举或某个用户显式地删除该队列为止)。.

下列头文件和函数跟以前的版本一样:

mesg.h头文件(图4-25);

svmsg.h头文件(图6-9);

服务器程序main函数(图6-15);

mesg_send函数(图4-27)。

图6-20给出了我们的客户程序main函数,它与图6-17的差别很小。它打开服务器的众所周知队列(MQ_KEY1),然后用IPC_PRIVATE键创建自己的队列。这两个队列标识符成为client函数(图6-21)的参数。当客户运行完时,它的私用队列被删除。

图6-20 客户程序main函数

图6-21 client函数

图6-21(续)

图6-21是client函数。该函数与图6-18几乎相同,但是作为请求的一部分传递的是本客户的私用队列标识符,而不是本客户的进程ID。mesg结构中的消息类型仍保留为1,因为它是两个方向的消息都使用的类型。

图6-23是server函数。与图6-16相比的主要变化是将这个函数编写为一个无限循环,每次循环给一个客户请求调用fork。

给SIGCHLD建立信号处理程序

10 既然是在给每个客户派生一个子进程,我们必须顾虑僵尸进程。UNPv1的5.9节和5.10节详细讨论了这一点。这里我们给SIGCHLD信号建立一个信号处理程序,这样当某个子进程终止时,我们的sig_chld(图6-22)就被调用。

12~18 服务器父进程阻塞在mesg_recv调用中,等待下一个客户消息的到达。

25~45 调用fork派生一个子进程,由子进程尝试打开所请求的文件,发回一个出错消息或该文件的内容。我们特意把fopen调用放在子进程而不是父进程中,以防该文件在某个远程文件系统上,因为要是这样,如果发生任何网络问题,文件的打开就可能花一段时间。

图6-22给出了SIGCHLD信号的处理程序。它复制自UNPv1的图5-11。

图6-22 调用waitpid的SIGCHLD信号处理程序

每次调用我们的信号处理程序时,它就在一个循环中调用waitpid,以取得已终止的任何子进程的终止状态。该信号处理程序随后返回。这可能造成一个问题,因为父进程会把大部分时间花在阻塞在mesg_recv函数(图6-13)的msgrcv调用中。当我们的信号处理程序返回时,这个msgrcv调用就被中断。也就是说该函数将返回一个EINTR错误,如UNPv1的5.9节所述。

我们必须处理这个被中断的系统调用,图6-24给出Mesg_recv包裹函数的新版本。它允许有来自mesg_recv的EINTR错误(mesg_recv只是调用msgrcv),如果发生该错误,那就再次调用mesg_recv。

图6-23 server函数

图6-24 处理被中断系统调用的Mesg_recv包裹函数

6.9 消息队列上使用select和poll

System V消息队列的问题之一是它们由各自的标识符而不是描述符标识。这意味着我们不能在消息队列上直接使用select或poll(UNPv1第6章)。

实际上有一个版本的Unix(即IBM的AIX)把select扩展成能够在描述符之外处理System V消息队列。不过这是不可移植的,只适用于AIX。

当有人想编写一个同时处理网络连接和IPC连接的服务器程序时,这种缺失的特性往往暴露出来。使用套接字API或XTI API(UNPv1)的网络通信使用的是描述符,因而允许使用select或poll。管道和FIFO也适合这两个函数,因为它们也是由描述符标识的。

解决该问题的办法之一是:让服务器创建一个管道,然后派生一个子进程,由子进程阻塞在msgrcv调用中。当有一个消息准备好被处理时,msgrcv返回,子进程接着从所指定的队列中读出该消息,并把该消息写入管道。服务器父进程当时可能在该管道以及一些网络连接上select。这种办法的负面效果是消息被处理了三次:一次是在子进程使用msgrcv读出时,一次是在子进程写入管道时,最后一次是在父进程从该管道中读出时。为避免这样的额外处理,父进程可以创建一个在它自身和子进程之间分享的共享内存区,然后把管道用作父子进程间的一种标志(习题12.5)。

在图5-14中我们给出了一种不需要fork就在Posix消息队列上间接使用select或poll的办法。在Posix消息队列上可以只使用单个进程的原因是它们提供了通知能力,当某个空队列上有一个消息到达时,这种通知能力将产生一个信号。System V消息队列不提供这种能力,因此必须fork一个子进程,由该子进程阻塞在msgrcv调用中。

与网络编程相比,System V消息队列另一个缺失的特性是无法窥探一个消息,而这是recv、recvform和recvmsg函数的MSG_PEEK标志提供的能力(UNPv1第356页 [10] )。要是提供了这种能力,刚刚描述的(用于绕过select问题的)父子进程情形就可以做得更为有效,办法是让子进程指定msgrcv的窥探标志,当有一个消息准备好时就写1个字节到管道中,以通知父进程读出该消息。

6.10 消息队列限制

正如3.8节中所指出的那样,消息队列上往往存在某些系统限制。图6-25给出了两种实现上的这些值。第一栏是内容为所说明限制值的内核变量的传统SystemV名字。

图6-25 System V消息队列的典型系统限制

许多源自SVR4的实现还有从它们的初始实现继承来的额外限制:msgssz和msgseg。msgssz往往是8,这是存放消息数据的“节(segment)”的大小(单位为字节)。一个21字节数据的消息将存放在3个这样的节中,其中最后一节的后3个字节未用上。msgseg是分配了的节数,往往是1 024。因历史原因,它一直存放在某个短整数中,因而必须小于32 768。所有消息数据的总共可用字节数是这两个变量的乘积,通常是8×1 024=8 192字节。

本节的目的是输出一些典型值,以辅助在代码移植上所作的计划。当一个系统运行大量使用消息队列的应用时,这些参数(或类似参数)的内核微调通常是必需的(关于内核参数微调已在3.8节中讲述过)。

例子

图6-26给出了确定图6-25中所示四个限制值的程序。

图6-26 确定System V消息队列上的系统限制

图6-26(续)

确定最大消息大小

14~28 为确定最大消息大小,我们尝试发送一个含有65536字节数据的消息,如果失败了就尝试含有65408字节数据的消息,如此等等,直到msgsnd调用成功为止。

确定一个队列中可放置多少不同大小消息

29~44 从8字节消息开始查看一个给定队列上能放置多少消息。一旦确定该限制值,我们就删除其队列(丢弃其中的所有消息),并以16字节消息再次尝试。这样一直尝试到超过第一个步骤中确定的最大消息大小为止。我们预期较小的消息将在遇到每队列总消息数的限制,较大的消息将遇到每队列总字节数的限制。

确定同时可打开多少标识符

45~54 任何时刻能打开着的消息队列标识符最大数目通常存在一个系统限制。我们通过一直创建队列直到msgget失败来确定该限制。

我们先在Solaris 2.6下运行该程序,接着在Digital Unix 4.0B下运行,结果符合图6-25中所示的值。

solaris % limits

maximum amount of data per message = 2048

40 8-byte messages were placed onto queue,320 bytes total

40 16-byte messages were placed onto queue,640 bytes total

40 32-byte messages were placed onto queue,1280 bytes total

40 64-byte messages were placed onto queue,2560 bytes total

32 128-byte messages were placed onto queue,4096 bytes total

16 256-byte messages were placed onto queue,4096 bytes total

8 512-byte messages were placed onto queue,4096 bytes total

4 1024-byte messages were placed onto queue,4096 bytes total

2 2048-byte messages were placed onto queue,4096 bytes total

50 identifiers open at once

alpha % limits

maximum amount of data per message = 8192

40 8-byte messages were placed onto queue,320 bytes total

40 16-byte messages were placed onto queue,640 bytes total

40 32-byte messages were placed onto queue,1280 bytes total

40 64-byte messages were placed onto queue,2560 bytes total

40 128-byte messages were placed onto queue,5120 bytes total

40 256-byte messages were placed onto queue,10240 bytes total

32 512-byte messages were placed onto queue,16384 bytes total

16 1024-byte messages were placed onto queue,16384 bytes total

8 2048-byte messages were placed onto queue,16384 bytes total

4 4096-byte messages were placed onto queue,16384 bytes total

2 8192-byte messages were placed onto queue,16384 bytes total

63 identifiers open at once

Digital Unix下输出63个标识符的限制,而不是图6-25所示的64个,其原因在于已有一个系统守护进程在使用一个标识符。

6.11 小结

System V消息队列与Posix消息队列类似。新的应用程序应考虑使用Posix消息队列,不过大量的现有代码使用System V消息队列。然而把一个应用程序从使用System V消息队列重新编写成使用Posix消息队列不是件难事。Posix消息队列缺失的主要特性是从队列中读出指定优先级的消息的能力。这两种消息队列都不使用真正的描述符,从而造成在消息队列上使用select或poll的困难。

习题

6.1 修改图6-4中的程序以接受IPC_PRIVATE这样的路径名参数,若指定了这样的参数,就以一个私用键创建一个消息队列。这么一来,6.6节中的其余程序必须如何变动?

6.2 在图6-14中,我们为什么为发送给服务器的消息使用1这个类型?

6.3 在图6-14中,要是一个恶意的客户向服务器发送了许多消息,但它从来不去读服务器的应答,那么会发生什么?图6-19对于这种类型的客户有什么变动?

6.4 重新编写5.8节中Posix消息队列的实现,改用System V消息队列代替内存映射I/O。


[1]. 此处为UNPv1第2版英文原版书页码,第3版英文原版书为第774~775页。——编者注

[2]. 此处为APUE第1版英文原版书节号,第2版为15.3节。——编者注

[3]. 由原因推出结果。——编者注

[4]. 一个消息队列的名字在系统中的存在本身也占用其引用计数器的一个引用数。mq_unlink从系统中删除该名字意味着同时将其引用计数减1,若变为0则真正拆除该队列。——译者注

[5]. 跟mq_unlink一样,mq_close也将当前消息队列的引用计数减1,若变为0则附带拆除该队列。——译者注

[6]. 此处为APUE第1版英文原版书节号,第2版的17.4.1节讲述通过管道传递文件描述符。——编者注

[7]. 在由Posix.1定义的所有结构中(aiocb、group、itimerspec、lock、mq_attr、passwd、sched_param、sigaction、sigevent、siginfo_t、sival、stat、termio、timespec、utimbuf),只有siginfo_t是使用typedef定义的。Posix.1给它的所有简单系统数据类型(pid_t、pthread_t等)的名字添上_t后缀,但它们没有一个必须是结构, Posix.1也没有给这些数据类型的任何一个定义结构成员名。因此把一个必须是结构的数据类型定义为加_t后缀的siginfo_t导致作者认为是一个错误。这个古怪现象的原因也许是因为siginfo_t是由SVR4定义的,后来Posix一直忽略它,直到发展到P1003.4实时扩展为止。事实上1003.1b-1993第354页谈到该名字破坏了约定,所列的原因是为了遵循已有的实践,从而提高可移植性。

[8]. 无论何时处理不止一种实时信号,我们都必须给每种实时信号的信号处理函数指定一个sa_mask值,该掩码应该阻塞所有剩余的值较大的(即优先级较低的)实时信号。Posix规则保证当有多种实时信号待处理时,值最小的信号最先递交,然而保证较低优先级的实时信号不中断当前信号处理函数却是我们的责任。我们通过给信号处理函数指定一个sa_mask值来做到这一点。

[9]. 既然本例子中所有三种实时信号都从信号处理函数中相互阻塞了,也就是说在执行某个信号的处理函数期间,其他信号不会递交,因而不会中断当前信号处理函数的执行,这种情况下把printf作为一个简单的诊断工具来调用也许可行。然而输出各个信号递交顺序的更好技巧之一是按下述修改图5-17。首先分配两个全局变量:

[10]. 此处为UNPv1第2版英文原版书页码,第3版为第388页。——编者注