文章目录
- 前言
- 1.进程间通信相关介绍
- 2.管道
- 1.匿名管道
- 2.管道的原理
- 3.通过代码来演示匿名管道
- 4.命名管道
- 5.命名管道的原理
- 6.命名管道代码演示
- 3.System V共享内存
- 1.共享内存原理
- 2.相关系统接口的介绍与共享内存的代码演示
- 3.共享内存的一些特性
- 4.system V消息队列与system V信号量
- 5.理解IPC
前言
本文主要对Liunx下的进程间通信的方式进行介绍,主要会围绕管道,共享内存进行介绍,同时也会补充一点进行消息队列和信号量的相关知识。
1.进程间通信相关介绍
为啥要进行进程间通信呢?
有很多场景都需要进行进程间通信比如
数据传输:一个进程需要将它的数据发送给另一个进程资源共享:多个进程之间共享同样的资源。
通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
简单来说如果让不同进程之间进行数据交互,就需要进行进程间通信。IPC即为进程间通信的缩写:IPC是Inter-Process Communication(进程间通信)
进程间通信方式
管道 :
匿名管道pipe 命名管道
System V IPC
System V 消息队列 System V 共享内存 System V 信号量
POSIX IPC
消息队列
共享内存
信号量
互斥量
条件变量
读写锁
Liunx下进程间通信方式主要就是上述几种,POSIX IPC和System V IPC都是进程间通信的方式,但是它们的实现细节和接口有所不同。POSIX IPC是基于POSIX标准的一种操作系统接口,定义了UNIX和类UNIX操作系统所需的API。System V IPC是基于System V UNIX版本的一种进程间通信机制。
本文主要是介绍的就是System V IP。
我们知道进程都是独立的,所以为了实现进程间通信,且保持进程间的独立性,就需要
让不同的进程看到同一份资源
,这是进程间通信的必要前提条件,也是进程间通信的本质,上述几种的方式都是围绕这点来实现进程间通信的.
2.管道
1.匿名管道
管道是一种古老的进程间通信方式,它既不属于System V也不属于POSIX 通信方式,它主要是依赖于文件系统,因为管道的本质就是一种特殊的文件。我们之前也用过管道,比如我们使|连接两个不同的指令时就是在使用管道。简单来说把
从一个进程连接到另一个进程的一个数据流称为一个“管道”
ls -l会显示3行内容,wc -l会显示文件内容行数,利用管道最显示出了ls -显示内容是3行,这里的ls指令和wc指令是两个不同的程序,当程序运行起来就是两个不同的进程,利用管道就是实现了两个进程之间的数据交互。上述的方式是使用的匿名管道,所谓匿名管道,就是该管道的没有名字。
匿名管道主要用于父子进程间或者具有血缘关系的进程间进行通信,上述的ls和wc都是shell的子进程是满足这一点的。
2.管道的原理
其实管道的原理也很简单,之前说了管道是文件,当我们使用管道进行进程间通信的时候,其实就内存中会创建一个管道文件,这个管道文件也会被操作系统通过strcut file结构体管理起来,进程pcb指向的文描述符表中就有对应的管道文件描述符,要进行通信的进程就可以在管道中写入数据或者读取数据,这样两个独立的进程就相当于看到了同一份资源实现了进程间的通信。
这也是为什么说管道是依赖的于文件系统的原因。
这里匿名管道有很多注意的地方,
首先是要父子进程的要通过管道进行通信的时候,父进程是以读写方式打开管道文件的。
为啥呢?当子进程被创建出来的时候会继承父进程打开管道文件的方式,如果父进程只单单以读方式或者写方式打开文件,那么子进程也是如此,这样显然父子进程无法通信。当父子进程要通信的时候,父子进程要关闭不用的读写端,如果父进程是从管道里读数据,子进程是从管道中写数据,那么父进程就要关闭对应的写端文件描述符,子进程要关闭对应的读端文件描述符。反之亦然。
这又是为啥呢?因为管道这种通信方式是单向通信方式,必须固定数据的流向。匿名管道这种文件只存在于内存中不会向外设刷新数据,没有对应的inode节点和数据块。文件数据内容是存在对应的缓冲区的,这个缓冲区只能有一个读端和一个写端。
3.通过代码来演示匿名管道
关于匿名管道,系统提供了一个接口pipe来创建匿名管道,pipe的参数是输出型参数,当创建好匿名管道后,pipefd数组下标中有两个返回值,分别是以读方式创建管道的文件描述符和以写方式创建管道的文件描述符,这样就可以直接使用文件描述符对来定位匿名管道了。当创建成功pipe会返回0,失败会返回-1.
数组下标 | 数数组下标含义 |
---|---|
pipefd[0] | 表示管道读端 |
pipefd[1] | 表示管道写端 |
知道了系统调用接口,我们大致就确定了管道的代码编写思路。第一步:创建匿名管道,第二步创建不需要的子进程,第三步关闭不需要的fd,第四步开始通信,第五步回收子进程,第六步父进程退出。
#include<cassert>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{ int pipefd[2]={0};
//1.创建管道
int n=pipe(pipefd);
//创建失败直接退出
if(n<0)
{
std::cout<<"pipe error"<<errno<<": "<<strerror(errno)<<std::endl;
return 1;
}
std::cout<<"pipefd[0]: "<<pipefd[0]<<std::endl;
std::cout<<"pipefd[1]: "<<pipefd[1]<<std::endl;
//2.创建子进程
pid_t id=fork();
assert(id!=-1);
//子进程流程
if(id==0)
{
close(pipefd[0]);
const std::string namestr="hello 我是子进程\n";
int cnt=1;
char buffer[1024];
while(true)
{
snprintf(buffer,sizeof(buffer),"%s,计数器:%d,我的pid: %d\n",
namestr.c_str(),cnt++,getpid());
write(pipefd[1],buffer,strlen(buffer));
}
close(pipefd[1]);
exit(0);
}
//3.关闭不需要的fd 父进程读,子进程写
close(pipefd[1]);
//开始通信
char buffer[1024];
int cnt=0;
while(true)
{
int n=read(pipefd[0],buffer,sizeof(buffer)-1);
if(n>0)
{
buffer[n]='\0';
std::cout<<"child give me message: "<<buffer<<std::endl;
sleep(5);
}
if(cnt==5)
{
break;
}
cnt++;
}
close(pipefd[0]);
int status=0;
waitpid(id,&status,0);
std::cout<<"sig: "<<(status&0x7f)<<std::endl;
return 0;
}
通过上述代码我们确实看到了父子进程之间实现了通信,那么我们可以总结匿名管道的如下特点:
1.管道是半双工单向通信,2.管道的本质是文件,fd的生命周期是随进程的,进程退出内存中的管道文件也会被释放,因此管道的生命周期是随进程的.
哪怕父子进程退出的时候没有关闭文件描述符,管道文件照样会被释放。3.管道通信适用于有血缘关系的进程进行通信,常用于父子进程间通信。
上述代码我们还能看出一个现象,
管道读写次数不是严格匹配的,不是强相关的.
上述代码中子进程写的比较快父进程读的比较慢,因为每次写完数据后,父进程都需要sleep5秒,这个时候子进程一直在忘管道里写数据,我们看到父进程确实读取了很多数据。管道数据是存储在缓冲区,这个缓冲区是面向字节流的,因此表现就是读写不是强相关。
管道的几种场景
如果我们read读取完毕所有的管道的数据,如果对方不发新的数据,我们只能只能等待。如果我们wire写端将管道写满了,就无法在往管道写入数据只能等待另一个进程将管道中的数据读走之后在写入,管道大小是受限制的,一般为64k。也就说管道为空就不能读,管道为满就不能写。
因此管道具有一定的协同能力,让read和write按照一定的步骤进行通信,自带同步互斥机制。
如果关闭了管道的写端,读取完毕管道数据,在读就read就会返回0,表示读到了文件结尾,写端一直写,关闭读端,这会发生什么呢?显然这是无意义的操作,操作系统不会维护这种无意义的事情,操作系统会杀死一直写入的进程(通过发送SIGPIPE13号信号来终止进程).
补充
这个PIPE_BUF是管道的一个属性,这是一个宏表示4096。在管道中当要写入的数据量不大于PIPE_BUF时,linux将保证写入的原子性。当要写入的数据量大于PIPE_BUF时,linux将不再保证写入的原子性。
所谓原子性就是要么做完要么不做,比如一个进程向管道中写入100字节的数据,另一个进程在读取的时候直接就读完这100字节,也就是说要么不读要么读完,不能存在中间状态,这样就不会造成数据写入的时候将未读完的数据进行覆盖。
多个进程之间的管道通信简单应用
我们可以通过frok创建出一批进程,然后通过管道进程通信让父进程控制一批子进程,并让子进程执行父进程下发的不同任务。这样就简单实现了进程控制。
task.hpp
#pragma once
#define COMMAND_LOG 0;
#define COMMAND_MYSQL 1
#define COMMAND_REQEUST 2
#include<iostream>
#include<vector>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include<string.h>
typedef void (*fun_t)();
void PrintLog()
{
std::cout<<"打印日志任务,正在被执行...."<<std::endl;
}
void InsertMySQL()
{
std::cout<<"执行数据库任务,正在被执行...."<<std::endl;
}
void NetRequest()
{
std::cout<<"执行网络请求任务,正在被执行....."<<std::endl;
}
class Task
{
public:
Task()
{
funcs.push_back(PrintLog);
funcs.push_back(InsertMySQL);
funcs.push_back(NetRequest);
}
void Execute(int command)
{
if(command>=0&&command<funcs.size())
{
funcs[command]();
}
}
std::vector<fun_t>funcs;
};
.hpp是C++中将函数(类)声明和函数(类)实现写在一起的一种特殊形式的头文件,很多开源项目也是采用这种方式。上述代码模拟实现了几个特定的任务,注意看这些函数的参数返回值都是刻意写成这样的,为了后面比较方便的通过函数指针来调用对应的函数。定义一个任务类,主要的成员就是存放函数指针的vector容器。这都是为各个子进程后面执行不同的任务做铺垫.
#include <iostream>
#include <unistd.h>
#include <cassert>
#include <string>
#include <vector>
#include "task.hpp"
#include<sys/wait.h>
#include<sys/types.h>
using namespace std;
const int gum = 3;
Task t;
class EndPiont
{
public:
pid_t child_id;
int write_fd;
std::string processname;
EndPiont(int id, int fd) : child_id(id), write_fd(fd)
{
char namebuffer[64];
snprintf(namebuffer,
sizeof(namebuffer),
"process-%d[%d:%d]",
number++, child_id, write_fd);
processname = namebuffer;
}
std::string name() const
{
return processname;
}
~EndPiont() {}
private:
static int number;
};
int EndPiont::number = 0;
// 子进程要执行的任务
void WaitCommand()
{
while (true)
{
int command=0;
int n = read(0, &command, sizeof(command));
//assert(n == sizeof(int));
if (n == sizeof(int))
{
t.Execute(command);
}
else if (n == 0)
{
std::cout << "父进程让我退出,我就退出了" << std::endl;
break;
}
else
{
break;
}
}
}
// 创建进程
void createProcess(vector<EndPiont>&end_pionts)
{ vector<int>fds;
// 1.先构建进程控制结构,父进程写入,子进程读取
for (int i = 0; i < gum; i++)
{
int pipefd[2] = {0};
int n = pipe(pipefd);
assert(n == 0);
(void)n;
// 断言在Release版本下会去掉,为了避免告警这里n充当一次使用
pid_t child = fork();
assert(child != -1);
if (child == 0)
{
for(int j=0;j<fds.size();j++)
{
close(fds[j]);
}
close(pipefd[1]);
// 进行输出重定向子进程从标准输入读取
dup2(pipefd[0], 0);
WaitCommand();
close(pipefd[0]);
exit(0);
}
// 处理父进程执行流
close(pipefd[0]); // 父进程关闭不要的读端
end_pionts.push_back(EndPiont(child, pipefd[1]));
fds.push_back(pipefd[1]);
}
}
int ShowBoard()
{
std::cout << "##############################" << std::endl;
std::cout << "#0.执行日志任务 1执行数据库任务#" << std::endl;
std::cout << "#2.执行网络请求任务 3 退出######" << std::endl;
std::cout << "#请选择 ######" <<std::endl;
int command = 0;
cin >> command;
return command;
}
void ctrProcess( vector<EndPiont>&end_points)
{
int num = 0;
int cnt = 0;
while (true)
{
// 1.选择任务
int command = ShowBoard();
if(command == 3) break;
if(command < 0 || command > 2) continue;
// int index=rand()%end_points.size();
int index = cnt++;
cnt %= end_points.size();
std::cout << "选择了进程:" <<
end_points[index].name()
<< "| 处理任务" << command << std::endl;
write(end_points[index].write_fd,
&command, sizeof(command));
sleep(1);
}
}
void waitProcess( vector<EndPiont>&end_pionts)
{
for(int i=0;i<end_pionts.size();i++)
{
close(end_pionts[i].write_fd);
}
for(int i=0;i<end_pionts.size();i++)
{
waitpid(end_pionts[i].child_id,nullptr,0);
}
cout<<"父进程回收了所有子进程"<<endl;
}
int main()
{
vector<EndPiont> end_points;
createProcess(end_points);
ctrProcess(end_points);
waitProcess(end_points);
}
首先我们需要创建出一批子进程和一批管道,但是我们怎么知道向父进程通过哪个管道进行通信呢?为了更好的管理这些子进程和管道,我们设计一个EndPoint类,这里面有两个特别的重要字段,一个是管道文件描述符fd,这个fd是父进程需要用到的管道文件描述符用来确定管道,另一个是就是子进程的pid,这样每个子进程就和对应的管道的联系起来了。这里还是用到了先组织在描述的思想,我们父进程是下发任务的,因此父进程只用打开管道的写端,子进程只用打开管道的读端。
这里有个处理细节需要我们注意
这样每个fork出的子进程继承父进程的其他管道的写端都会被关闭,这样就会不会造成不必要的错误。这个处理非常的细节,一般难以想到。
当子进程继承父进程的管道文件描述符后,通过重定向的方式将管道对应的文件描述符给更改,这样waitcommand函数就不需要参数了,不重定向也可以,无非就是waitcommand多个参数的问题其他也没啥。
最后父进程快要退出的时候就关闭写端文件描述符,然后调用waitpid回收子进程即可。这里就简单实现了通过管道父进程控制了一批子进程,让子进程去执行不同的任务。
4.命名管道
之前匿名管道有血缘关系的进程之间进行通信,如果想让两个毫不相干的进程进行通信,可以使用FIFO文件来做这项工作,它经常被称为命名管道。所谓命名管道就是有名字的管道,也就是文件名的管道文件。它同样也是内存级文件,之前说了匿名管系统不会为其创建inode节点和数据块,但是命名管道系统会为其创建inode节点,因为命名管道是有文件名的。
我们可以直接创建使用指令就像touch普通文件一样,创建命名管道文件。
mkfifo filename
命名管道也是不会向外设刷新数据的。
命名管道文件名就只是一种符号用来标识文件,管道是内存级文件不会向外设外设刷新数据,所以当我们通过echo重定向后就会卡在那里等待其他进程读取数据,当我们把管道中的数据重定向给cat的时候,这个时候cat就从管道中读到之前重定向进管道的数据。
5.命名管道的原理
命名管道的原理和匿名管道原理类似,匿名管道主要是通过继承的方式让具有具有血缘关系的进程得到对应的管道文件描述符,命名管道因为有名字,可以直接让不同的进程得到对应的管道文件描述符,文件名加路径可以确定唯一的文件,这样就能保证不同进程看到同一份资源。这种命名管道用起来肯定是更简单的,因为它和操作普通文件类似,一个进程用读方式打开这个文件,另一个进程用写方式打开这个文件,就可以实现通信了,不用像匿名管道那样还要关闭不用的读写端。
6.命名管道代码演示
这里介绍一个系统调用接口mkfifo,用来创建管道文件。这个先前和mkfifo指令是同名的。
第一个参数是表示在文件所在路径,第二个参数mode表示权限,也就创建出来的管道的文件的读写可执行权限,这个可以自定义。
因为命名管道可以用与不同的进程之间进行通信,我们可以编写两个程序将其运行起来,进行通信,一个client端,一个server端进行演示。这样在编写Makefile文件的时候要一次编译两个程序代码。
.PHONY:all
all:server client
server:server.cc
g++ -o $@ $^ -std=c++11
client:client.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -rf server client
由于这个all没有依赖方法只有依赖关系,这样Makefile在推导的时候就会执行server和client的依赖关系和依赖方法,就可以一次编译两个可执行程序。
代码示例
comm.hpp
#pragma once
#include<iostream>
#include<string>
const std::string fifoname="./fifo";
uint32_t mode=0666;
#define NUM 1024
这个头文件里都是server和client都需要的公共部分,比如管道文所在路径和管道文件的权限。
server.cpp
include<iostream>
#include<sys/types.h>
#include<sys/stat.h>
#include<cerrno>
#include<string.h>
#include<fcntl.h>
#include <unistd.h>
#include"comm.hpp"
int main()
{
//创建管道文件
umask(0);
int n=mkfifo(fifoname.c_str(),mode);
if(n!=0)
{
std::cout<<errno<<":"<<strerror(errno)<<std::endl;
return 1;
}
//让服务端开启管道文件
int rfd=open(fifoname.c_str(),O_RDONLY);
if(rfd<0)
{
std::cout<<errno<<":"<<strerror(errno)<<std::endl;
return 2;
}
//正常通信
char buffer[NUM];
while(true)
{
buffer[0]=0;
size_t n=read(rfd,buffer,sizeof(buffer));
if(n>0)
{
buffer[n]=0;
std::cout<<"client# "<<buffer<<std::endl;
}
else if(n==0)
{
std::cout<<"client quit,me too"<<std::endl;
break;
}
else
{
std::cout<<errno<<" : "<<strerror(errno)<<std::endl;
break;
}
}
close(rfd);
//要退出时自动删除管道文件
unlink(fifoname.c_str());
return 0;
}
因为有文件默认权限掩码的存在,所以我需要使用 umask(0),将默认权限掩码置为0,这里只会进程的文件权限掩码,不会影响到系统。创建好管道文件后,server端直接像操作普通文件一样,使用open函数打开以读方式文件即可。之后就可以在管道文件中读取数据了。
这里还有一个细节需要注意:
创建管道文件后,程序退出后,如果下次在运行程序就会报错显示管道文件已经被创建,需要我们手动删除管道文件再次运行程序,这样就有点麻烦。这里有个接口函数unlink,传入管道文件所在路径后会自动删除管道文件释放管道.
client.cpp
#include<iostream>
#include<sys/types.h>
#include<sys/stat.h>
#include<cerrno>
#include<string.h>
#include<fcntl.h>
#include <unistd.h>
#include<stdlib.h>
#include<assert.h>
#include"comm.hpp"
int main()
{
//不用创建管道文件了,只需要打开对应文件即可
int wfd=open(fifoname.c_str(),O_WRONLY);
if(wfd<0)
{
std::cerr<<errno<<":"<<strerror(errno)<<std::endl;
return 1;
}
char buffer[NUM];
while(true)
{
std::cout<<"请输入你的消息"<<std::endl;
char *msg=fgets(buffer,sizeof(buffer)-1,stdin);
assert(msg);
(void)msg;
//直接退出
if(strcasecmp(buffer,"quit")==0)
{
break;
}
buffer[strlen(buffer)-1]=0;
size_t n=write(wfd,buffer,strlen(buffer));
assert(n>0);
(void)n;
}
close(wfd);
return 0;
}
当我们创建出管道文件后,管道文件就已经存在了,就不需要再次创建了。client端直接打开对应的文件进行写入即可。
总的来说
管道通信的本质就是让不同的进程看到同一份文件资源,这样就实现了进程间的通信。
3.System V共享内存
1.共享内存原理
共享内存区是最快的IPC形式。一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数据.
共享内存原理其实也很简单,就先申请创建一块物理空间,通过进程各自的页表映射到各自进程地址空间的共享区中,这样不同的进程都能看到同一份空间资源,从而实现通信。当进程不在通信时,只用在进程页表中取消映射关系,最后释放这块空间将空间归还给系统即可。
当我们申请共享内存的时候是我们作为用户向操作系统发出的请求,具体的操作分配还是需要操作系统来处理,系统中可能不止一两个进程在通信,可能存在很多进程使用共享内存通信,因此操作系统需要将这些共享内存块管理起来,于是操作系统也会为这次共享内存块创建对应的结构体,这就是之前我们提到的一个重要思想先描述再组织。
共享内存块结构体表示
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 */
};
这里面有个字段是结构体shm_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;
};
这个shm_perm结构体中有个字段是key,
这个key就是系统用来标识共享内存块的,这个key是整形变量。
我们可以用ipcs指令查看ipc通信资源。
这里默认显示的了3种通信方式的资源,分别是消息队列,共享内存,信号量。
ipcs指令选项 | 作用 |
---|---|
-m | 查看共享内存资源 |
-q | 查看消息队列资源 |
-s | 查看信号量资源 |
因为当前系统没有创建共享内存进行进程间通信,这里什么也没显示。
这个key和shmid都是用来标识共享内存块的,只是shmid是给用户看的,这个key是给系统看的。
也就是说在用户层面的操作都是以shmid为准的,但是在系统层面的操作都是以key为准的。
剩下的几个字段含义分别如下
字段 | 含义 |
---|---|
owner | 拥有者(也就是创建共享内存的用户) |
perms | 权限(对共享内存块的使用权限) |
bytes | 共享内存块的大小 |
nattch | 该内存块与多个进程相关联 |
2.相关系统接口的介绍与共享内存的代码演示
在编写代码之前我们先了解几个共享内存的系统接口
shmget//用来申请一块共享内存空间
第一个参数就是一个key用来标识共享内存块,这个key怎么获得呢?系统也为我们提供了一个接口ftok函数,这个函数会根据传入的参数生成一个特定的冲突概率极小的key值。
ftok第一个参数是是项目所在路径,另一个参数是项目id名称,这些参数你可以随便起,但是为了规范一点第一个参数还是老老实实写的项目路径好一点。
shmget函数第二个参数是用来表示申请多大的共享内存,单位是字节。第3个参数是标志位,和这个标志位早在文件系统中我们就见过了。一般shmget有两个常使用的标志位。
名称 | 作用 |
---|---|
IPC_CREAT | 如果发现内核中不存在由key值创建的共享内存,那就用key值创建一个共享内存,如果存在了用key值创建的共享内存,就返回该共享内存的shmid |
IPC_CREAT 或上IPC_EXCL: | 如果发现内核中不存在用key值创建的共享内存,那就用key值创建一个共享内存,如果存在了用key值创 建的共享内存,就出错返回,这样就保证了以这种方式创建的共享内存一定是全新的 |
这里注意一下:
IPC_EXCL 不能单独使用,一般配合IPC_CREAT使用,使用形式:IPC_CREAT |IPC_EXCL。同时创建出的共享块需要设置一下权限,和文件设置权限类似,直接在标志位上或上0x666即可。
(IPC_CREAT | IPC_EXCL | 0666),同时别忘了用umask(0)设置一下默认的权限掩码。
shmget如果创建成功会返回该共享内存块的shmid,创建失败返回-1.
第二个系统调用接口是shmat,这个系统接口是用来将创建出来的共享内存块挂接到进程上的。所谓挂接就是让进程地址空间和对应页表以及这个共享内存块建立映射关系。
shmat第一个参数就是共享内存的shmid,用来找到要挂接的共享内存块。我们知道这个创建出的共享内存块会被映射到进程地址空间的共享区中,shmat的返回值就是返回这个共享内存块在进程地址空间中映射在共享区的虚拟地址的起始地址。就和malloc的返回值是类似的,这第二个参数就是在进程地址空间的共享区选一个地址建立映射,
一般我们这里参数设置为nullptr,这样操作系统会在对应的进程地址空间选一个地方建立映射。
第三个参数还是标志位可以设置共享内存块的读写权限,比如可以设置成只读的,一般默认设置成0表示可读可写。
shmdt这个系统调用是当进程间不在需要通信时,用来取消进程与共享内存块之间的挂接。
这个shmdt函数参数就只有一个就是挂接上的内存共享空间的起始地址也就是shmat函数的返回值。
shmctl这函数是用于控制共享内存的,我们可以调用它释放共享内存块,共享内存块的生命周期是随系统的,如果不手动释放,除非退出系统不然就一直不会释放,这点也有点像malloc出的空间。
shmid:由shmget返回的共享内存标识码 cmd:将要采取的动作(有三个可取值)buf:指向一个保存着共享内存的模式状态和访问权限的数据结构返回值:成功返回0;失败返回-1.
关于第二个参数的说明
选项 | 说明 |
---|---|
IPC_STAT | 获取共享内存的当前关联值 此时参数buf作为输出型参数 |
IPC_SET | 在进程有足够权限的前提下 将共享内存的当前关联值设置为buf所指的数据结构中的值 |
IPC_RMID | 删除共享内存段 |
如果是要仅仅释放共享内存块的话,我们可以这样调用shmctl(shmid, IPC_RMID, nullptr);
关于共享内存块我们也可以使用指令进行删除,ipcrm -m shmid.指定共享内存的shmid就可以进行删除。
关于共享内存的接口介绍差不多了我们就直接写代码,代码还是可以分为client端,server端,以及公共部分的comm.hpp。
comm.hpp
#ifndef _COMM_HPP_
#define _COMM_HPP_
#include <iostream>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <cerrno>
#include <cstdio>
#include <string.h>
#include <sys/stat.h>
#include <cassert>
using namespace std;
#define PATHNAME "."
#define PROJID 0x6666
const int gsize = 4096;
// IPC_CREAT and IPC_EXCL
// 单独使用IPC_CREAT:创建一个共享内存,如果不存在就创建
//如果已经存在,获取已经存在的共享内存并且返回
// IPC_EXCL 不能单独使用,一般配合IPC_CREAT使用
// 创建一个共享内存,如果不存在就创建,如果已经存在立马出错返回
//获取key值
key_t GetKey()
{
key_t k = ftok(PATHNAME, PROJID);
if (k == -1)
{
std::cerr << errno << ":" << strerror(errno) << std::endl;
exit(-1);
}
return k;
}
std::string toHex(int x)
{
char buffer[64];
snprintf(buffer, sizeof(buffer), "0x%x", x);
return buffer;
}
static int createShmHelper(key_t k, int size, int flag)
{
int shmid = shmget(k, gsize, flag);
if (shmid == -1)
{
cerr << "error: " << errno << " : " << strerror(errno) << endl;
exit(2);
}
return shmid;
}
int createShm(key_t k, int size)
{
umask(0);
return createShmHelper(k, size, IPC_CREAT | IPC_EXCL | 0666);
}
int getShm(key_t k, int size)
{
return createShmHelper(k, size, IPC_CREAT);
}
//进程挂接到共享内存
char *attachShm(int shmid)
{
char *start = (char *)shmat(shmid, nullptr, 0);
return start;
}
//进程取消共享内存挂接
void detachShm(char *start)
{
int n = shmdt(start);
assert(n != -1);
(void)n;
}
//释放删除共享内存
void delShm(int shmid)
{
int n = shmctl(shmid, IPC_RMID, nullptr);
assert(n != -1);
(void)n;
}
#define SERVER 1
#define CLIENT 0
class Init
{
public:
Init(int t) : type(t)
{
key_t k = GetKey();
if (type == SERVER)
{
shmid = createShm(k, gsize);
}
else
{
shmid = getShm(k, gsize);
}
start = attachShm(shmid);
}
char *getStart() { return start; }
~Init()
{
detachShm(start);
if (type == SERVER)
{
delShm(shmid);
}
}
private:
char *start;
int type; // server or client
int shmid;
};
#endif
这里我们理一下思路,server端创建出共享内存,并且挂接上共享内存,client只用将创建好的共享内存挂接起来,不用再去创建,
所以这里有个getShm接口本质调用的是shmget且第三个参数是IPC_CREAT.。
然后在删除共享内存的时候只用server端去删除一次即可。
server.cpp
#include"comm.hpp"
#include<time.h>
#include<unistd.h>
int main()
{
Init init(SERVER);
char *start = init.getStart();
int n = 0;
sleep(5);
while(n <= 30)
{
cout <<"client -> server# "<< start << endl;
sleep(3);
n++;
}
return 0;
}
client.cpp
#include"comm.hpp"
#include<unistd.h>
int main()
{
Init init(CLIENT);
char *start = init.getStart();
char c = 'A';
while(c <= 'Z')
{
start[c - 'A'] = c;
c++;
start[c - 'A'] = '\0';
sleep(3);
}
return 0;
}
其实上述代码最主要的目的不是通信,是为了理解这个通信信道的建立方式。上述client将数据写入进共享内存,server从共享内存直接读取数据。
共享内存就是在物理内存上划分一块空间给各自进程使用,这样的话通信通信速度无疑是最快的,管道的话还需要把数据写入到管道文件中,再从文件中将数据拷贝到buffer数组中,共享内存不涉及这样多次数据拷贝。
3.共享内存的一些特性
共享内存是以PAGE页(4KB)为单位的,当用户申请的空间不是4kb的整数倍时,系统会申请向上对齐,申请4kb整数倍大小的空间,也就是说操作系统会多申请一部分空间,但是用户只能使用申请大小的空间。
共享内存没有保护机制(同步互斥)这是因为管道通过系统调用进行通信由系统操作处理,共享内存是直接通信。
4.system V消息队列与system V信号量
关于system V消息队列和system V信号量这里只是简单介绍一下。
消息队列就是系在内存中创建出队列节点,队列节点在内存中被创建后,进程就可以把要发送的数据发送到队列节点中,并且会标识一下是哪个进程发送的数据,这样就形成一条消息队列。不同的进程读取其他进程发送的队列节点信息就实现了进程间的通信。
消息队列生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,,消息队列一直存在。但是消息队列的缺点也很明显通信不及时,输送数据也有大小限制。
这里是发送消息数据数据块的结构体,这里有个mtype就是用来标识数据是哪个进程发送的,这个mtext就是柔性数组,里面存放的是要发送的消息队列节点数据。
在介绍system V信号量之前,我们先了解一下几个比较重要的概念。
我们把大家都能看到的资源称为:共享资源。
互斥:任何一个时刻,都只允许一个执行流在进行共享资源的访问。一般通过加锁的方式实现。
这种互斥的操作是为了更好的保护内存,让系统和各个进程稳定运行。我们把任何一个时刻都只允许一个执行流访问的共享资源叫做临界资源。也就是说只能互斥访问的共享资源叫做临界资源。
,之前的管道就是临界资源它会操作系统保护起来了,共享内存就只是共享资源。临界资源都是要通过代码访问的,凡是访问临界资源的代码叫做临界区。
介绍上面的概念之后,我们通过一个例子来理解信号量。我们去电影院看电影的时候都需要提前买票。买票的本质功能:1.对位置资源的预订机制,2.确保不会因为多放出去特定的座位资源,而导致冲突。信号量:本质就是一个描述资源的计数器,就和电影院的座位一样。任何一个执行流想访问临界资源的任何一个子资源的时候不能直接访问,需要先申请信号量,就和买票一样。对应的计数器就得减减,当执行流不在使用子资源的时候,就得释放这个子资源,给以后其他要访问的资源的执行流使用,对应的计数器就得加加,这点就像我们离开电影院后,电影院的座位会给接下来要看对电影的人使用。这里申请信号量被称为p操作,规还资源被称为V操作。
未来所有进程要申请临界资源的时候都需要先申请信号量进行资源的预订,那么意味着这个信号量也是共享资源,因此信号量也被归为了进程间通信。
5.理解IPC
我们来查看一下共享内存 消息对列 信号量的结构体定义。
我们发现共享内存,消息队列,信号量的起始字段都是 struct ipc_prem类型,这是为了便于统一管理icp资源特意设计的。
系统会有一个ipc_id_arr指针数组,保存着系统创建出的ipc资源结构体的起始地址,这样就实现了对对ipc资源的统一管理。
其实,我们思维发散一点,这不就是多态的思想吗?ipc_perm 不就是父类吗?通过这种巧妙的设计来实现对ipc资源的管理,这就是前辈高人设计出来的代码确实厉害。
上述主要介绍是System V IPC进程间通信。System V IPC和POSIX IPC是两种不同的进程间通信(IPC)的接口标准。它们都提供了三种IPC的方式:消息、信号量和共享内存。它们的主要区别有:System V IPC是基于AT&T的System V UNIX版本,而POSIX IPC是基于IEEE和ISO/IEC开发的一簇标准,用于保证编写的应用程序可以在多种操作系统上移植运行。
System V IPC使用key_t类型作为IPC对象的标识,而POSIX IPC使用名称作为IPC对象的标识,这个名称不一定是在文件系统中存在的。System V IPC接口不是多线程安全的,而POSIX IPC接口是多线程安全的
两者优点比较
POSIX IPC接口更简单易用,设计更符合现代的编程习惯。
POSIX IPC提供了一些System V IPC没有的功能,例如消息队列的通知机制(mq_notify)。POSIX IPC是多线程安全的,而System V IPC不是。
System V IPC也有以下优点:System V IPC支持更广泛,几乎所有的UNIX和Linux系统都完全实现了System V IPC,
而POSIX IPC在一些平台上可能不完全支持。System V IPC提供了一些POSIX IPC没有的功能,例如消息队列的类型选择(mtype)。System V IPC可能有更好的性能优化,因为它已经存在了很长时间。
以上内容如问题,欢迎指正!