- 进程为什么要通信
- 进程如何进行通信
- 进程间的常见通信方式
- 管道
- [System V 共享内存](#System V 共享内存)
- [System V 消息队列](#System V 消息队列)
- 临界资源
- [System V 信号量](#System V 信号量)
- 统一管理IPC
进程为什么要通信
进程之间需要协同作业,而协同的前提是通信。
数据是由类别的,如通知就绪、传递数据、控制相关的信息...
明确一个事实:进程具有独立性,进程=内核数据结构+代码和数据
进程如何进行通信
a. 由于进程的独立性,进程间通信成本可能会稍高!
b. 进程间通信的前提是:先让不同的进程,看到同一份 (操作系统的 )资源("一段内存"):

这部分资源必须由操作系统创建,因此OS会提供不同的调用接口来创建共享资源。根据调用接口不同,通信就有不同的种类!
进程间的常见通信方式
- system V IPC(本地通信)
- 消息队列
- 共享内存
- 信号量
- POSIX IPC
- 消息队列
- 共享内存
- 信号量
- 互斥量
- 条件变量
- 读写锁
- 管道
- 匿名管道
- 命名管道
管道
考虑能不能直接复用操作系统已实现的功能来实现通信呢?管道就是这么操作的
匿名管道
首先我们要知道,一个进程已不同方式打开一个文件会创建不同的file_struct,但是内存中的文件资源只需要缓冲一份:

父子进程也是同理,files_struct只会发生浅拷贝:

- 这也可以理解为什么父子进程会向同一个显示器输出。
- 这同样能理解进程是如何默认打开012的,这是因为所有进程都是bash的子进程,bash打开了012,子进程就会打开012.
- 同样,为什么子进程close了文件,不会影响父进程。因为struct file也有引用计数
注意到这个文件的内存缓冲区就是多个进程能一起看到的资源,我们把这个文件称为管道文件。
根据我们这种实现方式,我们发现这个内存文件完全没有必要 刷新缓冲区到磁盘中。因此管道是不需要也不会刷新缓冲区 的:

- 注意,这种方式只允许父子进程单向通信!
因此我们要实现这个通信,可以在父进程读写打开同一个文件。然后父进程关闭读(写)文件,子进程关闭写(读)文件。就能进行单向通信了:

then:

这时又引出两个问题:
- 既然要关闭文件,为什么一开始要打开文件?
------为了让子进程继承。 - 可以不关闭文件吗?
------可以的。建议关闭!
为此,系统封装了另外的管道文件:

返回值:

这里传入的pipefd[2]里面就存储了一个文件读写端,并且不需要文件路径和文件名,所以称这个为匿名管道。
这里我们自然而然会有新的问题:
- 如何实现双向通信?
------创建两个管道 - 为什么管道要实现成单向通信?
------管道本身是为了节省工作量而复用代码,因此简单为上,所以就是单向的。
使用管道
先简单创建一下管道:


注意的pipefd[0]对应的是管道文件的读,pipefd[1]对应的是管道文件的写
接下来我们来实现完整的读写:



运行代码:


稍微修改一下代码,看看管道的大小是多少:


可以看到我这个系统对应的管道文件大小是64kB!
管道的特点
管道的4种情况:
- 如果管道内部是空的并且write fd没有关闭,读取条件不具备,读进程就会阻塞
- 管道被写满且read fd没有关闭,写条件不具备,写进程就会阻塞
- 管道一直在读取并且write fd关闭了,读端read返回值就一直是0,即读取到文件结尾
- 管道一直在写并且read fd关闭了,写端进程会被os用13号信号关掉。
管道的5个特征:
- 匿名管道只能用于有血缘关系的管道之间进行通信,常用于父子进程通信。
- 管道内部,自带进程之间同步的机制。
- 管道文件的生命周期是随进程的
- 管道文件在通信时,是面向文件流的。写入次数和读取次数不是一一匹配的。
- 管道的通信模式是一种特殊的半双工模式
命名管道
- 原理
很自然的,我们想到匿名管道的局限性是只能有血缘关系的进程之间实现通信!但是我们想要的是不相关进程之间也能通信。
我们依旧可以让两个不相关进程的文件列表中指向同一个内核级文件(即不用刷新到磁盘中的文件),但是问题在于如何确保两个进程能够打开一个文件 。
为了保证这件事,我们可以给管道文件一个路径 ,这样我们就能使两个进程通过路径打开同一个管道!
有了路径的管道自然就是命名管道。
- 实现
OS自然会提供命名管道的接口,如:mkfifo

那么来创建一下:

可以看到我们的myfifo就是p文件,即管道文件。
然后尝试写入:

注意到写入之后管道就变成了一个进程,然后大小依旧为0.
尝试读取:

随后进程就结束了。
为什么管道文件大小一直是0呢?
前面就说过了,管道文件是不需要刷新到磁盘的,所以大小一直是0哦。
代码实现
我们需要的可是代码级别的命名管道,看看有什么函数接口:

可以看到一样的函数,我们可以在C里面调用。
现在来实现一个场景,构建两个进程,其中一个读管道,另一个写管道。因此我们需要一个进程来控制创建管道和销毁管道。
我们完全可以参考智能指针的方式来管理命名管道,我们来创建一个管理命名管道的类:
cpp
#pragma once
#include <iostream>
#include <cstdio>
#include <cerrno>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
const std::string comm_path="./myfifo";
#define DefaultFd -1
#define Creater 1
#define User 2
#define Read O_RDONLY
#define Write O_WRONLY
#define BaseSize 4096
class NamedPipe
{
private:
bool OpenNamedPipe(int mode)
{
_fd=open(_fifo_path.c_str(),mode);
if(_fd<0)
return false;
return true;
}
public:
NamedPipe(const std::string &path,int who)
:_fifo_path(path),_id(who),_fd(DefaultFd)
{
if(_id==Creater)
{
int res=mkfifo(_fifo_path.c_str(),0666);
if(res!=0)
{
perror("mkfifo");
}
std::cout<<"creater creat namedpipe"<<std::endl;
}
}
bool OpenForRead()
{
return OpenNamedPipe(Read);
}
bool OpenForWrite()
{
return OpenNamedPipe(Write);
}
int ReadNamedPipe(std::string *out)
{
char buffer[BaseSize];
int n=read(_fd,buffer,sizeof(buffer));
if(n>0)
{
buffer[n]=0;
*out=buffer;
}
return n;
}
int WriteNamePipe(const std::string&in)
{
return write(_fd,in.c_str(),in.size());
}
~NamedPipe()
{
if(_id==Creater)
{
int res=unlink(_fifo_path.c_str());
if(res!=0)
{
perror("unlink");
}
std::cout<<"creater free namedpipe"<<std::endl;
}
if(_fd!=DefaultFd)close(_fd);
}
private:
const std::string _fifo_path;
int _fd;
int _id;
};
然后我们就可以定制读端和写端的代码了,写端:
cpp
#include"namedPipe.hpp"
int main()
{
NamedPipe fifo(comm_path,User);
if(fifo.OpenForWrite())
{
std::cout<<"client open namedpipe done"<<std::endl;
while(true)
{
std::cout<<"Please Enter>";
std::string message;
std::getline(std::cin,message);
fifo.WriteNamePipe(message);
}
}
return 0;
}
读端:
cpp
#include"namedPipe.hpp"
int main()
{
NamedPipe fifo(comm_path,Creater);
if(fifo.OpenForRead())
{
std::cout << "server open named pipe done" << std::endl;
while(true)
{
std::string message;
int n=fifo.ReadNamedPipe(&message);
if(n>0)
{
std::cout<<"Clinet Say>"<<message<<std::endl;
}
else if(n==0)
{
std::cout<<"Client quit,Server too"<<std::endl;
break;
}
else
break;
}
}
return 0;
}
尝试下效果吧:

可以看到,当只创建读端的时候,读端会阻塞在open处。

完美实现了进程间通信。
System V 共享内存
根据这个名字很自然我们能想到一种更直接的通信方式,直接在内存中申请一块不同进程都能读取的内存空间,然后映射到共享区中:

经过长久的学习,我们对操作系统已有一定了解,对上述过程有如下理解:
- 上述步骤都由OS完成
- OS必须提供申请共享内存和挂接共享内存到虚拟地址的系统接口
- 共享内存是可以存在多份的
- OS必然要对共享内存管理,理应有对应的共享内存数据结构,理应每份共享内存都有其唯一的标识
- 共享内存=内存空间(数据)+共享内存的属性
shmget
来看看申请共享内存的系统调用接口:

我们来看看每个参数和返回值,首先是返回值:

总之就是申请失败时返回-1,错误码设置。
再看参数key。
实际上我们申请了共享内存又如何确保不同的进程进入同一个共享内存,就是要我们用户端规定不同的进程输入同一个key值就能访问同一个共享内存。
size显然就是申请的共享内存空间大小,这里建议是4096的整数倍
shmflg则是不同的flag,我们来看看有什么:

IPC_CREAT:如果你创建的共享内存不存在,创建之,如果存在,获取该共享内存并返回。
IPC_CREAT|IPC_EXCL:如果你创建的共享内存,创建之,如果存在则出错返回!
意味着下面的标识能确保这个共享内存是刚创建出来的,上面的标识则是获取已创建的共享内存。
除此之外标识符还需要传入创建的文件的权限,如0666!
ftok
要生成一个唯一标识的key值还是有点麻烦的,我们可以交给系统调用接口来实现:

我们只需要传入相同的pathname和proj_id就能获取相同的key了。来简单尝试一下吧:



可以看到每次生成的key值都是一样的。接下来参考命名管道的思路,我们来实现一下封装。
代码实现
我们先来简单地申请一下shm:



可以看到第一次创建的时候的确申请成功了shm,但是第二次之后就失败了。这意味着这片shm已经存在。这是个严重的问题,这说明了共享内存的生命周期不是随进程而是随内核的。我们进程结束前释放共享内存否则就会泄漏。先用系统调用接口释放一下shm:

此时我们需要在析构函数中调用shmctrl:


在cmd中传入IPC_RMID即可删除shm:


非常完美,我们现在能申请好了共享内存,下一步自然是将共享内存挂接到页表中,这需要函数shmat:

挂接成功就会返回虚拟地址中共享内存的起始地址。
解除挂接就是另一个函数shmdt.
来实现挂接:


完事具备,来尝试写入:
读端

写端


注意到我们在shm读取数据会重复读取,并且读取之后不会清空读过的数据!
这意味着:
- 共享内存不提供保护机制
- 共享内存是所有IPC中速度最快的,因为极大减少了内存拷贝时间
但是没有保护机制终究是不好的,正确的解决方法在下面信号量再继续讲解。我们先用管道来解决这个问题。
只需要每次写入和读取前先对管道写入和读取即可:
读端

写端


完整代码
Shm.hpp
cpp
#ifndef __SHM_HPP__
#define __SHM_HPP__
#include<iostream>
#include<cstring>
#include<cerrno>
#include<string>
#include<unistd.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
const char*pathname="/home/zhangwho/lesson20-29/lesson24/4.shm";
const int proj_id=0x66;
const int Creater=1;
const int User=2;
const int BaseSize=4096;
class Shm
{
private:
key_t Getkey()
{
key_t k=ftok(_pathname.c_str(),_proj_id);
if(k<0)
{
perror("ftok");
}
return k;
}
bool GetShmForCreater()
{
if(_who==Creater)
{
_shmid=shmget(_key,BaseSize,IPC_CREAT|IPC_EXCL|0666);
if(_shmid<0)
perror("shmget");
else
return true;
}
return false;
}
bool GetShmForUser()
{
if(_who==User)
{
_shmid=shmget(_key,BaseSize,IPC_CREAT|0666);
if(_shmid<0)
perror("shmget");
else
return true;
}
return false;
}
void* Attachshm()
{
if(_addrshm!=nullptr)
DetachShm();
void*shmaddr=shmat(_shmid,nullptr,0);
if(shmaddr==nullptr)
{
perror("shmat");
}
return shmaddr;
}
void DetachShm()
{
if(_addrshm==nullptr)
return;
shmdt(_addrshm);
}
public:
Shm(int who,std::string pn,int pid)
:_who(who),_pathname(pn),_proj_id(pid)
{
_key=Getkey();
if(_who==Creater)
GetShmForCreater();
else if(_who==User)
GetShmForUser();
_addrshm=Attachshm();
}
void Clear()
{
if(_addrshm!=nullptr)
{
memset(_addrshm,0,BaseSize);
}
}
void*Addr()
{
return _addrshm;
}
~Shm()
{
if(_who==Creater)
{
shmctl(_shmid,IPC_RMID,nullptr);
}
}
private:
int _who;
std::string _pathname;
int _proj_id;
key_t _key;
int _shmid;
void* _addrshm;
};
#endif
cpp
#include"Shm.hpp"
#include"namedPipe.hpp"
int main()
{
Shm myshm(Creater,pathname,proj_id);
char*shmaddr=(char*)myshm.Addr();
NamedPipe myfifo(comm_path,Creater);
myfifo.OpenForRead();
while(true)
{
std::string temp;
int n=myfifo.ReadNamedPipe(&temp);
if(!n)break;
std::cout<<"shm memory content:"<<shmaddr<<std::endl;
sleep(1);
}
return 0;
}
cpp
#include"Shm.hpp"
#include"namedPipe.hpp"
int main()
{
Shm myshm(User,pathname,proj_id);
myshm.Clear();
char*shmaddr=(char*)myshm.Addr();
char ch='A';
NamedPipe myfifo(comm_path,User);
myfifo.OpenForWrite();
while(ch<='Z')
{
shmaddr[ch-'A']=ch;
ch++;
std::string temp = "wakeup";
myfifo.WriteNamePipe(temp);
sleep(2);
}
return 0;
}
System V 消息队列
在内存中申请一块消息队列空间供给进程通信。进程可以将数据块连在队列后来写入,也可以读取队列中的数据块:

消息队列的接口和共享内存类似,这里不做赘述。




临界资源
学习了共享内存和消息队列以及管道,我们发现了他们之间的不同。现在明确他们的不同,以及增加进程间通信的理解:
- 多个执行流能看到同一份资源,这份资源称为共享资源
- 被保护起来的资源称为临界资源。用同步和互斥的方式保护共享资源即可得到临界资源
- 互斥:任何时刻只能有一个进程在访问共享资源
- 资源要被程序员访问,即通过代码访问。故代码可分为:访问共享资源的代码+不访问共享资源的代码
- 对共享资源的保护即对访问共享资源的代码进行保护
System V 信号量
有了上述的理解,我们可以对共享资源进行保护。System V中的保护方式即为信号量,信号量可以理解为计数器。
具体地我们可以将共享资源分成多个区块:

一个进程对其中一个区块资源访问,访问前可以申请该区块的信号量。访问结束后释放信号量。
如果这个区块的信号量已被申请,那么只能等待。
如果共享资源的信号量是01的:

即一个进程在访问该资源时,其他进程无法访问。这就是所谓的进程互斥
注意到我们的信号量也是不同进程之间都能读取的,这意味着信号量本身是安全的,否则无法对资源进行保护。
我们称信号量减操作为P,增操作为V,则信号量的PV操作 理应是原子的
具体接口参照共享内存
统一管理IPC
OS是如何统一管理共享内存、消息队列和信号量的呢?
- 首先制定了System V规则
- 接口统一命名和参数,通过xxxget和xxxctl方式控制
- 每个信号量都有xxxid_ds;struct ipc_perm,如shmid_ds:

虽然共享内存、消息队列和信号量的结构体互不相同,但是都有ipc_perm。我们只需要对ipc_perm的指针放在同一个数组进行管理,然后根据其类型不同进行强转即可得到对应的共享内存或消息队列或信号量。
这就是C语言实现的多态。