目录
一、进程间通信介绍
什么是通信?
通信就是一个人通过某种方式向另一个人传输信息,那么什么是进程间通信?进程间通信就是一个进程向另一个进程传输信息。大家都知道进程是具有独立性的,那进程之间要通信,这个成本一定不低,为什么呢?因为进程的很多设计都是按照独立性展开的。那么进程间通信的目的是什么?
进程间通信的目的:
数据传输:一个进程需要将它的数据发送给另一个进程
资源共享:多个进程之间共享同样的资源。
通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
为什么要有通信?
因为我们有时需要多进程协同完成我们拟定的任务,比如 | (管道)。
如何进行通信呢?
这个问题在很多年前,我们的前辈就已经帮助我们解决了。并且经过这么多年的发展演变也留下了两套标准:
System V进程间通信--------> 本地通信
POSIX进程间通信 ------> 可以跨主机通信管道 -------> 基于文件系统的
要进行通信有两套方案,一套是标准,另一套就是管道,管道虽然不属于标准,但是也可以用来通信。
进程间通信的分类
System V :
消息队列 共享内存 信号量
POSIX :
消息队列 共享内存 信号量
互斥量 条件变量 读写锁管道 :
匿名管道pipe
命名管道
说了那么多,通信的本质就是 操作系统需要直接或间接的为通信双方(进程)提供“内存空间”,且通信双方要能“看到”这样一份公共资源。不同的通信种类有自己对应的通信模块,这份公共资源是由每个通信种类各自的通信模块提供的。
二、管道
1. 什么是管道?
管道是Unix中最古老的进程间通信的形式。
我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”
2. 匿名管道
父进程通过文件描述符表,获取到一个文件,通过fork 创建一个子进程,子进程会继承父进程文件描述符表当中的内容,这样子进程就也拿到了这个文件的地址,(这个文件有属于自己的内核缓冲区)那么此时两个进程就相当于看到了同一份内核资源,那么这个资源是谁给的呢?当然是文件系统!那么我们称这个文件叫管道文件,它是一份内存级文件。它不需要向磁盘进行刷新。
当然拿一个普通文件来讲,也可以让一个进程往文件里写数据再刷新到磁盘里,另一个进程从磁盘中把文件加载到内存,再从内存中把数据读取到自己内部,这样也可以做到通信的效果,但是效率太低,不会采用这种方式。
这样一个内存级文件,我们不知道它的名称,只知道它是一个管道文件,那么我们称它为匿名管道。
站在文件描述符角度-深度理解管道
通过下图可以看到,一个进程分别是以读和写的方式打开一个文件,创建子进程后,他们两就建立了联系,而后父进程关闭读端,子进程关闭写端,那么此时就形成一了个单向通信的管道。
为什么管道要设立成单向通信的,而不是双向通信的,那是因为设计者在设计管道的时候,考虑到单向管道结构简单,而且不用考虑很多情况,安全系数更高,而要实现双向管道,要判断管道内的数据是不是我写的,情况很多很复杂,不好处理,也不好实现。
那么问题来了,我们想要双向通信怎么办?简单!直接创建两个管道,一个父写子读,一个子写父读,这样不就可以了嘛。不过一般在设计一个东西时,都是先有的技术再有的命名,而管道一般都是单向传输,所以起名管道也很应景。
匿名管道目前只能用于父子进程之间进行进程通信。
pipe函数
代码例子:
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <cstdio>
#include <cstring>
#include <string>
using namespace std;
int main()
{
//第一步,创建管道文件,打开读写端
int fds[2];
int n = pipe(fds);
if(n != 0)
{
perror("pipe");
exit(-1);
}
//第二步 创建子进程
pid_t id = fork();
if(id == -1)
{
perror("fork");
exit(-1);
}
else if(id == 0)
{
//子进程进行写入
close(fds[0]);
//子进程通信代码
const char* s = "你好,我是子进程";
int cnt = 0;
while(true)
{
char buffer[1024]; //这个缓冲区只有子进程可以看到
snprintf(buffer,sizeof buffer,"child->parent say:%s[%d] pid:%d",s,++cnt,getpid());
write(fds[1],buffer,strlen(buffer)); //把缓冲区内容写到管道里
sleep(1); //每隔1秒写入一次
}
//子进程
exit(0);
}
//父进程进行读取
close(fds[1]);
//父进程通信代码
while(true)
{
char buffer[1024];
ssize_t s = read(fds[0],buffer,sizeof(buffer)-1); //从管道往缓冲区里读数据
if(s > 0)
buffer[s] = 0; //给末尾加个\0
cout<<"#"<< buffer<< "| parent pid:"<< getpid()<<endl;
// 父进程没有sleep
}
n = waitpid(id,nullptr,0);
if(n != id)
{
perror("waitpid");
exit(-1);
}
return 0;
}
结合上面的代码和下图,可以看到子进程往管道里写入信息,父进程从管道中读取信息并打印出来。
管道的读写规则:
- 写端不写或者写的很慢,读端读取完数据后,管道内没数据了,读端再读的时候,默认会直接阻塞当前正在读取的进程。读取是按照你指定大小读取的
- 如果读端不读或者读的很慢,写端写满后就不写了,因为再写会造成数据覆盖,此时会阻塞当前正在写入的进程。
- 进程往管道内写入一段数据后,关闭了自己的写端,另一个进程在读端读取完数据后,再读的时候read的返回值为0,如果没有后续操作,那么进程就结束了(双方和谐退出)。写端关闭后,读端是会知道写端关闭了的。
- 写端正常写入,读端读取一段数据后关闭了读端,读端关闭后操作系统会给写端进程发送信号(13号),异常终止写端进程
管道的特点:
- 进程退出,管道释放,管道的生命周期跟着进程一起的;
- 管道可以让具有血缘关系的进程之间进行通信,常用于父子通信;
- 管道是面向字节流的(网络);
- 半双工 --- 单向通信的一种特殊概念
- 内核会对管道操作进行同步与互斥 (是一个对共享资源进行保护的方案)
拓展:下面的博客里有一个关于匿名管道里容易被忽略的问题,感兴趣的可以看看。
基于匿名管道的进程池_晚风不及你的笑427的博客-CSDN博客
3. 命名管道
我们知道具有血缘关系的父子进程可以通过匿名管道进行通信,那么如果让两个毫无干系的进程也能实现通信,该怎么办呢?
我们可以创建一个有名字的管道,像匿名管道那样让进程在管道内写入或读取数据。
mkfifo函数
unlink函数
代码例子:
//client.cc
#include "comm.hpp"
int main()
{
//以写的方式打开管道文件
std::cout<<"client begin "<<std::endl;
int wfd = open(NAMED_PIPE,O_WRONLY);
std::cout<<"client end "<<std::endl;
if(wfd < 0)
exit(1);
//write
char buffer[1024];
while(true)
{
std::cout << "Please Say# ";
fgets(buffer,sizeof(buffer),stdin);//从输入流中获取内容
if(strlen(buffer) > 0)
buffer[strlen(buffer)-1] = 0;
ssize_t n = write(wfd,&buffer,strlen(buffer));//把内容写入管道文件
assert( n == strlen(buffer));
(void)n;
}
close(wfd);
return 0;
}
//server.cc
#include "comm.hpp"
int main()
{
bool r = createFifo(NAMED_PIPE);
assert(r);
(void)r;
//以读的方式打开管道文件
std::cout<<"server begin "<<std::endl;
int rfd = open(NAMED_PIPE,O_RDONLY);
std::cout<<"server end "<<std::endl;
if(rfd < 0)
exit(1);
//read
char buffer[1024];
while(true)
{
ssize_t s = read(rfd,buffer,sizeof(buffer)-1);
if(s > 0)
{
buffer[s] =0; //读取后输出
std::cout << "client->server# " << buffer << std::endl;
}
else if(s == 0)
{
std::cout << "client quit,me too! " << std::endl;
break;
}
else
{
std::cout << "err string: " << strerror(errno) << std::endl;
break;
}
}
close(rfd);
//删除管道文件
removeFifo(NAMED_PIPE);
return 0;
}
//comm.hpp
#include <iostream>
#include <string>
#include <cerrno>
#include <cstring>
#include <cassert>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
//管道文件的名称及路径
#define NAMED_PIPE "/tmp/mypipe"
//创建管道文件
bool createFifo(const std::string& path)
{
umask(0);
int n = mkfifo(path.c_str(),0600);
if(n == 0)
return true;
else
{
std::cout<<"errno: "<<errno<<"err string: "<<strerror<<std::endl;
return false;
}
}
//删除管道文件
void removeFifo(const std::string& path)
{
int n = unlink(path.c_str());
assert(n == 0);
(void)n;//这里是为了防止报警告,说我们的变量n没有被使用
}
操作演示:
命名管道是如何让不同进程看到同一份资源的?
可以让不同进程打开指定名称(路径+文件名)的同一份文件,因为路径+文件名是具有唯一性的。
那么匿名管道是如何确定唯一性的?匿名管道是通过父子间继承关系来确定唯一性的。
命名管道的特性和匿名管道的特性几乎是一样的
匿名管道与命名管道的区别
- 匿名管道由pipe函数创建并打开。
- 命名管道由mkfifo函数创建,打开用open
- FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在它们创建与打开的方式不同,一但这些工作完成之后,它们具有相同的语义
三、system V共享内存
我们知道要让两个进程进行通信,首先要让两个进程看到同一份公共资源,其次才是通信。那么共享内存中怎么让两个进程看到同一份公共资源呢?
1. 共享内存的原理
首先在内存上申请一块空间,这块空间不是操作系统主动申请的,而是操作系统提供的接口,用户通过这个接口让操作系统帮忙在内存上开辟的一块空间。再将创建好的内存映射进进程A的地址空间,也称为挂接,其实就是让进程和共享内存建立关联关系,进程B也需要进行挂接,两个进程都建立好关联关系后就可以通信了。如果后面不想通信了,可以通过取消进程和共享内存的关联关系终止通信(断开挂接),也可以通过释放共享内存终止通信。
2. 共享内存的概念
1.共享内存是设计操作系统的程序员为了实现进程间通信专门设计的,有专用的接口,用来IPC(进程间通信)的;
2. 共享内存是一种通信方式,所有想通信的进程都可以用;
3. 有了第二点,那么我们知道操作系统中一定会同时存在很多个共享内存;
共享内存是让不同的进程看到同一个内存块,进而实现进程间通信的。
共享内存区是最快的IPC形式。一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数据
3. 共享内存函数
shmget函数
作用:用来创建共享内存
我们前面说过操作系统会同时存在很多的共享内存,那我们如何让两个进程看到同一个共享内存,此时这个key值(唯一标识符)就起作用了,一个进程通过key值创建共享内存,另一个进程通过key值获取这个共享内存,这时他们两就看到同一份公共资源了。这个共享内存是由物理内存块和共享内存的相关属性组成的,而key值是共享内存属性内容中的一个,它相当于文件系统中的inode,通过shmget函数,将key设置进共享内存属性中,它用来标识该共享内存在内核中的唯一性。
这个key值可以通过ftok函数设置
#pragma once
#include <iostream>
#include <cstdlib>
#include <cstring>
#include <cstdio>
#include <cerrno>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#define PATHNAME "."
#define PROJ_ID 0x66
#define MAX_SIZE 4096
key_t getKey()
{
key_t k = ftok(PATHNAME,PROJ_ID);//创建一个唯一标识符
if(k < 0)
{
std::cerr << errno << ":" << strerror(errno) << std::endl;
exit(1);
}
return k;
}
int getShmHelter(key_t k,int flags)
{
//k是要通过shmget,设置进入共享内存属性中的,
//它用来表示该共享内存在内核中的唯一性
int shmid = shmget(k,MAX_SIZE,flags);//创建共享内存
if(shmid == -1)
{
std::cerr << errno << ":" << strerror(errno) << std::endl;
exit(2);
}
return shmid;
}
//获取共享内存 返回一个shmid
int getShm(key_t k)
{
return getShmHelter(k,IPC_CREAT);
}
//创建共享内存 返回一个shmid
int createShm(key_t k)
{
return getShmHelter(k,IPC_CREAT | IPC_EXCL);
}
//shm_client.cc
#include "comm.hpp"
using namespace std;
int main()
{
key_t k = getKey();
printf("0x%x\n",k);
int shmid = getShm(k);
printf("shmid: %d\n",shmid);
return 0;
}
//shm_server.cc
#include "comm.hpp"
using namespace std;
int main()
{
key_t k = getKey();
printf("0x%x\n",k);
int shmid = getShm(k);
printf("shmid: %d\n",shmid);
return 0;
}
如何查看共享内存资源(指令)
ipcs -m #用指令的方式查看ipc资源 (-q 查看消息队列资源,-s 查看信号量)
ipcrm -m shmid #用指令的方式释放ipc资源
ipc资源的特性:如果没有显示的释放共享内存资源,该共享内存生命周期随内核,不是随进程的,只要不删除,就一直存在于内核中,除非重启系统。没有释放共享内存的前提下,一个key值只能申请一个共享内存资源。
shmctl函数
作用:用于控制共享内存
//释放/删除 共享内存 删除失败返回-1
void delShm(int shmid)
{
if(shmctl(shmid,IPC_RMID,nullptr) == -1)
{
std::cerr << errno << ":" << strerror(errno) << std::endl;
}
}
//shm_server.cc
#include "comm.hpp"
#include <unistd.h>
using namespace std;
int main()
{
key_t k = getKey();
printf("K: 0x%x\n",k);
int shmid = createShm(k);
printf("shmid: %d\n",shmid);
cout << "等待5秒" << endl;
sleep(5);
cout << shmid <<" 资源被删除" << endl;
delShm(shmid); //一般来说谁创建的谁删除
return 0;
}
shmat函数
作用:将共享内存段连接到进程地址空间
//进行链接
void* attachShm(int shmid)
{
void* mem = shmat(shmid,nullptr,0);
if((long long)mem == -1L) //Linux下默认是64位,转int会出现精度丢失问题
{
std::cerr<<"shmat:" << errno << ":" << strerror(errno) << std::endl;
exit(3);
}
return mem;
}
//shm_server.cc
#include "comm.hpp"
#include <unistd.h>
using namespace std;
int main()
{
key_t k = getKey();
printf("K: 0x%x\n",k);
int shmid = createShm(k);
printf("shmid: %d\n",shmid);
char* start = (char*)attachShm(shmid);
printf("关联成功 %p\n",start);
cout << "等待5秒" << endl;
sleep(5);
cout << shmid <<" 资源被删除" << endl;
delShm(shmid);
return 0;
}
没有权限怎么办呢?
那么就要在创建共享内存的时候把权限加上。
修改完后再运行,就发现有共享内存的起始地址了。
shmdt函数
作用:将共享内存段与当前进程脱离
//断开链接
void detachShm(void* start)
{
if(shmdt(start) == -1)
{
std::cerr<<"shmdt:" << errno << ":" << strerror(errno) << std::endl;
}
}
//shm_client.cc
#include "comm.hpp"
#include <unistd.h>
using namespace std;
int main()
{
key_t k = getKey();
printf("0x%x\n",k);
int shmid = getShm(k);
printf("shmid: %d\n",shmid);
sleep(2);
char* start = (char*)attachShm(shmid);//进行链接
printf("client关联成功 %p\n",start);
cout << "等待4秒" << endl;
sleep(4);
cout << "client去除关联" << endl;
detachShm(start);//断开链接
return 0;
}
//shm_server.cc
#include "comm.hpp"
#include <unistd.h>
using namespace std;
int main()
{
key_t k = getKey();
printf("K: 0x%x\n",k);
int shmid = createShm(k);//创建共享内存
printf("shmid: %d\n",shmid);
sleep(2);
char* start = (char*)attachShm(shmid);//进行链接
printf("server关联成功 %p\n",start);
cout << "等待3秒" << endl;
sleep(3);
cout << "server去除关联" << endl;
detachShm(start);//断开链接
cout << "等待10秒" << endl;
sleep(10);
cout << shmid <<" 资源被删除" << endl;
delShm(shmid);//删除 共享内存
return 0;
}
让两个进程对同一个共享内存建立关联关系后,再去掉关联关系。
两个进程都链接到同一个共享内存后,我们就可以让两个进程进行通信了。
//进行通信 /server读取
int n = 8;
while(n--)
{
printf("client say: %s\n",start);
sleep(1);
}
//进行通信 /client进行写入
const char* message = "hello server 我是client,正在与你通信\n";
int cnt = 1;
int n = 5;
while(n--)
{
snprintf(start,MAX_SIZE,"%s [pid: %d][消息编号:%d]",message,getpid(),cnt++);
sleep(1);
}
共享内存的特点
优点:
是所有进程间通信中速度最快的,一个进程往共享内存中写入,另一个进程立马就能看到,他可以大大减少数据的拷贝次数。
在正常情况下一个进程写入,一个进程读取,同样的代码,用管道实现需要进行4次数据拷贝,而共享内存只需要两次(都不考虑键盘输入和显示器输出的情况下,如果有就都多加两次)。
缺点:没有同步和互斥操作,不对数据做任何保护。
注意:共享内存的删除操作并非直接删除,而是拒绝后续映射,只有在当前映射链接数为0时,表示没有进程访问了,才会真正被删除。
完整代码:lesson9/shm · 晚风不及你的笑/MyCodeStorehouse - 码云 - 开源中国 (gitee.com)
四、system V消息队列(了解)
消息队列提供了一个从一个进程向另外一个进程发送一块数据的方法,
每个数据块都被认为是有一个类型,接收者进程接收的数据块可以有不同的类型值。
IPC资源必须删除,否则不会自动清除,除非重启,所以system V IPC资源的生命周期随内核。
五、system V信号量(了解)
1. 信号量是什么?
本质是一个计数器,通常用于表示公共资源中资源数量多少的。公共资源是可以被多个进程同时访问的资源,访问没有被保护的公共资源会产生数据不一致问题,比如一个进程正在写入,刚写一半另一个进程就来读取了,你肯定不想让他读取,但是你又没办法阻止,我进程写入的数据全部放一起才有意义,你另一个进程只读一半是没有意义的,所以就产生数据不一致问题。
为什么要让进程看到同一份资源呢?因为我想让进程通信,通信的目的是什么?是想让进程实现协同,但是进程之间是相互独立的,没办法直接通信,那么就需要让进程看到同一份资源才能实现通信。可以看到引入一个问题,会有解决方法,但同时又会有新的问题出现,这个问题就是数据不一致,那么我们将公共资源保护起来就可以解决问题,这个保护起来的公共资源叫临界资源。
这些资源是如何被进程使用的?一定是进程有对应的代码来访问这部分临界资源,这部分代码称为临界区,那么有临界区就肯定会有非临界区。
那么如何对公共资源进行保护呢?
互斥和同步,这里暂时不对同步的概念作说明,互斥就是一个进程进行访问资源时,另一个进程此时只能等待这个进程访问完毕后才能访问。要么不做,要做就做完,这种两态的情况我们称为原子性。
信号量是用来让多进程(线程)之间进行协同的,未来让进程之间进行原子性的互斥或同步,信号量是其中一种解决方案。前面说过信号量是一个计数器,要想让两个进程看到同一个计数器,这就转换成两个进程看到同一个公共资源的问题了,所以信号量也属于进程间通信的范畴。
2. 为什么要有信号量?
共享内存可以作为一个整体使用,也可以划分为一个一个的子资源使用。比如一个进程想访问共享内存的一个子资源,但是子资源数量是有限的,那么就必须先申请信号量,只有申请到了信号量操作系统才可以让进程去访问这份子资源,如果没有申请到,就不让访问,这就是对共享内存做保护。
打个比方:共享内存就相当于是电影院,子资源相当于是电影院内的座位,进程相当于是人,那么信号量就相当于是售票处或者票,你想到电影院看电影,就必须先买票,有了票以后你才有自己的座位,才能看电影,如果当前的票没了,你没有买到,那么你自然就不能看电影了。
我们前面说过,所有的进程在访问公共资源前必须先申请信号量,那么申请信号量的前提是所有进程必须要能看到同一个信号量,说明信号量本身就是公共资源,那么信号量是不是也要保证自身的安全呢?是一定要的,我们知道信号量是一个计数器,存储公共资源数量的,一个进程申请一个资源后这个计数器要 --(n--),使用完后释放资源计数器要++,这个++,--的操作是原子性的,也就是说你要么就++,要么就--,没有其他的操作。
如果一个信号量的初识值为1,那么表示这个公共资源作为一个整体使用,那么一个进程取走该资源后,另一个进程就取不到了,只能等他访问完后才能访问,这就是这就是互斥,我们一般把只提供两种状态的信号量称为二元信号量。