【Linux】进程间通信

文章目录

一.进程间通信介绍

1.进程间通信的概念

进程间通信简称IPC(Interprocess communication),进程间通信就是在不同进程之间传播或交换信息。

2.进程间通信的目的

  • 数据传输: 一个进程需要将它的数据发送给另一个进程。
  • 资源共享: 多个进程之间共享同样的资源。
  • 通知事件: 一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件,比如进程终止时需要通知其父进程。
  • 进程控制: 有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。

3.进程间通信的本质

进程间通信的本质就是,让不同的进程看到同一份资源。

由于各个运行进程之间具有独立性,这个独立性主要体现在数据层面,而代码逻辑层面可以私有也可以公有(例如父子进程),因此各个进程之间要实现通信是非常困难的。

各个进程之间若想实现通信,一定要借助第三方资源,这些进程就可以通过向这个第三方资源写入或是读取数据,进而实现进程之间的通信,这个第三方资源实际上就是操作系统提供的一段内存区域。

因此,进程间通信的本质就是,让不同的进程看到同一份资源(内存,文件内核缓冲等)。 由于这份资源可以由操作系统中的不同模块提供,因此出现了不同的进程间通信方式。

4.进程间通信的分类

管道

  • 匿名管道
  • 命名管道

System V IPC

  • System V 消息队列
  • System V 共享内存
  • System V 信号量

POSIX IPC

  • 消息队列
  • 共享内存
  • 信号量
  • 互斥量
  • 条件变量
  • 读写锁

二.管道

1.什么是管道

管道是Unix中最古老的进程间通信的形式,我们把从一个进程连接到另一个进程的数据流称为一个"管道"。

例如,统计我们当前使用云服务器上的登录用户个数。

其中,who命令和wc命令都是两个程序,当它们运行起来后就变成了两个进程,who进程通过标准输出将数据打到"管道"当中,wc进程再通过标准输入从"管道"当中读取数据,至此便完成了数据的传输,进而完成数据的进一步加工处理。

注明 : who命令用于查看当前云服务器的登录用户(一行显示一个用户),wc -l用于统计当前的行数。

2.匿名管道

(1)匿名管道的原理

匿名管道用于进程间通信,且仅限于本地父子进程之间的通信。

进程间通信的本质就是,让不同的进程看到同一份资源,使用匿名管道实现父子进程间通信的原理就是,让两个父子进程先看到同一份被打开的文件资源,然后父子进程就可以对该文件进行写入或是读取操作,进而实现父子进程间通信。

注意:

  • 这里父子进程看到的同一份文件资源是由操作系统来维护的,所以当父子进程对该文件进行写入操作时,该文件缓冲区当中的数据并不会进行写时拷贝。
  • 管道虽然用的是文件的方案,但操作系统一定不会把进程进行通信的数据刷新到磁盘当中,因为这样做有IO参与会降低效率,而且也没有必要。也就是说,这种文件是一批不会把数据写到磁盘当中的文件,换句话说,磁盘文件和内存文件不一定是一一对应的,有些文件只会在内存当中存在,而不会在磁盘当中存在。

(2)pipe函数

pipe函数用于创建匿名管道,pip函数的函数原型如下:

cpp 复制代码
int pipe(int pipefd[2]);

pipe函数的参数是一个输出型参数,数组pipefd用于返回两个指向管道读端和写端的文件描述符:

数组元素 含义
pipefd[0] 管道读端的文件描述符
pipefd[1] 管道写端的文件描述符

pipe函数调用成功时返回0,调用失败时返回-1。

(3)匿名管道使用步骤

在创建匿名管道实现父子进程间通信的过程中,需要pipe函数和fork函数搭配使用,具体步骤如下:

1.父进程调用pipe函数创建管道。

2.父进程创建子进程。

3.父进程关闭写端,子进程关闭读端。

注意:

  1. 管道只能够进行单向通信,因此当父进程创建完子进程后,需要确认父子进程谁读谁写,然后关闭相应的读写端。
  2. 从管道写端写入的数据会被内核缓冲,直到从管道的读端被读取。

我们可以站在文件描述符的角度再来看看这三个步骤:

1.父进程调用pipe函数创建管道。

2.父进程创建子进程。

3.父进程关闭写端,子进程关闭读端。

例如,在以下代码当中,子进程向匿名管道当中写入10行数据,父进程从匿名管道当中将数据读出。

cpp 复制代码
//child->write, father->read                                                                                                                                                                                                                                                                                                                                                                                                                                                        
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
	int fd[2] = { 0 };
	if (pipe(fd) < 0){ //使用pipe创建匿名管道
		perror("pipe");
		return 1;
	}
	pid_t id = fork(); //使用fork创建子进程
	if (id == 0){
		//child
		close(fd[0]); //子进程关闭读端
		//子进程向管道写入数据
		const char* msg = "hello father, I am child...";
		int count = 10;
		while (count--){
			write(fd[1], msg, strlen(msg));
			sleep(1);
		}
		close(fd[1]); //子进程写入完毕,关闭文件
		exit(0);
	}
	//father
	close(fd[1]); //父进程关闭写端
	//父进程从管道读取数据
	char buff[64];
	while (1){
		ssize_t s = read(fd[0], buff, sizeof(buff));
		if (s > 0){
			buff[s] = '\0';
			printf("child send to father:%s\n", buff);
		}
		else if (s == 0){
			printf("read file end\n");
			break;
		}
		else{
			printf("read error\n");
			break;
		}
	}
	close(fd[0]); //父进程读取完毕,关闭文件
	waitpid(id, NULL, 0);
	return 0;
}

(4)管道读写规则

pipe2函数与pipe函数类似,也是用于创建匿名管道,其函数原型如下:

cpp 复制代码
int pipe2(int pipefd[2], int flags);

pipe2函数的第二个参数用于设置选项。

  1. 当没有数据可读时:
  • O_NONBLOCK disable:read调用阻塞,即进程暂停执行,一直等到有数据来为止。
  • O_NONBLOCK enable:read调用返回-1,errno值为EAGAIN。
  1. 当管道满的时候:
  • O_NONBLOCK disable:write调用阻塞,直到有进程读走数据。
  • O_NONBLOCK enable:write调用返回-1,errno值为EAGAIN。
  1. 如果所有管道写端对应的文件描述符被关闭,则read返回0。
  2. 如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能导致write进程退出。
  3. 当要写入的数据量不大于PIPE_BUF时,Linux将保证写入的原子性。
  4. 当要写入的数据量大于PIPE_BUF时,Linux将不再保证写入的原子性。

(5)管道的特点

1.管道内部自带同步与互斥机制。

我们将一次只允许一个进程使用的资源,称为临界资源。管道在同一时刻只允许一个进程对其进行写入或是读取操作,因此管道也就是一种临界资源。

临界资源是需要被保护的,若是我们不对管道这种临界资源进行任何保护机制,那么就可能出现同一时刻有多个进程对同一管道进行操作的情况,进而导致同时读写、交叉读写以及读取到的数据不一致等问题。

为了避免这些问题,内核会对管道操作进行同步与互斥:

  • 同步: 两个或两个以上的进程在运行过程中协同步调,按预定的先后次序运行。比如,A任务的运行依赖于B任务产生的数据。
  • 互斥: 一个公共资源同一时刻只能被一个进程使用,多个进程不能同时使用公共资源。(为了更好地进行临界区的保护,可以让多执行流在任何时刻,都只能有一个进程进入临界区)

实际上,同步是一种更为复杂的互斥,而互斥是一种特殊的同步。对于管道的场景来说,互斥就是两个进程不可以同时对管道进行操作,它们会相互排斥,必须等一个进程操作完毕,另一个才能操作,而同步也是指这两个不能同时对管道进行操作,但这两个进程必须要按照某种次序来对管道进行操作。

也就是说,互斥具有唯一性和排它性,但互斥并不限制任务的运行顺序,而同步的任务之间则有明确的顺序关系。

2.管道的生命周期随进程。

管道本质上是通过文件进行通信的,也就是说管道依赖于文件系统,那么当所有打开该文件的进程都退出后,该文件也就会被释放掉,所以说管道的生命周期随进程。

3.管道提供的是流式服务。

对于进程A写入管道当中的数据,进程B每次从管道读取的数据的多少是任意的,这种被称为流式服务,与之相对应的是数据报服务:

  • 流式服务: 数据没有明确的分割,不分一定的报文段。
  • 数据报服务: 数据有明确的分割,拿数据按报文段拿。

4.管道是半双工通信的。

在数据通信中,数据在线路上的传送方式可以分为以下三种:

  1. 单工通信(Simplex Communication):单工模式的数据传输是单向的。通信双方中,一方固定为发送端,另一方固定为接收端。
  2. 半双工通信(Half Duplex):半双工数据传输指数据可以在一个信号载体的两个方向上传输,但是不能同时传输。
  3. 全双工通信(Full Duplex):全双工通信允许数据在两个方向上同时传输,它的能力相当于两个单工通信方式的结合。全双工可以同时(瞬时)进行信号的双向传输。

管道是半双工的,数据只能向一个方向流动,需要双方通信时,需要建立起两个管道。

(6)管道的四种特殊情况

在使用管道时,可能出现以下四种特殊情况:

  1. 写端进程不写,读端进程一直读,那么此时会因为管道里面没有数据可读,对应的读端进程会被挂起,直到管道里面有数据后,读端进程才会被唤醒。
  2. 读端进程不读,写端进程一直写,那么当管道被写满后,对应的写端进程会被挂起,直到管道当中的数据被读端进程读取后,写端进程才会被唤醒。
  3. 写端进程将数据写完后将写端关闭,那么读端进程将管道当中的数据读完后,就会继续执行该进程之后的代码逻辑,而不会被挂起。
  4. 读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么操作系统会将写端进程杀掉。

其中前面两种情况就能够很好的说明,管道是自带同步与互斥机制的,读端进程和写端进程是有一个步调协调的过程的,不会说当管道没有数据了读端还在读取,而当管道已经满了写端还在写入。读端进程读取数据的条件是管道里面有数据,写端进程写入数据的条件是管道当中还有空间,若是条件不满足,则相应的进程就会被挂起,直到条件满足后才会被再次唤醒。

第三种情况也很好理解,读端进程已经将管道当中的所有数据都读取出来了,而且此后也不会有写端再进行写入了,那么此时读端进程也就可以执行该进程的其他逻辑了,而不会被挂起。

第四种情况也不难理解,既然管道当中的数据已经没有进程会读取了,那么写端进程的写入将没有意义,因此操作系统直接将写端进程杀掉。而此时子进程代码都还没跑完就被终止了,属于异常退出,那么子进程必然收到了某种信号。

我们可以通过以下代码看看情况四中,子进程退出时究竟是收到了什么信号。

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
	int fd[2] = { 0 };
	if (pipe(fd) < 0){ //使用pipe创建匿名管道
		perror("pipe");
		return 1;
	}
	pid_t id = fork(); //使用fork创建子进程
	if (id == 0){
		//child
		close(fd[0]); //子进程关闭读端
		//子进程向管道写入数据
		const char* msg = "hello father, I am child...";
		int count = 10;
		while (count--){
			write(fd[1], msg, strlen(msg));
			sleep(1);
		}
		close(fd[1]); //子进程写入完毕,关闭文件
		exit(0);
	}
	//father
	close(fd[1]); //父进程关闭写端
	close(fd[0]); //父进程直接关闭读端(导致子进程被操作系统杀掉)
	int status = 0;
	waitpid(id, &status, 0);
	printf("child get signal:%d\n", status & 0x7F); //打印子进程收到的信号
	return 0;
}

运行结果显示,子进程退出时收到的是13号信号。

通过kill -l命令可以查看13对应的具体信号。

cpp 复制代码
[wph1.0@iZ2zegkdfv6wv8qexoazxdZ pipe]$ kill -l

由此可知,当发生情况四时,操作系统向子进程发送的是SIGPIPE信号将子进程终止的。

(7)管道的大小

管道的容量是有限的,如果管道已满,那么写端将阻塞或失败,那么管道的最大容量是多少呢?

方法一:使用man手册

根据man手册,在2.6.11之前的Linux版本中,管道的最大容量与系统页面大小相同,从Linux 2.6.11往后,管道的最大容量是65536字节。

然后我们可以使用uname -r命令,查看自己使用的Linux版本。

根据man手册,我使用的是Linux 2.6.11之后的版本,因此管道的最大容量是65536字节。

方法二:使用ulimit命令

其次,我们还可以使用ulimit -a命令,查看当前资源限制的设定。

根据显示,管道的最大容量是 512 × 8 = 4096 512\times8=4096512×8=4096 字节。

方法三:自行测试

这里发现,根据man手册得到的管道容量与使用ulimit命令得到的管道容量不同,那么此时我们可以自行进行测试。

前面说到,若是读端进程一直不读取管道当中的数据,写端进程一直向管道写入数据,当管道被写满后,写端进程就会被挂起。据此,我们可以写出以下代码来测试管道的最大容量。

cpp 复制代码
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
int main()
{
	int fd[2] = { 0 };
	if (pipe(fd) < 0){ //使用pipe创建匿名管道
		perror("pipe");
		return 1;
	}
	pid_t id = fork(); //使用fork创建子进程
	if (id == 0){
		//child 
		close(fd[0]); //子进程关闭读端
		char c = 'a';
		int count = 0;
		//子进程一直进行写入,一次写入一个字节
		while (1){
			write(fd[1], &c, 1);
			count++;
			printf("%d\n", count); //打印当前写入的字节数
		}
		close(fd[1]);
		exit(0);
	}
	//father
	close(fd[1]); //父进程关闭写端

	//父进程不进行读取

	waitpid(id, NULL, 0);
	close(fd[0]);
	return 0;
}

可以看到,在读端进程不进行读取的情况下,写端进程最多写65536字节的数据就被操作系统挂起了,也就是说,我当前Linux版本中管道的最大容量是65536字节。

3.命名管道

(1)命名管道的原理

匿名管道只能用于具有共同祖先的进程(具有亲缘关系的进程)之间的通信,通常,一个管道由一个进程创建,然后该进程调用fork,此后父子进程之间就可应用该管道。

如果要实现两个毫不相关进程之间的通信,可以使用命名管道来做到。命名管道就是一种特殊类型的文件,两个进程通过命名管道的文件名打开同一个管道文件,此时这两个进程也就看到了同一份资源,进而就可以进行通信了。

注意:

  1. 普通文件是很难做到通信的,即便做到通信也无法解决一些安全问题。
  2. 命名管道和匿名管道一样,都是内存文件,只不过命名管道在磁盘有一个简单的映像,但这个映像的大小永远为0,因为命名管道和匿名管道都不会将通信数据刷新到磁盘当中。

(2)使用命令创建命名管道

我们可以使用mkfifo命令创建一个命名管道。

cpp 复制代码
[wph1.0@iZ2zegkdfv6wv8qexoazxdZ fifo]$ mkfifo fifo

可以看到,创建出来的文件的类型是p,代表该文件是命名管道文件。

使用这个命名管道文件,就能实现两个进程之间的通信了。我们在一个进程(进程A)中用shell脚本每秒向命名管道写入一个字符串,在另一个进程(进程B)当中用cat命令从命名管道当中进行读取。

现象就是当进程A启动后,进程B会每秒从命名管道中读取一个字符串打印到显示器上。这就证明了这两个毫不相关的进程可以通过命名管道进行数据传输,即通信。

之前我们说过,当管道的读端进程退出后,写端进程再向管道写入数据就没有意义了,此时写端进程会被操作系统杀掉,在这里就可以很好的得到验证:当我们终止掉读端进程后,因为写端执行的循环脚本是由命令行解释器bash执行的,所以此时bash就会被操作系统杀掉,我们的云服务器也就退出了。

(3)创建一个命名管道

在程序中创建命名管道使用mkfifo函数,mkfifo函数的函数原型如下:

cpp 复制代码
int mkfifo(const char *pathname, mode_t mode);

mkfifo函数的第一个参数是pathname,表示要创建的命名管道文件。

  • 若pathname以路径的方式给出,则将命名管道文件创建在pathname路径下。
  • 若pathname以文件名的方式给出,则将命名管道文件默认创建在当前路径下。(注意当前路径的含义)

mkfifo函数的第二个参数是mode,表示创建命名管道文件的默认权限。

例如,将mode设置为0666,则命名管道文件创建出来的权限如下:

但实际上创建出来文件的权限值还会受到umask(文件默认掩码)的影响,实际创建出来文件的权限为:mode&(~umask)。umask的默认值一般为0002,当我们设置mode值为0666时实际创建出来文件的权限为0664。

若想创建出来命名管道文件的权限值不受umask的影响,则需要在创建文件前使用umask函数将文件默认掩码设置为0。

cpp 复制代码
umask(0); //将文件默认掩码设置为0

mkfifo函数的返回值。

  • 命名管道创建成功,返回0。
  • 命名管道创建失败,返回-1。

创建命名管道示例:

使用以下代码即可在当前路径下,创建出一个名为myfifo的命名管道。

cpp 复制代码
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>

#define FILE_NAME "myfifo"

int main()
{
	umask(0); //将文件默认掩码设置为0
	if (mkfifo(FILE_NAME, 0666) < 0){ //使用mkfifo创建命名管道文件
		perror("mkfifo");
		return 1;
	}

	//create success...

	return 0;
}

运行代码后,命名管道myfifo就在当前路径下被创建了。

(4)命名管道的打开规则

  1. 如果当前打开操作是为读而打开FIFO时。
  • O_NONBLOCK disable:阻塞直到有相应进程为写而打开该FIFO。
  • O_NONBLOCK enable:立刻返回成功。
  1. 如果当前打开操作是为写而打开FIFO时。
  • O_NONBLOCK disable:阻塞直到有相应进程为读而打开该FIFO。
  • O_NONBLOCK enable:立刻返回失败,错误码为ENXIO。

(5)用命名管道实现serve&client通信

实现服务端(server)和客户端(client)之间的通信之前,我们需要先让服务端运行起来,我们需要让服务端运行后创建一个命名管道文件,然后再以读的方式打开该命名管道文件,之后服务端就可以从该命名管道当中读取客户端发来的通信信息了。

服务端的代码如下:

cpp 复制代码
//server.c
#include "comm.h"

int main()
{
	umask(0); //将文件默认掩码设置为0
	if (mkfifo(FILE_NAME, 0666) < 0){ //使用mkfifo创建命名管道文件
		perror("mkfifo");
		return 1;
	}
	int fd = open(FILE_NAME, O_RDONLY); //以读的方式打开命名管道文件
	if (fd < 0){
		perror("open");
		return 2;
	}
	char msg[128];
	while (1){
		msg[0] = '\0'; //每次读之前将msg清空
		//从命名管道当中读取信息
		ssize_t s = read(fd, msg, sizeof(msg)-1);
		if (s > 0){
			msg[s] = '\0'; //手动设置'\0',便于输出
			printf("client# %s\n", msg); //输出客户端发来的信息
		}
		else if (s == 0){
			printf("client quit!\n");
			break;
		}
		else{
			printf("read error!\n");
			break;
		}
	}
	close(fd); //通信完毕,关闭命名管道文件
	return 0;
}

而对于客户端来说,因为服务端运行起来后命名管道文件就已经被创建了,所以客户端只需以写的方式打开该命名管道文件,之后客户端就可以将通信信息写入到命名管道文件当中,进而实现和服务端的通信。

客户端的代码如下:

cpp 复制代码
//client.c
#include "comm.h"

int main()
{
	int fd = open(FILE_NAME, O_WRONLY); //以写的方式打开命名管道文件
	if (fd < 0){
		perror("open");
		return 1;
	}
	char msg[128];
	while (1){
		msg[0] = '\0'; //每次读之前将msg清空
		printf("Please Enter# "); //提示客户端输入
		fflush(stdout);
		//从客户端的标准输入流读取信息
		ssize_t s = read(0, msg, sizeof(msg)-1);
		if (s > 0){
			msg[s - 1] = '\0';
			//将信息写入命名管道
			write(fd, msg, strlen(msg));
		}
	}
	close(fd); //通信完毕,关闭命名管道文件
	return 0;
}

对于如何让客户端和服务端使用同一个命名管道文件,这里我们可以让客户端和服务端包含同一个头文件,该头文件当中提供这个共用的命名管道文件的文件名,这样客户端和服务端就可以通过这个文件名,打开同一个命名管道文件,进而进行通信了。

共用头文件的代码如下:

cpp 复制代码
//comm.h
#pragma once

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include <fcntl.h>

#define FILE_NAME "myfifo" //让客户端和服务端使用同一个命名管道

代码编写完毕后,先将服务端进程运行起来,之后我们就能在客户端看到这个已经被创建的命名管道文件。

接着再将客户端也运行起来,此时我们从客户端写入的信息被客户端写入到命名管道当中,服务端再从命名管道当中将信息读取出来打印在服务端的显示器上,该现象说明服务端是能够通过命名管道获取到客户端发来的信息的,换句话说,此时这两个进程之间是能够通信的。

当客户端和服务端运行起来时,我们还可以通过ps命令查看这两个进程的信息,可以发现这两个进程确实是两个毫不相关的进程,因为它们的PID和PPID都不相同。也就证明了,命名管道是可以实现两个毫不相关进程之间的通信的。

服务端和客户端之间的退出关系

当客户端退出后,服务端将管道当中的数据读完后就再也读不到数据了,那么此时服务端也就会去执行它的其他代码了(在当前代码中是直接退出了)。

当服务端退出后,客户端写入管道的数据就不会被读取了,也就没有意义了,那么当客户端下一次再向管道写入数据时,就会收到操作系统发来的13号信号(SIGPIPE),此时客户端就被操作系统强制杀掉了。

通信是在内存当中进行的

若是我们只让客户端向管道写入数据,而服务端不从管道读取数据,那么这个管道文件的大小会不会发生变化呢?

cpp 复制代码
//server.c
#include "comm.h"

int main()
{
	umask(0); //将文件默认掩码设置为0
	if (mkfifo(FILE_NAME, 0666) < 0){ //使用mkfifo创建命名管道文件
		perror("mkfifo");
		return 1;
	}
	int fd = open(FILE_NAME, O_RDONLY); //以读的方式打开命名管道文件
	if (fd < 0){
		perror("open");
		return 2;
	}
	while (1){
		//服务端不读取管道信息
	}
	close(fd); //通信完毕,关闭命名管道文件
	return 0;
}

可以看到,尽管服务端不读取管道当中的数据,但是管道当中的数据并没有被刷新到磁盘,使用ll命令看到命名管道文件的大小依旧为0,也就说明了双方进程之间的通信依旧是在内存当中进行的,和匿名管道通信是一样的。

(6)用命名管道实现派发计算任务

需要注意的是两个进程之间的通信,并不是简单的发送字符串而已,服务端是会对客户端发送过来的信息进行某些处理的。

这里我们以客户端向服务端派发计算任务为例,客户端通过管道向服务端发送双操作数的计算请求,服务端接收到客户端的信息后需要计算出相应的结果。

这里我们无需更改客户端的代码,只需改变服务端处理通信信息的逻辑即可。

cpp 复制代码
//server.c
#include "comm.h"

int main()
{
	umask(0); //将文件默认掩码设置为0
	if (mkfifo(FILE_NAME, 0666) < 0){ //使用mkfifo创建命名管道文件
		perror("mkfifo");
		return 1;
	}
	int fd = open(FILE_NAME, O_RDONLY); //打开命名管道文件
	if (fd < 0){
		perror("open");
		return 2;
	}
	char msg[128];
	while (1){
		msg[0] = '\0'; //每次读之前将msg清空
		//从命名管道当中读取信息
		ssize_t s = read(fd, msg, sizeof(msg)-1);
		if (s > 0){
			msg[s] = '\0'; //手动设置'\0',便于输出
			printf("client# %s\n", msg);
			//服务端进行计算任务
		    char* lable = "+-*/%";
			char* p = msg;
			int flag = 0;
			while (*p){
				switch (*p){
				case '+':
					flag = 0;
					break;
				case '-':
					flag = 1;
					break;
				case '*':
					flag = 2;
					break;
				case '/':
					flag = 3;
					break;
				case '%':
					flag = 4;
					break;
				}
				p++;
			}
			char* data1 = strtok(msg, "+-*/%");
			char* data2 = strtok(NULL, "+-*/%");
			int num1 = atoi(data1);
			int num2 = atoi(data2);
			int ret = 0;
			switch (flag){
			case 0:
				ret = num1 + num2;
				break;
			case 1:
				ret = num1 - num2;
				break;
			case 2:
				ret = num1 * num2;
				break;
			case 3:
				ret = num1 / num2;
				break;
			case 4:
				ret = num1 % num2;
				break;
			}
			printf("%d %c %d = %d\n", num1, lable[flag], num2, ret); //打印计算结果
		}
		else if (s == 0){
			printf("client quit!\n");
			break;
		}
		else{
			printf("read error!\n");
			break;
		}
	}
	close(fd); //通信完毕,关闭命名管道文件
	return 0;
}

此时服务端接收到客户端的信息后,需要进行的处理动作就不是将其打印到显示器了,而是需要将信息经过进一步的处理,从而得到相应的结果。

(7)用命名管道实现进程遥控

比较有意思的是,我们可以通过一个进程来控制另一个进程的行为,比如我们从客户端输入命令到管道当中,再让服务端将管道当中的命令读取出来并执行。

下面我们只实现了让服务端执行不带选项的命令,若是想让服务端执行带选项的命令,可以对管道当中获取的命令进行解析处理。这里的实现非常简单,只需让服务端从管道当中读取命令后创建子进程,然后再进行进程程序替换即可。

这里也无需更改客户端的代码,只需改变服务端处理通信信息的逻辑即可。

cpp 复制代码
#include "comm.h"

int main()
{
	umask(0); //将文件默认掩码设置为0
	if (mkfifo(FILE_NAME, 0666) < 0){ //使用mkfifo创建命名管道文件
		perror("mkfifo");
		return 1;
	}
	int fd = open(FILE_NAME, O_RDONLY); //以读的方式打开命名管道文件
	if (fd < 0){
		perror("open");
		return 2;
	}
	char msg[128];
	while (1){
		msg[0] = '\0'; //每次读之前将msg清空
		//从命名管道当中读取信息
		ssize_t s = read(fd, msg, sizeof(msg)-1);
		if (s > 0){
			msg[s] = '\0'; //手动设置'\0',便于输出
			printf("client# %s\n", msg);
			if (fork() == 0){
				//child
				execlp(msg, msg, NULL); //进程程序替换
				exit(1);
			}
			waitpid(-1, NULL, 0); //等待子进程
		}
		else if (s == 0){
			printf("client quit!\n");
			break;
		}
		else{
			printf("read error!\n");
			break;
		}
	}
	close(fd); //通信完毕,关闭命名管道文件
	return 0;
}

此时服务端接收到客户端的信息后,便进行进程程序替换,进而执行客户端发送过来的命令。

(8)用命名管道实现文件拷贝

这里我们再用命名管道实现一下文件的拷贝。

需要拷贝的文件是file.txt,该文件当中的内容如下:

我们要做的就是,让客户端将file.txt文件通过管道发送给服务端,在服务端创建一个file-bat.txt文件,并将从管道获取到的数据写入file-bat.txt文件当中,至此便实现了file.txt文件的拷贝。

其中服务端需要做的就是,创建命名管道并以读的方式打开该命名管道,再创建一个名为file-bat.txt的文件,之后需要做的就是将从管道当中读取到的数据写入到file-bat.txt文件当中即可。

服务端的代码如下:

cpp 复制代码
//server.c
#include "comm.h"

int main()
{
	umask(0); //将文件默认掩码设置为0
	if (mkfifo(FILE_NAME, 0666) < 0){ //使用mkfifo创建命名管道文件
		perror("mkfifo");
		return 1;
	}
	int fd = open(FILE_NAME, O_RDONLY); //以读的方式打开命名管道文件
	if (fd < 0){
		perror("open");
		return 2;
	}
	//创建文件file-bat.txt,并以写的方式打开该文件
	int fdout = open("file-bat.txt", O_CREAT | O_WRONLY, 0666);
	if (fdout < 0){
		perror("open");
		return 3;
	}
	char msg[128];
	while (1){
		msg[0] = '\0'; //每次读之前将msg清空
		//从命名管道当中读取信息
		ssize_t s = read(fd, msg, sizeof(msg)-1);
		if (s > 0){
			write(fdout, msg, s); //将读取到的信息写入到file-bat.txt文件当中
		}
		else if (s == 0){
			printf("client quit!\n");
			break;
		}
		else{
			printf("read error!\n");
			break;
		}
	}
	close(fd); //通信完毕,关闭命名管道文件
	close(fdout); //数据写入完毕,关闭file-bat.txt文件
	return 0;
}

客户端的代码如下:

cpp 复制代码
//client.c
#include "comm.h"

int main()
{
	int fd = open(FILE_NAME, O_WRONLY); //以写的方式打开命名管道文件
	if (fd < 0){
		perror("open");
		return 1;
	}
	int fdin = open("file.txt", O_RDONLY); //以读的方式打开file.txt文件
	if (fdin < 0){
		perror("open");
		return 2;
	}
	char msg[128];
	while (1){
		//从file.txt文件当中读取数据
		ssize_t s = read(fdin, msg, sizeof(msg));
		if (s > 0){
			write(fd, msg, s); //将读取到的数据写入到命名管道当中
		}
		else if (s == 0){
			printf("read end of file!\n");
			 break;
		}
		else{
			printf("read error!\n");
			break;
		}
	}
	close(fd); //通信完毕,关闭命名管道文件
	close(fdin); //数据读取完毕,关闭file.txt文件
	return 0;
}

共用头文件的代码和之前的一样,如下:

cpp 复制代码
//comm.h
#pragma once

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include <fcntl.h>

#define FILE_NAME "myfifo" //让客户端和服务端使用同一个命名管道

编写完代码后,先运行服务端,再运行客户端,一瞬间这两个进程就相继运行结束了。

此时使用ll命令就可以看到,已经完成了file.txt文件的拷贝。

使用cat命令打印file-bat.txt文件当中的内容,发现和file.txt文件当中的内容相同,拷贝文件成功。

使用管道实现文件的拷贝有什么意义?

因为这里是使用管道在本地进行的文件拷贝,所以看似没什么意义,但我们若是将这里的管道想象成"网络",将客户端想象成"Windows Xshell",再将服务端想象成"centos服务器"。那我们此时实现的就是文件上传的功能,若是将方向反过来,那么实现的就是文件下载的功能。

(9)命名管道和匿名管道的区别

  • 匿名管道由pipe函数创建并打开。
  • 命名管道由mkfifo函数创建,由open函数打开。
  • FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在于它们创建与打开的方式不同,一旦这些工作完成之后,它们具有相同的语义。

(10)命令行当中的管道

现有data.txt文件,文件当中的内容如下:

我们可以利用管道("|")同时使用cat命令和grep命令,进而实现文本过滤。

cpp 复制代码
[wph1.0@iZ2zegkdfv6wv8qexoazxdZ 10-5]$ cat data.txt | grep linux

那么在命令行当中的管道("|")到底是匿名管道还是命名管道呢?

由于匿名管道只能用于有亲缘关系的进程之间的通信,而命名管道可以用于两个毫不相关的进程之间的通信,因此我们可以先看看命令行当中用管道("|")连接起来的各个进程之间是否具有亲缘关系。

下面通过管道("|")连接了三个进程,通过ps命令查看这三个进程可以发现,这三个进程的PPID是相同的,也就是说它们是由同一个父进程创建的子进程。

而它们的父进程实际上就是命令行解释器,这里为bash

也就是说,由管道("|")连接起来的各个进程是有亲缘关系的,它们之间互为兄弟进程。

现在我们已经知道了,若是两个进程之间采用的是命名管道,那么在磁盘上必须有一个对应的命名管道文件名,而实际上我们在使用命令的时候并不存在类似的命名管道文件名,因此命令行上的管道实际上是匿名管道。

三.system V进程间通信

管道通信本质是基于文件的,也就是说操作系统并没有为此做过多的设计工作,而system V IPC是操作系统特地设计的一种通信方式。但是不管怎么样,它们的本质都是一样的,都是在想尽办法让不同的进程看到同一份由操作系统提供的资源。

system V IPC提供的通信方式有以下三种

  1. system V共享内存
  2. system V消息队列
  3. system V信号量

其中,system V共享内存和system V消息队列是以传送数据为目的的,而system V信号量是为了保证进程间的同步与互斥而设计的,虽然system V信号量和通信好像没有直接关系,但属于通信范畴。

说明一下

system V共享内存和system V消息队列就类似于手机,用于沟通信息;system V信号量就类似于下棋比赛时用的棋钟,用于保证两个棋手之间的同步与互斥。

1.system V共享内存

(1)共享内存的基本原理

共享内存让不同进程看到同一份资源的方式就是,在物理内存当中申请一块内存空间,然后将这块内存空间分别与各个进程各自的页表之间建立映射,再在虚拟地址空间当中开辟空间并将虚拟地址填充到各自页表的对应位置,使得虚拟地址和物理地址之间建立起对应关系,至此这些进程便看到了同一份物理内存,这块物理内存就叫做共享内存。

注意:

这里所说的开辟物理空间、建立映射等操作都是调用系统接口完成的,也就是说这些动作都由操作系统来完成。

(2)共享内存数据结构

在系统当中可能会有大量的进程在进行通信,因此系统当中就可能存在大量的共享内存,那么操作系统必然要对其进行管理,所以共享内存除了在内存当中真正开辟空间之外,系统一定还要为共享内存维护相关的内核数据结构。

共享内存的数据结构如下:

cpp 复制代码
struct shmid_ds {
	struct ipc_perm     shm_perm;   /* operation perms */
	int         shm_segsz;  /* size of segment (bytes) */
	__kernel_time_t     shm_atime;  /* last attach time */
	__kernel_time_t     shm_dtime;  /* last detach time */
	__kernel_time_t     shm_ctime;  /* last change time */
	__kernel_ipc_pid_t  shm_cpid;   /* pid of creator */
	__kernel_ipc_pid_t  shm_lpid;   /* pid of last operator */
	unsigned short      shm_nattch; /* no. of current attaches */
	unsigned short      shm_unused; /* compatibility */
	void            *shm_unused2;   /* ditto - used by DIPC */
	void            *shm_unused3;   /* unused */
};

当我们申请了一块共享内存后,为了让要实现通信的进程能够看到同一个共享内存,因此每一个共享内存被申请时都有一个key值,这个key值用于标识系统中共享内存的唯一性。

可以看到上面共享内存数据结构的第一个成员是shm_permshm_perm是一个ipc_perm类型的结构体变量,每个共享内存的key值存储在shm_perm这个结构体变量当中,其中ipc_perm结构体的定义如下:

cpp 复制代码
struct ipc_perm{
	__kernel_key_t  key;
	__kernel_uid_t  uid;
	__kernel_gid_t  gid;
	__kernel_uid_t  cuid;
	__kernel_gid_t  cgid;
	__kernel_mode_t mode;
	unsigned short  seq;
};

记录一下:

共享内存的数据结构shmid_dsipc_perm结构体分别在/usr/include/linux/shm.h和/usr/include/linux/ipc.h中定义。

(3)共享内存的建立与释放

共享内存的建立大致包括以下两个过程:

  1. 在物理内存当中申请共享内存空间。
  2. 将申请到的共享内存挂接到地址空间,即建立映射关系。

共享内存的释放大致包括以下两个过程:

  1. 将共享内存与地址空间去关联,即取消映射关系。
  2. 释放共享内存空间,即将物理内存归还给系统。

(4)共享内存的创建

创建共享内存我们需要用shmget函数,shmget函数的函数原型如下:

cpp 复制代码
int shmget(key_t key, size_t size, int shmflg);

shmget函数的参数说明

  • 第一个参数key,表示待创建共享内存在系统当中的唯一标识。
  • 第二个参数size,表示待创建共享内存的大小。
  • 第三个参数shmflg,表示创建共享内存的方式。

shmget函数的返回值说明

  • shmget调用成功,返回一个有效的共享内存标识符(用户层标识符)。
  • shmget调用失败,返回-1。

注意: 我们把具有标定某种资源能力的东西叫做句柄,而这里shmget函数的返回值实际上就是共享内存的句柄,这个句柄可以在用户层标识共享内存,当共享内存被创建后,我们在后续使用共享内存的相关接口时,都是需要通过这个句柄对指定共享内存进行各种操作。

传入shmget函数的第一个参数key,需要我们使用ftok函数进行获取

ftok函数的函数原型如下:

cpp 复制代码
key_t ftok(const char *pathname, int proj_id);

ftok函数的作用就是,将一个已存在的路径名pathname和一个整数标识符proj_id转换成一个key值,称为IPC键值,在使用shmget函数获取共享内存时,这个key值会被填充进维护共享内存的数据结构当中。需要注意的是,pathname所指定的文件必须存在且可存取。

注意:

  1. 使用ftok函数生成key值可能会产生冲突,此时可以对传入ftok函数的参数进行修改。
  2. 需要进行通信的各个进程,在使用ftok函数获取key值时,都需要采用同样的路径名和和整数标识符,进而生成同一种key值,然后才能找到同一个共享资源。

传入shmget函数的第三个参数shmflg,常用的组合方式有以下两种:

组合方式 作用
IPC_CREAT 如果内核中不存在键值与key相等的共享内存,则新建一个共享内存并返回该共享内存的句柄;如果存在这样的共享内存,则直接返回该共享内存的句柄
IPC_CREAT IPC_EXCL 如果内核中不存在键值与key相等的共享内存,则新建一个共享内存并返回该共享内存的句柄;如果存在这样的共享内存,则出错返回

换句话说:

  • 使用组合IPC_CREAT,一定会获得一个共享内存的句柄,但无法确认该共享内存是否是新建的共享内存。
  • 使用组合IPC_CREAT | IPC_EXCL,只有shmget函数调用成功时才会获得共享内存的句柄,并且该共享内存一定是新建的共享内存。

至此我们就可以使用ftok和shmget函数创建一块共享内存了,创建后我们可以将共享内存的key值和句柄进行打印,以便观察,代码如下:

cpp 复制代码
#include <stdio.h>
#include <sys/types.h> 
#include <sys/ipc.h> 
#include <sys/shm.h> 
#include <unistd.h>
		
#define PATHNAME "/home/cl/Linuxcode/IPC/shm/server.c" //路径名

#define PROJ_ID 0x6666 //整数标识符
#define SIZE 4096 //共享内存的大小

int main()
{
	key_t key = ftok(PATHNAME, PROJ_ID); //获取key值
	if (key < 0){
		perror("ftok");
		return 1;
	}
	int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL); //创建新的共享内存
	if (shm < 0){
		perror("shmget");
		return 2;
	}
	printf("key: %x\n", key); //打印key值
	printf("shm: %d\n", shm); //打印句柄
	return 0;
}

该代码编写完毕运行后,我们可以看到输出的key值和句柄值:

Linux当中,我们可以使用ipcs命令查看有关进程间通信设施的信息。

单独使用ipcs命令时,会默认列出消息队列、共享内存以及信号量相关的信息,若只想查看它们之间某一个的相关信息,可以选择携带以下选项

  • -q:列出消息队列相关信息。
  • -m:列出共享内存相关信息。
  • -s:列出信号量相关信息。

例如,携带-m选项查看共享内存相关信息:

此时,根据ipcs命令的查看结果和我们的输出结果可以确认,共享内存已经创建成功了。

ipcs命令输出的每列信息的含义如下:

标题 含义
key 系统区别各个共享内存的唯一标识
shmid 共享内存的用户层id(句柄)
owner 共享内存的拥有者
perms 共享内存的权限
bytes 共享内存的大小
nattch 关联共享内存的进程数
status 共享内存的状态

注意: key是在内核层面上保证共享内存唯一性的方式,而shmid是在用户层面上保证共享内存的唯一性,key和shmid之间的关系类似于fd和FILE*之间的的关系。

(5)共享内存的释放

通过上面创建共享内存的实验可以发现,当我们的进程运行完毕后,申请的共享内存依旧存在,并没有被操作系统释放。实际上,管道是生命周期是随进程的,而共享内存的生命周期是随内核的,也就是说进程虽然已经退出,但是曾经创建的共享内存不会随着进程的退出而释放。

这说明,如果进程不主动删除创建的共享内存,那么共享内存就会一直存在,直到关机重启(system V IPC都是如此),同时也说明了IPC资源是由内核提供并维护的。

此时我们若是要将创建的共享内存释放,有两个方法,一就是使用命令释放共享内存,二就是在进程通信完毕后调用释放共享内存的函数进行释放。

使用命令释放共享内存资源

我们可以使用ipcrm -m shmid命令释放指定id的共享内存资源。

cpp 复制代码
[cl@VM-0-15-centos shm]$ ipcrm -m 8

注意: 指定删除时使用的是共享内存的用户层id,即列表当中的shmid。

使用程序释放共享内存资源

控制共享内存我们需要用shmctl函数,shmctl函数的函数原型如下:

cpp 复制代码
int shmctl(int shmid, int cmd, struct shmid_ds *buf);

shmctl函数的参数说明

  • 第一个参数shmid,表示所控制共享内存的用户级标识符。
  • 第二个参数cmd,表示具体的控制动作。
  • 第三个参数buf,用于获取或设置所控制共享内存的数据结构。

shmctl函数的返回值说明

  • shmctl调用成功,返回0。
  • shmctl调用失败,返回-1。

其中,作为shmctl函数的第二个参数传入的常用的选项有以下三个:

选项 作用
IPC_STAT 获取共享内存的当前关联值,此时参数buf作为输出型参数
IPC_SET 在进程有足够权限的前提下,将共享内存的当前关联值设置为buf所指的数据结构中的值
IPC_RMID 删除共享内存段

例如,在以下代码当中,共享内存被创建,两秒后程序自动移除共享内存,再过两秒程序就会自动退出。

cpp 复制代码
#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>

#define PATHNAME "/home/cl/Linuxcode/IPC/shm/server.c" //路径名

#define PROJ_ID 0x6666 //整数标识符
#define SIZE 4096 //共享内存的大小

int main()
{
	key_t key = ftok(PATHNAME, PROJ_ID); //获取key值
	if (key < 0){
		perror("ftok");
		return 1;
	}
	int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL); //创建新的共享内存
	if (shm < 0){
		perror("shmget");
		return 2;
	}
	printf("key: %x\n", key); //打印key值
	printf("shm: %d\n", shm); //打印句柄

	sleep(2);
	shmctl(shm, IPC_RMID, NULL); //释放共享内存
	sleep(2);
	return 0;
}

我们可以在程序运行时,使用以下监控脚本时刻关注共享内存的资源分配情况:

cpp 复制代码
[cl@VM-0-15-centos shm]$ while :; do ipcs -m;echo "###################################";sleep 1;done

通过监控脚本可以确定共享内存确实创建并且成功释放了。

(6)共享内存的关联

将共享内存连接到进程地址空间我们需要用shmat函数,shmat函数的函数原型如下:

cpp 复制代码
void *shmat(int shmid, const void *shmaddr, int shmflg);

shmat函数的参数说明

  • 第一个参数shmid,表示待关联共享内存的用户级标识符。
  • 第二个参数shmaddr,指定共享内存映射到进程地址空间的某一地址,通常设置为NULL,表示让内核自己决定一个合适的地址位置。
  • 第三个参数shmflg,表示关联共享内存时设置的某些属性。

shmat函数的返回值说明

  • shmat调用成功,返回共享内存映射到进程地址空间中的起始地址。
  • shmat调用失败,返回(void*)-1。

其中,作为shmat函数的第三个参数传入的常用的选项有以下三个:

选项 作用
SHM_RDONLY 关联共享内存后只进行读取操作
SHM_RND 若shmaddr不为NULL,则关联地址自动向下调整为SHMLBA的整数倍。公式:shmaddr-(shmaddr%SHMLBA)
0 默认为读写权限

这时我们可以尝试使用shmat函数对共享内存进行关联。

cpp 复制代码
#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>

#define PATHNAME "/home/cl/Linuxcode/IPC/shm/server.c" //路径名

#define PROJ_ID 0x6666 //整数标识符
#define SIZE 4096 //共享内存的大小

int main()
{
	key_t key = ftok(PATHNAME, PROJ_ID); //获取key值
	if (key < 0){
		perror("ftok");
		return 1;
	}
	int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL); //创建新的共享内存
	if (shm < 0){
		perror("shmget");
		return 2;
	}
	printf("key: %x\n", key); //打印key值
	printf("shm: %d\n", shm); //打印句柄

	printf("attach begin!\n");
	sleep(2);
	char* mem = shmat(shm, NULL, 0); //关联共享内存
	if (mem == (void*)-1){
		perror("shmat");
		return 1;
	}
	printf("attach end!\n");
	sleep(2);
	
	shmctl(shm, IPC_RMID, NULL); //释放共享内存
	return 0;
}

代码运行后发现关联失败,主要原因是我们使用shmget函数创建共享内存时,并没有对创建的共享内存设置权限,所以创建出来的共享内存的默认权限为0,即什么权限都没有,因此server进程没有权限关联该共享内存。

我们应该在使用shmget函数创建共享内存时,在其第三个参数处设置共享内存创建后的权限,权限的设置规则与设置文件权限的规则相同。

cpp 复制代码
int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL | 0666); //创建权限为0666的共享内存

此时再运行程序,即可发现关联该共享内存的进程数由0变成了1,而共享内存的权限显示也不再是0,而是我们设置的666权限。

(7)共享内存的去关联

取消共享内存与进程地址空间之间的关联我们需要用shmdt函数,shmdt函数的函数原型如下:

cpp 复制代码
int shmdt(const void *shmaddr);

shmdt函数的参数说明

  • 待去关联共享内存的起始地址,即调用shmat函数时得到的起始地址。

shmdt函数的返回值说明

  • shmdt调用成功,返回0。
  • shmdt调用失败,返回-1。

现在我们就能够取消共享内存与进程之间的关联了。

cpp 复制代码
#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>

#define PATHNAME "/home/cl/Linuxcode/IPC/shm/server.c" //路径名

#define PROJ_ID 0x6666 //整数标识符
#define SIZE 4096 //共享内存的大小

int main()
{
	key_t key = ftok(PATHNAME, PROJ_ID); //获取key值
	if (key < 0){
		perror("ftok");
		return 1;
	}
	int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL | 0666); //创建新的共享内存
	if (shm < 0){
		perror("shmget");
		return 2;
	}
	printf("key: %x\n", key); //打印key值
	printf("shm: %d\n", shm); //打印句柄

	printf("attach begin!\n");
	sleep(2);
	char* mem = shmat(shm, NULL, 0); //关联共享内存
	if (mem == (void*)-1){
		perror("shmat");
		return 1;
	}
	printf("attach end!\n");
	sleep(2);
	
	printf("detach begin!\n");
	sleep(2);
	shmdt(mem); //共享内存去关联
	printf("detach end!\n");
	sleep(2);

	shmctl(shm, IPC_RMID, NULL); //释放共享内存
	return 0;
}

运行程序,通过监控即可发现该共享内存的关联数由1变为0的过程,即取消了共享内存与该进程之间的关联。

注意: 将共享内存段与当前进程脱离不等于删除共享内存,只是取消了当前进程与该共享内存之间的联系。

(8)用共享内存实现serve&client通信

在知道了共享内存的创建、关联、去关联以及释放后,现在可以尝试让两个进程通过共享内存进行通信了。在让两个进程进行通信之前,我们可以先测试一下这两个进程能否成功挂接到同一个共享内存上。

服务端负责创建共享内存,创建好后将共享内存和服务端进行关联,之后进入死循环,便于观察服务端是否挂接成功。

服务端代码如下:

cpp 复制代码
//server.c
#include "comm.h"

int main()
{
	key_t key = ftok(PATHNAME, PROJ_ID); //获取key值
	if (key < 0){
		perror("ftok");
		return 1;
	}

	int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL | 0666); //创建新的共享内存
	if (shm < 0){
		perror("shmget");
		return 2;
	}
	
	printf("key: %x\n", key); //打印key值
	printf("shm: %d\n", shm); //打印共享内存用户层id

	char* mem = shmat(shm, NULL, 0); //关联共享内存

	while (1){
		//不进行操作
	}

	shmdt(mem); //共享内存去关联

	shmctl(shm, IPC_RMID, NULL); //释放共享内存
	return 0;
}

客户端只需要直接和服务端创建的共享内存进行关联即可,之后也进入死循环,便于观察客户端是否挂接成功。

客户端代码如下:

cpp 复制代码
//client.c
#include "comm.h"

int main()
{
	key_t key = ftok(PATHNAME, PROJ_ID); //获取与server进程相同的key值
	if (key < 0){
		perror("ftok");
		return 1;
	}
	int shm = shmget(key, SIZE, IPC_CREAT); //获取server进程创建的共享内存的用户层id
	if (shm < 0){
		perror("shmget");
		return 2;
	}

	printf("key: %x\n", key); //打印key值
	printf("shm: %d\n", shm); //打印共享内存用户层id

	char* mem = shmat(shm, NULL, 0); //关联共享内存

	int i = 0;
	while (1){
		//不进行操作
	}

	shmdt(mem); //共享内存去关联
	return 0;
}

为了让服务端和客户端在使用ftok函数获取key值时,能够得到同一种key值,那么服务端和客户端传入ftok函数的路径名和和整数标识符必须相同,这样才能生成同一种key值,进而找到同一个共享资源进行挂接。这里我们可以将这些需要共用的信息放入一个头文件当中,服务端和客户端共用这个头文件即可。

共用头文件的代码如下:

cpp 复制代码
//comm.h
#include <stdio.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>

#define PATHNAME "/home/cl/Linuxcode/IPC/shm/server.c" //路径名

#define PROJ_ID 0x6666 //整数标识符
#define SIZE 4096 //共享内存的大小

先后运行服务端和客户端后,通过监控脚本可以看到服务端和客户端所关联的是同一个共享内存,共享内存关联的进程数也是2,表示服务端和客户端挂接共享内存成功。

此时我们就可以让服务端和客户端进行通信了,这里以简单的发送字符串为例。

客户端不断向共享内存写入数据:

cpp 复制代码
//客户端不断向共享内存写入数据
int i = 0;
while (1){
	mem[i] = 'A' + i;
	i++;
	mem[i] = '\0';
	sleep(1);
}

服务端不断读取共享内存当中的数据并输出:

cpp 复制代码
//服务端不断读取共享内存当中的数据并输出
while (1){
	printf("client# %s\n", mem);
	sleep(1);
}

此时先运行服务端创建共享内存,当我们运行客户端时服务端就开始不断输出数据,说明服务端和客户端是能够正常通信的。

(9)共享内存与管道进行对比

当共享内存创建好后就不再需要调用系统接口进行通信了,而管道创建好后仍需要read、write等系统接口进行通信。实际上,共享内存是所有进程间通信方式中最快的一种通信方式。

我们先来看看管道通信:

从这张图可以看出,使用管道通信的方式,将一个文件从一个进程传输到另一个进程需要进行四次拷贝操作

  1. 服务端将信息从输入文件复制到服务端的临时缓冲区中。
  2. 将服务端临时缓冲区的信息复制到管道中。
  3. 客户端将信息从管道复制到客户端的缓冲区中。
  4. 将客户端临时缓冲区的信息复制到输出文件中。

我们再来看看共享内存通信:

从这张图可以看出,使用共享内存进行通信,将一个文件从一个进程传输到另一个进程只需要进行两次拷贝操作

  1. 从输入文件到共享内存。
  2. 从共享内存到输出文件。

所以共享内存是所有进程间通信方式中最快的一种通信方式,因为该通信方式需要进行的拷贝次数最少。

共享内存的缺点

但是共享内存也是有缺点的,我们知道管道是自带同步与互斥机制的,但是共享内存并没有提供任何的保护机制,包括同步与互斥。

共享内存的大小

因为系统分配共享内存是以4KB为基本单位,一般建议申请共享内存的大小为4KB的整数倍。

2.system V 信号量(了解)

(1)前提概念

  1. 公共资源: 大家都能看到的资源
  2. 互斥: 任何一个时刻, 都只允许一个执行流在进行共享资源的访问
  3. 临界资源: 任何一个时刻, 都只允许一个执行流在进行访问的共享资源
  4. 临界区: 临界资源是要通过代码访问的, 凡是访问临界资源的代码
  5. 原子性: 要么不做, 要么做完, 只有两种确定状态的属性

(2)感性认识信号量

举一个生活中的例子:

比如看电影, 我们在看电影之前需要先买票

买票的本质功能:

  • 对座位资源的预定机制
  • 确保不会因为多放出去特定的座位资源, 而导致冲突

如果放映厅是顶级VIP放映厅,只有一个座位,有一个人买票了后其他人就不能买了, 人与人买票之间存在互斥

信号量(也叫信号灯): 本质是一个计数器(count), 描述资源数量的计数器

任何一个执行流想访问临界资源中的一个子资源的时候都不能直接访问

  • P操作, 预定资源。我们首先要申请信号量资源 --- count-- --- 只要我申请信号量成功, 我未来就一定能够拿到一个子资源, 一旦count减为0后, 有进程想要访问, 那么这个进程就会被挂起阻塞

    下来我们进入自己的临界区, 访问对应的临界资源

  • V操作,释放资源。访问完后, 释放信号量资源 --- count++ --- 只要将计数器增加,就表示将我们对应的资源进行了归还

我们想让两个不同的进程看到同一个"计数器(count)"(资源),所以信号量被归类到了进程间通信

信号量本身也是一个临界资源,它能保护其他共享资源的同时,也需要保护自己的安全,信号量内部的加加减减具有原子性。

二元信号量:信号量为1,说明共享资源时一整个整体,提供互斥功能。

ps:关于信号量我们到信号、多线程等章节还会在谈及,此章节以了解居多~


本文到此结束,码文不易,还请多多支持哦!!!

相关推荐
LyaJpunov4 分钟前
C++中move和forword的区别
开发语言·c++
pyliumy5 分钟前
rsync 全网备份
linux·运维·服务器
程序猿练习生9 分钟前
C++速通LeetCode中等第9题-合并区间
开发语言·c++·leetcode
z千鑫17 分钟前
【人工智能】如何利用AI轻松将java,c++等代码转换为Python语言?程序员必读
java·c++·人工智能·gpt·agent·ai编程·ai工具
一名路过的小码农19 分钟前
C/C++动态库函数导出 windows
c语言·开发语言·c++
苹果醋326 分钟前
SpringCloud系列之一---搭建高可用的Eureka注册中心
运维·nginx
诚诚k32 分钟前
docker存储
运维·docker·容器
sorel_ferris34 分钟前
Ubuntu-24.04中Docker-Desktop无法启动
linux·ubuntu·docker
ggb199936 分钟前
【python的坑】vpn下,python request报错 check_hostname requires server_hostname
linux·运维·服务器
小O_好好学1 小时前
vi | vim基本使用
linux·编辑器·vim