UNIX环境高级编程 学习笔记 第十五章 进程间通信

进程间通信可通过传送打开的文件,也可以经由fork和exec函数来传送,还可以通过文件系统传送。

IPC(InterProcess Communication,进程间通信)是进程通信方式的统称,不同UNIX系统支持的IPC形式不同:
在这里插入图片描述
虽然SUS列要求的是半双工管道,但允许实现支持全双工管道,上图中“(全)”表示该系统用全双工管道支持半双工管道的实现,即使应用在编写时假定基础操作系统只支持半双工管道,支持全双工管道的实现也能使这种应用程序正常工作。

上图中,对于全双工管道,如果该特征通过UNIX域套接字支持,则标注UDS。

上图中最后两种IPC支持不同主机上两个进程间通信,而前十种通常限于同一主机上的两进程间IPC。

管道历史上是半双工的,现在某些系统提供全双工管道,但为了移植性,应使用半双工管道。

管道只能在具有公共祖先的两个进程间使用,通常一个管道由一个进程创建,进程调用fork后,这个管道就能在父进程和子进程之间使用了。

每当在管道中键入一个命令序列,让shell执行时,shell会为每一条命令单独创建一个进程,然后用管道将前一条命令进程的标准输出与后一条命令的标准输入相连接。

创建管道:
在这里插入图片描述
经参数fd返回两个文件描述符,fd[0]为读而打开,fd[1]为写而打开。

fstat函数对管道的每一端都返回一个FIFO类型的文件描述符。可用S_ISFIFO宏测试管道。

POSIX.1规定stat结构的st_size成员对于管道是未定义的,但当fstat函数用于管道读端的文件描述符时,很多系统在st_size中存储管道中可用于读的字节数,但这不可移植。

在这里插入图片描述

在这里插入图片描述
对于从父进程到子进程的管道,父进程关闭管道的读端(fd[0]),子进程关闭管道的写端(fd[1]):
在这里插入图片描述
管道的一端被关闭后:
1.读一个写端被关闭的管道时,所有数据都被读取后,read函数返回0,表示文件结束。(读非阻塞的描述符时,没有数据可读时read函数返回-1,并将errno设置为EAGAIN)。
2.写读端被关闭的管道时,会产生SIGPIPE信号,如果忽略该信号或从其信号处理程序返回,则write函数返回-1,并将errno设置为EPIPE。

写管道或FIFO时,常量PIPE_BUF规定了内核的管道缓冲区大小,如果对管道调用write,要求写的字节数小于PIPE_BUF,这样写操作不会与其他进程对同一管道或FIFO的写操作交叉进行。

经过管道从父进程向子进程传递数据:

#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>

int main() {
    int n;
    int fd[2];
    pid_t pid;
    char line[1024];

    if (pipe(fd) < 0) {
        printf("pipe error\n");
        exit(1);
    }

    if ((pid = fork()) < 0) {
        printf("fork error\n");
		exit(2);
    } else if (pid > 0) {
        close(fd[0]);
		write(fd[1], "hello world\n", 12);
    } else {
        close(fd[1]);
		n = read(fd[0], line, 1024);
		write(STDOUT_FILENO, line, n);
    }
    exit(0);
}

可以将管道描述符复制到标准输入或标准输出上,之后子进程可执行另一个程序,该程序或者从标准输入(已创建的管道)上读数据,或将数据写至标准输出(该管道)。

编写一个每次一页地显示已产生的输出的程序,UNIX系统中自带分页程序,为了避免先将所有数据写到一个临时文件,然后再调用系统中的分页程序显示文件,可以通过管道直接将输出送到分页程序,为此,要先创建一个管道,调用fork创建一个子进程,使子进程的标准输入成为管道的读端,然后调用exec,执行分页程序:

#include <sys/wait.h>
#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>

#define DEF_PAGER "/bin/more"
#define MAXLINE 1024

int main(int argc, char *argv[]) {
    int n;
    int fd[2];
    pid_t pid;
    char *pager, *argv0;
    char line[MAXLINE];
    FILE *fp;

    if (argc != 2) {
        printf("usage: a.out <pathname>\n");
		exit(1);
    }

    if ((fp = fopen(argv[1], "r")) == NULL) {
        printf("can't open %s\n", argv[1]);
		exit(2);
    }

    if (pipe(fd) < 0) {
        printf("pipe error\n");
		exit(3);
    }

    if ((pid = fork()) < 0) {
        printf("fork error\n");
		exit(4);
    } else if (pid > 0) {
        close(fd[0]);

		while (fgets(line, MAXLINE, fp) != NULL) {
		    n = strlen(line);
	        if (write(fd[1], line, n) != n) {
		        printf("write error to pipe\n");
				exit(5);
		    }
		}
	
		if (ferror(fp)) {    // ferror函数返回0表示未出错
		    printf("fgets error\n");
		    exit(6);
		}
	
		close(fd[1]);    // 向管道读端发送文件结束符
	
		if (waitpid(pid, NULL, 0) < 0) {    // 要等待子进程结束,否则父进程结束会导致管道两端关闭,子进程可能还没有读取最后几次写到管道中的数据(由于子进程是分页程序,要等待用户翻页)
		    printf("waitpid error\n");
		    exit(7);
		}
		exit(0);
    } else {
        close(fd[1]);
		if (fd[0] != STDIN_FILENO) {
		    if (dup2(fd[0], STDIN_FILENO) != STDIN_FILENO) {    // 使标准输入也指向管道读端的文件表项
		        printf("dup2 error to stdin\n");
				exit(8);
		    }
		    close(fd[0]);
		}
	
		if ((pager = getenv("PAGER")) == NULL) {
		    pager = DEF_PAGER;
		}
	
		if ((argv0 = strrchr(pager, '/')) != NULL) {    // 令argv0指向最后一个/
		    argv0++;
		} else {
		    argv0 = pager;
		}
	
		if (execl(pager, argv0, (char *)0) < 0) {
		    printf("execl error for %s\n", pager);
		    exit(9);
		}
    }
    exit(0);
}

以上程序如想使用标准IO库代替read和write函数,可使用fdopen函数关联管道描述符和标准IO流,之后调用setvbuf将流设为行缓冲的,就可以用fputs和fgets函数读写管道了。

使用管道实现的TELL_WAIT、TELL_PARENT、WAIT_PARENT、TELL_CHILD、TELL_CHILD函数:

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

static int pfd1[2], pfd2[2];

void TELL_WAIT() {
    if (pipe(pfd1) < 0 || pipe(pfd2) < 0) {
        printf("pipe error\n");
    }
}

void TELL_PARENT(pid_t pid) {
    if (write(pfd2[1], "c", 1) != 1) {
        printf("write error\n");
    }
}

void WAIT_PARENT() {
    char c;

    if (read(pfd1[0], &c, 1) != 1) {
        printf("read error\n");
    }

    if (c != 'p') {
        printf("WAIT_PARENT: incorrect data\n");
		exit(1);
    }
}

void TELL_CHILD(pid_t pid) {
    if (write(pfd1[1], "p", 1) != 1) {
        printf("write error\n");
    }
}

void WAIT_CHID() {
    char c;

    if (read(pfd2[0], &c, 1) != 1) {
        printf("read error\n");
    }

    if (c != 'c') {
        printf("WAIT_CHILD: incorrect data\n");
		exit(1);
    }
}

创建一个连接到另一个进程的管道:
在这里插入图片描述
函数popen先创建一个管道并调用fork,然后关闭未使用的管道端,之后在子进程中调用exec执行参数cmdstring,并且返回一个标准IO文件指针,如果type参数指向的字符是’r’,则文件指针连接到子进程的标准输出;如果type参数指向的字符是’w’,则文件指针连接到子进程的标准输入:
在这里插入图片描述
即fork出的子进程将自己的标准输入或标准输出绑定到管道的读端或写端文件描述符上,父进程的popen函数将管道的写端或读端返回给调用点。

如果传给popen函数的命令错误,popen函数依然会返回一个文件指针,但shell不能执行错误的命令,于是在标准错误上打印以下信息:
在这里插入图片描述
pclose函数之后可返回该命令的退出状态,这个值与shell有关。

pclose函数关闭标准IO流,等待命令终止,然后返回shell的终止状态,如果shell不能被执行,则pclose返回的终止状态与shell执行exit(127)一样。

参数cmdstring由Bourne Shell以下列方式执行:

sh -c cmdstring

这表明shell将扩展参数cmdstring中的*等特殊字符。

使用popen函数重写分页程序:

#include <stdio.h>
#include <sys/wait.h>
#include <stdlib.h>

#define PAGER "${PAGER:-more}"     // 如果shell变量PAGER已定义,且其值非空,则使用其值,否则使用字符串more
#define MAXLINE 1024

int main(int argc, char *argv[]) {
    char line[MAXLINE];
    FILE *fpin, *fpout;

    if (argc != 2) {
        printf("usage: a.out <pathname>\n");
        exit(1);
    }

    if ((fpin = popen(argv[1], "r")) == NULL) {
        printf("can't open %s\n", argv[1]);
    }

    if ((fpout = popen(PAGER, "w")) == NULL) {
        printf("popen error\n");
    }
    
    while (fgets(line, MAXLINE, fpin) != NULL) {
        if (fputs(line, fpout) == EOF) {    // fputs函数在写入失败时返回-1(EOF),写入成功时返回一个非负值
		    printf("fputs error to pipe\n");
		}
    }

    if (ferror(fpin)) {
        printf("fgets error\n");
    }

    if (pclose(fpout) == -1) {
        printf("pclose error\n");
    }
    exit(0);
}

实现popen和pclose函数:

#include <errno.h>
#include <fcntl.h>
#include <sys/wait.h>
#include <unistd.h>
#include <limits.h>
#include <stdio.h>
#include <malloc.h>

static pid_t *childpid = NULL;
static int maxfd;

#ifdef OPEN_MAX
static long openmax = OPEN_MAX;
#else
static long openmax = 0;
#endif

#define OPEN_MAX_GUESS 256

long open_max(void) {
    if (openmax == 0) {      /* first time through */
        errno = 0;
        if ((openmax = sysconf(_SC_OPEN_MAX)) < 0) {
            if (errno == 0) {
                openmax = OPEN_MAX_GUESS;    /* it's indeterminate */
            } else {
                printf("sysconf error for _SC_OPEN_MAX\n");
            } 
        }
    }
 
    return openmax;
}

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) {    // first time through
        maxfd = open_max();
		if ((childpid = calloc(maxfd, sizeof(pid_t))) == NULL) {	// calloc函数每次分配maxfd个sizeof(pid_t)大小的空间,并且将分配的空间全部置0
		    return NULL;
		}
    }

    if (pipe(pfd) < 0) {
        return NULL;    // errno set by pipe()
    }

    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);    // dup和dup2函数执行后,新和旧描述符指向同一个文件表项,且新文件描述符的CLO_EXEC标志被清除(每个描述符都有自己的文件描述符标志,文件描述符标志当前只定义了CLO_EXEC一个)
				close(pfd[1]);
		    }
		} else {
		    close(pfd[1]);
		    if (pfd[0] != STDIN_FILENO) {
		        dup2(pfd[0], STDIN_FILENO);
				close(pfd[0]);
		    }
		}
	
		for (i = 0; i < maxfd; ++i) {    // POSIX要求函数popen关闭以前调用popen打开的、现在仍在子进程中打开着的IO流
		    if (childpid[i] > 0) {
		        close(i);
		    }
		}
	
		execl("/bin/sh", "sh", "-c", cmdstring, (char *)0);
		_exit(127);
    }

    /*     parent continus     */
    if (*type == 'r') {
        close(pfd[1]);
		if ((fp = fdopen(pfd[0], type)) == NULL) {
		    return NULL;
		}
    } else {
        close(pfd[0]);
		if ((fp = fdopen(pfd[1], type)) == NULL) { 
		    return NULL;
		}
    }

    childpid[fileno(fp)] = pid;    // fileno函数返回文件流参数对应的文件描述符
    return fp;
}

int pclose(FILE *fp) {
    int fd, stat;
    pid_t pid;

    if (childpid == NULL) {    // popen() has never been called
        errno = EINVAL;
		return -1;    
    }

    fd = fileno(fp);    
    if (fd >= maxfd) {    // invalid file descriptor
        errno = EINVAL;
		return -1;
    }

    if ((pid = childpid[fd]) == 0) {    // fp wasn't opened by popen()
        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;    // return child's termination status
}

如果函数pclose的调用者已经为信号SIGCHLD设置了一个信号处理程序,假设函数pclose的waitpid调用进行时子进程还未结束,则函数pclose中的waitpid函数会在子进程结束时返回-1并将errno置为EINTR,当waitpid调用被一个捕获到的信号中断时,只是再次调用waitpid。如果在SIGCHLD的信号处理程序中已wait了子进程,则函数pclose中的waitpid调用会返回-1,并将errno置为ECHLD。

根据以上分析,如果应用抢先在pclose函数前已经wait到了子进程,则pclose会返回-1。

popen函数不应由设置用户ID或设置组ID的程序调用,popen执行命令时等同于调用:

execl("/bin/sh", "sh", "-c", command, NULL);

它会从调用者的环境中执行shell,并由shell运行commamd,shell会以设置ID文件的模式所授予的权限执行命令。

在这里插入图片描述
上图中对输入的变换可能是路径名扩充,或提供历史机制(记住以前的命令),以下过滤器程序将大写字母改为小写:

#include <ctype.h>
#include <stdlib.h>
#include <stdio.h>

int main() {
    int c;

    while ((c = getchar()) != EOF) {
        if (isupper(c)) {
	    	c = tolower(c);
	    }
		if (putchar(c) == EOF) {
		    printf("output error\n");
		}
		if (c == '\n') {
		    fflush(stdout);
		}
    }
    exit(0);
}

将以上程序命名为myuclc,以下程序会使用它:

#include <sys/wait.h>
#include <stdlib.h>
#include <stdio.h>

#define MAXLINE 1024

int main() {
    char line[MAXLINE];
    FILE *fpin;

    if ((fpin = popen("/root/Desktop/apue/ch15/1515/myuclc", "r")) == NULL) {
        printf("popen error\n");
    }

    for ( ; ; ) {
        fputs("promt> ", stdout);
		fflush(stdout);    // 标准输出一般是行缓冲,而提示符不含换行,需要冲洗流
		if (fgets(line, MAXLINE, fpin) == NULL) {    // read from pipe
		    break;
		}
		if (fputs(line, stdout) == EOF) {
		    printf("fputs error to pipe\n");
		}
    }

    if (pclose(fpin) == -1) {
        printf("pclose error\n");
	}
	
    putchar('\n');
    exit(0);
}

当过滤程序既产生某个过滤程序的输入,又读取该过滤程序的输出时,它就变成了协同进程。

在这里插入图片描述
简单的协同进程:

#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>

int main() {
    int n, int1, int2;
    char line[1024];

    while ((n = read(STDIN_FILENO, line, 1024)) > 0) {
        line[n] = 0;    // null terminate
        if (sscanf(line, "%d%d", &int1, &int2) == 2) {    // 读取两个以空白符分隔的数字
		    sprintf(line, "%d\n", int1 + int2);
		    n = strlen(line);
		    if (write(STDOUT_FILENO, line, n) != n) {
		        printf("write error\n");
		    }
		} else {
		    if (write(STDOUT_FILENO, "invalid args\n", 13) != 13) {
		        printf("write error\n");
		    }
		}
    }
    exit(0);
}

将以上程序命名为add2,以下程序使用该协同进程:

#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>

static void sig_pipe(int);    // signal handler

int main() {
    int n, fd1[2], fd2[2];
    pid_t pid;
    char line[1024];

    if (signal(SIGPIPE, sig_pipe) == SIG_ERR) {
        printf("signal error\n");
    }

    if (pipe(fd1) < 0 || pipe(fd2) < 0) {
        printf("pipe error\n");
    }

    if ((pid = fork()) < 0) {
        printf("fork error\n");
    } else if (pid > 0) {
        close(fd1[0]);
		close(fd2[1]);

		while (fgets(line, 1024, stdin) != NULL) {
		    n = strlen(line);
		    if (write(fd1[1], line, n) != n) {
		        printf("write error to pipe\n");
		    }
		    if ((n = read(fd2[0], line, 1024)) < 0) {
		        printf("read error from pipe\n");
		    }
	        if (n == 0) {
			    printf("child closed pipe");
		        break;
			}
			line[n] = 0;
			if (fputs(line, stdout) == EOF) {
			    printf("fputs error\n");
			}
		}
	
		if (ferror(stdin)) {
		    printf("fgets error on stdin\n");
		}
		
		exit(0);
    } else {    // child
        close(fd1[1]);
		close(fd2[0]);
		if (fd1[0] != STDIN_FILENO) {
		    if (dup2(fd1[0], STDIN_FILENO) != STDIN_FILENO) {
		        printf("dup2 error to stdin\n");
		    }
		    close(fd1[0]);
		}
	
		if (fd2[1] != STDOUT_FILENO) {
		    if (dup2(fd2[1], STDOUT_FILENO) != STDOUT_FILENO) {
		        printf("dup2 error to stdout\n");
		    }
		    close(fd2[1]);
		}
	
		if (execl("./add2", "add2", (char *)0) < 0) {
		    printf("execl error\n");
		}
    }
    exit(0);
}

static void sig_pipe(int signo) {
    printf("SIGPIPE caught\n");
    exit(1);
}

运行它:
在这里插入图片描述

如果在以上程序的运行期间杀掉了协同进程add2,我们在下次输入数字时会由于管道的另一端关闭而产生信号SIGPIPE。

如果没有SIGPIPE的信号处理程序,在进程运行期间杀掉协同进程add2,此时输入一行,父进程会由于收到SIGPIPE而终止,此时,可使用shell命令echo $?查看进程是否是由SIGPIPE终止的,此命令执行结果是128+信号编号。

add2中调用的是底层IO函数read和write,如果换成标准IO函数:

#include <stdio.h>
#include <stdlib.h>

int main() {
    int int1, int2;
    char line[1024];

    while (fgets(line, 1024, stdin) != NULL) {
        if (sscanf(line, "%d%d", &int1, &int2) == 2) {
		    if (printf("%d\n", int1 + int2) == EOF) {
		        printf("printf error\n");
		    }
		} else {
		    if (printf("invalid args\n") == EOF) {
		        printf("printf error\n");
			}
		}
    }
    exit(0);
}

如果使用该协同进程,程序不会工作,问题在于调用fgets时,标准IO库会分配一个缓冲区并选择缓冲类型,由于标准输入是一个管道,因此标准IO库默认将其设为全缓冲的。同样的事情也出现在标准输出上。当add2被阻塞在读上,主进程被阻塞在读管道上,造成了死锁。

我们可以在协同进程的while循环前加上以下代码:

if (setvbuf(stdin, NULL, _IOLBF, 0) != 0) {
    printf("setvbuf error\n");
}
if (setvbuf(stdout, NULL, _IOLBF, 0) != 0) {
    printf("setvbuf error\n");
}

这可以使fgets函数在有一行可用时就返回,且使printf函数在一行被输出后就调用fflush。

我们使用以下函数作为协同进程时,程序不能工作:

#! /bin/awk -f
{ print $1 + $2 }

此协同进程不能工作的原因也是标准IO缓冲,但此时我们不能改变其缓冲方式。解决方法是令awk认为它的标准输入和标准输出都被连接在了终端上,从而使其标准IO例程将标准输入和标准输出设置为行缓冲,可用伪终端来实现。

FIFO有时被称为命名管道,未命名管道只能用于具有公共祖先的进程,其祖先创建管道的情况。未命名管道可在不相关的进程间交换数据。

FIFO是一种文件类型,文件是否是FIFO编码在stat结构的st_mode中,我们可以使用S_ISFIFO宏测试它是否是一个FIFO。

创建FIFO类似于创建一个文件,FIFO在文件系统中有其路径名。

在这里插入图片描述
mode参数与open函数的相同。mkfifoat函数可在参数fd表示的目录的相对路径处创建FIFO,path参数有以下三种情况:
1.如果path参数是绝对路径,则忽略参数fd,此时mkdifoat函数等同于mkfifo函数。
2.如果path参数是一个相对路径,且参数fd是一个有效的打开目录的文件标识符,则path参数是相对于fd参数的。
3.如果path参数是一个相对路径,且fd参数值为AT_FDCWD,则path参数是相对于当前目录的,此时mkfifoat函数等同于mkfifo函数。

我们使用以上函数创建好FIFO后,可使用open函数打开,并且对于普通文件的IO函数如close、read、write、unlink等都能用于FIFO。

应用可使用函数mknod或mknodat创建FIFO,但原来这两个函数没有包含在POSIX.1中,因此为POSIX.1创建了mkfifo和mkfifoat函数。现在函数mknod和mknodat包含在POSIX.1的XSI选项中了。

POSIX.1也包含了命令mkfifo,可用shell创建FIFO并使用shell的IO重定向访问FIFO。

打开FIFO时,标志O_NONBLOCK的影响:
1.当不带O_NONBLOCK只读打开调用open时,open函数会阻塞,直到其他进程以写打开该FIFO。类似地,以写调用open且不带O_NONBLOCK打开FIFO时,FIFO会被阻塞,直到其他进程以读打开FIFO。
2.如果指定了O_NONBLOCK标志,以只读打开FIFO时会立即返回,但以只写打开调用open函数时,如果没有以读打开此FIFO的进程,open函数会返回-1,并将errno置为ENXIO。

如果向没有被其他进程以读打开的FIFO写时,会产生信号SIGPIPE。当最后一个写者关闭了FIFO时,读者会收到一个EOF。

一个FIFO可能会有多个写者,因此要考虑原子写操作。常量PIPE_BUF指定了可以被原子写到FIFO的数据量。

FIFO的用途:
1.可被shell命令用来从一个管道向另一个管道传递数据,而不用创建中间文件。
2.可在客户-服务器应用中在服务器进程和客户端进程间传递信息。

FIFO可用来在一串shell命令间复制输出流,这可以不用在磁盘上创建中间文件。管道只能用在线性连接的进程间,而FIFO有名字,可用于非线性连接。

在这里插入图片描述
我们可以使用tee命令(从标准输入读,并将读到的内容存入指定文件并写到标准输出)完成以上过程,而不用使用临时文件,tee复制它的标准输入到标准输出和一个文件中:

mkfifo fifo1
prog3 < fifo1 &    # 后台启动prog3,并从FIFO读
prog1 < infile | tee fifo1 | prog2    # 启动prog1,并将tee的输入送往fifo1和prog2

在这里插入图片描述
FIFO也可用于多个客户端进程和一个服务器进程之间通信:
在这里插入图片描述
由于有多个写者,因此客户的写请求大小不应大于PIPE_BUF,这样可防止客户交叉写。

这种结构的问题是服务器进程怎样发送应答给客户端进程,客户端不知道什么时候读取属于自己的应答,一种解决方法是客户进程发送其进程ID,客户端根据进程ID创建另一个FIFO,通过此FIFO读取自己的应答:
在这里插入图片描述

但以上方法服务器端不知道客户进程是否崩溃,使得客户的FIFO留在文件系统中。服务端必须捕捉SIGPIPE信号,因为可能客户进程还没接收应答就崩溃,造成服务器端写一个没有读者的、基于已崩溃客户进程的FIFO。

每当客户进程数量从1变成0,服务器端都会从FIFO中read到一个EOF,为了避免服务器端处理这种情况,可将服务器端的FIFO以读写方式打开,这样该FIFO就永远有一个写者了。

有三种类型的IPC称为XSI IPC:消息队列、信号量和共享内存。

每个IPC结构(消息队列、信号量、共享内存段)在内核中使用一个非负的标识符来引用,例如,向消息队列中取放消息只需要这个消息队列的标识符。与文件描述符不同的是,IPC标识符不是小整数,每当创建一个IPC结构,与之相关的标识符加1,直到最大的整数再回到0。

这个标识符是IPC对象的内部名字,而互相合作的进程需要一个外部名来使用相同的IPC对象,因此,IPC对象有一个键与之联系,键为外部名。

当一个IPC结构被创建,都应指定一个键,键的数据类型为key_t,通常在头文件sys/types.h中被定义为长整型,这个键由内核变换成标识符。

使客户端进程和服务器进程在同一IPC结构上汇聚的方法:
1.服务器进程可指定键IPC_PRIVATE创建一个新IPC结构,将返回的标识符存放在某处(如文件中)以便客户端进程取用,键IPC_PRIVATE保证服务器进程创建一个新的IPC结构。IPC_PRIVATE键也可用于父子进程,父进程指定IPC_PRIVATE创建一个新IPC结构,所返回的标识符可供调用fork后的子进程使用,子进程也可将此标识符作为exec函数的参数传给新程序。
2.可将键存在一个公共文件中,服务器进程使用该键创建一个新的IPC结构,但可能该IPC已与一个IPC结构相结合,此时get函数(msgget、semget、shmget函数)会出错返回,服务器进程必须处理该错误,删除已存在的IPC结构并重建它。
3.有一个客户进程和服务器进程都知道的路径名和项目ID(0~255之间的数字),接着调用ftok函数将这两个值变为一个键,并将该键放在2中的公共文件中。

函数ftok可将路径名和项目ID转换为键:
在这里插入图片描述
path参数必须引用一个现有的文件,产生键时,只使用id参数的低8位。

函数ftok取得给定文件的stat结构中的st_dev(目录所在设备(磁盘)的设备号)和st_ino(i节点编号)字段,然后再将它们与id参数组合起来,ftok函数通常会为不同的路径名返回不同的键,但由于i节点编号和键通常都存放在长整型中,所以创建键时可能会丢失信息,因此两个不同的路径名如果使用同一项目ID,则可能产生相同的键。

3个get函数(msgget、semget、shmget)都有两个类似参数,一个key和一个整型flag,创建一个新IPC结构时,如果key是IPC_PRIVATE或和当前某种类型的IPC结构没有关联,则需要指明flag参数的IPC_CREAT位。为引用一个现有队列,key必须等于创建队列时指明的key值,并且IPC_CREAT不必被指明。

不能指定IPC_CREAT作为键引用一个现有队列,这个键总是用于创建新队列,为引用用IPC_CREAT为键创建的现有队列,要知道它的相关内部标识符,然后在其他IPC调用中(如msgsnd、msgrcv)使用该内部标识符,这样可以绕过get函数。

创建新IPC结构时,要确保没有引用具有同一键的现有IPC结构,那么必须在flag中同时指定IPC_CREAT和IPC_EXCL位,这样如果IPC结构已存在时会出错,并返回EEXIST。(与open函数指定了O_CREAT和O_EXCL相似)

每个IPC结构都关联了一个ipc_perm结构,它规定了权限和所有者,至少包括以下成员:
在这里插入图片描述
完整定义可在头文件sys/ipc.h中查看。

创建IPC结构后,如果想修改ipc_perm结构中的值,可调用相关函数,但调用进程必须是IPC结构的创建者或超级用户。

ipc_perm结构中的mode字段:
在这里插入图片描述
某些实现定义了每种权限的符号常量,但这些常量并不包括在SUS中。

linux中,可使用命令ipcs -l查看XSI IPC的限制。

XSI IPC的一个问题是它在系统范围内起作用,且没有引用计数,如果创建了一个消息队列,并向其中放入了几则消息,则消息会持续到:某个进程调用msgrcv或msgctl读消息或删除消息队列;某进程执行ipcrm命令删除消息队列;正在自举的系统删除消息队列。而管道在最后一个引用的进程终止时,管道就被删除了。对FIFO,最后一个引用FIFO的进程终止时,虽然FIFO的名字仍留在系统中,直到被显式删除,但留在FIFO中的数据已被删除了。

XSI IPC的另一个问题是这些IPC结构在文件系统中没有名字,不能用操作文件的函数访问或修改它们的属性,为支持这些IPC对象,内核增加了十几个函数。

XSI IPC不使用文件描述符,因此不能对它们使用多路转接IO函数,很难使用一个以上这样的结构,或在文件或设备IO中使用这样的IPC结构。

分配给特定队列的标识符无法猜到或事先存放在一个头文件中。

在这里插入图片描述
上图中的无连接指无需事先调用某种形式的打开函数就能发送消息的能力。以上所有形式的IPC都限制在一台主机上,因此它们都是可靠的,当消息通过网络发送时,就要考虑消息丢失的可能性。流控制指如果系统资源(缓冲区)短缺,或接收进程不能再接收更多消息,则发送进程就要休眠,当流控制条件消失时,发送进程应自动唤醒。

消息队列是消息的链接表,存储在内核中,由消息队列标识符标识,以下描述中消息队列简称队列,其标识符简称队列ID。

msgget函数可用来创建一个新队列或打开一个现有队列,msgsnd函数将新消息添加到队列尾端,每个消息包含一个正的长整型类型字段、一个非负的长度以及实际数据字节数,这些都会在将消息添加到队列时,传送给msgsnd函数。msgrcv函数用于从队列中取消息,不一定按消息的先进先出顺序取,也可按消息的类型字段取。

每个队列都有一个msqid_ds结构与其关联:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
上图中导出的表示此限制来源于其他限制,如Linux中最大消息数是根据最大队列数和队列中允许的最大数据量决定的,最大队列数还要根据系统上安装的RAM数量决定。

在这里插入图片描述
创建新队列时,要初始化msqid_ds结构的下列成员:
1.ipc-perm。
2.msg_qnum、上次调用msgsend的进程id(msg_lspid)、msg_lrpid、msg_stime、msg_rtime都设为0。
3.msg_ctime设为当前时间。
4.msg_qbytes设为系统限制值。

msgget函数成功调用时返回非负队列ID,此值可被用于其他三个消息队列函数。

msgctl函数:
在这里插入图片描述
cmd参数:
1.IPC_STAT,取此队列的msqid_ds结构,并将其存放在buf指向的结构中。
2.IPC_SET,将字段msg_perm.uid、msg_perm.gid、msg_perm.mode和msg_qbytes从buf指向的结构复制到与这个队列相关的msqid_ds结构中,此命令只能由以下进程使用:有效用户ID等于msg_perm.cuid或msg_perm.uid的进程;有超级用户特权的进程。只有超级用户能增加msg_qbytes的值。
3.IPC_RMID,从系统中删除该消息队列以及其中的所有数据。使用已被删除队列的进程在下一次对队列进行操作时会得到EIDRM错误。此命令只能由以下进程使用:有效用户ID等于msg_perm.cuid或msg_perm.uid的进程;有超级用户特权的进程。

以上三个cmd参数也可用于信号量和共享存储。

在这里插入图片描述
消息总是放到队列尾端。参数nbytes为非负的长度,表示消息数据的长度。

参数ptr指向一个长整型数,它是一个正的整型,含义为消息类型,其后紧跟着的是消息数据,如果nbytes参数为0,则没有消息数据。如果最大消息长度为512bytes,我们可定义以下结构:
在这里插入图片描述
ptr参数可指向mymesg结构。消息类型可使接收者以不同于先进先出的顺序取消息。

某些平台既支持32位环境,又支持64位环境,这会影响到long和指针的大小,如果一个32位程序通过管道或套接字与一个64位程序交换此结构,会出问题,因为32位的程序中,期望mtext字段在结构的起始地址后的第四个字节处开始,而64位程序会期望mtext字段在结构起始地址后的第八个字节处开始。但实际上XSI消息队列不会出现此问题,IPC系统调用的32位和64位版本有不同的入口,会处理其中的差异。唯一的问题是当64位程序向32位程序发送消息时,如果它在8字节的long中设置的值大于32位程序中4字节的long能表示的最大值,则mtype字段会截短一段。

参数flag可设为IPC_NOWAIT,此时若消息队列已满(或队列中消息总数等于系统限制值;或队列中字节总数等于系统限制值),则msgsnd函数立即出错返回EAGAIN,如没有指定IPC_NOWAIT,则进程会被阻塞到:有空间容纳要发送的信息;系统中删除了此队列,此时会返回错误EIDRM;捕捉到一个信号并从信号处理函数返回,此时会返回错误EINTR。

删除消息队列时,由于消息队列没有维护引用计数器,删除队列后,仍在使用这一队列的进程下次操作时会错误返回,信号量也是如此。但删除文件时,只有最后一个进程关闭了它的文件描述符之后,才删除文件中的内容。

当msgsnd函数返回成功时,消息队列相关的msqid_ds结构随之更新,会更新最后一次发送消息的进程pid msg_lspid、最后一次发送消息的时间msg_stime、以及队列中的消息数msg_qnum。

从队列中取消息:
在这里插入图片描述
参数ptr指向一个长整型数,这个长整型数的含义为返回的消息类型,其后紧跟存储实际消息的缓冲区,参数nbytes指定数据缓冲区的长度,如果返回的消息的长度大于nbytes,且在flag中设置了MSG_NOERROR位,则消息会被截断,此时我们并不知道消息被截断了,如果没有设置MSG_NOERROR,而消息又太长,则出错返回E2BIG,消息还会留在消息队列中。

参数type可指定想要哪种类型的消息:
1.0:返回队列中第一个消息。
2.>0:返回队列中类型为type的第一个消息。
3.<0:返回消息类型值小于type绝对值的消息,如果有多个,则返回类型值最小的消息。

type值非0用于以非先进先出次序读消息。如果应用对消息赋予优先权,那么type可以是优先权值。如果一个消息队列由多个客户进程和一个服务器进程使用,那么type字段可以用来包含客户进程ID(只要进程ID能存放在长整型中)。

可将flag值指定为IPC_NOWAIT,使操作不阻塞,这样,如果没有所指定类型的消息可用,则msgrcv函数返回-1,errno设为ENOMSG,如果没有指定IPC_NOWAIT,则进程会一直阻塞到有了指定类型的消息可用,或者从系统中删除了该队列(此时返回-1,errno设为EINRM)或捕捉到一个信号并从信号处理函数返回(此时返回-1,errno设为EINTR)。

msgrcv函数成功执行时,内核会更新与该消息队列相关联的msgid_ds结构,以指示调用者的ID msg_lrpid、调用时间msg_rtime、队列中消息数减少1(msg_qnum)。

如需双向数据流,可使用消息队列或全双工管道,它们之间的速度在Solaris上已差不多,原本消息队列的目的是提供高于一般速度的IPC,但现在最好不用它。

信号量是一个计数器,用于为多个进程提供对共享数据的访问。

为获取共享资源:
1.测试控制该资源的信号量。
2.如为正,进程就可使用该资源。此时,进程将信号量减1,表示它使用了一个资源单位。
3.如为0,进程进入休眠,直到信号量大于0。

进程不再使用由一个信号量控制的共享资源时,该信号量值增加1,此时如果有进程在休眠等待此信号量,则唤醒它们。

信号量值的测试和减1操作应是原子操作。为此,信号量通常是在内核中实现的。

常用的信号量形式被称为二元信号量,它控制单个资源,其初始值为1,该值可以是任何正数,表示有多少个共享资源的单位可供共享。

XSI信号量比较复杂:
1.信号量需定义为含有一个或多个信号量值的集合,创建信号量时,要指定集合中信号量值的数量。
2.信号量的创建(semget函数)是独立于它的初始化(semctl函数)的,这是一个致命缺点,无法原子地创建一个信号量的集合,并对信号量集合中的各个信号量赋初值。
3.即使没有进程正在使用各种形式的XSI IPC,它们仍是存在的,有的程序在终止时没有释放分配给它的信号量,undo功能可以处理这种情况。

内核为每个信号量集合维护一个semid_ds结构:
在这里插入图片描述
在这里插入图片描述
SUS定义了以上字段,具体实现还可定义其他成员。

每个信号量由一个无名结构表示,它至少包含以下成员:
在这里插入图片描述
在这里插入图片描述
使用以下函数获取一个信号量id:
在这里插入图片描述
当key是创建新集合时,要对semid_ds结构的以下成员赋初值:
1.初始化ipc_perm结构,该结构中的mode成员被设置为flag中的相应权限位。
2.上次对信号量进行操作的时间sem_otime设为0。
3.信号量最后一次改变的时间sem_ctime设为当前时间。
4.集合中的信号量个数sem_nsems设为参数nsems。

如果是创建新集合,则必须指定参数nsems,如果是引用现有集合,则将其指定为0。

对信号量进行一些操作的函数:
在这里插入图片描述
第四个参数是可选的,取决于所请求的命令,该参数的类型为semun,它是多个命令特定参数的union:
在这里插入图片描述
通常应用必须定义联合semun,但在FreeBSD 8.0中,semun已经由sys/sem.h定义好了。

参数semnum表示信号量集合中的某个信号量,它的值有效范围为0~nsems-1。nsems为函数semget的参数,表示该集合中的信号量数。

cmd参数是下列命令中的一种,这些命令作用在参数semid代表的信号量集合上:
1.IPC_STAT:取此集合的semid_ds结构,并将其存放在参数arg.buf指向的地址中。
2.IPC_SET:按arg.buf指向的结构中的值,设置此集合相关的结构(即每个IPC结构相关联的ipc_perm结构)中sem_perm.uid(该信号量集合的拥有者的有效用户id)、sem_perm.gid(该信号量集合拥有者的有效组id)、sem_perm.mode(该信号量集合的读写权限)。该命令只能由有效用户id为sem_perm.cuid(该信号量创建者的有效用户id)或sem_perm.uid的进程或root进程执行。
3.IPC_RMID:从系统中删除该信号量集合。删除时仍在使用此信号量集合的进程在它们下次试图对此信号量集合操作时,将出错返回EIDRM。该命令只能由有效用户id为sem_perm.cuid或sem_perm.uid的进程或root进程执行。
4.GETVAL:返回semnum标识的信号量的semval值(即信号量当前值)。
5.SETVAL:设置semnum标识的信号量的semval值为arg.val的值。
6.GETPID:返回semnum标识的信号量的sempid值(即最后一次操作该信号量的进程id)。
7.GETNCNT:返回semnum标识的信号量的semncnt值(即等待semval变为所需值(当前的值比所需值小)的进程数)。
8.GETZCNT:返回semnum标识的信号量的semzcnt值(即等待semval变为0的进程数)。
9.GETALL:取该集合中所有的信号量的semval值,并将其存在arg.array指向的数组中。
10.SETALL:将该集合中所有信号量的semval值设为arg.array数组中的值。

除了GETALL以外的所有GET命令,semctl函数都返回对应值,其他命令若成功返回0,否则返回-1并设置errno。

对信号量进行操作的函数:
在这里插入图片描述
参数semoparray指向由sembuf结构组成的数组,该结构包含了对该信号量集合中的某个信号量要做的操作信息:
在这里插入图片描述
参数nops规定该数组的元素数。

进程终止时,它可能占用了经由信号量分配的资源,只要sem_buf结构中的成员sem_flag设置了SEM_UNDO标志,然后分配了资源(sem_op值<0),则内核会记住给该进程分配的资源数,进程终止时,内核会检验该进程是否还有尚未处理的信号量调整值,如有,则按调整值对相应信号量值处理。

对集合中每个信号量的操作由相应的sembuf结构中的sem_op值规定,该值为:
1.正数时,对应于进程释放占用的资源数,sem_op值会加到信号量值上,如指定了SEM_UNDO标志,则信号量调整值也会减少sem_op。
2.负数时,表示要获取由该信号量控制的资源。如果该信号量的值大于等于sem_op的绝对值,则从信号值中减去sem_op的绝对值,如指定了SEM_UNDO标志,则信号量调整值也会增加sem_op的绝对值。如果信号量的值小于sem_op的绝对值(资源不能满足需求),则:
(1)如sembuf结构的sem_flag成员指定了IPC_NOWAIT,则semop函数出错并返回EAGAIN。
(2)如未指定IPC_NOWAIT,则该信号量的semncnt值加1,调用进程将进入休眠状态,直到:
①此信号量的值变成大于等于sem_op的绝对值,此信号的semncnt会-1,并从信号量值中减去sem_op的绝对值。
②系统中删除了此信号量,此时出错返回EIDRM。
③进程捕捉到一个信号,并从信号处理程序返回,此时,信号量的semncnt-1,出错返回EINTR。
3.0时,表示调用进程希望等待到信号量值变为0。如果当前信号量值为0,则立即返回,否则:
(1)若指定了IPC_NOWAIT,则出错返回EAGAIN。
(2)若未指定,则该信号的semzcnt+1,调用进程被挂起,直到:
①此信号量变为0,之后函数返回,信号量的semzcnt-1。
②从系统中删除了此信号量,此时出错返回EIDRM。
③进程捕捉到一个信号,并从信号处理程序返回,此时,信号量的semzcnt-1,出错返回EINTR。

semop函数具有原子性,它或者执行数组中所有操作,或者一个也不做。

如果用带SETVAL或SETALL命令的semctl函数设置一个信号量的值,则在所有进程中,该信号量的调整值都将设为0。

如果多个进程间共享一个资源,则可用信号量、记录锁、互斥量之一来协调访问,我们可以使用映射到两个进程地址空间中的信号量、记录锁或互斥量。

若使用信号量,则先创建一个包含一个成员的信号量集合,然后将该信号量值初始化为1,分配资源时以sem_op为-1调用semop,释放资源时以sem_op为1调用semop,对每个操作都指定SEM_UNDO,以处理在未释放资源条件下进程终止的情况。

若使用记录锁,先创建一个空文件,且用该文件的第一个字节(无需存在)作为锁字节,为了分配资源,先对该字节获得一个写锁,释放该资源时,对该字节解锁,记录锁的性质确保锁的持有者进程终止时,内核会自动释放该锁。

若使用互斥量,需要所有进程将相同的文件映射到它们的地址空间里,并使用PTHREAD_PROCESS_SHARED互斥量属性在文件的相同偏移处初始化互斥量,为分配资源,对互斥量加锁,为释放资源,解锁互斥量,如果一个进程没有释放互斥量就终止了,要使用鲁棒互斥量,并在其他进程中调用pthread_mutex_consistent。
在这里插入图片描述
记录锁速度可接受,使用简单,如果不是性能要求极高,不要使用互斥量,因为在多个进程间共享的内存中恢复一个终止进程使用的互斥量比较难,并且进程共享的互斥量属性没有得到普遍支持。

共享存储允许多个进程共享给定的存储区,因为数据不需要在客户进程和服务器进程之间复制,所以这是最快的一种IPC,但多个进程要同步地访问给定的存储区,通常信号量用于同步共享存储的访问。

多个进程将同一文件映射到它们的地址空间就是一种共享存储的形式。XSI的共享存储与内存映射的文件的区别在于,前者没有相关的文件,XSI共享存储段是内存的匿名段。

内核为每个共享存储段维护着一个结构,该结构至少包含以下成员:
在这里插入图片描述
shmatt_t为无符号类型,它至少和unsigned short一样大。

获得一个共享存储的标识符:
在这里插入图片描述
参数size是该共享存储段的长度,单位是字节,实现上通常将其向上取为系统页长的整数倍。如果size不是系统页长的整数倍,那么最后一页的余下部分是不可使用的。创建新段时,必须指定size参数,如果想引用一个现存的段,则将size参数指定为0。创建一个新段后,段内内容初始化为0。

当创建一个新段时,初始化shmid_ds结构的下列成员:
1.ipc_perm结构,该结构中的mode按flag参数中的相应权限位指定。
2.上一次对共享内存操作的进程号shm_lpid、连接到共享内存的进程数量shm_nattch、最后一个进程访问共享内存的时间shm_atime、最后一个进程离开共享内存的时间shm_dtime都设为0。
3.最后一次修改共享内存的时间shm_ctime设为当前时间。
4.共享内存的空间shm_sgesz设为参数size。

对共享存储段执行操作的函数:
在这里插入图片描述
cmd参数:
1.IPC_STAT:取此段的shmid_ds结构,并将其存储在由buf指向的内存中。
2.IPC_SET:按buf指向的结构中的值设置与此共享存储段相关的shmid_ds结构中的下列字段:shm_perm.uid(共享存储段拥有者的有效用户id)、shm_perm.gid(共享存储段拥有者的有效组id)、shm_perm.mode(共享存储段的读写权限)。该命令只能由有效用户id为shm_perm.cuid(共享存储段的创建者的有效用户id)或sem_perm.uid的进程或root进程执行。
3.IPC_RMID:从系统中删除该共享存储段。每个存储段维护着一个连接计数(shmid_ds结构的shm_nattch成员),除非使用该段的最后一个进程终止或与该段分离,否则实际上不会删除该存储段。不管该段是否仍在使用,该段标识符都会被立即删除,所以不能再使用shmat函数与该段连接。该命令只能由有效用户id为shm_perm.cuid或sem_perm.uid的进程或root进程执行。
4.Linux和Solaris还提供本命令和下条命令,但它们并非SUS的组成部分。SHM_LOCK:在内存中对共享存储段加锁,此命令只能由root用户执行。
5.SHM_UNLOCK:解锁共享存储段,此命令只能由root用户执行。

进程可使用该函数将共享存储段连接到它的地址空间:
在这里插入图片描述
共享存储段连接到调用进程的哪个地址上由addr参数和flag中是否指定SHM_RND标志有关:
1.如果addr为0,此段将连接到由内核选择的第一个可用地址上,这是推荐的方式。
2.addr非0,且没有指定SHM_RND,则此段连接到addr指定的地址上。
3.addr非0,且指定了SHM_RND,则此段连接到addr - addr % SHMLBA所表示的地址上,SHM_RND的含义为取整,SHMLBA的含义为低边界地址倍数,它总是2的乘方,该算式是将地址向下取最近一个SHMLBA的倍数。

除非只计划在一种硬件上运行应用程序,否则不应指定共享存储段所连接到的地址,而是将addr参数指定为0,由系统选择地址。

如果在flag中指定了SHM_RDONLY标志,则以只读方式连接此段,否则以读写方式连接此段。

shmat函数的返回值是该段所连接的实际地址,如出错返回-1,如果shmat函数成功执行,则内核将使与该共享存储段相关的shmid_ds结构中的shm_nattch计数器值加1。

对共享存储段的操作结束时,调用以下函数与该段分离:
在这里插入图片描述
参数addr是调用shmat时的返回值。成功执行后,相关shmid_ds结构中的shm_nattch计数器值-1。

打印各种类型数据在内存中存放的位置:

#include <sys/shm.h>
#include <stdio.h>
#include <stdlib.h>

#define ARRAY_SIZE 40000
#define MALLOC_SIZE 100000
#define SHM_SIZE 100000
#define SHM_MODE 0600    // user raed & write

char array[ARRAY_SIZE];    // uninitialized data = bss

int main() {
    int shmid;
    char *ptr, *shmptr;

    printf("array[] from %p to %p\n", (void *)&array[0], (void *)&array[ARRAY_SIZE]);    // %p含义为以十六进制整数方式输出指针地址
    printf("stack around %p\n", (void *)&shmid);
    
    if ((ptr = malloc(MALLOC_SIZE)) == NULL) {
        printf("malloc error\n");
		exit(1);
    }
    printf("malloced from %p to %p\n", (void *)ptr, (void *)ptr + MALLOC_SIZE);

    if ((shmid = shmget(IPC_PRIVATE, SHM_SIZE, SHM_MODE)) < 0) {
        printf("shmget error\n");
		exit(1);
    }

    if ((shmptr = shmat(shmid, 0, 0)) == (void *)-1) {
        printf("shmat error\n");
		exit(1);
    }

    printf("shared memory attached from %p to %p\n", (void *)shmptr, (void *)shmptr + SHM_SIZE);

    if (shmctl(shmid, IPC_RMID, 0) < 0) {
        printf("shmctl error\n");
		exit(1);
    }

    exit(0);
}

在一个基于Intel的64位Linux上运行该程序:
在这里插入图片描述
以图形表示:
在这里插入图片描述
共享存储可由两个不相关的进程使用,但如果进程是相关的,则某些实现提供了不同的技术。

下面说明的技术用于FreeBSD 8.0、Linux 3.2.0、Solaris 10,而Mac OS X 10.6.8不支持将字符设备映射到进程地址空间。

在读设备/dev/zero时,该设备是0字节的无限资源。它也接收写向它的任何数据,但会忽略这些数据,将此设备作为IPC,对其进行存储映射时,它具有一些特殊性质:
1.创建一个未命名的存储区,其长度是mmap函数的第二个参数,将其向上取整为系统的最近页长。
2.存储区都初始化为0。
3.如果多个进程的共同祖先对mmap函数指定了MAP_SHARED标志,则这些进程可共享此存储区。

使用特殊设备/dev/zero进行IPC的一个例子:

#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>

#define NLOOPS 1000
#define SIZE sizeof(long)    // size of shared memory area

static int update(long *ptr) {
    return (*ptr)++;    // return value before increment
}

static int pfd1[2], pfd2[2];

void TELL_WAIT() {
    if (pipe(pfd1) < 0 || pipe(pfd2) < 0) {
        printf("pipe error\n");
    }
}

void TELL_PARENT(pid_t pid) {
    if (write(pfd2[1], "c", 1) != 1) {
        printf("write error\n");
    }
}

void WAIT_PARENT() {
    char c;

    if (read(pfd1[0], &c, 1) != 1) {
        printf("read error\n");
    }

    if (c != 'p') {
        printf("WAIT_PARENT: incorrect data\n");
		exit(1);
    }
}

void TELL_CHILD(pid_t pid) {
    if (write(pfd1[1], "p", 1) != 1) {
        printf("write error\n");
    }
}

void WAIT_CHILD() {
    char c;

    if (read(pfd2[0], &c, 1) != 1) {
        printf("read error\n");
    }

    if (c != 'c') {
        printf("WAIT_CHILD: incorrect data\n");
		exit(1);
    }
}

int main() {
    int fd, i, counter;
    pid_t pid;
    void *area;

    if ((fd = open("/dev/zero", O_RDWR)) < 0) {
        printf("open error\n");
		exit(1);
    }

    // MAP_PRIVATE时程序不能工作
    if ((area = mmap(0, SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0)) == MAP_FAILED) {
        printf("mmap error\n");
		exit(1);
    }
    close(fd);    // can close /dev/zero now that it's mapped

    TELL_WAIT();

    if ((pid = fork()) < 0) {
        printf("fork error\n");
		exit(1);
    } else if (pid > 0) {
        for (i = 0; i < NLOOPS; i += 2) {
		    if ((counter = update((long *)area)) != i) {
		        printf("parent: expected %d, got %d\n", i, counter);
		        exit(1);
		    }
	
		    TELL_CHILD(pid);
		    WAIT_CHILD();
		}
    } else {
        for (i = 1; i < NLOOPS; i += 2) {
		    WAIT_PARENT();
	
		    if ((counter = update((long *)area)) != i) {
		        printf("child: expected %d, got %d\n", i, counter);
				exit(1);
		    }
	
		    TELL_PARENT(getppid());
		}
    }

    exit(0);
}

使用特殊设备的优点在于调用mmap前不用存在一个实际文件,映射时自动创建一个指定长度的映射区,但它只在两个相关进程间起作用。

mmap函数的很多实现提供了类似于/dev/zero的设施,称为匿名存储映射,为了使用匿名存储映射,需要在调用mmap时指定MAP_ANON标志,并将文件描述符指定为-1,这样得到的区域是匿名的(因为它不通过一个文件描述符与一个路径名相结合),并且可与后代进程共享。

POSIX信号量意在解决XSI信号量的以下缺陷:
1.POSIX信号量性能更高。
2.POSIX信号量使用更简单,没有信号量集。
3.POSIX信号量被删除时,对这个信号量的操作能继续正常工作,直到该信号量的最后一次引用被释放。而XSI信号量被删除时,使用该信号量标识符的操作会失败,并将errno设为EIDRM。

POSIX信号量有两种形式:命名的和未命名的,差异在于创建和销毁的形式上,未命名信号量只存在于内存中,并要求使用信号量的进程必须可以访问指定内存,这意味着它们只能应用在同一进程中的线程或不同进程中已经映射相同内存内容到它们的地址空间中的线程。而命名信号量可通过名字被任何知道它们名字的进程中的线程使用。

创建新的或使用现有的信号量:
在这里插入图片描述
使用现有信号量时,只需指定信号量名字name参数和oflag参数(传入0即可),当oflag参数有O_CREAT标志时,如果命名信号量不存在,则创建一个新的,否则会使用现有信号量,但不会有额外的初始化发生。

指定O_CREAT标志时,需要两个额外参数,mode参数指定谁能访问信号量,取值与ipc_perm结构中的mode成员相同,赋值给信号量的权限会被调用者的文件创建屏蔽字影响。

当我们打开一个现有信号量时,接口不允许指定模式,实现经常以读和写打开信号量。

创建信号量时,value参数指定了信号量的初始值,该参数的取值范围为0~SEM_VALUE_MAX。

如果我们想确保是创建信号量,可将oflag参数设为O_CREAT | O_EXECL,此时如果信号量已存在,会导致sem_open调用失败。

为增加可移植性,信号量命名时需遵循一定规则:
1.名字第一个字符应为/,尽管没有要求POSIX信号量的实现要使用文件系统,但如果使用了文件系统,应在名字被解释时消除二义性。
2.名字不应包含其他/以避免实现定义的一些行为,如,实现使用了文件系统时,则名字/mysem和//mysem会被认定为同一个文件名,但如果没有使用文件系统,则这两种命名不同。
3.信号量名字最大长度由实现定义,不应长于_POSIX_NAME_MAX个字符,这是文件系统的实现能允许的最大名字长度限制。

sem_open函数成功时返回一个信号量指针,可传递到其他信号量函数上,如完成信号量操作时,释放与信号量相关的资源:
在这里插入图片描述
如果进程没有调用sem_close就退出,内核将自动关闭任何打开的信号量,但这不会影响信号量值,如果已经对它增加了1,这并不会因为退出而改变,没有XSI信号量的类似SEM_UNDO的标志。

销毁命名信号量:
在这里插入图片描述
sem_unlink函数如果没有被引用的信号量,则信号量会被立即销毁,否则销毁将延迟到最后一个打开的引用关闭。

将信号量减1:
在这里插入图片描述
调用sem_wait时,如果信号量计数是0会发生阻塞,直到成功使信号量减1或被信号中断才返回。可用sem_trywait函数避免阻塞,当信号量是0时,该函数返回-1并将errno设为EAGAIN。

也可阻塞一段时间:
在这里插入图片描述
tsptr参数指定的是绝对时间,超时是基于CLOCK_REALTIME时钟的。如果信号量可被立即减1,那么超时值就不重要了,即使超时值是过去的某个时间,减一操作依然会成功。如果超时,sem_timedwait函数将返回-1,并将errno设为ETIMEDOUT。

使信号量的值增加1:
在这里插入图片描述
单个进程中,使用未命名POSIX信号量更容易,这仅仅是创建和销毁信号量的方式不同,创建它:
在这里插入图片描述
pshared参数非0时表示在多个进程中使用信号量。value参数指定了信号量的初始值。

需要声明一个sem_t类型变量传给sem参数来实现初始化,如果要在两个进程之间使用信号量,需要保证sem参数指向两个进程之间共享的内存范围。

丢弃未命名信号量:
在这里插入图片描述
调用sem_destroy后,不能再以信号量sem调用信号量函数,除非通过sem_init函数重新初始化它。

返回信号量值:
在这里插入图片描述
调用成功后,信号量的值通过指针参数valp返回,之后我们使用该值时,信号量的值可能已经改变了,除非使用额外的同步机制避免这种竞争,否则该函数只能用于调试。

测试在以下两种平台上竞争分配和释放信号,比较其性能:
在这里插入图片描述
可见POSIX信号量在Solaris 10上性能提高了12%,在Linux 3.2.0上提高了94%,通过跟踪程序我们发现,Linux上的实现将文件映射到了进程地址空间中,并没有使用系统调用操作各自的信号量。

SUS没有定义当一个线程对一个普通互斥量加锁,而另一个线程试图去解锁它的情况,这种情况下错误检查互斥量和递归互斥量会出错,由于二进制信号量可当作互斥量使用,因此这种情况下可用信号量提供互斥。

假设我们想创建一个能被一个线程加锁而被另一个线程解锁的锁,它的结构可以是:
在这里插入图片描述
实现以上基于信号量的锁:

#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <semaphore.h>
#include <limits.h>
#include <fcntl.h>

struct slock {
    sem_t *semp;
    char name[_POSIX_NAME_MAX];
};

struct slock *s_alloc() {
    struct slock *sp;
    static int cnt;

    if ((sp = malloc(sizeof(struct slock))) == NULL) {
        return NULL;
    } 

    do {
        snprintf(sp->name, sizeof(sp->name), "/%ld.%d", (long)getpid(), cnt++);
		sp->semp = sem_open(sp->name, O_CREAT | O_EXCL, S_IRWXU, 1);    // S_IRWXU means read, write, execute by user
		                                                                // 这三个宏变量都定义在头文件fcntl.h中
    } while ((sp->semp == SEM_FAILED) && (errno == EEXIST));

    if (sp->semp == SEM_FAILED) {
        free(sp);
		return NULL;
    }

    sem_unlink(sp->name);
    return sp;
}

void s_free(struct slock *sp) {
    sem_close(sp->semp);
    free(sp);
}

int s_lock(struct slock *sp) {
    return sem_wait(sp->semp);
}

int s_try_lock(struct slock *sp) {
    return sem_trywait(sp->semp);
}

int s_unlock(struct slock *sp) {
    return sem_post(sp->semp);
}

gcc编译时,POSIX信号量相关函数需要-l pthread选项。

以上程序根据进程ID和计数器来创建名字,我们不用用一个互斥量保护计数器,因为当两个竞争的线程同时调用s_alloc并以同一个名字调用sem_open时,O_EXCL标志将使一个线程成功而另一个失败,失败的线程的errno被置为EEXIST。我们打开一个信号量后立即调用unlink,此时由于信号量已被打开,信号量名字会被删除,从而其他进程无法访问,但本进程还可继续访问,这简化了进程结束时的清理工作。

最简单的客户进程-服务器进程关系是客户进程调用fork然后子进程调用exec执行服务器进程,fork前可先创建两个半双工管道使数据可双向传输,执行的服务器进程可能是一个设置用户id的程序,这使它具有特权。服务器进程可通过客户进程的实际用户id来识别客户进程的身份(调用exec前后进程实际用户id不变)。

以上机制下,可构建一个open服务器进程,它为客户进程打开文件而不是客户进程自己调用open,这样可在正常的权限之外附加权限检查(服务器进程决定要打开的文件,这是通过设置用户id获得的打开此文件的权限)。这样设计的缺陷在于,对于子进程(服务器进程)来说,它需要将文件内容传送给父进程,但这样特殊设备文件不能工作,我们希望的是子进程打开文件并将文件描述符传回,但子进程不能向父进程传送文件描述符。

服务器进程可读FIFO,其他客户进程向FIFO写,这种机制下服务器进程是一个守护进程,所有客户进程用某种命名IPC与其联系(如上述的FIFO),而不能用管道(管道需要进程有继承关系)。如果使用FIFO,如果服务器进程想向客户进程发送数据,则对每个客户进程都要有单独的FIFO,如果只有客户进程向服务器进程方向上的数据,那么只需要一个FIFO(如 System V行式打印机,服务器进程是负责打印的守护进程)。但如果使用消息队列,有两种方法:
1.服务器进程和所有客户进程间只使用一个队列,每个消息的类型字段指明谁是消息的接收者,如客户进程可用类型字段1来发送它们的消息,在请求中应包含客户进程的id,此后服务器进程再发送响应消息时将消息类型字段设为客户进程id,即服务器只接收类型字段为1的消息,客户进程只接收类型字段等于它们进程id的消息。
2.每个客户进程使用一个单独的消息队列,向服务器进程发第一个请求前,客户进程先用IPC_PREVATE创建它自己的消息队列,服务器进程也有它自己的消息队列,其键或标识符是众所周知的,客户进程将其第一个请求发送到服务器进程的队列上,该请求中应包含客户进程队列的队列id,之后的消息交换在客户进程的队列上进行。

以上使用消息队列的两种方法都能通过使用共享内存+同步方式(如信号量或记录锁)替代实现。

客户进程和服务器进程无关时,主要问题是服务器进程如何确定客户进程身份(有效用户id),当服务器进程是设置用户id的程序,做特权操作时确认客户的身份是必要的,虽然这几种IPC都经过内核,但并未提供设施使内核标识发送者。

对于消息队列,我们在客户进程和服务器进程之间使用一个消息队列时,如果同时只有一个消息在该队列上,此时队列的msg_lspid包含了对方(发送方)的进程id,但没有可移植的方法通过进程id获得有效用户id(虽然内核在进程表中有这两个值,但除非彻底检查内核存储空间,否则已知一个无法得到另一个)。

可使用以下技术使服务器进程获取客户进程有效用户id,此技术可用于FIFO、消息队列、信号量、共享存储,假定客户进程有自己的FIFO用于接收服务器的消息,且该FIFO权限为用户读写,客户进程给服务器的众所周知FIFO发送消息时,要包含客户专用FIFO标识,假定服务器进程拥有root特权,则服务器进程也能读写该用户的FIFO,然后服务器进程对客户进程的FIFO调用stat或fstat,即可获取客户进程的有效用户id,服务器进程假设客户进程的有效用户id是FIFO的所有者(stat.st_uid字段),服务器进程还应验证该FIFO只有用户读写权限(确保不让其他人读消息),以及检查与该FIFO有关的三个时间量(上次访问的时间st_atime、数据上次修改的时间st_mtime、i节点上次修改时间st_ctime)是否与当前时间接近(如不早于当前时间15或30s)。但如果一个恶意客户进程创建一个FIFO并使其他用户成为其所有者(创建者还是恶意用户),并设置该文件的权限为用户读写,那系统中就有了安全性问题。

如果使用XSI IPC实现此服务器进程获取客户进程的有效用户id,可使用每个IPC相关的ipc_perm结构,它标识了IPC结构的创建者(cuid、cgid)。

在这里插入图片描述
如果调用wait,第一个子进程是popen函数产生的,此时system函数中调用了wait,假如popen函数的子进程结束了,system函数中的wait调用发现该子进程不是自己创建后,会再次调用wait直到system函数创建的子进程结束,之后pclose函数调用wait时wait函数会因为没有子进程在等待而出错返回,从而pclose函数也出错返回。

在这里插入图片描述
从上图可知,关闭管道写端,select会表明描述符可读,read读完所有数据后,返回0表明到达了文件尾端。而poll如果返回的是POLLHUP(对方描述符挂起),表明可能有数据可读,如果有数据可读,读完所有数据后,read函数返回0表明文件到达了尾端,如果没数据可读,POLLIN(可读)事件不会返回,即使我们还需调用read接收文件结束符。

上图中E表示exception,ERR表示error,INV表示无效的文件描述符。当管道的读端被关闭时,select表明描述符可写,但调用write写时,会产生SIGPIPE信号,如果我们忽略此信号或从信号处理函数返回,write函数会失败,并将errno设为EPIPE。对于poll,这种情况下行为取决于平台。

如果popen函数以“r”执行cmdstring,但子进程将输出写到标准错误时,会发生什么?首先子进程会将输出写到父进程的标准错误(两者使用同一文件表项),子进程为了将数据送到父进程,在cmdstring参数中重定向标准错误2>&1即可。

popen函数会fork一个子进程,而子进程会执行shell,shell也会调用fork(shell执行外部命令时,会创建一个新的进程来执行命令,外部命令指存储在磁盘上,需要时加载到内存执行的命令,而内部命令是shell的一部分),之后shell的子进程执行命令,shell一直会等待命令执行结束,之后shell退出,这也是pclose中的waitpid所等待的。

POSIX.1声明没有定义为读写打开的FIFO,虽然大多UNIX系统允许读写FIFO,使用非阻塞的方法实现读写打开的FIFO,要点是open FIFO两次,一次读一次写,但不使用写打开的描述符,而是保持描述符的写打开从而防止因客户进程从1变为0时产生的文件结束符(最后一个写者关闭FIFO时,会向读者发送一个文件结束符)。打开FIFO两次需要使用非阻塞模式(因为当FIFO没有读者和写者时,以非阻塞打开FIFO时,不管是以读打开还是以写打开都会阻塞在open函数,直到以读打开有了写者或以写打开有了读者),由于非阻塞条件下FIFO只允许先打开读端(此时如果先打开写端,open会出错,即必须要有读者),之后再写打开FIFO,之后再关闭读端即可。

当恶意进程读取了客户进程和服务器进程之间的消息队列中的消息,会干涉到客户和服务器之间的协议,客户请求或服务器应答会消失。恶意进程能读取消息队列中消息的条件为知道队列标识符或键并且消息队列可被任何人访问。

当我们在共享内存中存放链表时,我们不能存储链表真实地址,而是应该记录下链表对象的偏移值,可以是相对于共享内存段的开始。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值