一.什么是进程间通信
进程间通信这五个字很好理解,就是进程和进程之间通信。
那么为什么要有进程间通信呢?
1.数据传输:一个进程需要将它的数据发送给另一个进程
2.资源共享:多个进程之间共享同样的资源
3.通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件
4.进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
在这里再举一个例子,例如在网络中,我们通过聊天软件和远处的别人进行聊天就是一种进程间的通信,是你在这边的进程和他在那边的进程之间的通信。
那么进程间通信又有那些呢?
我们大致可以分为三种:管道,System V进程间通信,POSIX进程间通信。
1.管道 :是基于文件系统的进程间通信,又分为匿名管道和命名管道。
2.System V :聚焦在本地的通信。
3.POSIX :让通信可以跨主机。
但是在这篇博客中,只讲前两种,第三种会在后面与网络有关的博客中讲到。
那么我们应该如何理解通信的本质呢?
1.操作系统需要直接或者间接给通信双方进程提供“内存空间”。
2.要通信的进程,必须看到一份公共的资源。
二.管道进程间通信
在Linux中对于管道的使用,大家可能也有过了解,但是管道到底是什么,以及它是如何实现的呢?
管道又可以分为匿名管道和命名管道,我们先来看看匿名管道。
又由于管道是基于文件系统的,这里会涉及到文件操作和基础IO的内容,对于文件操作和基础IO的内容,我之前写过一篇博客来详细讲过,在这里就不再详细讲,有需要的可以到下面这个博客中看:
匿名管道
首先管道是基于文件系统的,那么在文件系统中,如果一个进程打开了一个文件,那么操作系统就会为这个文件创建一个文件结构体struct file变量,当我们对文件写入的时候,就是进程向这个struct file进行写入,同时我们也可以从这个struct file中读取。
其中这个struct file就可以用来做管道。
如下图所示。
但是现在我们只有一个进程,还做不到进程间通信,所以我需要创建一个子进程来继续完成下面的工作。
这个是时候管道的基本雏形就已经出来,但是这个图还有一些问题,我们稍后慢慢讲。
pipe函数
对于struct file来说,通常都是我们来打开一个文件时才会有的,但是现在的管道操作不需要打开文件,所以这个struct file又该怎么创建呢?
操作系统给我们提供了一个pipe的函数,这个函数可以给我们的进程创建一个特殊的struct file,这个struct file不属于磁盘中的任何文件,而是操作系统创建的一个临时的struct file,其目的就是为了给我们提供管道的操作。
同时这个pipe系统调用会默认打开读和写的操作。
返回值:
如果成功创建管道,则返回0,如果创建失败,则返回-1,并设置errno来表示错误。
pipe系统调用如上图所示,其中参数为一个大小为2的数组,当调用这个函数后,操作系统就会自动的为当前这个进程创建一个管道,同分别以读和写的形式来打开管道,其中,pipefd[0]为读取操作的文件描述符,pipefd[1]为写入操作的文件描述符。
调用这个函数后,效果如下图所示:
然后我们在进行创建一个子进程,创建完成后如下图所示:
此时我们的父子进程都能对管道进行读写操作,但是这不是我们所希望的,因为管道应该是单向的,而不是双向的,而且此时的这种情况有一点的不安全,所以,我们需要关闭一些文件描述符,在下面的代码演示中,我会进行一个子进程往管道写入,而父进程往管道读取的操作,所以接下来还要关闭一些文件描述符,关闭后如下图所示。
使用例子
这样我们就能达到一个进程间通信的效果了,完整示例代码如下:
#include<iostream>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<cassert>
#include<cstdlib>
#include<cstdio>
#include<cstring>
using namespace std;
int main()
{
int fds[2] = {0,0};
int ret = pipe(fds);//其中fds[0]为读,fds[1]为写
pid_t id = fork();//创建子进程
assert(id >= 0);
//我想做的是,父进程读取管道,子进程写入管道
//子进程
if(id == 0)
{
//子进程关闭读取管道
close(fds[0]);
char buffer[1024];//缓冲区
int cnt = 0;//计数器
const char * str = "hello linux";
while(1)
{
char buffer[1024];//缓冲区
sprintf(buffer,"我是父进程,我在往管道写入数据:%s:%d",str,cnt++);//将内容写入缓冲区中
write(fds[1],buffer,strlen(buffer));//将缓冲区中的数据写入到管道中
sleep(1);
}
close(fds[1]);
exit(0);
}
//父进程
//父进程关闭写入管道
close(fds[1]);
char buffer[1024];//缓冲区
int cnt = 5;
while(cnt--)
{
int sz = read(fds[0],buffer,sizeof(buffer)-1);//sz为返回读到几个字节
buffer[sz] = '\0';//按C语言的标准给末尾添加\0
cout<<buffer<<endl;
sleep(1);
}
close(fds[0]);
int status = 0;//存储子进程的退出信息
waitpid(id,&status,0);//阻塞等待子进程
cout<<(status&0x7F)<<endl;
return 0;
}
管道的读写特征:
1.读慢,写快(当父进程读取的很慢,而子进程写入的很快,管道会充斥着大量的数据)
2.读快,写慢(当子进程写入很慢,而父进程读取很快,则父进程会进行阻塞等待)
3.写关闭,读到0(当写入端关闭的时候,读取端会读到末尾)
4.读关闭,操作系统会终止终端,并且给写入的进程发送信号,终止终端。
管道的特征:
1. 管道是生命周期进程。(当该进程结束后,管道也会相应的删除)
2.管道可以用来进行具有血缘关系的进程之间进行通信,常用于父子进程通信。
3.管道是面向字节流的。
4.管道是半双工通信。(即单向通信)
5.管道是互斥与同步机制的。(对共享资源进行保护的方案)
匿名管道一般都是使用与有血缘关系的进程之间的通信,例如上面的代码中,是父子进程之间的通信,那么如果想在没有血缘关系的进程之间通信,又应该怎么做呢?
我们可以使用命名管道。
命名管道
看完了匿名管道,我们再来看一下命名管道。
命名管道和匿名管道最大的区别就是有名和没名,在匿名管道中,我们创建了一个没有名字的管道,也无法在通过路径来找到,而命名管道是有名的,我们可以通过路径来找到它,所有它就可以在没有血缘关系的进程之间进行通信。
那么命名管道又是如何做到在不同的进程间进行通信的呢?
命名管道的操作,会在磁盘中,创建一个真实存在的文件,然后需要通信的两个进程,都要打开这个文件,其中一个进程往这个管道文件里写入,另一个进程往这个管道文件里读取,这样就可以进行进程间通信了。
但是当有一个进程往管道文件里面进行写入的时候,并不会真真正正的往文件里写入数据,而是往这个文件的struct file中写入,而struct file中的数据是不会写入到文件中的,因为这个过程涉及到IO,会非常浪费性能。
如下图所示。
mkfifo函数
知道了命名管道的原理后,我们就可以来进行操作了。
首先,使用命名管道的前提是,在磁盘中,有这个管道文件,这个管道文件的创建,操作系统为我们提供了一个系统接口来完成,mkfifo系统调用。
下面是这个系统调用的手册说明
mkfifo这个系统调用有两个参数:pathname,mode
pathname:你创建这个管道文件的路径。
mode:你创建这个管道文件的权限,例如0666,就是这个管道文件对所有人都可读可写可执行。
这个系统调用的功能就是在磁盘中创建一个管道文件。
使用效果如下。
会创建一个管道文件。
返回值:
mkfifo如果成功创建了管道文件,会返回0,如果出现错误,则会返回-1,并且设置errno来表示错误。
unlink函数
能创建管道文件,那么也要能删除管道文件,unlink就是用来删除管道文件的。
unlink的参数只有一个:管道文件的文件名
使用后会删除该管道文件。
返回值:
如果删除成功,则返回0,删除失败,返回-1,并且适当的设置errno来表示错误
使用例子
对于命名管道的操作的前置条件已经说完了,接下来就可以来尝试使用一下了。
在下面我会写一个模拟单向聊天的小程序,就是一个进程给另一个进程发送消息。
在这个程序中,我会进行模块化编程,整个程序有三个文件:server.cc、client.cc、common.hpp
common.hpp
在这个hpp文件中,我定义了两个函数
CreatPipe() :创建管道文件
RemotePipe():删除管道文件
同时定义了个宏,就是这个管道文件的名字
#pragma once
#include <cerrno>
#include <cstring>
#include <cassert>
#include <iostream>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#define NAMED_PIPE "named_pipe" //管道文件名
//创建命名管道
bool CreatPipe()
{
umask(0);
int ret = mkfifo(NAMED_PIPE,0666);//创建命名管道
if(ret == 0)
return true;
else if(ret == -1)
{
std::cout<<"errno: "<<errno<<" "<<strerror(errno)<<std::endl;//如果创建失败,输出错误信息
return false;
}
}
//删除命名管道
bool RemotePipe()
{
int ret = unlink(NAMED_PIPE);//删除管道文件
if(ret == 0)
return true;
else if(ret == -1)
{
std::cout<<"errno: "<<errno<<" "<<strerror(errno)<<std::endl;//如果删除失败,输出错误信息
return false;
}
}
client.cc
在这个client.cc文件中,就是一个发送消息的进程。
将我们需要发送的信息写入到管道文件中。
#include"common.hpp"
int main()
{
int fd = open(NAMED_PIPE,O_WRONLY);//以写的形式打开管道文件
assert(fd != -1);
char buffer[1024];//缓冲区
while(true)
{
std::cout<<"# please input:";//输入提示符
fgets(buffer,sizeof(buffer)-1,stdin);//将你要发送的消息读取到buffer缓冲区中
buffer[strlen(buffer)-1] = '\0';//去除末尾的 \n
ssize_t ret = write(fd,buffer,sizeof(buffer)-1);//将buffer中的内容写入到管道文件中
if(ret == -1)
{
std::cout<<"errno: "<<errno<<" "<<strerror(errno)<<std::endl;
}
}
close(fd);//关闭管道文件
return 0;
}
server.cc
在这个sercer.cc中,也是一个进程,在这个进程中,我创建了管道文件,并且从管道文件中读取内容,再将其输出。
#include"common.hpp"
int main()
{
CreatPipe();//创建命名管道
int fd = open(NAMED_PIPE,O_RDONLY);//以读的形式打开管道文件
assert(fd != -1);
char buffer[1024];
while(true)
{
ssize_t ret = read(fd,buffer,sizeof(buffer)-1);//从管道文件中读取内容
buffer[ret] = '\0';
if(ret > 0)//说明读取成功
{
std::cout<<"client -> server #"<<buffer<<std::endl;
}
else if(ret == 0)//说明管道文件已关闭,退出循环
{
std::cout<<"client quit, me too"<<std::endl;
break;
}
}
close(fd);
RemotePipe();//删除命名管道
return 0;
}
写到这里,这个小程序就完成了,接下来将两个.cc文件进行编译,再一起运行起来。
如下图所示。
三.system V进程间通信
讲完了管道类型的进程间通信,接下来到system V进程间通信了。
system V进程间通信是聚焦在本地的进程间通信,也可以说是和管道差不多,在同一台机器上进行通信。
system V进程间通信有三种:共享内存、消息队列,信号量。
但在这里,只讲解共享内存方法的通信。
共享内存
什么是共享内存?
让不同的进程看到同一块内存空间,就叫做共享内存。
在C/C++的内存分布中,大家都知道有堆区和栈区,同时在堆区和栈区之间还有一块区域叫做共享内存。这块共享内存就是给我们的进程是实现进程间通信的,如下图所示。
通过共享内存的方式,可以让不同的进程看到同一份资源而实现进程间通信。
那么如何通过共享内存来实现进程间通信呢?我们接着往下讲解。
要想实现system V的进程间通信,我们要先来学习一些函数。
shmget函数
shmget函数是用来创建一块共享内存的。我们来看看手册。
通过手册,我们可以看到,shmget有三个参数:key、size、shmflg
这个函数的参数稍微有点复杂,我们一个一个来说。
key:
key值是指能唯一标识一块共享内存的标志,key值的内容是什么不重要,重要的是它能唯一标识一块共享内存,至于key值如何获取,我在下面会讲到。
size:
size是指我们创建的共享内存的大小,一般这个大小我们通常设置为4kb的整数倍。
shmflg:
这个参数是一组标志位,通常这个标志位的参数只有两个用的最多:IPC_CREAT、IPC_EXCL。
其中,IPC_EXCL不能单独使用,一般都是结合IPC_CREAT来一起使用的。
标志位的用法有两种:
①IPC_CREAT:如果共享内存不存在,则创建它,如果存在则获取它。
什么意思呢?
进程间通信通常涉及到两个进程,这两个进程需要有一个进程创建这块共享内存,而另一个进程则需要获取这块共享内存。
②IPC_CREAT | IPC_EXCL:如果共享内存不存在,则创建它,如果存在,则出错返回,这样传参的意义是确保我们创建的共享内存是全新的。
返回值:
如果创建共享内存成功,则返回一个非负整数,即共享内存的标识符,否则返回-1并设置errno来表示错误。
ftok函数
ftok函数的作用就是来生成我们的key值。我们来看看手册。
ftok有两个参数,其中返回值就是我们所需要的key值。
pathname:我们在Linux中一个所存在的路径名。
proj_jd : 一个我们想任意给的数字id。
ftok这个函数,会通过我们所给的参数,自动生成出一个key值出来,注意,相同的pathname和proj_id会生成出相同的key值。
返回值:
如果成功,则返回key值,否则返回-1,并设置errno来表示错误。
创建共享内存
当把上面的两个函数学会后,就可以创建我们的共享内存了,我们来操作一下。
在实现这里的功能的时候,我们可以适当的对shmget函数和ftok函数进行一个封装,以便我们使用。如下面的代码所示。
#include <cstring>
#include <iostream>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#define PATHNAME "." //当前路径
#define PROJ_ID 1 //proj_id随便给,在这里给1
#define SIZE 4096 //共享内存大小
//获取key值
key_t GetKey()
{
key_t key = ftok(PATHNAME,PROJ_ID);//获取key值
if(key == -1)
{
std::cerr<<"获取key值失败: "<<strerror(errno)<<std::endl;
exit(1);
}
return key;
}
//创建共享内存
int CreatShm(key_t key)
{
int id = shmget(key,SIZE,IPC_CREAT | IPC_EXCL | 0666);//创建共享内存,其中0666是指这块共享内存的权限
if(id == -1)
{
std::cerr<<"创建共享内存失败: "<<strerror(errno)<<std::endl;
exit(2);
}
return id;
}
int main()
{
key_t key = GetKey();
printf("0x%x\n",key);//将key值输出,进行观察
CreatShm(key);
return 0;
}
在上面的代码中,我写了两个函数,一个是获取key值,一个是创建共享内存。
当这段代码运行起来后,可以成功的创建共享内存了。
那么我们怎么确定这块共享内存存在呢?
通过ipcs -m命令,可以查看我们在系统中的共享内存。如下图所示。
通过实践,我们发现,当test程序第一次运行的时候是能成功的,但是第二次及以上的时候再运行时,会失败,如下图所示。
同时,我们可以观察到,输出的key值是唯一的,因为我们给的参数没有变。
但是为什么后面再运行程序时,会创建共享内存失败呢?
那是因为我们第一次运行的时候,共享内存就已经被创建出来了,当这个程序结束后,共享内存是不会被主动删除的,需要我们通过命令或者代码来删除。
首先我们来介绍一下通过命令的方式来删除。
我们可以通过使用 ipcrm -m [id] 的方式来删除,如下图所示。
获取共享内存
当一个进程可以创建共享内存后,那么另一个进程就要获取共享内存,同样的,获取共享内存也是用到shmget函数,我们来实现一下。
//获取共享内存
int GetShm(key_t key)
{
int id = shmget(key,SIZE,IPC_CREAT);
if(id == -1)
{
std::cerr<<"获取共享内存失败: "<<strerror(errno)<<std::endl;
exit(6);
}
return id;
}
shmctl函数
学习完了命令行方式的删除共享内存,接下来再来看一下通过函数的方式来删除共享内存。
先来看一下函数的手册。
这个函数有三个参数:
shmid :共享内存的id。
cmd :这个cmd也是一个标志位,通过标志位可以实现不同的功能,但是在这先讲删除共享内存的功能,当需要删除共享内存的时候,传参传IPC_RMID即可。
buf :在删除操作中,这个参数一般为nullptr即可。
返回值:
如果删除成功,则返回0,否则返回-1,并设置errno来表示错误。
删除共享内存
知道了shmctl函数后,接下来就可以来实现删除共享内存的操作了。代码如下。
//删除共享内存
void DeleteShm(int shmid)
{
int ret = shmctl(shmid,IPC_RMID,nullptr);
if(ret == -1)
{
std::cerr<<"删除共享内存失败: "<<std::endl;
exit(3);
}
}
shmat函数
当创建共享内存和删除共享内存都搞完后,接下来还需要做的工作是连接共享内存。
创建共享内存是不够的,还需要将进程和共享内存连接起来,连接共享内存,需要使用shmat函数,我们先来看一下手册。
shmat一共有三个参数,其中我们一般只关心第一个。
shmid:我们需要连接共享内存的id。
shmaddr:我们需要连接到那个地址空间中,这个参数绝大多数情况下是不需要传参的,给一个nullptr即可。
shmflg:这个参数与我们的读写权限有关,一般也不需要管,设置成0即可。
返回值:
如果连接成功,则返回这段共享内存的起始地址,否则返回-1,并设置errno来表示错误。
连接共享内存
知道了shmat函数后,接下来就可以实现出连接共享内存的函数了,如下面代码所示。
//连接共享内存
void* AttachShm(int shmid)
{
void* ret = shmat(shmid,nullptr,0);
if((long long)ret == -1)
{
std::cerr<<"连接共享内存失败: "<<strerror(errno)<<std::endl;
exit(4);
}
return ret;
}
shmdt函数
共享内存连接成功后就可以开始使用了,使用完成后,进程还要与共享内存断开连接,这里就要用到shmdt函数。我们先来看一下这个函数的手册。
这个函数的参数只有一个,就是共享内存的起始地址。
返回值:
如果断开连接成功,则返回0,否则返回-1,并设置errno来表示错误。
断开共享内存
知道了shmdt函数后,接下就可以完成断开共享内存的函数了。如下面代码所示。
//断开连接共享内存
int DisattchShm(void* start)
{
int ret = shmdt(start);
if(ret == -1)
{
std::cerr<<"断开共享内存失败: "<<strerror(errno)<<std::endl;
exit(5);
}
return ret;
}
使用例子
现在,创建共享内存,获取共享内存,连接共享内存,断开共享内存,删除共享内存的函数我们都已经实现了,接下来就可以来真真正正的实现进程间通信了,在这里例子中,我会模拟客户端给服务器端发送消息的案例。
同时在这里我会使用模块化编程,其中有三个文件:client.cc、server.cc、common.hpp。
client.cc用来模拟客户端,server.cc模拟服务器端,common.hpp来实现函数定义。
common.hpp
在这个文件中,我实现了获取key值,创建共享内存,获取共享内存,连接共享内存,断开共享内存,删除共享内存的功能函数。
#include <cstring>
#include <cstdlib>
#include <cstdio>
#include <iostream>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>
#define PATHNAME "."
#define PROJ_ID 1
#define SIZE 4096
//获取key值
key_t GetKey()
{
key_t id = ftok(PATHNAME,PROJ_ID);
if(id == -1)
{
std::cerr<<"生成key值失败: "<<strerror(errno)<<std::endl;
exit(1);
}
return id;
}
//创建共享内存
int CreatShm(key_t key)
{
int ret = shmget(key,SIZE,IPC_CREAT | IPC_EXCL | 0666);
if(ret == -1)
{
std::cerr<<"创建共享内存失败: "<<strerror(errno)<<std::endl;
exit(2);
}
return ret;
}
//获取共享内存
int GetShm(key_t key)
{
int ret = shmget(key,SIZE,IPC_CREAT);
if(ret == -1)
{
std::cerr<<"获取共享内存失败: "<<strerror(errno)<<std::endl;
exit(3);
}
return ret;
}
//连接共享内存
void* AttachShm(int shmid)
{
void* ret = shmat(shmid,nullptr,0);
if((long long)ret == -1)
{
std::cerr<<"连接共享内存失败: "<<strerror(errno)<<std::endl;
exit(4);
}
return ret;
}
//断开连接共享内存
void DisattchShm(void* start)
{
int ret = shmdt(start);
if(ret == -1)
{
std::cerr<<"断开共享内存失败: "<<strerror(errno)<<std::endl;
exit(5);
}
}
//删除共享内存
void DeleteShm(int shmid)
{
int ret = shmctl(shmid,IPC_RMID,nullptr);
if(ret == -1)
{
std::cerr<<"删除共享内存失败: "<<strerror(errno)<<std::endl;
exit(6);
}
}
server.cc
在这个源文件中,我通过服务器端来创建共享内存和删除共享内存,同时,每隔1秒中就将共享内存中的内容进行输出。
#include "common.hpp"
int main()
{
//1.创建共享内存
key_t key = GetKey();
int id = CreatShm(key);
//2.连接共享内存
void* start = AttachShm(id);
//3.使用
int count = 10;
while(count--)
{
printf("client say: ");
printf("%s\n",start);
sleep(1);
}
//4.断开连接共享内存
DisattchShm(start);
//5.删除共享内存
DeleteShm(id);
return 0;
}
client.cc
在这个源文件中,我不断的往共享内存中写入数据。
#include "common.hpp"
int main()
{
//1.获取共享内存
key_t key = GetKey();
int id = GetShm(key);
//1.连接共享内存
void* start = AttachShm(id);
//2.使用
char message[1024] = "我正在给你发消息";
int cnt = 0;
int count = 10;
while(count--)
{
snprintf((char*)start,SIZE,"%s:%d",message,cnt++);
sleep(1);
}
//3.断开连接共享内存
DisattchShm(start);
return 0;
}
至此,我们的程序就已经写完了,运行效果如下。
共享内存属性的获取
如果我们想要查找我们的共享内存的具体属性时,又该如何操作呢?
通过查看shmctl手册,我们可以看到手册中提到了struct shmid_ds和struct ipc_perm两个结构体中的信息,这些信息是操作系统暴露给我们以便于去查看的信息,那么这些信息怎么获取呢,使用shmctl函数即可。
获取共享内存信息代码如下:
shmctl(id,IPC_STAT,&ds); printf("共享内存的大小:%d,最后一次连接时间:%d,key值:%d",ds.shm_segsz,ds.shm_atime,ds.shm_pe
四.管道和共享内存的区别
管道实现的进程间通信和共享内存实现的进程间通信又有什么区别呢?
共享内存的优点:
共享内存是所有进程间通信方法中,速度最快的一个,它能大大的减少数据的拷贝次数。
共享内存的缺点:
不能进行同步和互斥的操作,对数据没有任何保护。
对于共享内存的优点,这很好理解,但是缺点呢,该怎么理解?
这个缺点,我们通过现象来理解,将上面使用例子的代码中的client.cc源文件中,
将sleep(1),改为sleep(5),就能看到现象。
当server.cc对共享内存的数据进行输出的时候,它不像管道一样,会将共享内存中的内存清空,而是一直输出。