Linux进程间通讯技术

Linux进程间通讯

文章目录

1.进程通讯基本认知

1.1 进程通讯的概念

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

  • 人与人之间的通信是什么?交换信息
  • 进程之间的信息是什么?是数据!
  • 所以进程通信可以理解为进程之间传递数据

进程通信要做的事可以理解为让多个进程看到同一块资源


1.2 进程通讯的目的

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

1.3 进程通讯的本质

进程通信的本质就是让多个进程看到同一块资源

  • 这块资源常指的是内存资源,在linux下一切皆文件,所以也可以理解成文件资源
  • 进程通信的方式也是围绕着这一句话展开的

1.4 进程通讯的分类

进程通讯主要分为三类:管道System V IPCPOSIX IPC

  • System V和POSIX我理解为不同的标准
  • System V是Unix系统的某个版本
  • Posix是由IEEE和ISO开发的一套标准,两个都可以说是一种系统接口的协议

我们主要了解匿名管道命名管道消息队列共享内存信号量,通信方式不同,自然通信策略也不同

请添加图片描述


2.管道技术基本认知

2.1 管道的概念

  • 管道是Unix中最古老的进程间通信的形式
  • 我们把从一个进程连接到另一个进程的一个数据流称为一个"管道"
  • 管道只能够进行单向通信,一个进程读一个进程写
  • 管道分为匿名管道和命名管道。匿名管道,创建管道的名字是不知道的,命名管道,创建管道的名字是知道的

例如,我们使用who | wc -l 命令,统计我们当前使用云服务器上的登录用户个数

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

请添加图片描述

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


2.2 为什么需要管道

我们为什么需要管道,或者说两个进程间能直接传递数据吗?

因为进程间不能直接通信,每个进程都是独立的,进程有自己的地址空间,如果两个进程直接传递数据,会发生写时拷贝。所以此时我们需要一个媒介,也就是这里讲的管道。借助管道我们可以实现两个进程之间的单向通信

所以管道的作用自然也就出来了,管道的作用就是让两个进程看到同一份资源,这个资源常常指的是内存资源,也可抽象为文件资源

请添加图片描述


2.3 管道的四个特点

管道有4个特点:

  1. 管道内部自带同步与互斥机制
  2. 管道的生命周期随进程
  3. 管道提供的是流式服务
  4. 管道是半双工通信的

首先我们来理解下管道内部自带同步与互斥机制

我们将一次只允许一个进程使用的资源,称为临界资源。管道在同一时刻只允许一个进程对其进行写入或是读取操作,因此管道也就是一种临界资源,临界资源是需要被保护的,若是我们不对管道这种临界资源进行任何保护机制,那么就可能出现同一时刻有多个进程对同一管道进行操作的情况,进而导致同时读写、交叉读写以及读取到的数据不一致等问题,为了避免这些问题,内核会对管道操作进行同步与互斥

  • 同步: 两个或两个以上的进程在运行过程中协同步调,按预定的先后次序运行。比如,A任务的运行依赖于B任务产生的数据
  • 互斥: 一个公共资源同一时刻只能被一个进程使用,多个进程不能同时使用公共资源

实际上,同步是一种更为复杂的互斥,而互斥是一种特殊的同步。对于管道的场景来说,互斥就是两个进程不可以同时对管道进行操作,它们会相互排斥,必须等一个进程操作完毕,另一个才能操作,而同步也是指这两个不能同时对管道进行操作,但这两个进程必须要按照某种次序来对管道进行操作,也就是说,互斥具有唯一性和排它性,但互斥并不限制任务的运行顺序,而同步的任务之间则有明确的顺序关系

我们再来理解下管道的生命周期随进程

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

我们再来理解下管道提供的是流式服务:

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

  • 流式服务(随意读): 数据没有明确的分割,不分一定的报文段
  • 数据报服务(单位读): 数据有明确的分割,拿数据按报文段拿

最后我们来理解下管道是半双工通信的:

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

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

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

请添加图片描述


2.4 管道的四种情况

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

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

  • 其中前面两种情况就能够很好的说明,管道是自带同步与互斥机制的,读端进程和写端进程是有一个步调协调的过程的,不会说当管道没有数据了读端还在读取,而当管道已经满了写端还在写入。读端进程读取数据的条件是管道里面有数据,写端进程写入数据的条件是管道当中还有空间,若是条件不满足,则相应的进程就会被挂起,直到条件满足后才会被再次唤醒
  • 第三种情况也很好理解,读端进程已经将管道当中的所有数据都读取出来了,而且此后也不会有写端再进行写入了,那么此时读端进程也就可以执行该进程的其他逻辑了,而不会被挂起
  • 第四种情况也不难理解,既然管道当中的数据已经没有进程会读取了,那么写端进程的写入将没有意义,因此操作系统直接将写端进程杀掉。而此时子进程代码都还没跑完就被终止了,属于异常退出,那么子进程必然收到了某种信号

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

#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号信号,由此可知,当发生情况四时,操作系统向子进程发送的是SIGPIPE信号将子进程终止的


2.5 管道的大小获取

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

方法一:使用man手册

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

请添加图片描述

  • 使用uname -r 命令可以查看自己的Linux内核版本

请添加图片描述


方法二:使用ulimit命令

  • 其次,我们还可以使用ulimit -a命令,查看当前资源限制的设定
  • 根据显示,管道的最大容量是 512 × 8 = 4096 字节

请添加图片描述


方法三:自行测试

  • 这里发现,根据man手册得到的管道容量与使用ulimit命令得到的管道容量不同,那么此时我们可以自行进行测试
  • 前面说到,若是读端进程一直不读取管道当中的数据,写端进程一直向管道写入数据,当管道被写满后,写端进程就会被挂起。据此,我们可以写出以下代码来测试管道的最大容量
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <sys/type.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字节

请添加图片描述


2.6 命名管道与匿名管道的区别

  • 匿名管道用于有亲缘关系的进程,命名管道用于任意两个进程
  • 匿名管道的打开交给了pipe函数,pipe函数会创建并打开。命名管道的创建交给了mkfifo函数,管道的打开交给了open
  • 两者唯一区别就是打开和创建方式不同,创建和打开完成后,两者其实都是一样的。(因为创建和打开的工作就是让两个进程看到一块资源,怎么操作资源就是文件操作的事了)

3.匿名管道技术

3.1 对匿名管道的理解

使用场景:匿名管道用于有亲子关系的进程,常见用于父子也可以用于兄弟等

请添加图片描述

  • 管道实现形态上是文件,但是管道本身不占用磁盘或其他外部存储的空间
  • 管道文件的内容在内存的缓冲区上,缓冲区的内容不会刷新到磁盘的文件上

个人认为是没必要刷新到磁盘上,从内存读取的速度比从从磁盘上快的多,管道传输数据也是单向的,数据被接收的进程读取后没必要刷新到磁盘上。硬要刷新到磁盘上的话那没必要用管道…让这两个进程加载磁盘上的同一个文件不就行了。作为管道,一旦创建成功,应用除了传递数据外,能够做的只有删除操作了


3.2 匿名管道实现通讯的原理

管道也是一个文件,我们站在文件的角度来理解管道实现通信的原理

  • 原理:管道是一个文件,当一个进程以读和写的方式打开一个管道时,创建一个子进程,子进程会以父进程为模板,拷贝父进程的部分内容。此时file_strcut里的数组(文件描述符与文件的映射关系)会是父进程的拷贝。此时,父子进程都指向了管道文件(同一块空间),并且子进程也是以读写方式打开的该文件(因为子进程会继承父进程代码,父进程再创建子进程之前以读写方式打开的文件),如果将一个进程对文件进行写,一个进程对文件进行读,由于来给你进程指向同一空间,所以读进程拿到的数据就是写进程写进去的数据。此时就完成了对文件的通信

原理步骤图解:

第一步:父进程以读写方式打开管道文件

请添加图片描述

第二步:父进程创建子进程,子进程部分拷贝父进程内容,父子指向同一管道文件

请添加图片描述

第三步:父进程写,子进程读,实现文件的通信

请添加图片描述

图解了上面的原理,我们可以在深入一点,剖析下内核,站在内核角度去理解进程间通讯的原理:

让我们来思考一个问题:文件加载到内存需要开辟空间,一个进程如何找到文件的内存进行通信读写的?

  • task_struct有一个files_struct指针,可以找到files_struct,files_struct里有一个数组,数组下标对应文件描述符,可以找到对应文件struct_file。这样就找到文件了
  • struct_file中有一个struct_path,可以找到对应目录,目录中保存文件名和inode的对应关系,就可以找到文件的inode。文件inode中有一个struct address_space,进入里面有struct radix_tree_root page_tree,就可以找到对应内存空间
  • 再调用struct_file里的const struct file_operations *f_op指针,调用对应的读写函数。就实现了进程间的通信

上面的回答,让我们用内核剖析图来解释吧:

请添加图片描述

请添加图片描述


3.3 匿名管道的创建方法

一点补充:匿名管道用于两个有亲缘关系的进程之间的单向通信,比如父进程写子进程读。如果有多个进程需要通信,那就建立多个管道

对于匿名管道的创建,我们通常使用pipe函数来实现

int pipe(int pipefd[2]);

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

请添加图片描述

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

函数文档:

请添加图片描述

pipe函数的作用:

请添加图片描述


3.4 匿名管道的读写规则

pipe2函数与pipe函数类似,也是用于创建匿名管道,不过多了个flag参数,其函数原型如下:

int pipe2(int pipefd[2], int flags);

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

  1. 当没有数据可读时

    • O_NONBLOCK disable:read调用阻塞,即进程暂停执行,一直等到有数据来为止
    • O_NONBLOCK enable:read调用返回-1,errno值为EAGAIN

  2. 当管道满的时候

    • O_NONBLOCK disable:write调用阻塞,直到有进程读走数据
    • O_NONBLOCK enable:write调用返回-1,errno值为EAGAIN

  3. 如果所有管道写端对应的文件描述符被关闭,则read返回0

  4. 如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能导致write进程退出

  5. 当要写入的数据量不大于PIPE_BUF时,Linux将保证写入的原子性

  6. 当要写入的数据量大于PIPE_BUF时,Linux将不再保证写入的原子性


3.5 匿名管道的使用实例

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

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

请添加图片描述

2.父进程创建子进程

请添加图片描述

3.父进程关闭写端,子进程关闭读端
请添加图片描述

注意:

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

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

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

请添加图片描述

  1. 父进程创建子进程

请添加图片描述

  1. 父进程关闭写端,子进程关闭读端

请添加图片描述

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

//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.命名管道技术

4.1 对命名管道的理解

匿名管道只能用于两个有亲缘关系的进程通信,如果没有亲缘关系又要通信的话,此时就需要命名管道了

请添加图片描述


4.2 命名管道实现通讯的原理

  • 其实原理和匿名管道差不多,只是需要先创建一个命名管道再一个进程以读或者写的方式来打开该管道文件,再另外一个进程不需要创建管道,只需要以写或者读的方式来打开管道文件。再调用读写系统调用来往文件写或者读,来进行进程间通信
  • 两进程分别对同一管道文件分别用读或写的方式打开,两进程看到同一文件(资源)
  • 不需要创建子进程,可以是两个不相关的进程

如下图:

请添加图片描述

注意:

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

4.3 命名管道的创建方法

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

请添加图片描述

int mkfifo(const char *pathname, mode_t mode);

请添加图片描述

关于mkfifo的参数:

  1. mkfifo函数的第一个参数是pathname,表示要创建的命名管道文件
    • 若pathname以路径的方式给出,则将命名管道文件创建在pathname路径下
    • 若pathname以文件名的方式给出,则将命名管道文件默认创建在当前路径下
  2. mkfifo函数的第二个参数是mode,表示创建命名管道文件的默认权限
    • 例如,将mode设置为0666,则命名管道文件创建出来的权限如下:prw-rw-rw-
    • 但实际上创建出来文件的权限值还会受到umask(文件默认掩码)的影响,实际创建出来文件的权限为:mode&(~umask)
    • umask的默认值一般为0002,当我们设置mode值为0666时实际创建出来文件的权限为0664,即:prw-rw-r–
    • 若想创建出来命名管道文件的权限值不受umask的影响,则需要在创建文件前使用umask函数将文件默认掩码设置为0

关于mkfifo的返回值:

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

4.4 命名管道的读写规则

命名管道的第二个参数mode_t mode和匿名管道一样的使用方法

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

4.5 命名管道的使用实例

在当前目录下创建一个管道文件的例子:

#include<stdio.h>
#include<sys/types.h>
#include <sys/stat.h>

int main()
{
  if(mkfifo("./myfifo",0644)<0)
  {
    perror("mkfifo");
    return 1;
  }
  return 0;
}

请添加图片描述

我们也可以写一个简易的server和client作为命名管道例子:

  • server:服务端读
  • client:客户端写
  • 在服务端创建一个管道文件,客户端打开文件后进行写入,服务端再打开文件读取数据就实现了服务端和客户端两个进程的通信
  • 启动的时候是先启动服务端,因为要创建管道文件

server.c 代码:

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

int main()
{
  if(mkfifo("./myfifo",0644)<0)
  {
    perror("mkfifo");
    return 1;
  }
  int fd=open("myfifo",O_RDWR);
  if(fd<0)
  {
    perror("open");
    return 2;
  }
  while(1)
  {
    char buf[128];
    ssize_t num=read(fd,buf,sizeof(buf)-1);
    buf[num]='\0';
    printf("server has got the msg:%s\n",buf);
    sleep(1);
  }
  return 0;
}

client.c 代码:

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

int main()
{
  int fd=open("myfifo",O_RDWR);
  if(fd<0)
  {
    perror("open");
    return 1;
  }
  while(1)
  {
    const char* msg="hello world\n";
    write(fd,msg, strlen(msg));
    printf("client has wrote the msg!\n\n");
    sleep(1);
  }
  return 0;
}

Makefile 代码:

.PHONY:all
all:client server 

client:client.c
	gcc -o $@ $^

server:server.c
	gcc -o $@ $^

.PHONY:clean
clean:
	rm -f client server

运行效果:

请添加图片描述

命名管道还有很多用途。比如进程遥控,派发计算任务,文件拷贝等等

扩展:命名管道实现进程遥控

  • 学了命名管道,比较有意思的是,我们可以通过一个进程来控制另一个进程的行为,比如我们从客户端输入命令到管道当中,再让服务端将管道当中的命令读取出来并执行
  • 下面我们只实现了让服务端执行不带选项的命令,若是想让服务端执行带选项的命令,可以对管道当中获取的命令进行解析处理。这里的实现非常简单,只需让服务端从管道当中读取命令后创建子进程,然后再进行进程程序替换即可
  • 这里也无需更改客户端client.c的代码,只需改变服务端处理通信信息的逻辑即可
//client.c修改后的代码
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include <fcntl.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;
}

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

请添加图片描述

扩展:命名管道实现派发计划任务

  • 需要注意的是两个进程之间的通信,并不是简单的发送字符串而已,服务端是会对客户端发送过来的信息进行某些处理的
  • 这里我们以客户端向服务端派发计算任务为例,客户端通过管道向服务端发送双操作数的计算请求,服务端接收到客户端的信息后需要计算出相应的结果
  • 这里我们无需更改客户端client.c的代码,只需改变服务端处理通信信息的逻辑即可
//server.c修改后的代码
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include <fcntl.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;
}

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

请添加图片描述

扩展:命名管道实现文件拷贝

  • 这里我们再用命名管道实现一下文件的拷贝,比如file.txt文件

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

请添加图片描述

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

服务端server.c代码:

//server.c代码
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include <fcntl.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;
}

而客户端需要做的就是,以写的方式打开这个已经存在的命名管道文件,再以读的方式打开file.txt文件,之后需要做的就是将file.txt文件当中的数据读取出来并写入管道当中即可

客户端client.c代码:

//client.c代码
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include <fcntl.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;
}

请添加图片描述

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

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

请添加图片描述


5.System V IPC技术

管道通信本质是基于文件的,也就是说操作系统并没有为此做过多的设计工作,而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信号量就类似于下棋比赛时用的棋钟,用于保证两个棋手之间的同步与互斥


5.1 System V 共享内存技术

5.1.1 共享内存的基本原理

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

请添加图片描述

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


5.1.2 共享内存的数据结构

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

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

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结构体的定义如下:

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中定义


5.1.3 共享内存的创建与释放过程

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

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

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

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

5.1.4 共享内存的创建(shmget)

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

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

shmget函数的参数说明:

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

shmget函数的返回值说明:

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

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

【关键】对于shmget的参数获取详解:

第一个参数:key_t key

  • 传入shmget函数的第一个参数key,需要我们使用ftok函数进行获取
key_t ftok(const char *pathname, int proj_id);
  • ftok函数的作用:将一个已存在的路径名pathname和一个整数标识符proj_id转换成一个key值,称为IPC键值,在使用shmget函数获取共享内存时,这个key值会被填充进维护共享内存的数据结构当中。需要注意的是,pathname所指定的文件必须存在且可存取

注意:

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

第三个参数:int shmflg

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

请添加图片描述

换句话说:

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

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

#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:列出信号量相关信息

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

请添加图片描述

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


5.1.5 共享内存的释放(ipcrm)
  • 通过上面创建共享内存的实验可以发现,当我们的进程运行完毕后,申请的共享内存依旧存在,并没有被操作系统释放。实际上,管道是生命周期是随进程的,而共享内存的生命周期是随内核的,也就是说进程虽然已经退出,但是曾经创建的共享内存不会随着进程的退出而释放
  • 这说明,如果进程不主动删除创建的共享内存,那么共享内存就会一直存在,直到关机重启(system V IPC都是如此),同时也说明了IPC资源是由内核提供并维护的
  • 此时我们若是要将创建的共享内存释放,有两个方法,一就是使用命令释放共享内存,二就是在进程通信完毕后调用释放共享内存的函数进行释放

方法一:使用命名释放共享内存

我们可以使用ipcrm -m shmid命令释放指定id的共享内存资源,比如下图:ipcrm -m 0

请添加图片描述


方法二:使用程序释放共享内存资源

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

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

shmctl函数的参数说明:

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

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

请添加图片描述

shmctl函数的返回值说明:

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

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

#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;
}

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

while :; do ipcs -m;echo "###################################";sleep 1;done

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

请添加图片描述


5.1.6 共享内存的关联(shmat)

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

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

shmat函数的参数说明:

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

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

请添加图片描述

shmat函数的返回值说明:

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

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

#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函数创建共享内存时,在其第三个参数处设置共享内存创建后的权限,权限的设置规则与设置文件权限的规则相同

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

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

请添加图片描述


5.1.7 共享内存的去关联(shmdt)

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

int shmdt(const void *shmaddr);

shmdt函数的参数说明:

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

shmdt函数的返回值说明:

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

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

#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的过程,即取消了共享内存与该进程之间的关联

请添加图片描述

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


5.1.8 共享内存与管道的对比

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

我们先来看看管道通信:

请添加图片描述

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

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

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

请添加图片描述

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

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

最终结论:

  • 所以共享内存是所有进程间通信方式中最快的一种通信方式,因为该通信方式需要进行的拷贝次数最少
  • 但是共享内存也是有缺点的,我们知道管道是自带同步与互斥机制的,但是共享内存并没有提供任何的保护机制,包括同步与互斥

5.2 System V 消息队列技术

5.2.1 消息队列的基本原理

消息队列实际上就是在系统当中创建了一个队列,队列当中的每个成员都是一个数据块,这些数据块都由类型和信息两部分构成,两个互相通信的进程通过某种方式看到同一个消息队列,这两个进程向对方发数据时,都在消息队列的队尾添加数据块,这两个进程获取数据块时,都在消息队列的队头取数据块,如下图:

请添加图片描述

  • 其中消息队列当中的某一个数据块是由谁发送给谁的,取决于数据块的类型

总结一下:

  1. 消息队列提供了一个从一个进程向另一个进程发送数据块的方法
  2. 每个数据块都被认为是有一个类型的,接收者进程接收的数据块可以有不同的类型值
  3. 和共享内存一样,消息队列的资源也必须自行删除,否则不会自动清除,因为system V IPC资源的生命周期是随内核的

5.2.2 消息队列的数据结构

当然,系统当中也可能会存在大量的消息队列,系统一定也要为消息队列维护相关的内核数据结构

消息队列的数据结构如下:

struct msqid_ds {
	struct ipc_perm msg_perm;
	struct msg *msg_first;      	/* first message on queue,unused  */
	struct msg *msg_last;       	/* last message in queue,unused */
	__kernel_time_t msg_stime; 	 	/* last msgsnd time */
	__kernel_time_t msg_rtime;  	/* last msgrcv time */
	__kernel_time_t msg_ctime;  	/* last change time */
	unsigned long  msg_lcbytes; 	/* Reuse junk fields for 32 bit */
	unsigned long  msg_lqbytes; 	/* ditto */
	unsigned short msg_cbytes;  	/* current number of bytes on queue */
	unsigned short msg_qnum;    	/* number of messages in queue */
	unsigned short msg_qbytes;  	/* max number of bytes on queue */
	__kernel_ipc_pid_t msg_lspid;   /* pid of last msgsnd */
	__kernel_ipc_pid_t msg_lrpid;   /* last receive pid */
};

可以看到消息队列数据结构的第一个成员是msg_perm,它和shm_perm是同一个类型的结构体变量,ipc_perm结构体的定义如下:

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;
};

消息队列的数据结构msqid_dsipc_perm结构体分别在/usr/include/linux/msg.h和/usr/include/linux/ipc.h中定义


5.2.3 消息队列的创建(msgget)

创建消息队列我们需要用msgget函数,msgget函数的函数原型如下:

int msgget(key_t key, int msgflg);

说明一下:

  1. 创建消息队列也需要使用ftok函数生成一个key值,这个key值作为msgget函数的第一个参数
  2. msgget函数的第二个参数,与创建共享内存时使用的shmget函数的第三个参数相同
  3. 消息队列创建成功时,msgget函数返回的一个有效的消息队列标识符(用户层标识符)

5.2.4 消息队列的释放(msgctl)

释放消息队列我们需要用msgctl函数,msgctl函数的函数原型如下:

int msgctl(int msqid, int cmd, struct msqid_ds *buf);

说明一下:

  1. msgctl函数的参数与释放共享内存时使用的shmctl函数的三个参数相同,只不过msgctl函数的第三个参数传入的是消息队列的相关数据结构

5.2.5 消息队列发送数据方法(msgsnd)

向消息队列发送数据我们需要用msgsnd函数,msgsnd函数的函数原型如下:

int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);

msgsnd函数的参数说明:

  • 第一个参数msqid,表示消息队列的用户级标识符
  • 第二个参数msgp,表示待发送的数据块
  • 第三个参数msgsz,表示所发送数据块的大小
  • 第四个参数msgflg,表示发送数据块的方式,一般默认为0即可

其中msgsnd函数的第二个参数必须为以下结构:

struct msgbuf{
	long mtype;       /* message type, must be > 0 */
	char mtext[1];    /* message data */
    //该结构当中的第二个成员mtext即为待发送的信息,当我们定义该结构时,mtext的大小可以自己指定
};

msgsnd函数的返回值说明:

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

5.2.6 消息队列获取数据方法(msgrcv)

从消息队列获取数据我们需要用msgrcv函数,msgrcv函数的函数原型如下:

ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);

msgrcv函数的参数说明:

  • 第一个参数msqid,表示消息队列的用户级标识符
  • 第二个参数msgp,表示获取到的数据块,是一个输出型参数
  • 第三个参数msgsz,表示要获取数据块的大小
  • 第四个参数msgtyp,表示要接收数据块的类型

msgrcv函数的返回值说明:

  • msgsnd调用成功,返回实际获取到mtext数组中的字节数
  • msgsnd调用失败,返回-1

5.3 System V 信号量技术

5.3.1 信号量基本概念
  • 由于进程要求共享资源,而且有些资源需要互斥使用,因此各进程间竞争使用这些资源,进程的这种关系叫做进程互斥
  • 系统中某些资源一次只允许一个进程使用,称这样的资源为临界资源或互斥资源
  • 在进程中涉及到临界资源的程序段叫临界区
  • IPC资源必须删除,否则不会自动删除,因为system V IPC的生命周期随内核

5.3.2 信号量的数据结构

在系统当中也为信号量维护了相关的内核数据结构

信号量的数据结构如下:

struct semid_ds {
	struct ipc_perm sem_perm;       		 /* permissions .. see ipc.h */
	__kernel_time_t sem_otime;      		 /* last semop time */
	__kernel_time_t sem_ctime;      		 /* last change time */
	struct sem  *sem_base;      			 /* ptr to first semaphore in array */
	struct sem_queue *sem_pending;      	 /* pending operations to be processed */
	struct sem_queue **sem_pending_last;     /* last pending operation */
	struct sem_undo *undo;         			 /* undo requests on this array */
	unsigned short  sem_nsems;      		 /* no. of semaphores in array */
};

信号量数据结构的第一个成员也是ipc_perm类型的结构体变量,ipc_perm结构体的定义如下:

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;
};

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


5.3.3 信号量的创建(semget)

创建信号量集我们需要用semget函数,semget函数的函数原型如下:

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

说明一下:

  1. 创建信号量集也需要使用ftok函数生成一个key值,这个key值作为semget函数的第一个参数
  2. semget函数的第二个参数nsems,表示创建信号量的个数
  3. semget函数的第三个参数,与创建共享内存时使用的shmget函数的第三个参数相同
  4. 信号量集创建成功时,semget函数返回的一个有效的信号量集标识符(用户层标识符)

5.3.4 信号量的释放(semctl)

删除信号量集我们需要用semctl函数,semctl函数的函数原型如下:

int semctl(int semid, int semnum, int cmd, ...);

5.3.5 信号量的操作(semop)

对信号量集进行操作我们需要用semop函数,semop函数的函数原型如下:

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

6.对于进程间通讯的一点总结

通过对system V系列进程间通信的学习,可以发现共享内存、消息队列以及信号量,虽然它们内部的属性差别很大,但是维护它们的数据结构的第一个成员确实一样的,都是ipc_perm类型的成员变量

这样设计的好处就是,在操作系统内可以定义一个struct ipc_perm类型的数组,此时每当我们申请一个IPC资源,就在该数组当中开辟一个这样的结构,如下图:

请添加图片描述

也就是说,在内核当中只需要将所有的IPC资源的ipc_perm成员组织成数组的样子,然后用切片的方式获取到该IPC资源的起始地址,然后就可以访问该IPC资源的每一个成员了


扩展:信号量是如何保护临界区的

  • 进程间通信通过共享资源来实现,这虽然解决了通信的问题,但是也引入了新的问题,那就是通信进程间共用的临界资源,若是不对临界资源进行保护,就可能产生各个进程从临界资源获取的数据不一致等问题
  • 保护临界资源的本质是保护临界区,我们把进程代码中访问临界资源的代码称之为临界区,信号量就是用来保护临界区的,信号量分为二元信号量和多元信号量
  • 比如当前有一块大小为100字节的资源,我们若是以25字节为一份,那么该资源可以被分为4份,那么此时这块资源可以由4个信号量进行标识

请添加图片描述

  • 信号量本质是一个计数器,在二元信号量中,信号量的个数为1(相当于将临界资源看成一整块),二元信号量本质解决了临界资源的互斥问题,以下面的伪代码进行解释:

请添加图片描述

  • 根据以上代码,当进程A申请访问共享内存资源时,如果此时sem为1(sem代表当前信号量个数),则进程A申请资源成功,此时需要将sem减减,然后进程A就可以对共享内存进行一系列操作,但是在进程A在访问共享内存时,若是进程B申请访问该共享内存资源,此时sem就为0了,那么这时进程B会被挂起,直到进程A访问共享内存结束后将sem加加,此时才会将进程B唤起,然后进程B再对该共享内存进行访问操作
  • 在这种情况下,无论什么时候都只会有一个进程在对同一份共享内存进行访问操作,也就解决了临界资源的互斥问题
  • 实际上,代码中计数器sem减减的操作就叫做P操作,而计数器加加的操作就叫做V操作,P操作就是申请信号量,而V操作就是释放信号量,联想一下操作系统原理的生产者消费者模型是不是贼像,对就是这么来的!

请添加图片描述


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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

「已注销」

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

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

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

打赏作者

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

抵扣说明:

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

余额充值