进程间通信
1. 介绍
在第8章,介绍了进程控制原语以及如何使用多进程工作。但是这些进程仅仅通过使用fork和exec来传递打开的文件,或者通过文件系统传递打开的文件,来进行交换信息。
fork产生的子进程拷贝父进程文件描述符。
现在将介绍其他的进程间通信技术–进程间通信(IPC)。下图介绍了4种进程间通信的不同方式,本文接下来将介绍它们的实现:
- 半双工管道和FIFO;
- 全双工管道和命名全双工管道。
- XSI消息队列、XSI信号量、XSI共享存储。
- 消息队列(实时),信号量,共享内存(实时)。
- 套接字和STREAMS
注意Single UNIX Specification 仅要求实现半双工管道,虽然支持全双工管道的实现也是被允许的。即使正确编写的应用假定底层的操作系统仅支持半双工管道,支持全双工管道的实现也能正常工作。我们使用"全"表示使用全双工管道支持半双工管道的实现。
前4种IPC方式仅支持同一主机间进程间通信,套接字和STREAMS是仅有的两种支持不同主机进程之间通信的IPC方式。
2 管道
#include <unistd.h>
int pipe(int fd[2]);
//返回值:成功返回0,发生错误返回-1
explain
函数pipe创建一个管道,文件描述符fd[0]为读打开,文件描述符fd[1]为写打开。
调用fork复制父进程以开辟子进程,复制的内容当然包括管道描述符,在父进程中关闭 read 文件描述符,在子进程中关闭 write 文件描述符。这样,在父进程管道中写的内容就可以在子进程中管道的读端读取。
父子进程中仅存在一个管道,它存在于内存中,由父子进程共享。当在父进程不关闭管道读端,子进程中不关闭管道写端时,父子进程对管道的读写结果依赖于系统调用结果,我们无法限制管道的读写在父进程或子进程执行的时间节点(顺序)。
管道是最古老的UNIX系统IPC方式,所有的UNIX系统都提供这种方式。管道有两处限制:
- 历史上,它们一直都是半双工的(例如,数据仅朝一个方向流动)。一些系统支持全双工管道,但出于最大的移植性考虑,我们不应该假定这种情况。
- 管道仅能在两个有公共祖先的进程中使用。通常,管道被一个进程创建,然后进程调用
fork
,管道则在父进程和子进程中被使用。
我们将看到FIFOs没有第二种局限性,UNIX域套接字没有这两种局限性。
尽管有这些限制,半双工管道依然是最常用的IPC方式。每次你在管道中键入一个命令序列,让shell执行时,shell会为每个命令创建一个单独的进程,并链接该进程的标准输出至下一个使用的管道的标准输入。
调用pipe函数创建一个管道。
#include <unistd.h>
int pipe(int fd[2]);
//返回值:成功返回0,发生错误返回-1
两个文件描述符由fd参数返回:fd[0]为读打开,fd[1]为写打开,f[1]的输出为f[0]的输入。
单个进程的管道几乎没有任何用处。通常进程会调用pipe创建管道,接着调用fork,从而创建父进程与子进程间IPC管道。
fork 复制所有父进程地址空间的数据至子进程(写时复制),所以fd[2]被复制,但是管道处于内核,所以管道不被复制,父进程子进程共享管道。
fork后,如果想要数据流向为父进程到子进程,f[0]为读创建,f[1]为写创建,关闭父进程管道读终端(读文件描述符f[0]),关闭子进程管道写终端(写文件描述符f[1]),反之亦然。
当管道的一端被关闭时,下列两条规则起作用:
- 当读一个写端被关闭的管道时,在所有数据被读取完毕时,read返回0,表示文件结束。从技术上讲,如果管道写端还有进程写入,就不会产生文件结束(EOF)。可以复制(duplicate)一个管道描述符,这样多个进程就有为写打开的管道了。然而,通常情况下一个管道仅有一个写端进程和一个读端进程。下一章介绍的FIFOs,通常一个FIFO会有多个写端进程。
- 如果写一个读端被关闭的管道,会产生
SIGPIPE
信号,如果忽略忽略该信号或者捕获它并从信号处理程序返回,write返回-1并将errno置为EPIPE
。
当我们写一个管道(或者FIFO),常量PIPE_BUF
指定内核管道的缓冲区大小。写等于或者少于PIPE_BUF
字节数的数据时,不会被其他进程对同一管道(或者FIFO)的写数据插入。但是如果多个进程同时写管道(或者FIFO),且如果我们写入的字节数超过PIPE_BUF
,数据可能会被来自其他写端进程的数据插入。我们可以使用pathconf
或者fpathconf
确定PIPE_BUF
的值。
管道只对文件描述符负责,当不同进程都持有写端或读端文件描述符,会有竞争关系,数据只写一次(每次大小不超过PIPE_BUF,否则可能由于竞争出现交叉写),只读一次,只会有一个进程去读写数据,其选择取决于内核调度。
#include "apue.h"
int main(void) {
int n;
int fd[2];
int pid;
char line[MAXLINE];
if (pipe(fd) < 0)
err_sys("pipe error");
if ((pid = fork()) < 0) {
err_sys("fork error");
}
else if (pid > 0) {
/* parent */
close(fd[0]);
write(fd[1], "hello world\n", 12);
}
else {
/* child */
close(fd[1]);
n = read(fd[0], line, MAXLINE);
write(STDOUT_FILENO, line, n);
}
exit(0);
}
在早先的例子上,我们都是直接在管道描述符上进行读写操作。更有趣的是复制管道描述符到标准输入和标准输出上。通常,子进程运行一些其他的进程,然后该进程能从它的标准输入读(我们创建的管道),或者向它的标准输出写(管道)。
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <sys/wait.h>
#include <stdlib.h>
#define DEF_PAGER "/bin/more"
#define DEF_FILE "/home/Link/test/io_test.txt"
#define BUF_SIZE 128
int main(int argc, char *argv[]) {
int fd[2];
if(pipe(fd) != 0) {
printf("fail to pipe.");
}
pid_t pid;
if((pid = fork()) < 0) {
printf("fail to fork.");
}
else if(pid > 0) {
close(fd[0]);
FILE *file = fopen(DEF_FILE, "w+");
if(file == NULL) {
printf("fail to fopen file: %s.", DEF_FILE);
return -1;
}
char buff[BUF_SIZE];
while(fgets(buff, BUF_SIZE, file) != NULL) {
write(fd[1], buff, strlen(buff));
}
close(fd[1]);
waitpid(pid, NULL, 0);
}
else {
//child process
close(fd[1]);
if(fd[0] != STDIN_FILENO) {
dup2(fd[0], STDIN_FILENO);
close(fd[0]);
}
char *paper, *argv0;
if((paper = getenv("PAGER")) == NULL) {
paper = DEF_PAGER;
}
if((argv0 = strrchr(paper, '/')) != NULL) {
argv0++;
}
else {
argv0 = paper;
}
if(execl(paper, argv0, (char *)0) < 0) {
printf("fail to execl.");
}
char buff[BUF_SIZE];
while(fgets(buff, BUF_SIZE, stdin) != NULL) {
printf(":%s", buff);
}
}
return 0;
}
例子
#include "apue.h"
static int pfd1[2], pfd2[2];
void TELL_WAIT(void) {
if (pipe(pfd1) < 0 || pipe(pfd2) < 0)
err_sys("pipe error");
}
void TELL_PARENT(pid_t pid) {
if (write(pfd2[1], "c", 1) != 1)
err_sys("write error");
}
void WAIT_PARENT(void) {
char c;
if (read(pfd1[0], &c, 1) != 1)
err_sys("read error");
if (c != ’p’)
err_quit("WAIT_PARENT: incorrect data");
}
void TELL_CHILD(pid_t pid) {
if (write(pfd1[1], "p", 1) != 1)
err_sys("write error");
}
void WAIT_CHILD(void) {
char c;
if (read(pfd2[0], &c, 1) != 1)
err_sys("read error");
if (c != ’c’)
err_quit("WAIT_CHILD: incorrect data");
}
在调用fork
前,我们创建了两个管道。当TELL_CHILD
被调用时,父进程通过上方的管道写了一个p
;当 TELL_PARENT
被调用时,子进程通过下方的管道写了一个字符c
。WAIT_XXX
函数做一个阻塞的读操作来读取上述单个字符。
注意每个管道都有一个额外的读进程,在上面并没有提到。除了来自pdf1[0]
的子进程读,上方的管道还有父进程所持有的打开的读描述符。对这个程序来说,这并没有什么影响,因为i父进程不从该管道读取数据。
popen 和 pclose 函数
因为一个常见的操作就是创建一个连接其他进程的管道,然后读取输出或者发送输入,历史原因,标准IO库提供了popen
和pclose
函数。这两个函数处理所有的工作:创建一个管道,fork子进程,关闭不使用的管道端口,执行shell来运行命令,和等待命令结束。
#include <stdio.h>
FILE *popen(const char *cmdstring, const char *type);
// 返回值:成功返回file指针,失败返回空指针;
int pclose(FILE *fp);
// 返回值:字符串命令的终止状态,或者错误时返回-1;
函数popen
调用fork和exec来执行cmdstring
,并返回一个标准I/O库文件指针。如果类型是"r",文件指针连接cmdstrig
的标准输出。如果类型是“w”,文件指针连接cmdstring
的标准输入。
函数pclose
关闭标准I/O流,等待命令结束,并返回shell的终止状态。如果shell不能被执行,pclose
返回的终止状态就好像shell执行了exit(127)。
cmdstring
通过Bourne shell,以下列格式执行:
sh -c cmdstring
这意味着shell将拓展在cmdstring中的任何特殊字符。例如:
fp = popen("ls *.c", "r");
或者:
fp = popen("cmd 2 > &1", "r");
例子
#include "apue.h"
#include <sys/wait.h>
#define PAGER "${PAGER:-more}"
int main(int argc, char *argv[]) {
char line[MAXLINE];
FILE *fpin, *fpout;
if(argc != 2)
err_quit("usage: a.out <pathname>");
if((fpin = fopen(argv[1], "r")) == NULL)
err_sys("can't open %s", argv[1]);
if((fpout = popen(PAGER, "w")) == NULL) {
err_sys("popen error");
}
while (fgets(line, MAXLINE, fpin) != NULL) {
if(fputs(line, fpout) == EOF)
err_sys("fputs error to pipe");
}
if(ferror(fpin))
err_sys("fgets error")
if(pclose(fpout) == -1)
err_sys("pclose error");
exit(0);
}
shell命令${PAGER:-more}
的意思是:如果shell变量PAGER已经定义,且其值非空,则使用其值,否则使用字符串more。
实例:函数popen和pclose
#include "apue.h"
#include <errno.h>
#include <fcntl.h>
#include <sys/wait.h>
static pid_t *childpid = NULL;
FILE *popen(const char *cmdstring, const char *type) {
int i;
int pfd[2];
pid_t pid;
FILE *fp;
if((type[0] != 'r' && type[0] != 'w')||type[1] != 0) {
errno = EINVAL;
return(NULL);
}
if(childpid == NULL) {
maxfd = open_max();
if((childpid = calloc(maxfd, sizeof(pid_t))) == NULL)
return(NULL);
}
if(pipe(pfd) < 0)
return(NULL);
if(pfd[0] >= maxfd || pfd[1] >= maxfd) {
close(pfd[0]);
close(pfd[1]);
errno = EMFILE;
return (NULL);
}
if((pid = fork()) < 0) {
return (NULL);
}
else if(pid == 0) {
if(*type == "r") {
close(pfd[0]);
if(pfd[1] != STDOUT_FILENO) {
dup2(pfd[1], STDOUT_FILENO);
close(pfd[1]);
}
else {
close(pfd[1]);
if(pfd[0] != STDIN_FILENO) {
dup2(pfd[0], STDIN_FILENO);
close(pfd[0]);
}
}
}
// close all descriptors in childpid[]
for(i = 0; i < maxfd; i++) {
if(childpid[i] > 0)
close(i);
}
execl("/bin/sh", "sh", "-c", cmdstring, (char *)0);
_exit(127);
}
// parent continues...
if(*type == 'r') {
close(pfd[1]);
if((fp = fdopen(pfd[0], type)) == NULL)
return NULL;
}
else {
close(pfd[0])
if((fp = fdopen(pfd[0], type)) == NULL)
return NULL;
}
childpid[fileno(fp)] = pid;
return fp;
}
int pclose(FILE *fp) {
int fd, stat;
pid_t pid;
if(childpid == NULL) {
errno = EINVAL;
return (-1);
}
fd = fileno(fp);
if(fd >= maxfd) {
errno = EINVAL;
return (-1);
}
if((pid = childpid[fd]) == 0) {
errno = EINVAL;
return(-1);
}
childpid[fd] = 0;
if(fclose(fp) == EOF) {
return (-1);
}
while(waitpid(pid, &stat, 0) < 0) {
if(errno != EINTR)
return (-1);
}
return (stat);
}
虽然popen
的核心与我们早先在本章节使用的代码类似,也有很多细节值的我们注意。首先,每次popen
被调用,我们都需要记住我们创建的子进程的进程ID和它的文件描述符,以及FILE指针。我们选择存储在数组childpid
中存储子进程id,并通过文件描述符进行索引。以这种方法,当pclose
使用FILE指针作为文件描述符参数调用时,我们使用标准IO流函数fileno
来获得文件描述符然后获得子进程ID来调用waitpid
。因为可能对一个所给进程多次调用popen
,我们动态分配childpid
数组(popen
第一次被调用时),为其分配最大文件描述符数目的大小。
注意,open_max
可以返回当前可打开文件的最大个数的近似值,如果这个值对系统来说是模糊的的话。我们应当小心的使用值大于或等于open_max
函数返回值的管道文件描述符。在popen
中,如果open_max
的返回值很小,我们就关闭管道文件描述符,并且将errno
设置为EMFILE
来表示有太多的文件描述符被打开了,并返回-1。在pclose
中,如果对应传入文件指针参数的文件描述符比期望中的值更大,我们将errno
设为EINVAL
并返回-1。
在popen
函数中,调用pipe
和fork
然后为每个进程复制合适的文件描述符与我们早期在本章中所做的类似。
POSIX.1 要求关闭任何在子进程中依然打开的流,这些流源于popen
的早期调用。为了实现这一点,我们在子进程中遍历了childpid数组,关闭掉任何仍打开的文件描述符。
如果pclose
的调用者为SIGCHLD
信号绑定一个信号处理程序将会发生什么?在pclose
中调用的waitpid
将会返回EINTR
错误。因为调用者被允许捕获这个信号(或者其他可能中断waitpid
调用的信号),我们简单的再次调用waitpid
,如果它被一个信号中断。
注意,如果如果应用调用waitpid
并获得了由popen
创建的子进程的退出状态我们将在应用调用pclose
时,其内部调用waitpid
,发现子进程不存在并返回-1,将errno
置为ECHILD
。这种行为正是POSIX。1所要求的。
注意popen
不应当被设置用户ID或设置组ID的进程调用。当他执行命令时,popen
等同于:
execl("/bin/sh", "sh", "-c", command, NULL);
使用继承自调用者的环境执行shell和命令。一个恶意的用户能够操纵这种环境,由shell不以预期的方式执行命令,而是通过设置ID文件模式来赋予更高权限的方式来执行这些命令。
popen
十分适合做的一件事情就是执行简单的过滤来变换运行命令的输入或者输出。当一个命令想要构建它自己的管道时就是这种情形。
例子
考虑一个应用,它向标准输出写一个提示,然后从标准输入读一行。使用popen
函数,我们可以在应用和它的输入之间插入一个程序来转换输入格式。
转换可能是路径名拓展,或者是提供历史机制(记录早期输入的命令)。
#include "apue.h"
#include <sys/wait.h>
int main(void) {
char line[MAXLINE];
FILE *fpin;
if((fpin = popen("myuclc", r)) == NULL)
err_sys("popen error");
for( ; ; ) {
fputs("prompt> ", stdout);
fflush(stdout);
if(fgets(line, MAXLINE, fpin) == NULL)
break;
if(fputs(line, stdout) == EOF)
err_sys("fputs error to pipe");
}
if(pclose(fpin) == -1)
err_sys("pclose error");
putchar('\n');
exit(0);
}
在写提示后,我们需要调用fflush
,因为标准输出通常是行缓冲的,提示并没有包含一个新行。
4 协同进程
一个UNIX系统过滤器是一个从标准输入读,写标准输出的程序。过滤器通常在shell管道中线性连接。当生成过滤器的输入和读取过滤器的输出是同一个程序的时候,过滤器就成为了一个协程。
一个协程通常在shell的后台运行,且它的标准输入和标准输出使用一个管道连接待另一个程序上。虽然初始化一个协同程序,并将其输入和输出连接到另一个进程的shell语法是十分奇特的,但是协同程序对C程序来说依然是十分有用的。
鉴于popen
提供我们一个通往另一进程的标准输入或源自另一进程的标准输出的单方向管道,而协同程序则有两个连接其他进程的单向管道一个通往它的标准输入,一个源自它的标准输出。我们希望写它的标准输入,让他处理数据,然后读它的标准输出。
例子
让我们通过一个实例来观察协同程序。进程创建了2个管道:一个连接协同程序的标准输入,另一个连接协调程序的标准输出。
下图的程序时一个简单的协同程序。它从它的标准输入读取两个数,计算它们的和,并且将其写入到标准输出。
#include "apue.h"
int main(void) {
int n, int1, int2;
char line[MAXLINE];
while((n = read(STDIN_FILENO, line, MAXLINE)) > 0) {
line[n] = 0;
if(sscanf(line, "%d%d", &int1, &int2) == 2) {
sprintf(line, %d\n, int1 + int2);
n = strlen(line);
if(write(STDOUT_FILENO, line, n) != n)
err_sys("write error");
}
else {
if(write(STDOUT_FILENO, "invalid args\n", 13) != 13)
err_sys("write error");
}
}
exit(0);
}
我们编译这个程序生成二进制文件add2。
下图的进程在从标准输入读取两个数字后,引用了协同程序add2
。协同程序的值被写入到标准输出。
#include "apue.h"
static void sig_pip(int);
int main(void) {
int n, fd1[2], fd2[2];
pid_t pid;
char line[MAXLINE];
// 写一个读端已经关闭的管道产生SIGPIPE信号
if(signal(SIGPIPE, sig_pipe) == SIG_ERR)
err_sys("signal error");
if(pipe(fd1) == -1 || pipe(fd2) == -1)
err_sys("pipe error");
if((pid = fork()) < 0) {
err_sys("fork error");
}
else if(pid == 0) {
// child
close(fd1[1]); // close write fd
if(fd1[0] != STDIN_FILENO) {
if(dup2(fd1[0], STDIN_FILENO) != STDIN_FILENO)
err_sys("dup2 error to stdin");
close(fd1[0]);
}
close(fd2[0]);
if(fd2[1] != STDOUT_FILENO) {
if(dup2(fd2[1], STDOUT_FILENO) != STDOUT_FILENO)
err_sys("dup2 error to stdout");
close(fd2[1]);
}
if(execl("./add2", "add2", NULL) < 0)
err_sys("execl error");
}
else {
// parent
close(fd1[0]); // close read fd
close(fd2[1]);
while(fgets(line, MAXLINE, stdin) != NULL) {
n = strlen(line);
if(write(fd1[1], line, n) != n)
err_sys("write error to pipe");
if((n = read(fd2[0], line, MAXLINE)) < 0)
err_sys("read error from pipe");
else if(n == 0) {
err_sys("child closed pipe");
break;
}
line[n] = 0;
if(fputs(line, stdout) == EOF)
err_sys("fputs error");
}
if(ferror(stdin))
err_sys("fgets error on stdin");
exit(0);
}
exit(0);
}
static void sig_pipe(int signo) {
printf("SIGPIPE caught\n");
exit(1);
}
这里,我们创建了两个管道,并在父进程和子进程中分别关闭了它们不需要使用的文件描述符。我们必须使用两个管道:一个用于写协同程序的标准输入,一个用于读协同程序的标准输出。在调用execl
函数前,子进程调用dup2
转移管道文件描述符至标准输入和标准输出上。
如果我们编译运行上述程序,它会按预期执行。进一步,如果我们在程序等待我们输入两个数字时,kill协同程序add2
,信号处理函数将被触发因为进程试图写一个读端关闭的管道,触发SIGPIPE
信号。子进程于父进程之后退出也是为了避免触发此信号。
例子
在协同程序add2
中,我们故意使用底层的I/O(UNIX系统调用):read和write。如果我们使用标准I/O重写这个协同程序会发生什么呢?
int main(void) {
int int1, int2;
char line[MAXLIEN];
while(fgets(line, MAXLINE, stdin) != NULL) {
if(sscanf(line, "%d%d", &int1, &int2) == 2) {
if(printf("%d\n", int1 + int2) == EOF)
err_sys("printf error");
}
else {
if(printf("invalid agrs\n") == EOF)
err_sys("printf error");
}
}
exit(0);
}
如果我们使用这个新的协同程序,它不再起作用。问题出在默认的标准I/O缓冲机制上。当协同程序被使用时,第一个用于标准输入的fgets将造成标准I/O库分配一个缓冲区并选择缓冲类型。因为标准输入是一个管道,所以标准I/O库默认是全缓冲的。对于便准输出同样如此。当add2
在读标准输入被阻塞时(缓冲区没有填满,注意协同程序无法进行合适的冲洗),主程序在读管道时被阻塞,造成死锁。
这里,我们可以这一将要运行的协同程序。我们可以在循环处理前,将标准输入和标准输出改为行缓冲的。
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
)。
对于全缓冲,仅当填满标准I/O缓冲区时才进行实际的I/O操作,即底层I/O系统调用,所以
fgets
调用必须等待缓冲区满时,才能进行数据读取(read),read一行进line中。同样,printf
向标准I/O缓冲区写了一行,但在缓冲区满之前不会将其写到管道(文件)中,所以读管道会被阻塞。
使用行缓冲,则在遇到换行符时,进行实际的I/O操作。
5 FIFOs
FIFOs
有时也被叫做命名管道。无名管道仅能在两个相关的进程中使用,它们的共同祖先创建了管道。然而使用FIFOs
,不相关的进程也能够交换数据。
在第四章中,我们了解到FIFO
是一种文件类型。stat
数据结构的st_mode
成员的编码之一表明文件是否是一个FIFO
。我们可以使用宏S_ISFIFO
来检测它。
创建一个FIFO
类似于创建文件。实际上,FIFO
的pathname
在文件系统中确实存在。
#include <sys/stat.h>
int mkfifo(const char *path, mode_t mode);
int mkfifoat(int fd, const char *path, mode_t mode);
// 返回值:成功返回0,发生错误返回-1
mode
参数的说明与open
函数(3.3节)相同,新的FIFO
的用户和用户组所有权与在4.6节中的说明相同。
mkfifoat
函数与mkfifo
函数相似,只是它能够使用相对路径创建FIFO
,其相对路径为path
,相对于的路径由文件描述符fd
指定,与其他*at函数相同,这里有三种情况:
- 传入参数
path
是绝对路径,则fd
参数被忽略,mkfifoat
与mkfifo
行为相类似。 - 传入参数
path
是相对路径,传入参数fd
是一个合法的,打开目录的文件描述符,则path
相对于这个被打开的目录。 - 传入参数
path
是相对路径,传入参数fd
是一个特定的值AT_FDCWD
,则path
相对于当前的工作路径,mkfifoat
与mkfifo
行为相类似。
一旦我们使用mkfifo
或者mkfifoat
创建了一个FIFO
,我们可以使用open
函数打开它。实际上,普通的文件I/O函数(例如,close,read,write,unlink)都可以用于FIFO
。
当使用O_NONBLOCK
标志打开FIFO
时,会造成以下影响:
- 在通常的情况下(没有使用
O_NONBLOCK
),一个只读打开的FIFO
会一直阻塞,直到一些其他的进程打开FIFO来写入数据。类似的,一个只写打开的FIFO
会一直阻塞,直到一些其他的进程打开FIFO来读入数据。 - 如果指定了
O_NONBLOCK
标志,只读方式打开的open函数会立刻返回,但是以只写方式打开的open会返回-1,并将errno
设置为ENXIO
,如果没有进程打开FIFO来读取数据的话。
与使用管道时相同,如果我们写一个没有进程以只读方式打开的FIFO,就会产生SIGPIPE
信号。当最后一个FIFO的写端进程关闭了FIFO,就会生成文件结束标记(EOF
),FIFO的读端进程将会读取到它。
对于给定的FIFO有多个写端进程是很正常的。这意味着如果我们不想多个写端进程的写入数据间相互嵌入,我们就必须考虑以原子操作的方式写入。和管道相同,常量PIPE_BUF
指定了我们可以以原子方式写入FIFO的数据的最大数量。
FIFOs
有以下两种用途:
- shell命令使用
FIFOs
将数据从一条shell管道传输到另一条,无需创建中间临时文件。 - 客户进程-服务器进程应用程序中,FIFO用作汇聚点,在客户进程和服务器进程间传递数据。
例子——使用FIFOs
复制输出流
FIFOs
能被用于在一系列的shell命令中复制一个输出流。这避免了向一个中间的磁盘文件写数据(类似于使用管道来避免中间磁盘文件)。但不同的是,管道中能用于进程间的线性连接,而FIFO有名字,所以它可以被用于非线性连接。
想象有一个过程需要对一个经过过滤的输入流处理两次。
使用FIFO和UNIX程序tee(1),我们能在不产生临时文件的情况下完成这个任务。(tee程序将其标准输入拷贝到其标准输出和由命令行传入的文件名中)。
mkfifo fifo1
prog3 < fifo1 &
prog1 < infile | tee fifo1 | prog2
我们创建一个FIFO然后在后台运行prog3
,从FIFO
中读取。我们随后运行prog1
并使用tee
将其输入发送到FIFO和prog2
中。
例子——使用FIFO进行客户端-服务器通信
FIFOs
的另一种用法是在客户端和服务器之间发送数据。如果我们的服务器和多个客户端进行通信,每个客户端都可以向服务器创建的客户端可见的FIFO写它的请求。(可见意味着连接服务器的客户端都知道FIFO的路径)。
因为FIFO有多个写端进程,客户端发送向服务器的请求需要少于PIPE_BUF
字节,这样就避免了多个客户端之间的写交叉。
在客户端-服务器通讯时使用FIFOs
的问题是服务器如何向客户端发送回复。使用单个FIFO是不合适的,因为客户端不知道如何在合适的时间点读取自己请求的回复,因为无法确定待读取的回复一定是发给自己的回复。一种解决办法是客户端在请求时包含自己的进程ID信息,服务器为每个客户端进程创建一个专用的FIFO,并依据请求的进程ID信息发送回复。
这种安排是有效的,虽然对服务器来说不可能知道客户端是否崩溃。客户端崩溃,将客户端指定的FIFO遗留在文件系统中。服务器必须去捕获SIGPIPE
信号,因为客户端可能在服务器写回复之前崩溃,使得客户端指定的FIFO只用写端进程,没有读端进程,而导致写一个没有读端的FIFO,产生SIGPIPE
信号。
如果服务器以只读方式打开监听的FIFO,当没有客户端写该FIFO时,即所有写端进程关闭FIFO,读一个没有写端进程的FIFO会返回文件结束标志(eof
)。为了避免这种情况,监听的FIFO可以以读写方式打开。
6 XSI IPC
被称为XSI IPC
的三种IPC
类型为:消息队列,信号量和共享内存。它们之间有许多相似之处。
6.1 标识符和键
每个IPC
结构(消息队列,信号量,或者共享内存)都用一个非负整数标识符加以引用。例如,为了向消息队列发送消息或者从消息队列取消息,我们只需要知道队列的标识符。不像文件描述符,IPC
标识符不是小的整数。当一个IPC
结构被创建然后被移除,相关的标识符会持续的增长知道到达最大的正值,然后又转回到0。
对一个IPC
对象来说,标识符时一个内部的名字。为了使多个相互合作的进程在使用相同的IPC
对象时达成一致,需要一个外部的命名方案。为了这个目的,IPC
对象与一个键相关联,它扮演着IPC
对象外部名字的角色。
无论何时创建一个IPC
对象(通过调用msgget
, semget
, 或者shmget
),必须指定一个键。键的数据类型是系统的基本数据类型key_t
,它通常在头文件<sys/types>
中用一个长整形定义。键会在kernel中被转换为标识符。
有几种方式能够让服务器和客户端在相同的IPC
数据结构上达成一致。
-
服务器可以通过指定一个
IPC_PRIVATE
键来创建一个新的IPC
结构,然后将返回的标识符存储在某个地方(例如一个文件)方便客户端获取。IPC_PRAVATE
键保证了服务器创建了一个新的IPC
结构。这种方式的缺陷是需要进行文件系统操作–服务器将整型标识符写入一个文件随后客户端取回这个标识符。
IPC_PRIVATE
键也被用于父子进程关系。父进程指定IPC_PRIVATE
键来创建一个新的IPC
结构,然后调用fork,这样IPC
结构的标识符也将被子进程复制,它在父子进程中都能被访问,在子进程调用exec时可以作为参数传入执行的程序中。 -
服务器和客户端可以使用同一个键,例如,在它们的公共头文件中定义一个键。然后,服务器可以在创建新的
IPC
结构时指定这个键。这种方法的问题是,这个公共键可能已经被其它IPC
结构在创建时指定了,在这种情况下,get
函数(msgget
,semget
, 或者shmget
)将返回一个错误。服务器必须处理这个错误,删除已经存在的IPC
结构,并尝试重新创建它。 -
客户端和服务器可以使用相同的路径名和项目ID(项目ID是0~255之间的字符值),然后调用
ftok
函数将这两个至转化为一个键。这个键然后被用于步骤2。只有ftok
提供的服务才能从路径名和项目ID生成一个键。
#include <sys/ipc.h>
key_t ftok(const char *path, int id);
// 返回值:成功返回键,错误返回-1;
path参数必须引用一个已存在的文件。当生成一个键时id只有低8位被使用。
由ftok
创建的键通常取stat
结构的st_dev
和st_ino
字段结合所给的pathname
和项目ID生成。然而,因为i-node序号和键通常存储在长整形中,当创建一个键时会发生信息丢失。这意味着使用不同的文件路径名和相同的进程ID有可能产生相同的键。
三个get
函数(msgget
, semget
和shmget
)都有两个相似的参数:key和整数flag。如果key是IPC_PRIVATE
或者key没有指向一个特定类型的IPC结构且flag被指定为IPC_CREAT
,那么一个新的IPC
结构将被创建,创建键通常由服务器完成。为了引用一个存在的队列(通常客户端需要这样做),key必须与队列被创建时指定的key相同,且不能指定IPC_CREAT
。
注意永远不可能使用IPC_PRIVATE
来引用一个已经存在的队列,因为IPC_PRIVATE
仅在创建新的队列时使用。为了引用使用IPC_PRIVATE
创建的队列,我们必须知道与之相关的标识符,然后在其他的IPC调用中使用该标识符(例如msgsnd
和msgrcv
),通过传参实现。
如果我们想要创建一个新的IPC
结构,并确保我们没有引用一个具有相同标识符的现有IPC
结构,我们必须指定flag参数,设置其IPC_CREAT
和IPC_EXCL
标志位。这样如果相同标识符的IPC
结构已经存在的话,就会返回一个EEXIST
错误。(这与在调用open时设置O_CREAT
和O_EXCL
标志类似)。
6.2 权限结构
XSI IPC
为每个IPC
结构关联一个ipc_perm
结构。这个结构定义了权限和所有权且至少包含一些成员:
struct ipc_perm {
uid_t uid; // 所有者有效用户ID
gid_t gid; // 所有者有效组ID
uid_c cuid; // 创建者有效用户ID
gid_t cgid; // 创建者有效组ID
mode_t mode;// 访问模式
...
};
每个定义都包含额外的成员,见<sys/ipc.h>
获取完整定义。
当创建一个IPC
结构时,所有的字段都被初始化。随后,我们可以调用msgctl
,semctl
和shmctl
来修改uid
,gid
和mode
字段。为了改变这些值,调用进程必须IPC
结构的创建者或者超级用户。修改这些字段与调用chown
或chamod
类似。
mode字段的值与文件访问权限的取值类似,但是对IPC
结构来说,没有对应执行权限的取值。另外,消息队列和共享内存使用术语read
和write
,但是信号量使用read
和alter
。各IPC
的六种权限如下图所示:
权限 | 位 |
---|---|
用户读 | 0400 |
用户写(alter) | 0200 |
组读 | 0040 |
组写(alter) | 0020 |
其他读 | 0004 |
其他写(alter) | 0002 |
一些实现定义了表示每个权限的符号常量,但是这些常量并非Single UNIX Specification标准。
6.3 配置限制
所有的三种格式的XSI IPC
都有内置的限制。大部分这种限制都能够通过重新配置内核修正。
6.4 优缺点
XSI IPC
的一个基本问题是IPC
结构是系统范围的且没有引用计数。例如,如果我们创建一个消息队列,在队列中放入一些消息,然后终止,消息队列和它的内容不会被删除。它们会存在于系统中,直到特定的读或者删除发生:进程调用msgrcv
或者msgctl
;某进程执行ipcrm(1)
命令;系统重启。和管道相比,当最后一个引用它的进程终止时,它被彻底移除。和FIFO相比,虽然名字保存在文件系统中直到显式删除,但是当最后一个引用FIFO的进程终止后,任何残存在FIFO中的数据都会被移除。
XSI IPC
的另一个问题是文件系统无法通过名字访问这些IPC
结构。我们无法使用3,4章中所述的函数来访问IPC
结构并修改它们的属性。大约有十几个系统添加到内核中以支持这些IPC
对象。我们不能使用ls命令显示IPC
,我们不能使用rm命令移除它们。取而代之的是新添加的两个新的命令,ipcs(1)
和ipcrm(1)
。
因为这些格式的IPC
不使用文件描述符,我们不能对它们使用多路转接I/O函数(select和poll)。这使得一次使用一个以上的IPC
结构变得十分困难,同样也不能对这些IPC
结构使用文件或设备IO。例如,在不使用某种形式的忙等循环的情况下(busy-wait loop),服务器就不能等待一条消息,并接收消息后将其放置到两条消息队列中的其中一条。
Andrade, Carges, and Kovach对使用System V IPC
构建的一个业务处理系统进行了综述。他们宣称System V IPC
使用的命名空间(标识符)是一个优点,而不是我们如前所述的问题。因为使用标识符允许一个进程使用一个单个的函数调用msgsnd
来向消息队列发送消息,而其他的IPC
通常需要open,write,和close。这种观点是错误的。为了避免使用一个键和调用msgget
,客户端必须以某种方式包含服务器队列的标识符。分配给特定队列的标识符取决于当线程被创建时,有多少其他的消息队列存在,和自内核引导启动后,内核中用于分配给新队列的内核中表项被使用了多少次。这是一个动态的值,不能被猜测到或者存储在一个头文件中。正如我们在15.6.1节中提到的,一个服务器至少必须将分配的标识符写入到一个文件中,等待它的客户端来读。
消息队列的被这些作者列出来的其他优点,它们是可靠的,流控制的,面向记录的,且它们可以非先进先出的顺序被处理。
IPC 类型 | 无连接 | 可靠性 | 流控制 | 记录 | 消息类型或优先级 |
---|---|---|---|---|---|
消息队列 | 否 |
无连接,这里指的是不必首先调用某种形式的open
函数就可以发送消息的能力。正如我们早先所描述的,我们不认为消息队列是无连接的,因为一些技术需要获得队列标识符,因为i所有的只写IPC
的形式都被限制在一台主机上,所以我们认为它们都是可靠的。当消息通过网络发送时,需要考虑到消息丢失的可能性。流控制意味着发送方被设置为休眠如果系统资源短缺(缓冲),或者接收者无法在接收任何消息。当流控制条件消失时,发送方应当被自动唤醒。
上图中没有展示出的一个特性是IPC
设施能否为每个客户端创建一条独一无二的与服务器的连接。17章的UNIX流套接字将提供这项能力。
7 消息队列
消息队列是内核中消息串连的链表它又消息队列标识符标识。我们可以称消息队列为queue
,它的标识符为queue ID
。
使用msgget
可以创建一个新的队列或者打开已存在的队列。使用msgsnd
能够添加新的消息至队列尾端每个消息包含一个正的长整型类型字段,一个非负长度和实际的数据字节(与长度一致),当消息被添加到消息队列时,所有的这些都会通过msgsnd
指定。消息通过调用msgrcv
从消息队列中获得。我们不必先进先出的顺序捕获消息。相反,我们可以根据它们的类型字段捕捉消息。
每个队列都有一个msqid_ds
结构与之相关联。
struct msqid_ds {
struct ipc_perm msg_perm; // 见6.2
msgqnum_t msg_qnum; // 队列消息数量
msglen_t msg_qbytes; // 消息队列中最大的字节数
pid_t msg_lspid; // 上一个`msgsnd`的pid
pid_t msg_lrpid; // 上一个`msgrcv`的pid
time_t msg_stime; //上一个`msgsnd`的时间
time_t msg_rtime; // 上一个`msgrcv`的时间
time_t msg_time; // 上一次改变的时间
};
这个结构体定义了当前的队列状态。展现的成员由Single UNIX Specification 定义。具体实现可能包含标准没有覆盖的字段。