《UNUX环境高级编程》(15)进程间通信

1、引言

2、管道

  • 管道是UNIX系统IPC(进程间通信)最古老的方式,所有UNIX系统都提供这种通信机制
  • 管道有以下局限性:
    • 历史上,它们是半双工的(数据只能在一个方向上流动)。现在某些操作系统提供全双工管道,但是为了可移植性,应该按照半双工来进行编程
    • 管道只能在具有公共祖先的两个进程间使用。通常一个管道由一个进程创建,在进程fork之后,父子进程之间通过该管道进行通信。
  • 命名管道(FIFO)没有第二种限制,UNIX域套接字没有这两种限制

2.1、pipe函数

  • 通过pipe函数创建管道
    int pipe(int pipefd[2]);
    
    • 函数成功返回后,pipefd中保存两个文件描述符:
      • pipefd[0]:读打开
      • pipefd[1]:写打开
        pipefd[1]的输出是pipefd[0]的输入。
    • 对于全双工实现的管道,这两个文件描述符都是读/写打开的。
    • 对于管道,通过fstat函数应用于其读端(pipefd[0])时,获得的stat文件属性中的st_size字段是无意义的。
  • 下图中左图显示管道的两端在一个进程中相互连接;右图则强调数据需要通过内核在管道中流动
    在这里插入图片描述

2.2、管道常用操作

  • 通常,进程先调用pipe,接着调用fork,从而创建父进程与子进程间的IPC通道。
    在这里插入图片描述

    • 对于从父进程到子进程的管道,父进程关闭管道的读端(fd[0]),子进程关闭写端(fd[1])。
      在这里插入图片描述

    • 对于从子进程到父进程的管道,父进程关闭管道的写端(fd[1]),子进程关闭读端(fd[0])。

  • 当管道的一端被关闭后,遵守下列规则

    • read一个写端已被关闭的管道时,在所有数据都被读取后,read返回0,表示文件结束。如果管道的写端还在,就并不会产生文件的结束(如果管道没数据,则read阻塞)
    • 如果write一个读端已被关闭的管道,产生信号SIGPIPE。该信号默认操作是终止进程,如果忽略该信号或捕获到该信号且捕获函数返回,则write返回-1errnoEPIPE
  • 内核的管道缓冲区大小

    • 在写\管道(或FIFO)时,常量PIPE_BUF规定了内核的管道缓冲区大小。
      • 如果对管道调用write且写入字节数小于PIPE_BUF,那么此操作不会与其他进程对同一管道(或FIFO)的write操作交叉
      • 如果同时有多个进程写该管道(或FIFO)且我们write的数据量大于PIPE_BUF,那么我们所写的数据可能会与其他进程所写的数据相互交叉
  • 实例1:父进程创建管道并fork子进程,指定父进程为管道写入端、子进程为读取端,父子进程间单向通信

    #include "apue.h"
    
    int
    main(void)
    {
    	int		n;
    	int		fd[2];
    	pid_t	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 */
    		/*父进程关闭管道的读端(fd[0])*/
    		close(fd[0]);
    		write(fd[1], "hello world\n", 12);
    	} else {					/* child */
    		/*子进程关闭写端(fd[1])*/
    		close(fd[1]);
    		n = read(fd[0], line, MAXLINE);
    		write(STDOUT_FILENO, line, n);
    	}
    	exit(0);
    }
    
  • 实例2:试着编写一个程序,其功能是每次一页地显示已产生的输出。本例要求在命令行中有一个参数指定要显示的文件的名称。

    #include "apue.h"
    #include <sys/wait.h>
    
    #define	DEF_PAGER	"/bin/more"		/* default pager program */
    
    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)
    		err_quit("usage: a.out <pathname>");
    
    	if ((fp = fopen(argv[1], "r")) == NULL)
    		err_sys("can't open %s", argv[1]);
    	if (pipe(fd) < 0)
    		err_sys("pipe error");
    
    	if ((pid = fork()) < 0) {
    		err_sys("fork error");
    	} else if (pid > 0) {								/* parent */
    		close(fd[0]);		/* close read end */
    
    		/* parent copies argv[1] to pipe */
    		while (fgets(line, MAXLINE, fp) != NULL) {
    			n = strlen(line);
    			if (write(fd[1], line, n) != n)
    				err_sys("write error to pipe");
    		}
    		if (ferror(fp))
    			err_sys("fgets error");
    
    		close(fd[1]);	/* close write end of pipe for reader */
    
    		if (waitpid(pid, NULL, 0) < 0)
    			err_sys("waitpid error");
    		exit(0);
    	} else {										/* child */
    		close(fd[1]);	/* close write end */
    		if (fd[0] != STDIN_FILENO) {
    			/*fd[0]复制到标准输入*/
    			if (dup2(fd[0], STDIN_FILENO) != STDIN_FILENO)
    				err_sys("dup2 error to stdin");
    			close(fd[0]);	/* don't need this after dup2 */
    		}
    
    		/* get arguments for execl() */
    		if ((pager = getenv("PAGER")) == NULL)
    			pager = DEF_PAGER;
    		if ((argv0 = strrchr(pager, '/')) != NULL)
    			argv0++;		/* step past rightmost slash */
    		else
    			argv0 = pager;	/* no slash in pager */
    
    		if (execl(pager, argv0, (char *)0) < 0)
    			err_sys("execl error for %s", pager);
    	}
    	exit(0);
    }
    
    • 程序中通过管道将输出直接送到分页程序。程序中先创建一个管道,fork一个子进程,使子进程的标准输入成为管道的读端,然后调用exec,执行用的分页程序。
    • 在调用fork之前,先创建一个管道。调用fork之后,父进程关闭其读端,子进程关闭其写端。然后子进程调用dup2,使标准输入成为管道的读端。当执行分页程序时,其标准输入将是管道的读端。
    • 将一个描述符复制到另一个上(在子进程中,fd[0]复制到标准输入),在复制之前应该比较该描述符的值是否已经具有所希望的值。如果shell没有打开标准输入,那么程序开始处的fopen应已使用描述符0,所以fd[0]决不会等于标准输入。
    • 在尝试使用环境变量PAGER获得用户分页程序名称时,如果操作没有成功,那么将使用系统默认值
  • 实例3:8.9节中TELL_WAITTELL_PARENTWAIT_PARENTTELL_CHILDWAIT_CHILD的管道的实现。

    #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");
    }
    
    • 程序对应的图例如下
      在这里插入图片描述

3、函数popen和pclose

  • popen函数通过管道执行命令行程序。popen函数创建一个管道,fork一个子进程,关闭未使用的管道端,执行一个shell命令,然后通过pclose函数等待命令终止

    FILE *popen(const char *command, const char *type);
    int pclose(FILE *stream);
    
    • popen函数
      • 先执行fork,然后调用exec执行command,并返回一个标准I/O文件指针。调用者通过该文件指针与命令程序通信。
      • 如果type参数是"r",则文件指针连接到command的标准输出
      • 如果type参数是"w",则文件指针连接到commad的标准输入。
        在这里插入图片描述
      • 注意,command按照以下方式执行
        sh -c command
        
        即当popen执行命令时,相当于调用
        execl("/bin/sh","sh","-c",command,(char*)0);
        
        因此command中可以使用shell支持的任何特殊字符。如
        fp = popen("ls *.c","r");
        fp = popen("cmd 2>&1","r");
        
      • 注意,与system函数类似,popen不能由设置用户ID位或设置组ID位的程序调用。因此如果一个进程正在以特殊的权限运行(由于设置用户ID位或设置组ID位造成),它又想生成另一个进程执行另一个程序,则它不能使用popen,防止特殊权限传递下去。
    • pclose函数:
      • 关闭标准I/O流,等待命令终止,然后返回shell的终止状态(与system函数返回值类似)。
  • 实例1:用popen重写第2节中的实例2,其结果如下:

    #include "apue.h"
    #include <sys/wait.h>
    
    /*如果shell变量PAGER已经定义,且其值为非空,
    则使用其值,否则使用字符串more*/
    #define	PAGER	"${PAGER:-more}" /* environment variable, or default */
    
    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");
    
    	/* copy argv[1] to pager */
    	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);
    }
    
  • 实例2:函数popenpclose的实现,略过。

  • 实例3:考虑一个应用程序,它向标准输出写一个提示,然后从标准输入读1行。使用popen,可以在应用程序和输入之间插一个程序一遍对输入进行变化处理。下图展示了这种情况下的进程安排。
    在这里插入图片描述
    首先展示过滤程序

    #include "apue.h"
    #include <ctype.h>
    
    int
    main(void)
    {
    	int		c;
    
    	while ((c = getchar()) != EOF) {
    		if (isupper(c))
    			c = tolower(c);
    		if (putchar(c) == EOF)
    			err_sys("output error");
    		if (c == '\n')
    			fflush(stdout);
    	}
    	exit(0);
    }
    

    将上述程序编译成可执行文件myuclc,然后下面的程序会用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)	/* read from pipe */
    			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、协同进程

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

    • 协同进程通常在shell后台运行,其标准输入和标准输出通过管道连接到另一个程序。
    • popen只提供连接到另一个进程的标准输入或标准输出的一个单向管道,而协同进程则有连接到另一个进程的两个单向管道:一个连到其标准输入,另一个则来自其标准输出。我们想将数据写到其标准输入,经其处理后,再从其标准输出读取数据。
  • 实例:用一个实例来观察协同进程。进程创建了两个管道:一个是协同进程的标准输入,另一个是协同进程的标准输出。下图展示了这种安排。
    在这里插入图片描述
    下面程序是一个简单的协同进程,他从其标准输入读取两个数,计算它们的和,然后将和写至其标准输出

    #include "apue.h"
    
    int
    main(void)
    {
    	int		n, int1, int2;
    	char	line[MAXLINE];
    
    	while ((n = read(STDIN_FILENO, line, MAXLINE)) > 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)
    				err_sys("write error");
    		} else {
    			if (write(STDOUT_FILENO, "invalid args\n", 13) != 13)
    				err_sys("write error");
    		}
    	}
    	exit(0);
    }
    

    对程序进程编译,将其可执行目标代码存入名为add2的文件中。

    #include "apue.h"
    
    static void	sig_pipe(int);		/* our signal handler */
    
    int
    main(void)
    {
    	int		n, fd1[2], fd2[2];
    	pid_t	pid;
    	char	line[MAXLINE];
    
    	if (signal(SIGPIPE, sig_pipe) == SIG_ERR)
    		err_sys("signal error");
    
    	if (pipe(fd1) < 0 || pipe(fd2) < 0)
    		err_sys("pipe error");
    
    	if ((pid = fork()) < 0) {
    		err_sys("fork error");
    	} else if (pid > 0) {							/* parent */
    		close(fd1[0]);
    		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");
    			if (n == 0) {
    				err_msg("child closed pipe");
    				break;
    			}
    			line[n] = 0;	/* null terminate */
    			if (fputs(line, stdout) == EOF)
    				err_sys("fputs error");
    		}
    
    		if (ferror(stdin))
    			err_sys("fgets error on stdin");
    		exit(0);
    	} else {									/* child */
    		close(fd1[1]);
    		close(fd2[0]);
    		if (fd1[0] != STDIN_FILENO) {
    			if (dup2(fd1[0], STDIN_FILENO) != STDIN_FILENO)
    				err_sys("dup2 error to stdin");
    			close(fd1[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", (char *)0) < 0)
    			err_sys("execl error");
    	}
    	exit(0);
    }
    
    static void
    sig_pipe(int signo)
    {
    	printf("SIGPIPE caught\n");
    	exit(1);
    }
    
    • 该程序从标准输入读取两个数之后调用add2协同进程,并将协同进程送来的值写到其标准输出
    • 这个程序创建了两个管道,父进程、子进程各自关闭它们不需要使用的管道端。必须使用两个管道:一个作为协同进程的标准输入,另一个则用作它的标准输出。然后,子进程通过调用dup2使管道描述符移至其标准输入和标准输出,最后调用了execl
    • 如果程序在等待输入的时候杀死了add2协同进程,然后又输入两个数,那么程序对没有读进程的管道进行写操作时,会调用信号处理程序。
  • 实例:如果将协同进程add2的实现换成下面程序的标准I/O,而不是底层I/Oreadwrite,则会发生什么?

    #include "apue.h"
    
    int
    main(void)
    {
    	int		int1, int2;
    	char	line[MAXLINE];
    
    	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 args\n") == EOF)
    				err_sys("printf error");
    		}
    	}
    	exit(0);
    }
    
    • 题目中的答案是:不能运行。因为标准I/O库函数是有缓冲区的,对于管道类型,标准I/O库默认使用全缓冲。当add2从其标准输入读取而发生阻塞时,程序从管道读也会发生阻塞,于是会产生死锁。此时就应该根据需要调用setvbuf函数更改缓冲区类型

5、FIFO

  • FIFO有时被称为命名管道,匿名管道只能在两个相关进程之间使用(这两个进程有一个共同的祖先进程)。但是FIFO没有这种限制,不相关的进程也可以通过它交换数据
  • FIFO是一种文件类型(七大文件类型之一),通过stat结构的st_mode成员可以知道该文件是否是FIFO类型,通过S_ISFIFO宏对st_mode进行测试。

5.1、 mkfifo函数

  • 通过mkfifo函数创建命名管道文件
    int mkfifo (const char *path, __mode_t mode);
    int mkfifoat (int fd, const char *path, __mode_t mode);
    
    • mode参数与open函数中的mode参数相同
    • 当创建了一个FIFO文件或者文件系统中已有该FIFO文件,就可以通过open来打开它,然后使用正常的文件I/O函数(如readwritecloseunlink等)与之交互。

5.2、FIFO注意事项

  • 当以O_NONBLOCK标志open一个FIFO时:

    • 当没有用O_NONBLOCK标志时,只读open要阻塞到某个其他进程写打开这个FIFO为止;类似的,只写open要阻塞到某个其他进程读打开这个FIFO为止。
    • 如果有O_NONBLOCK,只读open立即返回;对于只写open,如果没有进程读打开该FIFO,则只写open返回-1errnoENXIO
  • 与匿名管道类似,若write一个尚无读进程的FIFO,则产生信号SIGPIPE。若某个FIFO的最后一个写进程关闭了该FIFO,则将为该FIFO的读进程产生一个文件结束标志。

  • 一个给定的FIFO有多个写进程是很常见的。如果不希望多个进程所写的数据交叉,则必须要原子的写操作。和匿名管道一样,常量PIPE_BUF说明了可被原子写到FIFO的最大数据量

5.3、使用FIFO实例:客户进程-服务进程通信

  • FIFO的一个用途就是在客户进程和服务器进程之间传送数据。

  • 如果有一个服务器进程,它与很多客户进程有关,每个客户进程都可将请求写到一个该服务器进程创建的众所周知的FIFO

  • 因为该FIFO有多个写进程,因此客服进程发送给服务进程的请求长度要小于PIPE_BUF字节。这样避免客户进程之间写的内容交叉。

  • 按照上面的安排,如果服务器进程以只读方式打开众所周知的FIFO,则当客户进程从1变成0,服务器进程readFIFO会读到文件结束标志。为使服务器免于处理这种情况,可以用读写方式openFIFO

  • 上面的模型存在的问题:服务器进程不知道如何将回答送回各个客户进程。一种解决方案:每个客户进程都在其请求中包含它的进程ID。然后服务器进程为每个客户进程创建一个FIFO,使用的文件名以客户进程pid为基础。

6、XSI IPC

  • 有三种IPC称为XSI IPC消息队列信号量共享存储(共享内存)
  • 将这三种IPC称为XSI IPC的原因是,它们不像其他IPC使用文件系统命名空间(文件描述符),而是使用自己的一套命名空间(可以理解为自己的一套接口)

6.1、 XSI IPC的标识符(id)和键(key)

  • 每个XSI IPC结构(消息队列、信号量、共享内存)都用一个非负整数标识符来引用,即id。对于IPC结构的操作都需要知道其id。但是需要区别的是,这里的IPC结构的id不是文件描述符,而是一个很大的整数(文件描述符取最小的未用值)。

  • idIPC对象的内部名,我们通过该id调用函数从而完成对IPC的操作(如msgsnd等)。但是如果牵扯到多个进程要操作同一IPC结构,那么让这些进程都知道该id值是一件较为困难的事情。因此,我们需要将每个IPC对象都与一个键(key)相关联,即把这个键当做IPC对象的外部名

  • 我们统一一个键的命名方案(下文中的ftok函数)获得键值(类型是key_t),然后在每个进程中调用指定的get函数(msgget等)来通过内核将这个键变换成id,从而使得对于同一IPC对象,不同进程都能获得其id值。

  • 让多个进程(如客户进程和服务器进程)能够使用同一IPC对象的方法:

    • 服务器进程以IPC_PRIVATE作为键来调用get函数从而创建一个新IPC结构,并将返回的id值保存在某个文件中以便客户进程取用。
      • 缺点:文件系统需要服务器进程将整型id值写到文件中,然后客户进程又读这个文件获得该id值。
    • 可以在一个公用头文件中定义一个客户进程和服务器进程都认可的键。然后服务器进程指定以此键创建一个新的IPC结构
      • 缺点:该键可能已引用某个IPC结构,此时get函数错误返回。服务器进程必须删除已存在的IPC结构然后再重新创建。
    • 客户进程和服务器进程约定好同一个路径名和项目id(项目id0-255的值),然后函数ftok将这两个值变换为一个键。从而客户进程和服务器进程获得了相同的键值,并通过该键值调用get函数获取引用IPC对象的id值。
      key_t ftok(const char *pathname, int proj_id);
      
      • 其中pathname必须引用一个现有文件。 ftok提供的服务仅仅是通过路径名和项目id产生一个键,不涉及对IPC对象的任何操作

6.2、 IPC对象的权限结构

  • 每个XSI IPC对象,都有一个ipc_perm结构与之关联,该结构规定了在这个IPC对象的权限和所有者,至少有以下字段

    struct ipc_perm
      {
        __uid_t uid;			/* 所有者有效用户ID  */
        __gid_t gid;			/* 所有者有效组ID  */
        __uid_t cuid;			/* 创建者有效用户ID  */
        __gid_t cgid;			/* 创建者有效组ID  */
        unsigned short int mode;		/* 读写权限  */
    	...
      };
    
  • 在通过get函数创建IPC结构时,对所有字段赋初值。然后可以通过msgctlsemctlshmctl改变其中的uidgidmode字段,这类似于对文件调用chownchmod函数。

  • mode字段由下图的值组成。注意,对于任何IPC结构不存在执行权限。

在这里插入图片描述

6.3、XSI IPC的优缺点

  • 第一个缺点

    • IPC对象在系统范围内起作用,没有引用计数,即进程终止不会导致IPC对象销毁。
    • 比如一个进程创建了一个消息队列,放入几条消息,然后进程终止,那么该消息队列和其中的内容并不会被删除。它们会一直留在系统中直到发生下列事:某个进程通过msgrcv读消息,通过msgctl删除消息队列;正在自举的系统删除消息队列。
    • 相比于匿名管道,最后一个引用该管道的进程终止时管道被完全删除;对于FIFO,最后一个引用该FIFO的进程终止时,虽然FIFO文件没有被删除,但是留在FIFO中的数据已被删除了。
  • 第二个缺点:

    • 这些IPC结构在文件系统中没有名字。因此不能通过操作文件名、操作文件描述符的函数来操作XIS IPC对象(例如不能使用chmod函数修改它们的访问权限,不能使用ls命令查看该IPC对象)。
    • 因为XSI IPC不使用文件描述符,因此不适用于多路转接函数(selectpollepoll),使得很难一次使用一个以上的XSI IPC(可能造成忙等循环,不能使用多路转接I/O的优点)。
  • XIS 消息队列优点

    • 可靠的,流控制的,面向记录的,可以用非先进先出的次序处理
      在这里插入图片描述
    • 由于在操作消息队列前需要以某种方式获取其id,因此认为不是无连接的。
    • 因为所有这些IPC限制在一台主机上,所以是可靠的
    • 流控制的意思是,如果系统资源(缓冲区)短缺,或者接收进程不能再接收更多消息,则发送进程就要阻塞。当流控制条件消失时,发送进程唤醒。

7、 消息队列

  • 消息队列存储在内核中,由消息队列id标识。
  • 每个消息队列都有一个msqid_ds结构与之关联,该结构体定义了消息队列的当前状态
    struct msqid_ds
    {
      struct ipc_perm msg_perm;	/* 这个IPC对象的权限和所有者等信息 */
      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_ctime;		/* 最近修改时间 */
      ...
    };
    

7.1、msgget函数

  • 打开一个现有消息队列或者创建一个新的消息队列,并返回该消息队列id
    int msgget(key_t key, int msgflg);
    
    • 参数key:由ftok产生的键或IPC_PRIVATE
      • 为了引用已存在的IPC结构,该键必须是IPC创建时使用的键值。
      • IPC_PRIVATE用于创建IPC结构。
        #define IPC_PRIVATE	((__key_t) 0)	/* Private key.  */
        
    • 参数msgflg:该IPC结构的访问权限和几个标志。使用如IPC_CREAT|0666
      • 访问权限:见3.2节,如0666代表所有人均可读写
      • IPC_CREAT:创建新的消息队列。 当keyIPC_PRIVATE或者某个未与IPC对象关联的键,则msgflg参数应该指定IPC_CREAT。 当要引用一个已有IPC结构时,msgflg参数不能指定IPC_CREAT
      • IPC_EXCL与IPC_CREAT一同使用:表示如果要创建的消息队列已经存在,则返回错误EEXIST(类似于以O_CREATO_EXCL调用open函数)。
  • 注意,如果要使用一个已经存在的IPC结构并且知道该IPC结构的id标识,则可以不调用get函数(可以简单理解为此时msgget的功能只是通过键获取到对应的消息队列id,因此如果知道了该id值就不用调用msgget了)
  • 每一个消息队列对象都有一个关联的msqid_ds结构体。在通过msgget创建新消息队列时,会初始化msqid_ds结构体以下成员
    • msg_perm字段根据3.2节初始化,其中mode成员(读写权限)按照msgflg参数中的相应权限位设置
    • msg_qnummsg_lspidmsg_lrpidmsg_stimemsg_rtime设置为0
    • msg_ctime设置为当前时间
    • msg_qbytes设置为系统限制值

7.2、msgctl函数

  • 通过msgctl对消息队列进行操作,类似于ioctl函数
    int msgctl(int msqid, int cmd, struct msqid_ds *buf);
    
    • 参数msqid:消息队列id
    • 参数cmd:指定要对消息队列进行的操作
      • IPC_STAT,获取此消息队列的msqid_ds结构,并保存在buf
      • IPC_SET,设置消息队列的msqid_ds结构中的部分字段值:msg_perm.uidmsg_perm.gidmsg_perm.modemsg_qbytes。注意该操作需要以下权限之一:进程的有效用户id等于msg_perm.cuidmsg_perm.uid,或者进程有超级用户权限。
      • IPC_RMID,从系统中删除该消息队列及其中的内容。该删除操作立即生效,删除之后其他操作该消息队列的进程得到EIDRM错误。权限要求同IPC_SET
        以上三个cmd也可用于信号量和共享存储

7.3、msgsnd函数

  • 向消息队列中放入一条消息
    int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
    
    • msqid:消息队列id
    • msgp:发送给队列的消息。msgp可以是任何类型的结构体,但第一个字段必须为long类型,即表明此发送消息的类型(msgrcv根据此来以非先进先出的次序取消息)。第二个字段是消息的具体内容。msgp定义的参照格式如下:
      struct msgbuf {
      	long mtype;       /* 消息类型,必须大于零 */
      	char mtext[123];    /* 消息内容 */
      };
      
    • msgsz:要发送信息的长度(字节数),可以用以下的公式计算:msgsz = sizeof(struct msgbuf) - sizeof(long);
    • msgflg:发送方式
      • 0:当消息队列满时,msgsnd将会阻塞,直到消息能写进消息队列。阻塞期间若该消息队列被删除,则返回EIDRM;若被信号中断则返回EINTR
      • IPC_NOWAIT:类似于文件I/O的非阻塞标志,当消息队列已满的时候,msgsnd函数不等待立即返回EAGAIN。
      • IPC_NOERROR:若发送的消息大于msgsz字节,则把该消息截断,截断部分将被丢弃,且不通知发送进程。

7.4、msgrcv函数

  • 从消息队列取用数据
    ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp,int msgflg);
    
    • msqid:消息队列标识符
    • msgp:存放消息的结构体,结构体类型要与msgsnd函数发送的类型相同
    • msgsz:要接收消息的大小,不含消息类型long占用的字节数
    • msgtyp:要接收的消息类型
      • 0:接收第一个消息
      • >0:接收类型等于msgtyp的第一个消息(由msgbuf中的第一个成员mtype消息类型决定)
      • <0:接收类型等于或者小于msgtyp绝对值的第一个消息。若这种消息有若干个,则取类型值最小的消息。
    • msgflg:接收方式
      • 0: 阻塞式接收消息,没有该类型的消息msgrcv函数一直阻塞等待。阻塞期间若该消息队列被删除,则返回EIDRM;若被信号中断则返回EINTR
      • IPC_NOWAIT:不阻塞,如果没有满足条件的消息则立即返回,此时错误码为ENOMSG
      • IPC_EXCEPT:与msgtype配合使用返回队列中第一个类型不为msgtype的消息
      • IPC_NOERROR:如果队列中满足条件的消息内容大于所请求的size字节,则把该消息截断,截断部分将被丢弃
    • 返回值
      • 成功返回读取到的消息数据长度,失败返回-1并置位errno

7.5、消息队列总结:

  • msgsndmsgrcv函数可知,消息队列是双向的
  • 消息队列被设计之初,可用的其他形式IPC只有半双工管道,那时消息队列提供高速的进程间通信。但是现如今,比肩消息队列的IPC有很多种,且速度与之相比不落于下风。考虑到XSI IPC的缺点:没有引用计数、不能通过文件名或文件描述符引用IPC对象,因此不建议使用消息队列

8、XSI 信号量

信号量本质是一个计数器,为多个进程提供对共享数据对象的访问

  • 信号量并不是单个的非负值,而是一个或多个信号量值得集合- 信号量的创建(semget)和初始化(semctl)是分开的。即不能原子的创建并初始化一个信号量集合- 有的进程终止时并没有释放已经分配给它们的信号量,所以我们不得不为这种情况担心(undo功能用于处理这种情况)
    内核为每个信号量集合维护一个semid_ds结构
struct semid_ds
{<!-- -->
  struct ipc_perm sem_perm;		/* 这个IPC对象的权限和所有者等信息 */
  __time_t sem_otime;			/* 最近调用semop()时间 */
  __time_t sem_ctime;			/* 最近修改时间 */
  __syscall_ulong_t sem_nsems;		/* 集合中的信号量个数 */
  ...
};

集合中的每个信号量由以下无名结构表示,至少包含以下成员

struct {<!-- -->
  unsigned short semval;	/* 信号量值,总是&gt;=0 */
  pid_t 		 sempid;	/* 最后一次操作的进程pid */
  unsigned short semcnt;	/* 等待获取信号量资源的进程个数 */
  unsigned short semzcnt;	/* 等待semval==0的进程个数 */
};

5.1 semget函数

创建一个新信号量集合,或引用一个现有集合,并返回信号量集合id

int semget(key_t key, int nsems, int semflg);

参数的具体取值参考msgget函数。其中nsems参数为集合中信号量个数(创建新集合时可用)

当创建一个新信号量集合时,对semid_ds结构的下列成员赋初值

  • sem_perm字段根据3.2节初始化,其中mode成员(读写权限)按照semflg参数中的相应权限位设置- sem_otime设置为0- sem_ctime设置为当前时间- sem_nsems设置为nsems
5.2 semctl函数

通过该函数对信号量集合进行各种操作(如初始化)

int semctl(int semid, int semnum, int cmd, .../* union semun arg */);

第四个参数可选,当使用某些cmd取值时,需要第四个参数。该参数类型是联合体semun,即为多个命令特定参数的联合

union semun {<!-- -->
               int              val;    /* SETVAL用的值 */
               struct semid_ds *buf;    /* IPC_STAT, IPC_SET用的缓冲区 */
               unsigned short  *array;  /* GETALL, SETALL用的数组 */
           };

参数cmd可以操作于整个集合,或者集合中的某个指定信号量(根据第二个参数semnum指定,取值为0~nsems-1)

  • IPC_STAT:取集合的semid_ds结构并保存在arg.buf中- IPC_SET:按照arg.buf值设置信号量集合semid_ds结构体的sem_perm.uid、sem_perm.gid和sem_perm.mode字段。权限要求同msgctl函数- IPC_RMID:从系统删除该信号量集合,删除立即发生。若删除后其他进程对该信号量集合进行操作,返回EIDRM。权限要求同上。- GETVAL:返回semnum信号量的semval值- SETVAL:设置semnum信号量的semval值为arg.val- GETPID:返回semnum信号量的sempid值- GETNCNT:返回semnum信号量的semncnt值- GETZCNT:返回semnum信号量的semzcnt值- GETALL:获得集合中所有信号量值,保存在arg.array数组中- SETALL:设置集合中所有信号量值为arg.array
5.3 semop函数

执行信号量集合的具体操作(PV操作)

int semop(int semid, struct sembuf *sops, size_t nsops);

参数sops是一个包含有nsops个元素的结构体数组,其中每个元素都是sembuf结构体,用于表示对该信号量的具体操作

struct sembuf
{<!-- -->
  unsigned short int sem_num;	/* 信号量下标(0~nsems-1) */
  short int sem_op;		/* 信号量操作 */
  short int sem_flg;		/* 操作方式:IPC_NOWAIT、SEM_UNDO */
};

其中sem_num表示信号量集合中的哪一个信号量。

sem_op表示对该信号量的具体操作(负值、0、正值)

  • >0:对该信号量进行V操作。表示该进程要释放的以前占有的资源数。sem_op会加到semval上。如果指定了undo标志(sem_flg设置了SEM_UNDO),则从该进程的此信号调整值中减去sem_op。
  • <0:P操作,表示该进程要获取的该信号量资源数。若semval大于等于sem_op绝对值(即当前信号量资源数够用),则从semval中减去sem_op绝对值。如果指定了undo,则从该进程的此信号调整值中加上sem_op绝对值。 如果semval小于sem_op绝对值(即当前信号量资源不够用),则
  • - 若sem_flg有IPC_NOWAIT标志,则semop出错返回EAGAIN
  • 若未指定IPC_NOWAIT,则该信号量的semncnt+1,进程挂起直到发生以下事件:
    • - 此信号量semval变成大于等于sem_op绝对值,则信号量semncnt-1,从semval中减去sem_op绝对值。如果指定了undo,则从该进程的此信号调整值中加上sem_op绝对值。- 从系统中删除了该集合,出错返回EIDRM- 被信号中断,信号量semncnt-1,函数出错返回EINTR **==0:**表示调用进程希望等待到信号量semval为0

如果当前semval为0,则立即返回。否则

  • 若sem_flg指定了IPC_NOWAIT,函数出错返回EAGAIN
  • 若未指定IPC_NOWAIT,则信号量semzcnt+1,然后调用进程挂起直到以下事件发生:
    • - 信号量semval变为0,则semzcnt-1,函数返回- 从系统中删除了该集合,出错返回EIDRM- 被信号中断,信号量semnznt-1,函数出错返回EINTR **注意,semop函数具有原子性。对于该函数中的各种操作,要么一个也不做,要么全部都执行。**
5.4 信号量调整值

前文中介绍了,有的进程终止时并没有释放已经分配给它们的信号量,所以我们不得不为这种情况担心(比如很多进程终止了但是没有释放它们占用的信号量资源,导致还没终止的进程无法获得这些被占用的资源)。undo功能(semop函数中sembuf.sem_flg字段设为SEM_UNDO)用于解决这种问题。

无论何时只要为信号量操作指定了SEM_UNDO,则内核会为该进程针对该信号量记录一个调整值。该调整值用于记录该进程对指定信号量占用的资源数。当进程终止时,就会根据该调整值将占用的资源进行释放(加到semval中)

如果用SETVAL或SETALL调用semctl设置一个信号量的semval,则所有进程中的该信号量调整值都设为0(相当于该信号量初始化了)。

6. 共享存储(共享内存)

共享存储允许多个进程共享一个给定的存储区。因为数据不需要在客户进程和服务器进程之间复制,所以这是最快的一种IPC

注意对于一个给定的共享存储区,多个进程之间要同步的访问。通常使用信号量(或者记录锁、互斥量)用于同步共享存储访问

XSI共享存储和内存映射的区别是,XSI 共享存储没有相关的文件,存储段是内存的匿名段

内核为每一个共享存储段都维护一个shmid_ds结构体,至少包含以下字段

struct shmid_ds
  {<!-- -->
    struct ipc_perm shm_perm;		/* 这个IPC对象的权限和所有者等信息 */
    size_t shm_segsz;			/* 共享存储段的大小(字节) */
    __pid_t shm_cpid;			/* 创建者进程pid */
    __pid_t shm_lpid;			/* 最近一次操作该IPC结构的进程pid */
    shmatt_t shm_nattch;		/* 当前连接数 */
    __time_t shm_atime;			/* 最近一次调用shmat()时间 */
    __time_t shm_dtime;			/* 最近一次调用shmdt()时间 */
    __time_t shm_ctime;			/* 最近一次通过shmctl()对IPC结构进行改变的时间 */
	...
  };

6.1 shmget函数

创建一个新共享存储段或引用一个现有共享存储段,返回该共享内存id

int shmget(key_t key, size_t size, int shmflg);

参数的具体取值参考msgget函数。

其中size参数为共享存储段大小(创建新共享存储段时可用),段内的内容初始化为0。通常将其向上取整为页长的整数倍。但是若size不是页长的整数倍,那么最后一页的余下部分是不可以使用的。

当创建一个新共享存储段时,对shmid_ds结构的下列成员赋初值

  • shm_perm字段根据3.2节初始化,其中mode成员(读写权限)按照shmflg参数中的相应权限位设置- shm_lpid、shm_nattach、shm_atime、shm_dtime设置为0- shm_ctime设置为当前时间- shm_segsz设置为size
6.2 shmctl函数

对共享存储段进行多种操作

int shmctl(int shmid, int cmd, struct shmid_ds *buf);

参数cmd:

  • IPC_STAT:获取shmid_ds结构。将此共享存储段的shmid_ds结构复制到buf中- IPC_SET:按照buf设置共享存储段shmid_ds结构体的shm_perm.uid、shm_perm.gid和shm_perm.mode字段。权限要求同msgctl函数- IPC_RMID:在系统中删除该共享存储段。因为shmid_ds.shm_nattch维护了当前连接数,因此除非最后一个使用该段的进程终止或与该段分离,否则并不会实际的删除该共享存储段。但是该段id会被立即删除不能再被使用。权限要求同上。
6.3 shmat函数

通过shmat连接到一个已有的共享存储段,成功则返回指向共享存储段的指针,并使shmid_ds.shm_nattch+1

void *shmat(int shmid, const void *addr, int shmflg);

  • 若addr为0,则共享存储段连接到由内核选择的第一个可用地址上(推荐)- 若addr非0,则此段连接到addr指定地址上。- shmflg:是一组标志位,通常为0。如果在flag中指定了SHM_RDONLY位,则以只读方式连接此段,否则以读写的方式连接此段。
6.4 shmdt函数

当对共享存储段的操作结束,通过shmdt断开与共享存储段的连接,并使shmid_ds.shm_nattch-1。注意,这并不从系统中删除其标识符与相关的数据结构,仅仅是该进程断开与共享存储段的连接。

int shmdt(const void *shmaddr);

shmaddr是shmat函数的返回值

6.5 共享存储段的存储位置

共享存储段在堆和栈之间

在这里插入图片描述

可以看出类似于内存映射(mmap),共享存储段也在堆和栈之间。区别是用mmap映射的存储段是与文件关联的,而共享存储段则没有这种关联。

6.6 通过内存映射完成共享存储的功能

可以通过对/dev/zero文件的内存映射,来达到类似于共享存储的功能

由于/dev/zero文件提供无限的0,因此对该文件进行内存映射有以下功能:

  • 以mmap第二个参数为长度(通常向上取整为页的倍数)创建一个存储区- 存储区内容都会被初始化为0- 如果设置了MAP_SHARED标志,则多个进程可以共享此存储区。
    但是该技术只能在两个相关进程之间使用(即有相同的祖先进程),而不能是不相关进程之间使用

7. POSIX 信号量

需要将POSIX 信号量和XSI 信号量区别开来。

POSIX信号量意在解决XSI 信号量的几个缺陷

  • POSIX 信号量接口效率更高- POSIX 信号量接口使用更简单,没有信号集。- POSIX 信号量在删除时表现更完美。直到该信号量的最后一次引用被释放时才真正删除该信号量(而XSI 信号量则是立即删除,其他使用该信号量的函数返回错误EIDRM)
7.1 匿名信号量和命名信号量

POSIX信号量分为匿名和命名两种。

其中匿名信号量只通过sem_t指针标识,即它是没有名字的。匿名信号量只存在于内存中,要求使用该信号量的进程必须能访问该内存。这意味着它只能在同一进程的线程之间使用,如果要在多个进程之间使用,要求将相同内存内容映射到它们的地址空间中(共享存储)。

而命名信号量则通过一个字符串名字标识,任何知道该名字的进程中的线程均可使用该命名信号量

7.2 sem_open函数

通过sem_open函数创建一个新的命名信号量或者引用一个现有命名信号量,成功则返回指向该信号量的指针。该指针用于后续对该信号量的各种操作

sem_t *sem_open(const char *name, int oflag[,mode_t mode, unsigned int value]);

当引用现有信号量时,仅使用前两个参数。

name参数:信号量的名字,用于标识该信号量

**oflag参数:**创建信号量时,该参数为0。当该参数有O_CREAT,如果命名信号量不存在则创建一个新的;如果已存在则不会发生任何事。如果该参数指定O_CREAT|O_EXCL并且信号量已存在则出错返回。

**mode参数:**当创建新的命名信号量时可用,用于指定访问权限。其取值与打开文件的权限位相同,并且受文件创建屏蔽字的影响。

**value参数:**当创建信号量时,指定信号量初值

7.3 sem_close函数

当完成信号量操作时,可用该函数关闭指定信号量(关闭一个信号量并没有将他从系统中删除。POSIX 命名信号量是随内核持续的:即使当前没有进程打开该信号量,他的值仍保持。)

int sem_close(sem_t *sem);

如果进程没有调用sem_close而终止,则内核自动关闭任何打开的信号量

注意,这不会影响信号量值,因为POSIX 信号量中没有undo机制。因此进程结束关闭信号量不会导致信号量值得改变

7.4 sem_unlink函数

该函数用于从系统中删除命名信号量

int sem_unlink(const char *name);

如果该信号量没有被引用打开,则被直接销毁;如果该信号量正在被引用,则销毁将被延迟到最后一个打开的引用关闭

7.5 POSIX 信号量的P操作

不像XSI 信号量的P操作那样可以减去指定值,POSIX 信号量的P操作一次只能-1。

int sem_wait(sem_t *sem);
int sem_trywait(sem_t *sem);
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);

  • sem_wait:调用该函数时若信号量值为0则阻塞。直到成功使信号量-1或者被信号中断返回。- sem_trywait:避免阻塞,若调用时信号量值为0则直接返回EAGAIN- sem_timedwait:指定阻塞时间(绝对时间,超时基于CLOCK_REALTIME即系统实时时间,从1970年开始的时间),如果超时还未成功则返回ETIMEDOUT。
7.6 POSIX 信号量的V操作

POSIX 信号量的V操作一次只能+1

int sem_post(sem_t *sem);

7.7 匿名信号量的操作

上面的各节都是对命名信号量的操作,用于多个进程间的进程同步使用。

如果想在单个进程中使用POSIX信号量,那么匿名信号量更加容易。

通过sem_init函数创建(初始化)一个匿名信号量

int sem_init(sem_t *sem, int pshared, unsigned int value);

  • 参数sem:指向信号量变量的指针(需要提前定义并将其地址传入)- pshared:是否在多个进程中使用信号量(0线程同步;1进程同步)。如果多个进程间使用该匿名信号量,则需要sem参数指向两个进程的共享存储段范围内。- value:信号量初始值
    对匿名信号量使用结束,通过sem_destroy函数销毁
int sem_destroy(sem_t *sem);

调用该函数后,这个匿名信号量不能再使用,除非通过sem_init重新初始化它

通过sem_getvalue获取信号量当前值(命名、未命名信号量均可使用)

int sem_getvalue(sem_t *sem, int *sval);

对于匿名信号量的PV操作,与命名信号量接口一致

int sem_wait(sem_t *sem);
int sem_trywait(sem_t *sem);
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);

int sem_post(sem_t *sem);

8. 进程通信总结

  • 会使用匿名管道和FIFO,因为这两种技术仍然可以有效的应用于大量应用程序- 在新应用程序中避免使用消息队列和信号量,而应该考虑全双工管道和记录锁,它们使用起来简单的多- 共享存储有自己的用处,但是可以通过mmap函数提供类似功能(见6.6节)
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Elec Liu

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值