目录
进程间通信介绍
1、进程间通信的概念
2、进程间通信的目的
3、进程间通信的本质
4、进程间通信的分类
管道
匿名管道
匿名管道的原理
pipe函数
创建匿名管道
管道的四种情况和五种特性
命名管道
使用命令创建命名管道
创建一个命名管道
命名管道的打开规则
用命名管道实现服务端(server)和客户端(client)之间的通信
用命名管道实现派发计算任务
用命名管道实现进程遥控
命名管道和匿名管道的区别
命令行当中的管道
system V进程间通信
system V共享内存
共享内存数据结构
共享内存的创建
共享内存的释放
共享内存的关联
共享内存的去关联
共享内存与管道进行对比
System V消息队列
消息队列的基本原理
消息队列数据结构
消息队列的创建
消息队列的释放
向消息队列发送数据
从消息队列获取数据
System V信号量
信号量相关概念
信号量数据结构
进程间通信介绍
1、进程间通信的概念
进程间通信简称IPC(Interprocess communication),进程间通信就是在不同进程之间传播或交换信息。
2、进程间通信的目的
- 数据传输: 一个进程需要将它的数据发送给另一个进程。
- 资源共享: 多个进程之间共享同样的资源。
- 通知事件: 一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件,比如进程终止时需要通知其父进程。
- 进程控制: 有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
3、进程间通信的本质
进程间通信的本质:让不同的进程看到同一份资源。
由于各个运行进程之间具有独立性,这个独立性主要体现在数据层面,而代码逻辑层面可以私有也可以公有(例如父子进程),因此各个进程之间要实现通信是非常困难的。
各个进程之间若想实现通信,一定要借助第三方资源,这些进程就可以通过向这个第三方资源写入或是读取数据,进而实现进程之间的通信,这个第三方资源实际上就是操作系统提供的一段内存区域。
4、进程间通信的分类
管道
- 匿名管道
- 命名管道
System V IPC
- System V 消息队列
- System V 共享内存
- System V 信号量
POSIX IPC
- 消息队列
- 共享内存
- 信号量
- 互斥量
- 条件变量
- 读写锁
管道
管道是Unix中最古老的进程间通信的形式,我们把从一个进程连接到另一个进程的数据流称为一个“管道”。
匿名管道
匿名管道的原理
匿名管道用于进程间通信,且仅限于本地父子(兄弟,爷孙等等血缘关系)进程之间的通信。
进程间通信的本质就是,让不同的进程看到同一份资源,使用匿名管道实现父子进程间通信的原理就是,让两个父子进程先看到同一份被打开的文件资源,然后父子进程就可以对该文件进行写入或是读取操作,进而实现父子进程间通信。
注意:
- 这里父子进程看到的同一份文件资源是由操作系统来维护的,所以当父子进程对该文件进行写入操作时,该文件缓冲区当中的数据并不会进行写时拷贝。
- 管道虽然用的是文件的方案,但操作系统一定不会把进程进行通信的数据刷新到磁盘当中,因为这样做有IO参与会降低效率,而且也没有必要。也就是说,这种文件是一批不会把数据写到磁盘当中的文件,换句话说,磁盘文件和内存文件不一定是一一对应的,有些文件只会在内存当中存在,而不会在磁盘当中存在。
pipe函数
#include <unistd.h>int pipe(int pipefd[2]);
参数 pipefd[2]
是一个包含两个元素的整数数组,其中 pipefd[0]
用于从管道读取,pipefd[1]
用于向管道写入。
调用成功时返回 0,失败时返回 -1 并设置相应的 errno
。
创建匿名管道
在创建匿名管道实现父子进程间通信的过程中,需要pipe函数和fork函数搭配使用,具体步骤如下:
站在文件描述符角度创建管道!!!
1、父进程调用pipe函数创建管道。
2、父进程创建子进程。
3、父进程关闭写端,子进程关闭读端。
注意:
- 管道只能够进行单向通信,因此当父进程创建完子进程后,需要确认父子进程谁读谁写,然后关闭相应的读写端。
- 从管道写端写入的数据会被内核缓冲,直到从管道的读端被读取。
//child->write, father->read
#include <stdio.h>
#include <unistd.h>
#include <cstring>
#include <cstdlib>#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){//childclose(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);}//fatherclose(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;
}
管道的四种情况和五种特性
四种情况
- 正常情况,如果管道没有数据了,读端必须等待,直到有数据为止(写端写入数据了)
- 正常情况,如果管道被写满了,写端必须等待,直到有空间为止(读端读走数据)
- 写端关闭,读端一直读取, 读端会读到read返回值为0, 表示读到文件结尾
- 读端关闭,写端一直写入,OS会直接杀掉写端进程,通过想目标进程发送SIGPIPE(13)信号,终止目标进程
五种特性
- 匿名管道,可以允许具有血缘关系的进程之间进行进程间通信,常用与父子,仅限于此!!
- 匿名管道,默认给读写端要提供同步机制 !!!
- 面向字节流的 !!!
- 管道的生命周期是随进程的!!!
- 管道是单向通信的,半双工通信的一种特殊情况!!!
命名管道
- 匿名管道只能用于具有共同祖先的进程(具有亲缘关系的进程)之间的通信,通常,一个管道由一个进程创建,然后该进程调用fork,此后父子进程之间就可应用该管道。
- 如果要实现两个毫不相关进程之间的通信,可以使用命名管道来做到。命名管道就是一种特殊类型的文件,两个进程通过命名管道的文件名打开同一个管道文件,此时这两个进程也就看到了同一份资源,进而就可以进行通信了。
关键:
- 普通文件是很难做到通信的,即便做到通信也无法解决一些安全问题。
- 命名管道和匿名管道一样,都是内存文件,只不过命名管道在磁盘有一个简单的映像,但这个映像的大小永远为0,因为命名管道和匿名管道都不会将通信数据刷新到磁盘当中。
使用命令创建命名管道
我们可以使用 mkfifo 命令来创建一个命名管道!
mkfifo FIFO
FIFO的文件类型为 p ,说明 FIFO 就是管道文件!
我们来使用两个毫无关系的进程来进行通信,我们在一个进程(进程A)中用shell脚本每秒向命名管道写入一个字符串,在另一个进程(进程B)当中用 cat 命令从命名管道当中进行读取。
现象就是当进程A启动后,进程B会每秒从命名管道中读取一个字符串打印到显示器上。这就证明了这两个毫不相关的进程可以通过命名管道进行数据传输,即通信。
当管道的读端进程退出后,写端进程再向管道写入数据就没有意义了,此时写端进程会被操作系统杀掉,在这里就可以很好的得到验证:当我们终止掉读端进程后,因为写端执行的循环脚本是由命令行解释器bash执行的,所以此时bash就会被操作系统杀掉,我们的云服务器也就退出了。
创建一个命名管道
在程序中创建命名管道使用mkfifo函数!
int mkfifo(const char *pathname, mode_t mode);
mkfifo函数的参数
pathname
表示要创建的命名管道文件
- 若pathname以路径的方式给出,则将命名管道文件创建在pathname路径下。
- 若pathname以文件名的方式给出,则将命名管道文件默认创建在当前路径下。(注意当前路径的含义)
mode
表示创建命名管道文件的默认权限
例如,将mode设置为0666,但实际上创建出来文件的权限值还会受到umask(文件默认掩码)的影响,实际创建出来文件的权限为:mode&(~umask)。umask的默认值一般为0002,当我们设置mode值为0666时实际创建出来文件的权限为0664。
若想创建出来命名管道文件的权限值不受umask的影响,则需要在创建文件前使用umask函数将文件默认掩码设置为0。
mkfifo 函数的返回值
- 命名管道创建成功,返回0。
- 命名管道创建失败,返回-1。
创建命名管道示例
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>#define FILE_NAME "FIFO"int main()
{umask(0); //将文件默认掩码设置为0if (mkfifo(FILE_NAME, 0666) < 0) //使用 FIFO 创建命名管道文件{ perror("FIFO");return 1;}//create success...return 0;
}
命名管道的打开规则
1、如果当前打开操作是为读而打开FIFO时。
- O_NONBLOCK disable:阻塞直到有相应进程为写而打开该FIFO。
- O_NONBLOCK enable:立刻返回成功。
2、如果当前打开操作是为写而打开FIFO时。
- O_NONBLOCK disable:阻塞直到有相应进程为读而打开该FIFO。
- O_NONBLOCK enable:立刻返回失败,错误码为ENXIO。
用命名管道实现服务端(server)和客户端(client)之间的通信
- 我们需要先让服务端运行起来,我们需要让服务端运行后创建一个命名管道文件!
- 对于服务端来说,以读的方式打开该命名管道文件,之后服务端就可以从该命名管道当中读取客户端发来的通信信息了。
- 对于客户端来说,因为服务端运行起来后命名管道文件就已经被创建了,所以客户端只需以写的方式打开该命名管道文件,之后客户端就可以将通信信息写入到命名管道文件当中,进而实现和服务端的通信。
服务端(server)的代码:
#include "comm.h"int main()
{umask(0); //将文件默认掩码设置为0if (mkfifo(FILE_NAME, 0666) < 0)//使用FIFO创建命名管道文件{ perror("FIFO");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;
}
客户端(client)的代码:
#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;
}
共同头文件(comm.h)的代码:
//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 "FIFO" //让客户端和服务端使用同一个命名管道
我们将代码编译完成以后得到了两个可执行程序客户端(client) 和 服务端(server)
我们先将服务端(server) 进程运行起来,之后我们就能在客户端(client) 看到这个已经被创建的命名管道文件(FIFO)
- 我们再将客户端(client) 也运行起来,此时我们从客户端(client) 写入的信息被客户端(client) 写入到命名管道文件(FIFO)当中 !
- 服务端(server)再从命名管道文件(FIFO)当中将信息读取出来打印在服务端(server)的显示器上,该现象说明服务端(server)是能够通过命名管道获取到客户端(client) 发来的信息的,换句话说,此时这两个进程之间是能够通信的 !(并且还是两个毫无关系的进程)
当客户端(client)退出后,服务端(server)将管道当中的数据读完后就再也读不到数据了,那么此时服务端(server)也就会去执行它的其他代码了(在当前代码中是直接退出了)
当服务端(server)退出后,客户端(client)写入管道的数据就不会被读取了,也就没有意义了,那么当客户端(client)下一次再向管道写入数据时,就会收到操作系统发来的13号信号(SIGPIPE),此时客户端(client)就被操作系统(os)强制杀掉了。
注意:通信是在内存当中进行的
用命名管道实现派发计算任务
两个进程之间的通信,并不是简单的发送字符串而已,服务端(server)是会对客户端(client)发送过来的信息进行某些处理的。
- 这里我们以客户端(client)向服务端(server)派发计算任务为例
- 客户端(client)通过管道向服务端(server)发送双操作数的计算请求,服务端(server)接收到客户端(client)的信息后需要计算出相应的结果。
- 这里我们无需更改客户端(client)的代码,只需改变服务端(server)处理通信信息的逻辑即可。
//server.c
#include "comm.h"int main()
{umask(0); //将文件默认掩码设置为0if (mkfifo(FILE_NAME, 0666) < 0){ //使用FIFO创建命名管道文件perror("FIFO");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;
}
此时服务端(server)接收到客户端(client)的信息后,需要进行的处理动作就不是将其打印到显示器了,而是需要将信息经过进一步的处理,从而得到相应的结果!
用命名管道实现进程遥控
我们可以通过一个进程来控制另一个进程的行为,比如我们从客户端(client)输入命令到管道当中,再让服务端(server)将管道当中的命令读取出来并执行。
- 下面我们只实现了让服务端(server)执行不带选项的命令,若是想让服务端(server)执行带选项的命令,可以对管道当中获取的命令进行解析处理。
- 实现非常简单,只需让服务端(server)从管道当中读取命令后创建子进程,然后再进行进程程序替换即可。
- 我们无需更改客户端(client)的代码,只需改变服务端(server)处理通信信息的逻辑即可
服务端(server.c)
// server.c
#include "comm.h"int main()
{umask(0); //将文件默认掩码设置为0if (mkfifo(FILE_NAME, 0666) < 0){ //使用mkfifo创建命名管道文件perror("FIFO");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){//childexeclp(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;
}
此时服务端(server)接收到客户端(client)的信息后,便进行进程程序替换,进而执行客户端(client)发送过来的命令。
命名管道和匿名管道的区别
-
可见性和可访问性:
- 命名管道有一个名称,在文件系统中可见,因此可以被不同的进程通过指定的名称来访问和使用。
- 匿名管道没有名称,只能在具有亲缘关系(如父子进程)的进程间使用。
-
进程间关系:
- 命名管道可以用于没有亲缘关系的进程之间的通信。
- 匿名管道通常用于具有父子关系的进程之间。
-
持久性:
- 命名管道会一直存在,直到被显式删除。
- 匿名管道会随着创建它的进程结束而消失。
-
使用场景:
- 命名管道适用于需要在多个不相关的进程之间进行通信的情况,例如不同用户运行的程序之间的通信。
- 匿名管道常用于简单的父子进程之间的数据传递。
命令行当中的管道
我们创建一个 data.txt 的文件,内容如下:
我们可以利用管道(“|”)同时使用cat命令和grep命令,进而实现文本过滤。
cat data.txt | grep linux
那么在命令行当中的管道(“|”)到底是匿名管道还是命名管道呢?
我们知道,若是两个进程之间采用的是命名管道,那么在磁盘上必须有一个对应的命名管道文件名,而实际上我们在使用命令的时候并不存在类似的命名管道文件名,因此命令行上的管道实际上是匿名管道。
system V进程间通信
管道通信本质是基于文件的,也就是说操作系统并没有为此做过多的设计工作,而system V IPC是操作系统特地设计的一种通信方式。但是不管怎么样,它们的本质都是一样的,都是在想尽办法让不同的进程看到同一份由操作系统提供的资源。
system V IPC提供的通信方式有以下三种:
- system V共享内存
- system V消息队列
- system V信号量
其中,system V共享内存和system V消息队列是以传送数据为目的的,而system V信号量是为了保证进程间的同步与互斥而设计的,虽然system V信号量和通信好像没有直接关系,但属于通信范畴。
system V共享内存和system V消息队列就类似于手机,用于沟通信息;system V信号量就类似于下棋比赛时用的棋钟,用于保证两个棋手之间的同步与互斥。
system V共享内存
共享内存让不同进程看到同一份资源的方式就是:
- 在物理内存当中申请一块内存空间,然后将这块内存空间分别与各个进程各自的页表之间建立映射
- 再在虚拟地址空间当中开辟空间并将虚拟地址填充到各自页表的对应位置,使得虚拟地址和物理地址之间建立起对应关系
- 至此这些进程便看到了同一份物理内存,这块物理内存就叫做共享内存。
这里所说的开辟物理空间、建立映射等操作都是调用系统接口完成的,也就是说这些动作都由操作系统来完成。
共享内存数据结构
在系统当中可能会有大量的进程在进行通信,因此系统当中就可能存在大量的共享内存,那么操作系统必然要对其进行管理,所以共享内存除了在内存当中真正开辟空间之外,系统一定还要为共享内存维护相关的内核数据结构。
共享内存的数据结构如下:
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_perm ,shm_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;
};
共享内存的创建
共享内存的建立大致包括以下两个过程:
- 在物理内存当中申请共享内存空间。
- 将申请到的共享内存挂接到地址空间,即建立映射关系。
创建共享内存我们需要用shmget函数,shmget函数的函数原型如下:
int shmget(key_t key, size_t size, int shmflg);
参数:
- 参数key,表示待创建共享内存在系统当中的唯一标识。
- 参数size,表示待创建共享内存的大小。
- 参数shmflg,表示创建共享内存的方式。
返回值:
- shmget调用成功,返回一个有效的共享内存标识符(用户层标识符)。
- shmget调用失败,返回-1。
我们把具有标定某种资源能力的东西叫做句柄,而这里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值,然后才能找到同一个共享资源。
参数shmflg
- 使用组合 IPC_CREAT,一定会获得一个共享内存的句柄,但无法确认该共享内存是否是新建的共享内存。
- 使用组合 IPC_CREAT | IPC_EXCL,只有shmget 函数调用成功时才会获得共享内存的句柄,并且该共享内存一定是新建的共享内存。
现在我们就可以去创建一个共享内存了,代码如下:
#pragma once#include <iostream>
#include <cstdlib>
#include <string>const std::string pathname = "/home/whb/109/109/lesson30";
const int proj_id = 0x11223344;// 共享内存的大小,强烈建议设置成为n*4096
const int size = 4096; // 4096*2key_t GetKey()
{key_t key = ftok(pathname.c_str(), proj_id);if(key < 0){std::cerr << "errno: " << errno << ", errstring: " << strerror(errno) << std::endl;exit(1);}return key;
}std::string ToHex(int id)
{char buffer[1024];snprintf(buffer, sizeof(buffer), "0x%x", id);return buffer;
}int CreateShmHelper(key_t key, int flag)
{int shmid = shmget(key, size, flag);if(shmid < 0){std::cerr << "errno: " << errno << ", errstring: " << strerror(errno) << std::endl;exit(2);}return shmid;
}int CreateShm(key_t key)
{return CreateShmHelper(key, IPC_CREAT|IPC_EXCL|0644);
}int GetShm(key_t key)
{return CreateShmHelper(key, IPC_CREAT/*0也可以*/);
}
在Linux当中,我们可以使用 ipcs
命令查看有关进程间通信设施的信息。
单独使用ipcs
命令时,会默认列出消息队列、共享内存以及信号量相关的信息,若只想查看它们之间某一个的相关信息,可以选择携带以下选项:
- -q:列出消息队列相关信息。
- -m:列出共享内存相关信息。
- -s:列出信号量相关信息。
key | 系统区别各个共享内存的唯一标识 |
shmid | 共享内存的用户层id(句柄) |
owner | 共享内存的拥有者 |
perms | 共享内存的权限 |
bytes | 共享内存的大小 |
nattch | 关联共享内存的进程数 |
status | 共享内存的状态 |
注意: key是在内核层面上保证共享内存唯一性的方式,而shmid是在用户层面上保证共享内存的唯一性,key和shmid之间的关系类似于fd和FILE* 之间的的关系。
共享内存的释放
共享内存的释放大致包括以下两个过程:
- 将共享内存与地址空间去关联,即取消映射关系。
- 释放共享内存空间,即将物理内存归还给系统。
通过上面创建共享内存的实验可以发现,当我们的进程运行完毕后,申请的共享内存依旧存在,并没有被操作系统释放。实际上,管道是生命周期是随进程的,而共享内存的生命周期是随内核的,也就是说进程虽然已经退出,但是曾经创建的共享内存不会随着进程的退出而释放。
这说明,如果进程不主动删除创建的共享内存,那么共享内存就会一直存在,直到关机重启(system V IPC都是如此),同时也说明了IPC资源是由内核提供并维护的。
此时我们若是要将创建的共享内存释放,有两个方法:
- 一就是使用命令释放共享内存
- 二就是在进程通信完毕后调用释放共享内存的函数进行释放。
使用命令释放共享内存资源
我们可以使用 ipcrm -m shmid
命令释放指定id的共享内存资源。
ipcrm -m 8
注意: 指定删除时使用的是共享内存的用户层id,即列表当中的shmid。
使用程序释放共享内存资源
控制共享内存我们需要用shmctl函数,shmctl函数的函数原型如下:
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
参数
- 参数shmid,表示所控制共享内存的用户级标识符。
- 参数cmd,表示具体的控制动作。
- 参数buf,用于获取或设置所控制共享内存的数据结构。
返回值
- shmctl调用成功,返回0。
- shmctl调用失败,返回-1。
参数cmd 的选项
IPC_STAT | 获取共享内存的当前关联值,此时参数buf作为输出型参数 |
IPC_SET | 在进程有足够权限的前提下,将共享内存的当前关联值设置为buf所指的数据结构中的值 |
IPC_RMID | 删除共享内存段 |
共享内存的关联
将共享内存连接到进程地址空间我们需要用shmat函数,shmat函数的函数原型如下:
void *shmat(int shmid, const void *shmaddr, int shmflg);
参数:
- 参数shmid,表示待关联共享内存的用户级标识符。
- 参数shmaddr,指定共享内存映射到进程地址空间的某一地址,通常设置为NULL,表示让内核自己决定一个合适的地址位置。
- 参数shmflg,表示关联共享内存时设置的某些属性。
返回值:
- shmat调用成功,返回共享内存映射到进程地址空间中的起始地址。
- shmat调用失败,返回(void*)-1。
参数shmflg
SHM_RDONLY | 关联共享内存后只进行读取操作 |
SHM_RND | 若shmaddr不为NULL,则关联地址自动向下调整为SHMLBA的整数倍。公式:shmaddr-(shmaddr%SHMLBA) |
0 | 默认为读写权限 |
共享内存的去关联
取消共享内存与进程地址空间之间的关联我们需要用shmdt函数,shmdt函数的函数原型如下:
int shmdt(const void *shmaddr);
参数:
- 待去关联共享内存的起始地址,即调用shmat函数时得到的起始地址。
返回值:
- shmdt调用成功,返回0。
- shmdt调用失败,返回-1。
共享内存与管道进行对比
当共享内存创建好后就不再需要调用系统接口进行通信了,而管道创建好后仍需要read、write等系统接口进行通信。实际上,共享内存是所有进程间通信方式中最快的一种通信方式。
我们先来看看管道通信:
使用管道通信的方式,将一个文件从一个进程传输到另一个进程需要进行四次拷贝操作:
- 服务端将信息从输入文件复制到服务端的临时缓冲区中。
- 将服务端临时缓冲区的信息复制到管道中。
- 客户端将信息从管道复制到客户端的缓冲区中。
- 将客户端临时缓冲区的信息复制到输出文件中。
我们再来看看共享内存通信:
使用共享内存进行通信,将一个文件从一个进程传输到另一个进程只需要进行两次拷贝操作:
- 从输入文件到共享内存。
- 从共享内存到输出文件。
所以共享内存是所有进程间通信方式中最快的一种通信方式,因为该通信方式需要进行的拷贝次数最少。
但是共享内存也是有缺点的,我们知道管道是自带同步与互斥机制的,但是共享内存并没有提供任何的保护机制,包括同步与互斥。
System V消息队列
消息队列的基本原理
消息队列实际上就是在系统当中创建了一个队列,队列当中的每个成员都是一个数据块,这些数据块都由类型和信息两部分构成,两个互相通信的进程通过某种方式看到同一个消息队列,这两个进程向对方发数据时,都在消息队列的队尾添加数据块,这两个进程获取数据块时,都在消息队列的队头取数据块。
其中消息队列当中的某一个数据块是由谁发送给谁的,取决于数据块的类型
注意:
- 消息队列提供了一个从一个进程向另一个进程发送数据块的方法。
- 每个数据块都被认为是有一个类型的,接收者进程接收的数据块可以有不同的类型值。
- 和共享内存一样,消息队列的资源也必须自行删除,否则不会自动清除,因为system V IPC资源的生命周期是随内核的。
消息队列数据结构
当然,系统当中也可能会存在大量的消息队列,系统一定也要为消息队列维护相关的内核数据结构。
消息队列的数据结构如下:
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;
};
消息队列的创建
创建消息队列我们需要用msgget函数,msgget函数的函数原型如下:
int msgget(key_t key, int msgflg);
- 创建消息队列也需要使用ftok函数生成一个key值,这个key值作为msgget函数的第一个参数。
- msgget函数的第二个参数,与创建共享内存时使用的shmget函数的第三个参数相同。
- 消息队列创建成功时,msgget函数返回的一个有效的消息队列标识符(用户层标识符)。
消息队列的释放
释放消息队列我们需要用msgctl函数,msgctl函数的函数原型如下
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
msgctl函数的参数与释放共享内存时使用的shmctl函数的三个参数相同,只不过msgctl函数的第三个参数传入的是消息队列的相关数据结构。
向消息队列发送数据
向消息队列发送数据我们需要用msgsnd函数,msgsnd函数的函数原型如下:
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
参数:
- 第一个参数msqid,表示消息队列的用户级标识符。
- 第二个参数msgp,表示待发送的数据块。
- 第三个参数msgsz,表示所发送数据块的大小
- 第四个参数msgflg,表示发送数据块的方式,一般默认为0即可。
返回值:
- msgsnd调用成功,返回0。
- msgsnd调用失败,返回-1。
其中msgsnd函数的第二个参数必须为以下结构:
struct msgbuf{long mtype; /* message type, must be > 0 */char mtext[1]; /* message data */
};
注意: 该结构当中的第二个成员mtext即为待发送的信息,当我们定义该结构时,mtext的大小可以自己指定。
从消息队列获取数据
从消息队列获取数据我们需要用msgrcv函数,msgrcv函数的函数原型如下:
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
参数:
- 第一个参数msqid,表示消息队列的用户级标识符。
- 第二个参数msgp,表示获取到的数据块,是一个输出型参数。
- 第三个参数msgsz,表示要获取数据块的大小
- 第四个参数msgtyp,表示要接收数据块的类型。
返回值:
- msgsnd调用成功,返回实际获取到mtext数组中的字节数。
- msgsnd调用失败,返回-1。
System V信号量
信号量相关概念
- 由于进程要求共享资源,而且有些资源需要互斥使用,因此各进程间竞争使用这些资源,进程的这种关系叫做进程互斥。
- 系统中某些资源一次只允许一个进程使用,称这样的资源为临界资源或互斥资源。
- 在进程中涉及到临界资源的程序段叫临界区。
- IPC资源必须删除,否则不会自动删除,因为system V IPC的生命周期随内核。
信号量数据结构
在系统当中也为信号量维护了相关的内核数据结构。信号量的数据结构如下:
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;
};