引言
UNIX 内核管理的进程自主地操作,从而产生更稳定的系统。然而,每个开发人员最终都会遇到这样的情况,即其中一组进程需要与另一组进程通信,也许是为了交换数据或发送命令。这种通信称为进程间通信(Inter-Process Communication,IPC)。System V (SysV) UNIX 规范描述了以下三种 IPC 机制,它们统称为 SysV IPC:
- 消息队列
- 信号量
- 共享内存
此外,进程还可以通过其他机制通信,例如:
- 读、写和锁定文件
- 信号
- 套接字
- 管道
- FIFO(先进先出)
这后一组机制一般也称为 IPC。由于其简单性和有效性,本文将集中于 SysV IPC 方法。
了解 SysV 模型
三种 SysV IPC 方法具有类似的语法,尽管它们具有不同的用途。一般情况下,它们执行以下操作:
- 确定要用于
ftok(3)
的正确 IPC 密钥。 - 分别使用
msgget(2)
、semget(2)
或shmget(2)
获得用于消息队列、信号量或共享内存的特定于 IPC 的标识符,这些标识符与 IPC 密钥相关联。 - 使用
msgctl(2)
、semctl(2)
或shmctl(2)
修改 IPC 实例的属性。 - 利用特定的 IPC 实例。
- 最后,使用
msgctl(2)
、semctl(2)
或shmctl(2)
和IPC_RMID
标志销毁 IPC 实例。
每个 IPC 实例都被赋予一个标识符,以将它与系统上存在的其他 IPC 实例区分开来。例如,两个不同的应用程序可能分别决定使用共享内存段,因此系统范围的 IPC ID 将区分这两个实例。虽然可能不是那么明显,但是第一个挑战就是弄清如何分发这样的信息:即如何在没有准备某种 IPC 机制的情况下附加到一个公共 IPC 实例。
ftok
库调用使用某个给定文件中的索引节点信息和一个唯一标识符来得出一个密钥,只要该文件存在并且该标识符为常量,此密钥就保持相同。因此,两个进程可以使用它们的配置文件和编译时常量来得出相同的 IPC 密钥。常量的存在允许同一个应用程序通过改变常量来创建 IPC 机制的多个实例。
在一组进程独立得出各自的 IPC 密钥之后,它们必须使用某个 get
系统调用来获得与该特定 IPC 实例关联的特定标识符。各个 get
调用全都需要 IPC 密钥和一组标志,以及一些信号量和共享内存大小信息。由于 UNIX 是多用户系统,标志将包括熟悉的八进制形式的文件权限(因此 666 意味着任何人都可以执行读和写)。如果还设置了 IPC_CREAT
标志,则会在 IPC 实例不存在时创建该实例。如果没有设置 IPC_CREAT
标志并且还未创建 IPC 实例,则 get
调用将返回错误。
对于能够自己分发 IPC 实例标识符的应用程序,存在一种用于执行该任务的更简单方法。如果您在调用 get
以创建 IPC 时使用密钥IPC_PRIVATE
,则实例标识符将是唯一的。需要附加到该 IPC 的其他进程不需要调用 get
,因为它们已经拥有该标识符。
一旦拥有了标识符,应用程序就可以任意使用 IPC 实例。每种 IPC 方法都是不同的,并在它们各自的部分中进行处理。
通过队列传递消息
消息队列提供了一种机制,使得一个进程可以发送另一个进程能够获得的消息。在获得该消息之后,将从队列中删除该消息。消息队列非常独特,因为两个进程不必同时存在——一个进程可以发送一个消息并退出,而该消息可以在数天后才被另一个进程获得。
消息必须由一个长整数后面跟着消息数据组成。清单 1 显示了 C 语言中的这样一个结构,其中使用了一个 100 字节的消息。
清单 1. 示例消息的 C 语言定义
struct mq_message { long type; /* The type or destination */ char text[100]; /* Data */ };
消息接收者使用消息类型。当从队列轮询消息时,您可以选择第一个可用的消息,或者可以查找某种特定的消息类型。将要使用的消息类型特定于应用程序,从而使得队列独特于其他形式的 IPC,因为内核通过读取 type
字段,从而在一定程度上了解所传递的应用程序数据。
清单 2 显示了消息队列的消息提交部分。
清单 2. 向消息队列提交消息的程序
#include <sys/types.h> #include <sys/msg.h> #include <sys/ipc.h> #include <string.h> #include <stdio.h> int main (void) { key_t ipckey; int mq_id; struct { long type; char text[100]; } mymsg; /* Generate the ipc key */ ipckey = ftok("/tmp/foo", 42); printf("My key is %d ", ipckey); /* Set up the message queue */ mq_id = msgget(ipckey, IPC_CREAT | 0666); printf("Message identifier is %d ", mq_id); /* Send a message */ memset(mymsg.text, 0, 100); /* Clear out the space */ strcpy(mymsg.text, "Hello, world!"); mymsg.type = 1; msgsnd(mq_id, &mymsg, sizeof(mymsg), 0); }
清单 2 中的代码包括了必要的头文件,然后定义了将在 main
函数中使用的变量。第一要务是使用 /tmp/foo
作为命令文件和使用数字 42 作为 ID 来确定 IPC 密钥。出于演示的目的,这里使用 printf(3c)
将密钥显示在屏幕上。接下来,该代码使用 msgget
创建消息队列。msgget
的第一个参数是 IPC 密钥,第二个参数是一组标志。在该示例中,标志包括八进制权限(该权限允许具有 IPC 密钥的任何人完全使用该 IPC)和IPC_CREAT
标志(此标志导致 msgget
创建队列)。同样,结果被打印到屏幕上。
将消息发送到队列是非常简单的。在对消息中的内存空间清零之后,将一个熟悉的字符串复制到缓冲区的文本部分。将消息类型设置为 1,然后调用 msgsnd
。msgsnd
预期接受的参数为队列 ID、一个指向数据的指针和数据的大小,以及一个指示是否阻塞该调用的标志。如果该标志为IPC_NOWAIT
,则即使队列已满,该调用也会返回。如果该标志为 0,则调用将阻塞,直至队列上的空间被释放、队列被删除或应用程序收到某个信号。
该过程的客户端行为与此类似。清单 3 显示了检索服务器发送的消息的代码。
清单 3. 用于从队列检索消息的代码
#include <sys/types.h> #include <sys/msg.h> #include <sys/ipc.h> #include <string.h> #include <stdio.h> int main (void) { key_t ipckey; int mq_id; struct { long type; char text[100]; } mymsg; int received; /* Generate the ipc key */ ipckey = ftok("/tmp/foo", 42); printf("My key is %d ", ipckey); /* Set up the message queue */ mq_id = msgget(ipckey, 0); printf("Message identifier is %d ", mq_id); received = msgrcv(mq_id, &mymsg, sizeof(mymsg), 0, 0); printf("%s (%d) ", mymsg.text, received); }
获得 IPC 密钥和消息队列标识符的过程与服务器的代码类似。对 msgget
的调用不指定任何标志,因为服务器已经创建了队列。如果应用程序的设计允许客户端在服务器之前启动,则客户端和服务器都必须指定权限和 IPC_CREAT
标志,以便其中首先启动的应用程序创建队列。
然后 mq_client.c
调用 msgrcv
以从队列提取消息。前三个参数指定消息队列标识符、指向将包含消息的内存空间的指针和缓冲区的大小。第四个参数是类型参数,它允许您选择所要获得的消息:
- 如果类型为 0,则返回队列中的第一个消息。
- 如果类型为正整数,则返回队列中的第一个该类型的消息。
- 如果类型为负整数,则返回队列中具有最小值的第一个消息,且该最小值小于或等于指定类型的绝对值。例如,如果要将 2 然后再将 1 添加到队列,则使用类型 -2 调用
msgrcv
将返回 1,因为它最小,尽管它是队列中的第二个消息。
传递给 msgrcv
的第五个参数同样是阻塞标志。清单 4 显示了实际操作中的客户端和服务器。
清单 4. 客户端和服务器代码的输出
sunbox$ ./mq_server My key is 704654099 Message identifier is 2 sunbox$ ./mq_client My key is 704654099 Message identifier is 2 Hello, world! (104)
客户端和服务器的输出表明,它们得出了相同的 IPC 密钥,因为它们都引用同一个文件和标识符。服务器创建了 IPC 实例,内核为该实例分配了值 2,并且客户端应用程序知道这一点。这样,客户端从消息队列提取回“Hello, world!”就没什么奇怪的了。
此示例显示了最简单的情况。消息队列对于短期进程是有用的,例如将工作提交给重负荷后端应用程序(例如某个批处理应用程序)的 Web 事务。客户端也可以是服务器,并且多个应用程序可以向队列提交消息。消息类型字段允许应用程序将消息发送给特定的读取器。
使用信号量锁定资源
进程之间的通信不需要涉及到发送大量的数据。实际上,单个位可能就足以指示某个进程在使用某个资源。请考虑两个需要访问某个硬件部分的进程,但是一次只有一个进程能够使用该硬件。每个进程就包含引用计数器的点达成一致。如果一个进程读取该计数器并看到其值为 1,则它就知道另一个进程正在使用该硬件。如果该计数器的值为 0,则该进程就可以自由使用该硬件资源,前提是在该硬件操作期间将计数器设置为 1 并在结束操作时将其重置为 0。
此场景存在两个问题:第一个问题不过就是设置共享计数器并就其位置达成一致,再没有比这麻烦的了。第二个问题是锁定硬件资源所需要的获取和设置操作不是原子的。如果一个进程在读取计数器时,其值为 0,但是在它还没有机会将计数器设置为 1 之前,另一个进程已抢先读取了该计数器,则第二个进程就能够读取和设置计数器。两个进程都会认为它们可以使用该硬件。没有办法知道另一个或其他进程是否会设置该计数器。这称为争用条件。信号量通过提供一个公共应用程序接口,以及通过实现原子测试和设置操作,从而同时解决了这两个问题。
信号量的 SysV 实现比上述解决方案更通用。首先,信号量的值不需要是 0 或 1;它可以是 0 或任何正数。其次,可以执行一系列信号量操作,与用于 msgrcv
的 type
参数非常类似。这些操作作为一个指令集提供给内核,并且这些指令要么全部运行,要么一个也不会运行。内核要求将这些指令放在一个名为 sembuf
的结构中,该结构具有以下成员(按顺序):
sem_num
:描述正在操作该集合中的哪一个信号量。sem_op
:一个有符号整数,其中包含要执行的指令或测试。sem_flg
:熟悉的IPC_NOWAIT
标志(它指示测试应该立即返回还是阻塞直至通过)和SEM_UNDO
(它在进程提前退出时导致撤销该信号量操作)的组合。
sem_op
是放置许多配置的地方:
- 如果
sem_op
为 0,则测试sem_num
以确定它是否为 0。如果sem_num
为 0,则运行下一个测试。如果sem_num
不为 0,则在未设置IPC_NOWAIT
时,操作将阻塞直至信号量变为 0,而在设置了IPC_NOWAIT
时,则跳过其他测试。 - 如果
sem_op
是某个正数,则将信号量的值加上sem_op
的值。 - 如果
sem_op
是一个负整数,并且信号量的值大于或等于sem_op
的绝对值,则从信号量的值减去该绝对值。 - 如果
sem_op
是一个负整数,并且信号量的值小于sem_op
的绝对值,则在IPC_NOWAIT
为 true 时立即停止测试的执行,而在该值为 false 时则阻塞,直至信号量的值变得大于sem_op
的绝对值。
清单 5 中的示例阐明了信号量的使用,其中研究了一个可同时运行多次的程序,但是该程序确保一次只有一个进程处于关键部分。其中使用了简单情况下的信号量;当信号量的值为 0 时释放资源。
清单 5. 使用信号量来保护关键部分
#include <sys/types.h> #include <sys/sem.h> #include <sys/ipc.h> #include <string.h> /* For strerror(3c) */ #include <errno.h> /* For errno */ #include <unistd.h> /* rand(3c) */ #include <stdio.h> int main (int argc, char **argv) { key_t ipckey; int semid; struct sembuf sem[2]; /* sembuf defined in sys/sem.h */ /* Generate the ipc key */ ipckey = ftok("/tmp/foo", 42); /* Set up the semaphore set. 4 == READ, 2 == ALTER */ semid = semget(ipckey, 1, 0666 | IPC_CREAT); if (semid < 0) { printf("Error - %s ", strerror(errno)); _exit(1); } /* These never change so leave them outside the loop */ sem[0].sem_num = 0; sem[1].sem_num = 0; sem[0].sem_flg = SEM_UNDO; /* Release semaphore on exit */ sem[1].sem_flg = SEM_UNDO; /* Release semaphore on exit */ while(1) { /* loop forever */ printf("[%s] Waiting for the semaphore to be released ", argv[1]); /* Set up two semaphore operations */ sem[0].sem_op = 0; /* Wait for zero */ sem[1].sem_op = 1; /* Add 1 to lock it*/ semop(semid, sem, 2); printf("[%s] I have the semaphore ", argv[1]); sleep(rand() % 3); /* Critical section, sleep for 0-2 seconds */ sem[0].sem_op = -1; /* Decrement to unlock */ semop(semid, sem, 1); printf("[%s] Released semaphore ", argv[1]); sleep(rand() % 3); /* Sleep 0-2 seconds */ } }
清单 5 的开头与消息队列示例的开头相同。其中 msgget
在第二个参数中指定消息队列的大小,semget
指定信号量集(Semaphore Set) 的大小。信号量集是一组共享一个公共 IPC 实例的信号量。该集合中的信号量数量无法更改。如果已经创建了信号量集,则 semget
的第二个参数实际上被忽略。如果 semget
返回一个指示失败的负整数,则打印原因,并退出程序。
在主 while
循环之前,对 sem_num
和 sem_flg
进行了初始化,因为它们在整个示例中保持一致。此外还指定了 SEM_UNDO
,以便在信号量拥有者未能释放该信号量就已退出的情况下,不会锁定所有其他应用程序。
该循环中还打印了一个状态消息,以指示应用程序已开始等待信号量。此输出附带第一个命令行参数作为前缀,以将它与其他实例区分开来。在进入关键部分之前,应用程序锁定了信号量。此示例中指定了两个信号量指令。第一个为 0,意味着应用程序将等待,直至信号量值恢复为 0。第二个为 1,意味着在信号量恢复为零之后,将向该信号量加 1。应用程序调用 semop
以运行指令,并向其传递信号量 ID、数据结构的地址和要使用的 sembuf
指令数量。
在 semop
返回以后,应用程序知道它已经锁定了信号量,并打印一个消息以指示这一点。然后关键部分将会运行,在此例中是随机地暂停几秒。最后,使用 semop
值 -1 来运行单个 sembuf
命令,从而释放信号量,这实际上是从信号量减去 1,并将其值恢复为 0。随后打印更多的调试输出,应用程序随机暂停,然后继续执行。清单 6 显示了此应用程序的两个实例的输出。
清单 6. 两个使用信号量来保护关键部分的程序
sunbox$ ./sem_example a & ./sem_example b & [a] Waiting for the semaphore to be released [a] I have the semaphore [b] Waiting for the semaphore to be released [a] Released semaphore [b] I have the semaphore [a] Waiting for the semaphore to be released [b] Released semaphore [a] I have the semaphore [a] Released semaphore [a] Waiting for the semaphore to be released [a] I have the semaphore
清单 6 显示了运行的示例的两个实例,这两个实例分别具有名称 a 和 b。首先,a 获得信号量,在 a 拥有该信号量的同时,b 尝试获得一个锁。一旦释放了信号量,b 即获得锁。现在情况颠倒过来,变为等待 b 完成。最后,a 在信号量被释放后再次获得该信号量,因为 b 没有等待。
关于信号量,要注意的最后一个事项在于,它们被称为建议锁(Advisory Lock)。这意味着信号量本身并不阻止两个进程同时使用同一个资源;相反,它们旨在建议任何进程自愿询问该资源是否正在使用。
共享内存空间
共享内存也许是最强大的 SysV IPC 方法,并且此方法最容易实现。顾名思义,共享内存是在两个进程之间共享一个内存块。清单 7 显示了一个程序,该程序调用 fork(2)
来将自身划分为一个父进程和一个子进程,两个进程之间使用一个共享内存段进行通信。
清单 7. 演示共享内存用法的程序
#include <sys/types.h> #include <sys/ipc.h> #include <sys/shm.h> #include <unistd.h> #include <string.h> #include <errno.h> int main(void) { pid_t pid; int *shared; /* pointer to the shm */ int shmid; shmid = shmget(IPC_PRIVATE, sizeof(int), IPC_CREAT | 0666); if (fork() == 0) { /* Child */ /* Attach to shared memory and print the pointer */ shared = shmat(shmid, (void *) 0, 0); printf("Child pointer %p ", shared); *shared=1; printf("Child value=%d ", *shared); sleep(2); printf("Child value=%d ", *shared); } else { /* Parent */ /* Attach to shared memory and print the pointer */ shared = shmat(shmid, (void *) 0, 0); printf("Parent pointer %p ", shared); printf("Parent value=%d ", *shared); sleep(1); *shared=42; printf("Parent value=%d ", *shared); sleep(5); shmctl(shmid, IPC_RMID, 0); } }
您现在应该已经熟悉传递给 shmget
的参数了:密钥、大小和标志。此示例中的共享内存大小是单个整数。清单 7 与前一个示例的不同之处在于它对 IPC 密钥使用了 IPC_PRIVATE
。当使用了 IPC_PRIVATE
时,将保证创建一个唯一的 IPC ID,并且预期应用程序将自己分发该 ID。在此示例中,父进程和子进程都知道 shmid
,因为它们分别是对方的副本。fork
系统调用创建当前进程的第二个副本,称为子进程,此进程几乎与父进程完全相同。两个进程的执行都在 fork
之后恢复。返回值将用于确定当前进程是父进程还是子进程。
父进程和子进程看起来相似。首先,shmat
系统调用被用于获得指向共享内存段的指针。shmat
需要共享内存 ID、一个指针和某些标志。该指针用于请求特定的内存地址。通过传递 0,内核可以随心所欲地选择任何内存地址。标志大部分是特定于供应商的,不过 SHM_RDONLY
是一个公共标志,用于指示不写入的段。如清单 7 所示,shmat
的常见用法是让内核决定一切。
shmat
返回一个指向共享内存段的指针,此示例出于调试目的而将其打印到了屏幕上。然后每个进程依次修改该共享内存段,并打印出值。最后,父进程使用 shmctl(2)
来删除共享内存段。清单 8 显示了此程序的输出。
清单 8. 共享内存示例的输出
sunbox$ ./shared_memory Child pointer ff390000 Child value=1 Parent pointer ff380000 Parent value=1 Parent value=42 Child value=42
您可以从输出中看到相同内存空间的共享。起初,共享内存中的值为 1,这是由子进程设置并由父进程读取的。然后父进程将该值设置为 42,并由子进程读取。请注意,父进程和子进程拥有指向共享内存段的不同指针地址,尽管它们是在访问相同的物理内存。在使用物理地址时,这会导致某些数据结构出现问题,例如链表,因此当您在共享内存中构建复杂结构时,可以使用相对寻址。
此示例依赖在一个进程向共享内存写入时,另一个进程暂停。在实际应用程序中,这是不切实际的,因此,如果您的应用程序可能潜在地拥有多个向相同内存位置执行写入的进程,可以考虑使用信号量来锁定该区域。
结束语
UNIX 提供了若干种用于 IPC 的方法。SysV IPC 方法是消息队列、信号量和共享内存。消息队列允许一个应用程序提交消息,其他应用程序可以在以后获得该消息,甚至是在发送应用程序已结束之后。信号量确保多个应用程序可以锁定资源并避免争用条件。共享内存允许多个应用程序共享一个公共内存段,从而提供了一种传递和共享大量数据的快速方法。您还可以将这些方法结合起来使用。例如,您可以使用信号量来控制对共享内存段的访问。
IPC 方法对应用程序开发人员非常有用,因为它们提供了应用程序之间的标准通信方法,并且是跨不同 UNIX 风格可移植的。当您下次发现自己需要锁定资源或在进程之间共享数据时,可以试验一下 SysV IPC 机制。