Linux进程间通信

Linux进程间通信

学习内容:在前面的学习中,我们学习了如何创建进程以及如何对进程进行基本的控制,而这些都只是停留在父子进程之间的控制,本次将要学习不同的进程间进行通信的方法。

————————CONTENTS————————

Linux进程间通信概述

在之前的学习中我们了解到,进程是一个程序的一次执行。这里所说的进程一般是指运行在用户态的进程,而由于处于用户态的不同进程之间是彼此隔离的,它们必须通过某种方式来进行通信,就像如今不同地域的人们使用手机联系一样。接下来我们将学习如何为不同的进程间建立通信方式。

Linux下的进程通信手段基本上是从UNIX 平台上的进程通信手段继承而来的。而对UNIX发展做出重大贡献的两大主力AT&T的贝尔实验室及BSD(加州大学伯克利分校的伯克利软件发布中心)在进程间的通信方面的􀗗重点有所不同。前者是对UNIX早期的进程间通信手段进行了系统的改进和扩充,形成了“system V IPC”,其通信进程主要局限在单个计算机内;后者则跳过了该限制,形成了基于套接口(socket)的进程间通信机制。而Linux则把两者的优势都继承了下来,如下图所示:

  • UNIX进程间通信(IPC)方式包括管道、FIFO以及信号;
  • System V进程间通信(IPC)包括System V消息队列、System V信号量以及System V共享内存区;
  • Posix进程间通信(IPC)包括Posix消息队列、Posix信号量以及Posix共享内存区。

现在Linux中常用的进程间通信方式主要有以下几种:

  • 管道(Pipe)及有名管道(named pipe):管道可用于具有亲缘关系进程间的通信,有名管道,除具有管道所具有的功能外,它还允许无亲缘关系进程间的通信。
  • 信号(Signal):信号是在软件层次上对中断机制的一种模拟,它是比较复杂的通信方式,用于通知进程有某事件发生,一个进程收到一个信号与处理器收到一个中断请求效果上可以说是一样的。
  • 信号量(Semaphore):主要作为进程之间以及同一进程的不同线程之间的同步和互斥手段。
  • 共享内存(Shared memory):可以说这是最有用的进程间通信方式。它使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据的更新。这种通信方式需要依赖某种同步机制,如互斥锁和信号量等。
  • 消息队列(Messge Queue):消息队列是消息的链接表,包括Posix消息队列System V消息队列。它克服了前两种通信方式中信息量有限的缺点,具有写权限的进程可以按照一定的规则向消息队列中添加新消息;对消息队列有读权限的进程则可以从消息队列中读取消息。
  • 套接字(Socket):这是一种更为一般的进程间通信机制,它可用于网络中不同机器之间的进程间通信,应用非常广泛。

接下来将详细介绍前5种进程间通信方式,套接字在“网络编程”部分重点研究。

返回目录

一、管道

『1.管道概述』

管道是Linux中一种很重要的通信方式,它可以把一个程序的输出直接连接到另一个程序的输入。还记得我们之前使用man -k process | grep create命令搜索与创建进程相关的函数

这就是管道的一种使用方式,即把man -k process命令的输出当做grep create命令的输入,进行二次检索。

管道是Linux中进程间通信的一种方式。这里所说的管道主要指无名管道,它具有以下特点:

  • 它只能用于具有亲缘关系的进程之间的通信(也就是父子进程或者兄弟进程之间)。
  • 它是一个半双工的通信模式,具有固定的读端和写端。需要双方通信时,需要建立起两个管道。
  • 管道也可以看成是一种特殊的文件,对于它的读写也可以使用普通的read()和write()等函数。但是它不是普通的文件,并不属于其他任何文件系统,并且只存在于内核的内存空间中。
  • 数据的读出和写入:一个进程向管道中写的内容被管道另一端的进程读出。写入的内容每次都添加在管道缓冲区的末尾,并且每次都是从缓冲区的头部读出数据。

『2.管道系统调用』

(1)管道创建与关闭说明:

管道是基于文件􁧿述符的通信方式,当一个管道建立时,它会创建两个文件􁧿述符fds[0]和fds[1],其中fds[0]固定用于读管道,而fd[1]固定用于写管道,这样就构成了一个半双工的通道。

管道关闭时只需将这两个文件描述符关闭即可,可使用普通的close()函数逐个关闭各个文件描述符。

注意:当一个管道共享多对文件描述符时,若将其中一对读写文件描述符都删除,则该管道就失效。

(2)管道创建函数:

创建管道可以通过调用pipe()实现,以下列出了pipe()函数的语法要点:

(3)管道读写说明:

一般情况下使用管道时,先创建一个管道,再通过fork()函数创建一子进程,该子进程会继承父进程所创建的管道。为了实现父子进程之间的读写,只需把无关的读端或写端的文件描述符关闭即可。例如在下图中将父进程的写端fd[1]和子进程的读端fd[0]关闭。此时,父子进程之间就建立起了一条“子进程写入父进程读取”的通道。

同样,也可以关闭父进程的fd[0]和子进程的fd[1],这样就可以建立一条“父进程写入子进程读取”的通道。另外,父进程还可以创建多个子进程,各个子进程都继承了相应的fd[0]和fd[1],这时,只需要关闭相应端口就可以建立其各子进程之间的通道。

(4)管道使用实例:

在下面的测试代码中,首先创建管道,之后父进程使用fork()函数创建子进程,之后通过关闭父进程的读描述符和子进程的写描述符,建立起父子进程之间的管道通信。

/* pipe.c */
#include <unistd.h>
#include <sys/types.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#define MAX_DATA_LEN 256
#define DELAY_TIME 1
int main()
{
	pid_t pid;
	int pipe_fd[2];
	char buf[MAX_DATA_LEN];
	const char data[] = "Pipe Test Program";
	int real_read, real_write;
	memset((void*)buf, 0, sizeof(buf));
	/* 创建管道 */
	if (pipe(pipe_fd) < 0)
	{
		printf("pipe create error\n");
		exit(1);
	}
	/* 创建一子进程 */
	if ((pid = fork()) == 0)
	{
		/* 子进程关闭写描述符,并通过使子进程暂停1s等待父进程关闭相应的读描述符*/
		close(pipe_fd[1]);
		sleep(DELAY_TIME * 3);
		/* 子进程读取管道内容 */
		if ((real_read = read(pipe_fd[0], buf, MAX_DATA_LEN)) > 0)
		{
			printf("%d bytes read from the pipe is '%s'\n", real_read, buf);
		}
		/* 关闭子进程读描述符 */
		close(pipe_fd[0]);
		exit(0);
	}
	else if (pid > 0)
	{
		/* 父进程关闭读描述符,并通过使父进程暂停1s等待子进程关闭相应的写描述符*/
		close(pipe_fd[0]);
		sleep(DELAY_TIME);
		if((real_write = write(pipe_fd[1], data, strlen(data))) != -1)
		{
			printf("Parent wrote %d bytes : '%s'\n", real_write, data);
		}
		/* 关闭父进程写描述符 */
		close(pipe_fd[1]);
		/* 收集子进程退出信息 */
		waitpid(pid, NULL, 0);
		exit(0);
	}
}

运行结果如下图所示:

『3.标准流管道』

(1)标准流管道函数说明:

与Linux的文件操作中有基于文件流的标准I/O操作一样,管道的操作也支持基于文件流的模式。这种基于文件流的管道主要是用来创建一个连接到另一个进程的管道,这里的“另一个进程”也就是一个可以进行一定操作的可执行文件,例如,用户执行“ls -l”或者自己编写的程序“./pipe”等。由于这一类操作很常用,因此标准流管道就将一系列的创建过程合并到一个函数popen()中完成。它所完成的工作有以下几步。

  • 创建一个管道
  • fork()一个子进程
  • 在父子进程中关闭不需要的文件描述符
  • 执行exec函数族调用
  • 执行函数中所指定的命令

这个函数的使用可以大大减少代码的编写量,但同时也有一些不利之处,例如,它不如前面管道创建的函数那样灵活多样,并且用popen()创建的管道必须使用标准I/O函数进行操作,但不能使用前面的read()、write()一类不带缓冲的I/O函数。

与之相对应,关闭用popen()创建的流管道必须使用函数pclose()来关闭该管道流。该函数关闭标准I/O流,并等待命令执行结束。

(2)函数格式:

(3)函数使用实例:

下面的程序使用popen()来执行“ps -ef”命令,可以看出popen()函数使程序变得短小精悍:

/* standard_pipe.c */
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <fcntl.h>
#define BUFSIZE 1024
int main()
{
	FILE *fp;
	char *cmd = "ps -ef";
	char buf[BUFSIZE];

	if ((fp = popen(cmd, "r")) == NULL)
	{
		printf("Popen error\n");
		exit(1);
	}
	while ((fgets(buf, BUFSIZE, fp)) != NULL)
	{
		printf("%s",buf);
	}
	pclose(fp);
	exit(0);
}

运行结果如下:

『4.FIFO』

(1)有名管道说明

前面介绍的管道是无名管道,它只能用于具有亲缘关系的进程之间,这就大大地限制了管道的使用。有名管道的出现突破了这种限制,它可以使互不相关的两个进程实现彼此通信。该管道可以通过路径名来指出,并且在文件系统中是可见的。在建立了管道之后,两个进程就可以把它当作普通文件一样进行读写操作,使用非常方便。不过值得注意的是,FIFO是严格地遵循先进先出规则的,对管道及FIFO的读总是从开始处返回数据,对它们的写则把数据添加到末尾,它们不支持如lseek()等文件定位操作。

有名管道的创建可以使用函数mkfifo(),该函数类似文件中的open()操作,可以指定管道的路径和打开的模式。

在创建管道成功之后,就可以使用open()、read()和write()这些函数了。与普通文件的开发设置一样,对于为读而打开的管道可在open()中设置O_RDONLY,对于为写而打开的管道可在open()中设置O_WRONLY,在这里与普通文件不同的是阻塞问题。由于普通文件的读写时不会出现阻塞问题,而在管道的读写中却有阻塞的可能,这里的非阻塞标志可以在open()函数中设定为O_NONBLOCK。

(2)mkfifo()函数格式

(3)使用实例

下面的实例包含了两个程序,一个用于读管道,另一个用于写管道。其中在读管道的程序里创建管道,并且作为main()函数里的参数由用户输入要写入的内容。读管道的程序会读出用户写入到管道的内容,这两个程序采用的是阻塞式读写管道模式。

/* fifo_write.c 写管道*/
#include <sys/types.h>
#include <sys/stat.h>
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <limits.h>

#define MYFIFO "/tmp/myfifo"  //有名管道文件名
#define MAX_BUFFER_SIZE PIPE_BUF  

int main(int argc, char * argv[]) //参数为即将写入的字符串
{

	int fd;
	char buff[MAX_BUFFER_SIZE];
	int nwrite;

	if(argc <= 1)
	{
		printf("Usage: ./fifo_write string\n");
		exit(1);
	}
	sscanf(argv[1], "%s", buff);

	/*以只写阻塞方式打开FIFO管道*/
	fd = open(MYFIFO, O_WRONLY);
	if (fd == -1)
	{
		printf("Open fifo file error\n");
		exit(1);
	}

	/*向管道中写入字符串*/
	if ((nwrite = write(fd, buff, MAX_BUFFER_SIZE)) > 0)
	{
		printf("Write '%s' to FIFO\n", buff);
	}
	close(fd);
	exit(0);
}
/*fifo_read.c 读管道程序*/
#include <sys/types.h>
#include <sys/stat.h>
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <limits.h>
#define MYFIFO "/tmp/myfifo" 
#define MAX_BUFFER_SIZE PIPE_BUF
int main()
{
	char buff[MAX_BUFFER_SIZE];
	int fd;
	int nread;

	/*判断有名管道是否已存在,若尚未创建,则以相应的权限创建*/
	if (access(MYFIFO, F_OK) == -1)
	{
		if ((mkfifo(MYFIFO, 0666) < 0) && (errno != EEXIST))
		{
			printf("Cannot create fifo file\n");
			exit(1);
		}
	}

	/*以只读阻塞方式打开有名管道*/
	fd = open(MYFIFO, O_RDONLY);
	if (fd == -1)
	{
		printf("Open fifo file error\n");
		exit(1);
	}

	while (1)
	{
		memset(buff, 0, sizeof(buff));
		if ((nread = read(fd, buff, MAX_BUFFER_SIZE)) > 0)
		{
			printf("Read '%s' from FIFO\n", buff);
		}
	}
	close(fd);
	exit(0);
}

为了能够较好地观􁈏运行结果,需要把这两个程序分别在两个终端里运行,在这里首先启动读管道程序。读管道进程在建立管道之后就开始循环地从管道里读出内容,如果没有数据可读,则一直􄱫􀺎到写管道进程向管道写入数据。在启动了写管道程序后,读进程能够从管道里读出用户的􄗃入内容,程序运行结果如下所示:

终端一:

终端二:

返回目录


二、信号

『1.信号概述』

信号是UNIX中所使用的进程通信的一种最古老的方法。它是在软件层次上对中断机制的一种模拟,是一种异步通信方式。

使用kill -l命令可以列出该系统所支持的所有信号的列表:

在上图所示系统中,,信号值在32 之前的则有不同的名称,而信号值在32 以后的都是用“SIGRTMIN”或“SIGRTMAX”开头的,这就是两类..型的信号。前者是从UNIX 系统中继承下来的信号,为不可靠信号(也称为非实时信号);后者是为了解决前面“不可靠信号”的问题而进行了更改和扩充的信号,称为“可靠信号”(也称为实时信号)。

那么,为什么之前的信号不可靠呢?这里我们首先介绍一下信号的生命周期。

一个完整的信号生命周期可以分为3个重要阶段,这3个阶段由4个重要事件来刻画:信号产生、信号在进程中注册、信号在进程中注销、执行信号处理函数,相邻两个事件的时间间隔构成信号生命周期的一个阶段,如下图所示:

信号处理有多种方式,一般是由内核完成的,当然也可以由用户进程来完成,故在此没有明确画出。

  • 不可靠信号的处理过程:如果发现该信号已经在进程中注册,那么就忽略该信号。因此,若前一个信号还未注销又产生了相同的信号就会产生信号丢失。
  • 可靠信号的处理过程:不管该信号是否已经在进程中注册,都会被再注册一次,因此信号不会丢失。

用户进程对信号的响应可以有3种方式:

  • 忽略信号:即对信号不做任何处理,但是有两个信号不能忽略,即SIGKILL 及SIGSTOP。
  • 捕捉信号:定义信号处理函数,当信号发生时,执行相应的自定义处理函数。
  • 执行缺省操作:Linux对每种信号都规定了默认操作。

Linux中的大多数信号是提供给内核的,使用man 7 signal可以查看信号的定义及其默认操作。下图展示了一部分:

『2.信号的发送与捕捉』

现在我们想了解与信号有关的函数都有哪些,使用man -k signal可查询到所有与signal有关的函数,我们重点研究以下几个:kill()、raise()、alarm()以及pause()。

(1)kill()和raise()

  • 函数说明:

kill()函数同读者熟知的kill系统命令一样,可以发送信号给进程或进程组(实际上,kill系统命令只是kill()函数的一个用户接口)。这里需要注意的是,它不仅可以中止进程(实际上发出SIGKILL信号),也可以向进程发􄘱其他信号。

与kill()函数所不同的是,raise()函数允许进程向自身发送信号。

  • 函数格式:

使用man 2 kill/man 3 raise查询函数相关信息,如下:

pid取值:①pid为正数:要发送信号的进程号;②pid=0:信号被发送到所有和当前进程在同一个进程组的进程;③pid=-1:,信号发给所有的进程表中的进程(除了进程号最大的进程外);④pid<-1时,信号发送给进程组号为-pid的每一个进程。

返回值:①成功:0,②失败:-1。

返回值:①成功:0,②失败:-1。

  • 函数实例:

下面这个示例首先使用fork()创建了一个子进程,接着为了保证子进程不在父进程调用kill()之前退出,在子进程中使用raise()函数向自身发送SIGSTOP 信号,使子进程暂停。接下来再在父进程中调用kill()向子进程发送信号,如本示例中使用的是SIGKILL。

/* kill_raise.c */
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
	pid_t pid;
	int ret;

	if ((pid = fork()) < 0)
	{
		printf("Fork error\n");
		exit(1);
	}
	if (pid == 0)
	{
		
		printf("Child(pid : %d) is waiting for any signal\n", getpid());
		raise(SIGSTOP);
		exit(0);
	}
	sleep(3);
	if ((waitpid(pid, NULL, WNOHANG)) == 0)
	{
		if ((ret = kill(pid, SIGKILL)) == 0)
		{
				
			printf("Parent kill %d\n",pid);
		}
	}
	waitpid(pid, NULL, 0);
	exit(0);
}

运行结果如下图所示:

(2)alarm()和pause()

  • 函数说明:

alarm()也称为闹钟函数,它可以在进程中设置一个定时器,当定时器指定的时间到时,它就向进程发送SIGALARM信号。要注意的是,一个进程只能有一个闹钟时间,如果在调用alarm()之前已设置过闹钟时间,则任何以前的闹钟时间都被新值所代替。

pause()函数是用于将调用进程挂起直至捕捉到信号为止。这个函数很常用,通常可以用于判断信号是否已到。

  • 函数格式:

  • 函数实例:

该实例实际上已完成了一个简单的sleep()函数的功能,由于SIGALARM 默认的系统动作为终止该进程,因此程序在打􀦠信息之前,就会被结束了。

/* alarm_pause.c */
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main()
{
	int ret = alarm(5);
	pause();
	printf("I have been waken up.\n",ret);//此语句不会被执行
}

『3.信号的处理』

一个进程可以决定在该进程中需要对哪些信号进行什么样的处理。例如,一个进程可以选择忽略某些信号而只处理其他一些信号,另外,一个进程还可以选择如何处理信号。总之,这些都是与特定的进程相联系的。因此,首先就要建立进程与其信号之间的对应关系,这就是信号的处理。

信号处理的主要方法有两种,一种是使用简单的signal()函数,另一种是使用信号集函数组。下面分别介绍这两种处理方式。

(1)信号处理函数

  • 函数说明:

使用signal()函数处理时,只需要指出要处理的信号和处理函数即可。它主要是用于前32种非实时信号的处理,不支持信号传递信息,但是由于使用简单、易于理解,因此也受到很多程序员的欢迎。

Linux还支持一个更健壮、更新的信号处理函数sigaction(),推荐使用该函数。

  • 函数格式:

这里对函数原型进行简要说明:可先用如下的typedef进行替换说明:

typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);

第一个参数signum:指明了所要处理的信号类型,它可以取除了SIGKILL和SIGSTOP外的任何一种信号。  

第二个参数handler:描述了与信号关联的动作,它可以取以下三种值:

①SIG_IGN:忽略该信号,②SIG_DFL:恢复对信号的系统默认处理,③sighandler_t类型的函数指针:用户自定义的处理函数。

这里着重讲解sigaction()函数中第2个和第3个参数用到的sigaction结构。可使用man -k sigaction查看定义:

sa_handler 是一个函数指针,指定信号处理函数,取值与signal()函数相同;

sa_mask 是一个信号集,它可以指定在信号处理程序执行过程中哪些信号应当被屏蔽,在调用信号捕获函数之前,该信号集要加入到信号的信号屏蔽字中;

sa_flags 中包含了许多标志位,是对信号进行处理的各个选择项。

  • 使用实例:

第一个实例表明了如何使用signal()函数捕捉相应信号,并做出给定的处理。这里,my_func就是信号处理的函数指针。第二个实例是用sigaction()函数
实现同样的功能。以下是使用signal()函数的示例:

/* signal.c */
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>

//自定义信号处理函数
void my_func(int sign_no)
{
	if (sign_no == SIGINT)
	{
		printf("I have get SIGINT\n");
	}
	else if (sign_no == SIGQUIT)
	{
		printf("I have get SIGQUIT\n");
	}
}
int main()
{
	printf("Waiting for signal SIGINT or SIGQUIT...\n");

	//发出相应的信号,并跳转到信号处理函数处
	signal(SIGINT, my_func);
	signal(SIGQUIT, my_func);
	pause();
	exit(0);
}

运行结果如下所示:

若使用sigaction()函数实现同样功能,main()函数做如下修改:

//sigaction.c
//前部分省略
int main()
{
	struct sigaction action;
	printf("Waiting for signal SIGINT or SIGQUIT...\n");
	
	/* sigaction结构初始化 */
	action.sa_handler = my_func;
	sigemptyset(&action.sa_mask);
	action.sa_flags = 0;
	
	/* 发出相应的信号,并跳转到信号处理函数处 */
	sigaction(SIGINT, &action, 0);
	sigaction(SIGQUIT, &action, 0);
	pause();
	exit(0);
}

(2)信号集函数组

我们已经知道,我们可以通过信号来终止进程,也可以通过信号来在进程间进行通信,程序也可以通过指定信号的关联处理函数来改变信号的默认处理方式,也可以屏蔽某些信号,使其不能传递给进程。那么我们应该如何设定我们需要处理的信号,我们不需要处理哪些信号等问题呢?信号集函数就是帮助我们解决这些问题的。

以下为信号集函数:

int sigemptyset(sigset_t *set);
//该函数的作用是将信号集初始化为空。

int sigfillset(sigset_t *set);
//该函数的作用是把信号集初始化包含所有已定义的信号。

int sigaddset(sigset_t *set, int signo);
//该函数的作用是把信号signo添加到信号集set中,成功时返回0,失败时返回-1。

int sigdelset(sigset_t *set, int signo);
//该函数的作用是把信号signo从信号集set中删除,成功时返回0,失败时返回-1.

int sigismember(sigset_t *set, int signo);
//该函数的作用是判断给定的信号signo是否是信号集中的一个成员,如果是返回1,如果不是,返回0,如果给定的信号无效,返回-1;

int sigpromask(int how, const sigset_t *set, sigset_t *oset);
//该函数可以根据参数指定的方法修改进程的信号屏蔽字。新的信号屏蔽字由参数set(非空)指定,而原先的信号屏蔽字将保存在oset(非空)中。如果set为空,则how没有意义,但此时调用该函数,如果oset不为空,则把当前信号屏蔽字保存到oset中。

int sigpending(sigset_t *set);
//该函数的作用是将被阻塞的信号中停留在待处理状态的一组信号写到参数set指向的信号集中,成功调用返回0,否则返回-1,并设置errno表明错误原因。

int sigsuspend(const sigset_t *sigmask);
//该函数通过将进程的屏蔽字替换为由参数sigmask给出的信号集,然后挂起进程的执行。注意操作的先后顺序,是先替换再挂起程序的执行。程序将在信号处理函数执行完毕后继续执行。如果接收到信号终止了程序,sigsuspend()就不会返回,如果接收到的信号没有终止程序,sigsuspend()就返回-1,并将errno设置为EINTR。

注意:如果一个信号被进程阻塞,它就不会传递给进程,但会停留在待处理状态,当进程解除对待处理信号的阻塞时,待处理信号就会立刻被处理。

以下面的程序为例,介绍上述函数的用法:

#include <stdio.h>
#include <signal.h>
#include <unistd.h>
 
void handler(int sig)
{
    printf("Handle the signal %d\n", sig);
}
 
int main(int argc, char **argv)
{
    sigset_t sigset;    // 用于记录屏蔽字
    sigset_t ign;       // 用于记录被阻塞(屏蔽)的信号集
    struct sigaction act;
 
    // 清空信号集
    sigemptyset(&sigset);
    sigemptyset(&ign);
 
    // 向信号集中添加 SIGINT
    sigaddset(&sigset, SIGINT);
 
    // 设置处理函数 和 信号集
    act.sa_handler = handler;
    sigemptyset(&act.sa_mask);
    act.sa_flags = 0;
    sigaction(SIGINT, &act, 0);
 
    printf("Wait the signal SIGNAL...\n");
    pause();
 
    // 设置进程屏蔽字, 在本例中为屏蔽 SIGINT
    sigprocmask(SIG_SETMASK, &sigset, 0);
    printf("Please press Ctrl + C in 10 seconds...\n");
    sleep(10);
 
    // 测试 SIGINT 是否被屏蔽
    sigpending(&ign);
    if (sigismember(&ign, SIGINT))
    {
        printf("The SIGINT signal has ignored\n");
    }
 
    // 从信号集中删除信号 SIGINT
    sigdelset(&sigset, SIGINT);
    printf("Wait the signal SIGINT...\n");
 
    // 将进程的屏蔽字重新设置, 即取消对 SIGINT 的屏蔽, 并挂起进程
    sigsuspend(&sigset);
 
    printf("The process will exit in 5 seconds!\n");
    sleep(5);
 
    return 0;
}

运行结果如下图所示:

  • 首先,我们能过sigaction()函数改变了SIGINT信号的默认行为,使之执行指定的函数handler,所以输出了语句:Handle the signal 2。
  • 然后,通过sigprocmask()设置进程的信号屏蔽字,把SIGINT信号屏蔽起来,所以过了10秒之后,用sigpending()函数去获取被阻塞的信号集时,检测到了被阻塞的信号SIGINT,输出The SIGINT signal has ignored。
  • 最后,用函数sigdelset()函数去除先前用sigaddset()函数加在sigset上的信号SIGINT,再调用函数sigsuspend(),把进程的屏蔽字再次修改为sigset(不包含SIGINT),并挂起进程。
  • 由于先前的SIGINT信号停留在待处理状态,而现在进程已经不再阻塞该信号,所以进程马上对该信号进行处理,从而在最后不需要输入 Ctrl+C 也会出现后面的处理语句,过了5秒程序就成功退出了。

返回目录


三、信号量

『1.信号量概述』

在多任务操作系统环境下,多个进程会同时运行,并且一些进程之间可能存在一定的关联。多个进程可能为了完成同一个任务会相互协作,这样形成进程之间的同步关系。而且在不同进程之间,为了争夺有限的系统资源(硬件或软件资源)会进入竞争状态,这就是进程之间的互斥关系。

进程之间的互斥与同步关系存在的根源在于临界资源。临界资源是在同一个时刻只允许有限个(通常只有一个)进程可以访问(读)或修改(写)的资源,通常包括硬件资源(处理器、内存、存储器以及其他外围设备等)和软件资源(共享代码段,共享结构和变量等)。访问临界资源的代码叫做临界区,临界区本身也会成为临界资源。

信号量是用来解决进程之间的同步与互斥问题的一种进程之间通信机制,包括一个称为信号量的变量和在该信号量下等待资源的进程等待队列,以及对信号量进行的两个原子操作(PV操作)。其中信号量对应于某一种资源,取一个非负的整型值。信号量值指的是当前可用的该资源的数量,若它等于0 则意味着目前没有可用的资源。PV原子操作的具体定义如下:

  • P 操作:如果有可用的资源(信号量值>0),则占用一个资源(给信号量值减去一,进入临界区代码);如果没有可用的资源(信号量值等于0),则被阻塞到,直到系统将资源分配给该进程(进入等待队列,一直等到资源轮到该进程)。
  • V 操作:如果在该信号量的等待队列中有进程在等待资源,则唤醒一个阻塞进程。如果没有进程等待它,则释放一个资源(给信号量值加一)。

最简单的信号量是只能取0和1两种值,这种信号量被叫做二维信号量。在这里,我们主要讨论二维信号量。

『2.信号量的应用』

(1)函数说明:

在Linux系统中,使用信号量通常分为以下几个步骤:

  • 创建信号量或获得在系统已存在的信号量,此时需要调用semget()函数。不同进程通过使用同一个信号量键值来获得同一个信号量。
  • 初始化信号量,此时使用semctl()函数的SETVAL操作。当使用二维信号量时,通常将信号量初始化为1。
  • 进行信号量的PV 操作,此时􄈳用semop()函数。这一步是实现进程之间的同步和互斥的核心工作部分。
  • 如果不需要信号量,则从系统中删除它,此时使用semclt()函数的IPC_RMID 操作。此时需要注意,在程序中不应该出现对已经被删除的信号量的操作。

(2)函数格式:

  • semget():

  • semctl():

  • semop():

(3)使用实例:

下面的程序展示了信号量的概念及其基本用法。在示例程序中,使用信号量来控制父子进程之间的执行顺序。

方便起见,我们将信号量相关的函数封装成二维单个信号量的几个基本函数。它们分别为为信号量初始化函数(或者信号量赋值函数)init_sem()、P 操作􀠭数sem_p()、V操作函数sem_v()以及删除信号量的函数del_sem()等,具体实现如下所示:

/* sem_com.c */
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
union semun
{
	int val;
	struct semid_ds *buf;
	unsigned short *array;
};
/*信号量初始化(赋值)函数*/
int init_sem(int sem_id, int init_value)
{
	union semun sem_union;
	sem_union.val = init_value; //init_value为初始值
	if (semctl(sem_id, 0, SETVAL, sem_union) == -1)
	{
		perror("Initialize semaphore");
		return -1;
	}
	return 0;
}

/*从系统中删除信号量的函数*/
int del_sem(int sem_id)
{
	union semun sem_union;
	if (semctl(sem_id, 0, IPC_RMID, sem_union) == -1)
	{
		perror("Delete semaphore");
		return -1;
	}
}

/*P操作函数*/
int sem_p(int sem_id)
{
	struct sembuf sem_b; /*单个信号量的编号应该为0 */
	sem_b.sem_num = 0;  /*表示P操作*/
	sem_b.sem_op = -1;  /*系统自动释放将会在系统中残留的信号量*/
	sem_b.sem_flg = SEM_UNDO;
	if (semop(sem_id, &sem_b, 1) == -1)
	{
		perror("P operation");
		return -1;
	}
	return 0;
}

/*V操作函数*/
int sem_v(int sem_id)
{
	struct sembuf sem_b;
	sem_b.sem_num = 0; 
	sem_b.sem_op = 1; 
	sem_b.sem_flg = SEM_UNDO;
	if (semop(sem_id, &sem_b, 1) == -1)
	{
		perror("V operation");
		return -1;
	}
	return 0;
}

下面编写一个测试程序,调用这些简单易用的接口,从而解决控制两个进程之间的执行顺序的同步问题。代码如下:

/* fork.c */
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#define DELAY_TIME 3 /*为了突出演示效果,等待几秒钟*/
int main(void)
{
	pid_t result;
	int sem_id;

	sem_id = semget(ftok(".", 'a'), 1, 0666|IPC_CREAT); /*创建一个信号量*/
	init_sem(sem_id, 0);

	/*调用fork()函数*/
	result = fork();
	if(result == -1)
	{
		perror("Fork\n");
	}
	else if (result == 0) //返回值为0代表子进程
	{
		printf("Child process will wait for some seconds...\n");
		sleep(DELAY_TIME);
		printf("The returned value is %d in the child process(PID = %d)\n",
		result, getpid());
		sem_v(sem_id);
	}
	else //返回值大于0代表父进程
	{
		sem_p(sem_id);
		printf("The returned value is %d in the father process(PID = %d)\n",
		result, getpid());
		sem_v(sem_id);
		del_sem(sem_id);
	}
	exit(0);
}

可以先从fork.c中删去信号量相关的代码部分查看运行结果:

再添加信号量的控制部分并运行结果:

返回目录


四、共享内存

『1.共享内存概述』

采用共享内存通信的一个显而易见的好处是效率高,因为进程可以直接读写内存,而不需要任何数据的拷贝。对于像管道和消息队列等通信方式,则需要在内核和用户空间进行四次的数据拷贝,而共享内存则只拷贝两次数据:一次从输入文件到共享内存区,另一次从共享内存区到输出文件。实际上,进程之间在共享内存时,并不总是读写少量数据后就解除映射,有新的通信时,再重新建立共享内存区域。而是保持共享区域,直到通信完毕为止,这样,数据内容一直保存在共享内存中,并没有写回文件。共享内存中的内容往往是在解除映射时才写回文件的。因此,采用共享内存的通信方式效率是非常高的。

Linux的2.2.x内核支持多种共享内存方式,如mmap()系统调用,Posix共享内存,以及系统V共享内存。linux发行版本如Redhat 8.0支持mmap()系统调用及系统V共享内存,但还没实现Posix共享内存,接下来将主要介绍mmap()系统调用及系统V共享内存API的原理及应用。

『2.mmap()系统调用』

(1)函数说明:

mmap()系统调用使得进程之间通过映射同一个普通文件实现共享内存。普通文件被映射到进程地址空间后,进程可以向访问普通内存一样对文件进行访问,不必再调用read(),write()等操作。

注:实际上,mmap()系统调用并不是完全为了用于共享内存而设计的。它本身提供了不同于一般对普通文件的访问方式,进程可以像读写内存一样对普通文件的操作。而Posix或系统V的共享内存IPC则纯粹用于共享目的,当然mmap()实现共享内存也是其主要应用之一。

(2)函数格式:

  • mmap():

void* mmap ( void * addr , size_t len , int prot , int flags , int fd , off_t offset )

参数含义:

  • addr:指向欲映射的内存初始地址,通常设为NULL,代表让系统自动选定地址,映射成功后返回该地址。

  • len: 代表将文件中的多大的部分映射到内存。

  • prot: 映射区域的保护方式

    • PROT_EXEC映射区域可被执行
    • PROT_READ映射区域可被读取
    • PROT_WRITE映射区域可被写入
    • PROT_NONE映射区域不能存取
  • flags:影响映射区域的各种属性。在调用mmap()时必须指定

    • MAP_SHARED或MAP_PRIVATE
    • MAP_FIXED如果参数start所指的地址无法成功建立映射时,则放弃映射,不对地址做修正。
    • MAP_SHARED对映射区域的写入数据会复制会文件内,而且允许其它映射文件的进程共享。
    • MAP_PRIVATE对映射区域的写入操作会产生一个映射文件的复制,即私人的“写时复制”对此区域的任何修改都不会写回原来的文件内容。
    • MAP_ANONYMOUS建立匿名映射。此时会忽略参数fd,不涉及文件,而且映射区域无法和其他进程共享。
    • MAP_DENYWRITE只允许对映射区域的写入操作,其他对文件直接写入的操作将会被拒绝。
    • MAP_LOCKED将映射区域锁定住,这表示该区域不会被置换(swap)。
  • fd:要映射到内存中的文件描述符。如果使用匿名内存映射时,即flags中设置了MAP_ANONYMOUS,fd设为-1。有些系统不支持匿名内存映射,则可以使用fopen打开/dev/zero文件,然后对该文件进行映射,可以同样达到匿名内存映射的效果。

  • offset:文件映射的偏移量,通常设置为0,代表从文件最前方开始对应,offset必须是分页大小的整数倍。

  • 返回值:若映射成功则返回映射区的内存起始地址,否则返回MAP_FAILED(-1),错误原因存于errno 中。

  • munmap():

int munmap( void * addr, size_t len )

该调用在进程地址空间中解除一个映射关系,addr是调用mmap()时返回的地址,len是映射区的大小。当映射关系解除后,对原来映射地址的访问将导致段错误发生。

  • msync():

int msync ( void * addr , size_t len, int flags)

一般说来,进程在映射空间的对共享内容的改变并不直接写回到磁盘文件中,往往在调用munmap()后才执行该操作。可以通过调用msync()实现磁盘上文件内容与共享内存区的内容一致。

(3)使用实例:

下面给出两个进程通过映射普通文件实现共享内存通信。

示例包含两个子程序:map_normalfile1.c及map_normalfile2.c。编译两个程序,可执行文件分别为map_normalfile1及map_normalfile2。两个程序通过命令行参数指定同一个文件来实现共享内存方式的进程间通信。

map_normalfile2试图打开命令行参数指定的一个普通文件,把该文件映射到进程的地址空间,并对映射后的地址空间进行写操作。

map_normalfile1把命令行参数指定的文件映射到进程地址空间,然后对映射后的地址空间执行读操作。这样,两个进程通过命令行参数指定同一个文件来实现共享内存方式的进程间通信。

/*-------------map_normalfile1.c-----------*/
#include <sys/mman.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>
typedef struct{
  char name[4];
  int  age;
}people;
main(int argc, char** argv) // map a normal file as shared mem:
{
  int fd,i;
  people *p_map;
  char temp;
  
  fd=open(argv[1],O_CREAT|O_RDWR|O_TRUNC,00777);
  lseek(fd,sizeof(people)*5-1,SEEK_SET);
  write(fd,"",1);
  
  p_map = (people*) mmap( NULL,sizeof(people)*10,PROT_READ|PROT_WRITE,
        MAP_SHARED,fd,0 );
  close( fd );
  temp = 'a';
  for(i=0; i<10; i++)
  {
    temp += 1;
    memcpy( ( *(p_map+i) ).name, &temp,2 );
    ( *(p_map+i) ).age = 20+i;
  }
  printf(" initialize over \n ");
  sleep(10);
  munmap( p_map, sizeof(people)*10 );
  printf( "umap ok \n" );
}
/*-------------map_normalfile2.c-----------*/
#include <sys/mman.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>
typedef struct{
  char name[4];
  int  age;
}people;
main(int argc, char** argv)  // map a normal file as shared mem:
{
  int fd,i;
  people *p_map;
  fd=open( argv[1],O_CREAT|O_RDWR,00777 );
  p_map = (people*)mmap(NULL,sizeof(people)*10,PROT_READ|PROT_WRITE,
       MAP_SHARED,fd,0);
  for(i = 0;i<10;i++)
  {
  printf( "name: %s age %d;\n",(*(p_map+i)).name, (*(p_map+i)).age );
  }
  munmap( p_map,sizeof(people)*10 );
}

map_normalfile1.c首先定义了一个people数据结构,(在这里采用数据结构的方式是因为,共享内存区的数据往往是有固定格式的,这由通信的各个进程决定,采用结构的方式有普遍代表性)。map_normfile1首先打开或创建一个文件,并把文件的长度设置为5个people结构大小。然后从mmap()的返回地址开始,设置了10个people结构。然后,进程睡眠10秒钟,等待其他进程映射同一个文件,最后解除映射。

map_normfile2.c只是简单的映射一个文件,并以people数据结构的格式从mmap()返回的地址处读取10个people结构,并输出读取的值,然后解除映射。

分别把两个程序编译成可执行文件map_normalfile1和map_normalfile2后,在一个终端上先运行./map_normalfile1 ./test,程序输出结果如下:

在map_normalfile1输出initialize over之后,输出umap ok之前,在另一个终端上运行map_normalfile2 /tmp/test_shm,将会产生如下输出(为了节省空间,输出结果为稍作整理后的结果):

name: b	age 20;	name: c	age 21;	name: d	age 22;	name: e	age 23;	name: f	age 24;
name: g	age 25;	name: h	age 26;	name: I	age 27;	name: j	age 28;	name: k	age 29;

在map_normalfile1 输出umap ok后,运行map_normalfile2则输出如下结果:

name: b	age 20;	name: c	age 21;	name: d	age 22;	name: e	age 23;	name: f	age 24;
name:	age 0;	name:	age 0;	name:	age 0;	name:	age 0;	name:	age 0;

『3.System V共享内存』

(1)函数说明:

共享内存的实现分为两个步骤,第一步是创建共享内存,这里用到的函数是shmget(),也就是从内存中获得一段共享内存区域,第二步映射共享内存,也就是把这段创建的共享内存映射到具体的进程空间中,这里使用的函数是shmat()。到这里,就可以使用这段共享内存了,也就是可以使用不带缓冲的I/O 读写命令对其进行操作。除此之外,当然还有撤销映射的操作,其函数为shmdt()。这里就主要介绍这3个函数。

(2)函数格式:

  • shmget()

  • shmat()

  • shmdt()

(3)使用实例:

下面的程序将给出System V共享内存API的使用方法,并对比分析System V共享机制与mmap()映射普通文件实现共享内存之间的差异。首先给出两个进程通过System V共享内存通信的范例:

/***** testwrite.c *******/
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <unistd.h>
typedef struct{
	char name[4];
	int age;
} people;
main(int argc, char** argv)
{
	int shm_id,i;
	key_t key;
	char temp;
	people *p_map;
	char* name = "./test";
	key = ftok(name,0);
	if(key==-1)
		perror("ftok error");
	shm_id=shmget(key,4096,IPC_CREAT);	
	if(shm_id==-1)
	{
		perror("shmget error");
		return;
	}
	p_map=(people*)shmat(shm_id,NULL,0);
	temp='a';
	for(i = 0;i<10;i++)
	{
		temp+=1;
		memcpy((*(p_map+i)).name,&temp,1);
		(*(p_map+i)).age=20+i;
	}
	if(shmdt(p_map)==-1)
		perror(" detach error ");
}

/********** testread.c ************/
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <unistd.h>
typedef struct{
	char name[4];
	int age;
} people;
main(int argc, char** argv)
{
	int shm_id,i;
	key_t key;
	people *p_map;
	char* name = "./test";
	key = ftok(name,0);
	if(key == -1)
		perror("ftok error");
	shm_id = shmget(key,4096,IPC_CREAT);	
	if(shm_id == -1)
	{
		perror("shmget error");
		return;
	}
	p_map = (people*)shmat(shm_id,NULL,0);
	for(i = 0;i<10;i++)
	{
	printf( "name:%s\n",(*(p_map+i)).name );
	printf( "age %d\n",(*(p_map+i)).age );
	}
	if(shmdt(p_map) == -1)
		perror(" detach error ");
}

testwrite.c创建一个系统V共享内存区,并在其中写入格式化数据;testread.c访问同一个系统V共享内存区,读出其中的格式化数据。分别把两个程序编译为testwrite及testread,先后执行./testwrite及./testread则./testread运行结果如下:

通过对试验结果分析,对比系统V与mmap()映射普通文件实现共享内存通信,可以得出如下结论:

  • 1、 系统V共享内存中的数据,从来不写入到实际磁盘文件中去;而通过mmap()映射普通文件实现的共享内存通信可以指定何时将数据写入磁盘文件中。注:前面讲到,系统V共享内存机制实际是通过映射特殊文件系统shm中的文件实现的,文件系统shm的安装点在交换分区上,系统重新引导后,所有的内容都丢失。
  • 2、 系统V共享内存是随内核持续的,即使所有访问共享内存的进程都已经正常终止,共享内存区仍然存在(除非显式删除共享内存),在内核重新引导之前,对该共享内存区域的任何改写操作都将一直保留。
  • 3、 通过调用mmap()映射普通文件进行进程间通信时,一定要注意考虑进程何时终止对通信的影响。而通过系统V共享内存实现通信的进程则不然。

返回目录


五、消息队列

『1.消息队列概述』

消息队列,即一些消息的列表。用户可以从消息队列中添加消息和读取消息等。从这点上看消息队列具有一定的FIFO特性,但是它可以实现消息的随机查询,比FIFO具有更大的优势。同时,消息又是存在于内核中的,由“队列ID”来标识。

『2.消息队列的应用』

(1)函数说明:

消息队列的实现包括创建或打开消息队列、添加消息、读取消息和􁧗制消息队列这4种操作。

  • 创建或打开消息队列:msgget(),这里创建的消息队列的数量会受到系统消息队列数量的限制;
  • 添加消息:msgsnd()函数,它把消息添加到已打开的消息队列;
  • 读取消息:msgrcv(),它把消息从消息队列中取走,与FIFO不同的是,这里可以指定取走某一条消息;
  • 控制消息队列:msgctl(),它可以完成多项功能。

(2)函数格式:

  • msgget():

  • msgsnd():

  • msgrcv():

  • msgctl():

(3)使用实例:

这里首先介绍一个函数ftok(),它可以根据不同的路径和关键字产生标准的key。共享内存,消息队列,信号量它们三个都是找一个中间介质,来进行通信的,而使用ftok()产生一个号,就可以唯一区分这个介质了。

ftok()函数的具体形式如下:

key_t ftok(const char *pathname, int proj_id);

其中参数fname是指定的文件名,这个文件必须是存在的而且可以访问的。id是子序号,它是一个8bit的整数。即范围是0~255。当函数执行成功,则会返回key_t键值,否则返回-1。在一般的UNIX中,通常是将文件的索引节点取出,然后在前面加上子序号就得到key_t的值。

对于该函数,还有以下几点补充说明:

  • pathname是目录还是文件的具体路径,是否可以随便设置?
    • ftok根据路径名,提取文件信息,再根据这些文件信息及project ID合成key,该路径可以随便设置。
  • pathname指定的目录或文件的权限是否有要求?
    • 该路径是必须存在的,ftok只是根据文件inode在系统内的唯一性来取一个数值,和文件的权限无关。
  • proj_id是否可以随便设定,有什么限制条件?
    • proj_id是可以根据自己的约定,随意设置。这个数字,有的称之为project ID; 在UNIX系统上,它的取值是1到255。

以下是一个简单的验证程序:

    #include <stdio.h>    
    #include <sys/sem.h>    
    #include <stdlib.h>   
 
    int main()    
    {    
        key_t semkey;    
        if((semkey = ftok("./test", 1))<0)    
        {    
            printf("ftok failed\n");    
            exit(EXIT_FAILURE);    
        }       
        printf("ftok ok ,semkey = %d\n", semkey);    
        return 0;    
    }   

运行结果如下:

言归正传。下面实例体现了如何使用消息队列进行两个进程(发送端和接收端)之间的通信,包括消息队列的创建、消息发送与读取、消息队列的撤销和删除等多种操作。

/* msgsnd.c 消息队列发送端*/
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#define BUFFER_SIZE 512
struct message
{
	long msg_type;
	char msg_text[BUFFER_SIZE];
};
int main()
{
	int qid;
	key_t key;
	struct message msg;

	/*根据不同的路径和关键字产生标准的key*/
	if ((key = ftok(".", 'a')) == -1)
	{
		perror("ftok");
		exit(1);
	}

	/*创建消息队列*/
	if ((qid = msgget(key, IPC_CREAT|0666)) == -1)
	{
		perror("msgget");
		exit(1);
	}
	printf("Open queue %d\n",qid);

	while(1)
	{
		printf("Enter some message to the queue:");
		if ((fgets(msg.msg_text, BUFFER_SIZE, stdin)) == NULL)
		{
			puts("no message");
			exit(1);
		}
		msg.msg_type = getpid();

		/*添加消息到消息队列*/
		if ((msgsnd(qid, &msg, strlen(msg.msg_text), 0)) < 0)
		{
			perror("message posted");
			exit(1);
		}

		if (strncmp(msg.msg_text, "quit", 4) == 0)
		{
			break;
		}
	}
	exit(0);
}
/* msgrcv.c  消息队列接收端*/
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#define BUFFER_SIZE 512
struct message
{
	long msg_type;
	char msg_text[BUFFER_SIZE];
};
int main()
{
	int qid;
	key_t key;
	struct message msg;

	/*根据不同的路径和关键字产生标准的key*/
	if ((key = ftok(".", 'a')) == -1)
	{
		perror("ftok");
		exit(1);
	}
	
	/*创建消息队列*/
	if ((qid = msgget(key, IPC_CREAT|0666)) == -1)
	{
		perror("msgget");
		exit(1);
	}
	printf("Open queue %d\n", qid);

	do
	{

		/*读取消息队列*/
		memset(msg.msg_text, 0, BUFFER_SIZE);
		if (msgrcv(qid, (void*)&msg, BUFFER_SIZE, 0, 0) < 0)
		{
			perror("msgrcv");
			exit(1);
		}
		printf("The message from process %d : %s", msg.msg_type, msg.msg_text);
	} while(strncmp(msg.msg_text, "quit", 4));

	/*从系统内核中移走消息队列*/
	if ((msgctl(qid, IPC_RMID, NULL)) < 0)
	{
		perror("msgctl");
		exit(1);
	}
	exit(0);
}

以下是程序的运行结果,输入“quit”则两个进程都结束。

返回目录


参考资料

posted @ 2017-12-11 14:36  0x14b7狄  阅读(1673)  评论(4编辑  收藏  举报