
文章目录
- 前言
- 进程通信介绍
- 匿名管道
- 命名管道
- [System V共享内存](#System V共享内存)
- [System V消息队列](#System V消息队列)
- [System V信号量](#System V信号量)
前言
之前我们介绍的进程之间的独立性很强,但是,如果进程之间想要进行协同工作,该怎么安排进程之间的工作呢?这篇文章我们就来简单介绍下进程通信的相关话题。
进程通信介绍
目的
进程通信,主要是为了解决以下问题:
数据传输 :一个进程需要将它的数据发送给另一个进程
资源共享 :多个进程之间共享同样的资源
通知事件 :一个进程需要向另外一个或一组进程发送消息,通知发生了某种事件(如进程终止时要通知父进程)
进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变
本质
进程间是具有独立性的,而进程通信首先就要让不同的进程,先要看到同一份"资源",这个资源只能由操作系统来提供,访问这个资源只能使用系统调用来进行访问。
匿名管道
原理
如果父进程向文件缓冲区中写数据,子进程可以从缓冲区中读取数据,父子进程就具备了进程通信的前提条件,这种方式就是管道。管道的本质,是基于文件,进行内核级进程间的通信。如下图所示;

进行fork的时候,file结构体也要进行拷贝;管道通信的通信方式为单向通信,这种单向通行也称为单工通信,这种通信方式要求,在任何一个时刻,要么一直是父进程给子进程写消息,要么一直是子进程给父进程写消息。除此之外,还有全双工和半双工,全双工通信指的是,在任意一个时刻,通信双发可以互相给对方发消息;半双工通信指的是,在任意一个时刻,只允许一方给另一方发消息。
由于这种通信方式打开的文件是一个纯内存级的文件,不需要打开磁盘特定的文件,没有路径,也不需要文件名,因此,这种管道方式也称为匿名管道。
使用
以读写方式打开文件,可以使用管道通信相关的函数,如下所示:

这个参数的形参是一个输出型参数,会将读文件描述符和写文件描述符分别放到0下标和1下标中,若创建管道成功则返回0,否则返回-1。
看待管道,就如同看待文件一样,管道的使用和文件的使用一致,这也符合"Linux一切皆文件的思想"。
以下是一个使用示例:
c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <string.h>
int main()
{
//1.创建管道
int pipefd[2] = {0};
int n = pipe(pipefd);
if(n < 0)
{
perror("pipe");
return 1;
}
//printf("pipefd[0]:%d, pipefd[1]:%d\n",pipefd[0],pipefd[1]);
//2.创建子进程
pid_t id = fork();
if(id == 0)
{
close(pipefd[0]);
char* msg = "hello bit";
// write(pipefd[1],msg,strlen(msg));
int cnt = 10;
char outbuffer[1024];
while(cnt)
{
snprintf(outbuffer,sizeof(outbuffer),"c->f# %s %d %d\n",msg,cnt--,getpid());
write(pipefd[1],outbuffer,strlen(outbuffer));
sleep(1);
}
exit(0);
}
close(pipefd[1]);
char buffer[1024];
char inbuffer[1024];
while(1)
{
ssize_t n = read(pipefd[0],inbuffer,sizeof(inbuffer) - 1);
if(n > 0)
{
inbuffer[n] = 0;
printf("%s",inbuffer);
}
else if(n == 0)
{
}
else{
perror("read");
break;
}
}
pid_t rid = waitpid(id,NULL,0);
(void)rid;
return 0;
}
运行结果如下:

匿名管道只能用来进行父子通信,这是因为这是通过子进程继承父进程的内核资源来实现的,管道只能在具有"血缘关系"之间的进程之间使用。
在命令行用管道,其实就是创建了多个进程并进行通信的一个过程。
管道还具有以下特性:
管道是面向字节流的,写和读的次数不一定是成正比的。
管道的生命周期是随进程的。
管道通信,对于多进程而言,是自带互斥与同步机制的。
在读写过程中,可能存在以下几种情况:
子进程写得慢,父进程就要阻塞等,等管道有数据,父进程才能读,即管道为空,读就要阻塞,给写机会。
子进程写得快,父进程不读,管道一旦被写满,子进程就必须阻塞,管道满了,写就要阻塞,给读机会。
读端在读,写端关闭,读端读完管道中的剩余数据,再读,就会读取空串,read返回值为0,表明读管道,读到文件结尾。
写端一直在写,读端不读关闭fd,操作系统会直接杀掉写的进程,对应的退出信号为13。
在前两种情况中,使用管道时,管道为空和管道为满时,父子进程执行时具有一定的顺序性,这就是同步。
进程池
若想让父进程向管道进行写入,多个子进程进行读取,如下所示:

父进程可以可以通过任务表让不同子进程执行不同的任务,这种模式叫做Master Slaver模式的进程池,父进程为Master,子进程为Slaver。
进程池是先将进程预先创建出来,当有任务要完成时,再让对应的进程去完成对应的任务。父进程均匀地将任务分配给各个进程,这种方式叫做负载均衡,常见的负载均衡方式有三种:轮询、随机和权重。
命名管道
匿名管道只能在具有共同祖先的进程间进行使用,如果我们想在不相关的进程之间交换数据,那么就可以采用命名管道命名管道在磁盘当中是真实存在的。
使用
创建命名管道可以使用mkfifo指令,简单使用如下:

若想在程序中创建管道,可使用如下函数:

若想删除管道,可以使用unlink()函数。
注意:若通信没有打开之前,如果读端打开,写端如果没打开,读端open就会阻塞,直到写端打开,命名管道这样设计,是为了区分对端写关闭或者读关闭的情况。
以下是命名管道的一个服务端和客户端的使用代码:
cpp
//Pipe.hpp
#pragma once
#include <iostream>
#include <string>
#include <cstdlib>
#include <cstring>
#include <sys/types.h>
#include <sys/stat.h>
#include <errno.h>
#include <unistd.h>
#include <fcntl.h>
const std::string gcommonfile = "./fifo";
#define ForRead 1
#define ForWrite 0
class Fifo
{
public:
Fifo(const std::string &commfile = gcommonfile)
:_commfile(commfile),
_mode(0666),
_fd(-1)
{
}
// 1.创建管道
void Build()
{
umask(0);
if(IsExists())
{
return;
}
int n = mkfifo(_commfile.c_str(), _mode);
if(n < 0)
{
std::cerr << "mkfifo error: " << strerror(errno) << \
"errno:" << errno <<std::endl;
exit(1);
}
std::cerr << "mkfifo success: " << strerror(errno) << \
"Success errno:" << errno <<std::endl;
}
// 2.打开管道
void Open(int mode)
{
if(mode == ForRead)
_fd = open(_commfile.c_str(), O_RDONLY);
else if(mode == ForWrite)
_fd = open(_commfile.c_str(), O_WRONLY);
else {}
if(_fd < 0)
{
std::cerr << "open error: " << strerror(errno) << " errno: " << errno << std::endl;
exit(2);
}
else
{
std::cout << "open file success" << std::endl;
}
}
void Send(const std::string &msgin)
{
ssize_t n = write(_fd, msgin.c_str(),msgin.size());
(void)n;
}
int Recv(std::string *msgout)
{
char buffer[128];
ssize_t n = read(_fd, buffer,sizeof(buffer) - 1);
if(n > 0)
{
buffer[n] = 0;
*msgout = buffer;
return n;
}
else if(n == 0)
{
return 0;
}
else
{
return -1;
}
}
// 3.删除管道
void Delete()
{
if(!IsExists())
{
return;
}
int n = unlink(_commfile.c_str());
(void)n;
std::cout << "Unlink " << _commfile << std::endl;
}
~Fifo()
{
}
private:
bool IsExists()
{
// int fd = open(_commfile.c_str(), O_RDONLY);//若管道文件以读方式打开,没人写,会阻塞
// if(fd < 0)
// {
// return false;
// }
// else
// {
// close(fd);
// return true;
// }
struct stat st;
int n = stat(_commfile.c_str(), &st);
if(n == 0)
{
return true;
}
else
{
return false;
}
}
std::string _commfile;
mode_t _mode;
int _fd;
};
cpp
//Server.cpp
#include <iostream>
#include "Pipe.hpp"
int main()
{
//创建 && 打开
Fifo pipefile;
pipefile.Build();
//std::cout << "Server" << std::endl;
pipefile.Open(ForRead);
std::string msg;
while(true)
{
int n = pipefile.Recv(&msg);
if(n > 0)
std::cout << "Client Say#" <<msg << std::endl;
else
break;
}
pipefile.Delete();
return 0;
}
cpp
#include "Pipe.hpp"
int main()
{
//打开
Fifo fileClient;
fileClient.Open(ForWrite);
while(true)
{
std::cout << "Please Enter@ ";
std::string msg;
std::getline(std::cin, msg);
fileClient.Send(msg);
}
return 0;
}
System V共享内存
原理
之间我们介绍的匿名管道通信和命名管道通信,都是基于文件的,是基于已有的文件内核代码,进行的改良,本质是在复用代码。而System V共享内存是最快的进程通信方式。
共享内存的原理如下图所示:

进程通信的本质是让不同的进程看到同一份资源,而这种基于内存块映射到每一个进程的进程空间中的部分就叫做,共享内存的本质就是让不同的进程,把同一个内存块,映射到自己的虚拟地址空间,每一个进程得到自己的虚拟地址空间内存块的起始地址。将物理地址块映射到虚拟地址块这个过程,称作挂接,物理内存中的共享内存块会映射到进程空间的共享区中。
同时,共享内存块也会有相应的权限,这是因为共享内存会存在很多份,操作系统对管道的管理和对文件的管理都是类似的,而管理都是"先描述,再组织",共享内存也会有对应的结构体进行管理,结构体和对应的内存块一起形成了共享内存。
基于以上几点,共享内存的使用步骤为创建、关联挂接、使用、去关联和释放共享内存。
共享内存是采用多个进程,使用虚拟地址映射的方式,让不同的进程看到同一个内存块的方式。
使用
创建
创建共享内存的系统调用如下:

其中传入的size参数必须是4096的整数倍,否则会向上自动对齐到4096的整数倍。shmflg是一个选项,常用的有IPC_CREAT和IPC_EXEL,IPC_CREAT代表创建的共享内存不存在就创建,如果存在就获取,可以单独使用;IPC_EXEL选项不能单独使用,要和IPC_CREAT一块使用,代表如果创建的共享内存不存在就创建,如果存在,就出错返回。第一个参数key为要创建的共享内存的键值,在内核中,用来表示shm的唯一性。函数的返回值为共享内存的标识符。查看系统中已经有的共享内存可以使用"ipcs -m "命令,如下所示:

删除
共享内存(包括:system IPC)它的生命周期是随内核的,不随进程的,如果用户不主动删除ipc资源,这个ipc资源会和操作系统一样一直存在。删除的指令为"ipcrm -m [shmid]"。key只在内核中,标识共享内存的唯一性,用户使用共享内存,不使用这个key,而shmid只在用户中使用,在代码中使用shmid来访问共享内存。
在代码中删除共享内存可以使用shmctl函数,如下所示:

该函数成功返回0,出错返回-1。对于共享内存,删除也是控制的方式之一,除此之外,也可以获取或者设置共享内存的属性,第二个参数为对应的控制方式,常见的控制方式如下所示:

IPC_RMID为删除共享内存的操作。
第三个参数为一个输出型参数,为结构体缓冲区,可以将用户关心的属性存储在这个结构体缓冲区中。
映射
将共享内存映射到进程的进程地址空间,可以使用如下函数:

第一个参数为要挂接共享内存的shmid,第二个参数为指定的起始虚拟地址,第三个为挂接共享内存的模式,第二个第三个参数基本不设置。
若挂接成功,该函数会返回挂接好的共享内存的段的起始地址,否则返回-1。
查看系统共享内存的结果如下:

其中有一个属性为nattch,这个属性表达的是当前有多少个进程将该共享内存挂接到对应的地址空间。perms为共享内存的权限,这个权限可以在创建的时候直接将权限传给函数的第三个参数。
去关联
再删除的时候,若已经建立了映射关系,那么要先去关联再进行删除,去关联的函数如下所示:

去关联成功函数返回0,否则返回-1。
特点
共享内存有以下特点:
- 访问共享内存,是不需要系统调用的,因为shm已经映射到了进程的用户共享区了。
- 写端数据拷贝到shm,其它端立刻就能看到,因此共享内存是所有进程间通信方式中,速度最快的,拷贝次数少,直接映射,不需要系统调用。
- 共享内存没有资源的保护机制,没有同步或者互斥,保护机制要由用户自己来完成。
使用
System V共享内存的简答使用如下:
cpp
#pragma once
#include <iostream>
#include <sys/shm.h>
#include <unistd.h>
#include <cstdlib>
#include <cstdio>
const int gsize = 128;
// 用户指明,本质:等价于命名管道的文件路径
// PATHNAME,PROJ:确定了一个唯一的资源
#define PATHNAME "/tmp"
#define PROJ_ID 0x66
class Shm
{
public:
Shm()
: _shmid(-1),
_size(gsize),
_start_addr(nullptr)
{
}
~Shm() {}
void Get()
{
GetHelper(IPC_CREAT);
}
void PrintAttr()
{
struct shmid_ds ds;
int n = shmctl(_shmid, IPC_STAT, &ds);
if(n < 0)
{
perror("shmctl");
exit(4);
}
printf("key : 0x%x\n", ds.shm_perm.__key);
printf("shm_nattch : %ld\n", ds.shm_nattch);
printf("shm_segsz : 0x%lx\n", ds.shm_segsz);
}
void Create()
{
GetHelper(IPC_CREAT | IPC_EXCL | 0666);
}
void Detch()
{
int n = shmdt(_start_addr);
(void)n;
}
void Delete()
{
int n = shmctl(_shmid, IPC_RMID, nullptr);
(void)n;
}
void Attach()
{
_start_addr = shmat(_shmid, nullptr, 0);
if ((long long int)_start_addr == -1)
{
exit(3);
}
}
void *Addr()
{
return _start_addr;
}
int Size()
{
return _size;
}
private:
void GetHelper(int shmflg)
{
// 1. 构建键值
key_t k = GetKey();
if (k < 0)
{
std::cerr << "GetKey error";
exit(1);
}
// 2. 创建新的共享内存
_shmid = shmget(k, _size, shmflg);
if (_shmid < 0)
{
perror("shmget");
exit(2);
}
printf("key = 0x%x, _shmid = %d\n", k, _shmid);
}
// 动态生成键值
key_t GetKey()
{
return ftok(PATHNAME, PROJ_ID);
}
int _shmid;
int _size;
void *_start_addr;
};
cpp
#include "Shm.hpp"
int main()
{
// 生命周期的代码管理
Shm sharedmem;
sharedmem.Create();
sharedmem.Attach();
sleep(2);
sharedmem.PrintAttr();
char *shm_start = (char *)sharedmem.Addr();
int size = sharedmem.Size();
while (true)
{
//本质:读取内存
for (int i = 0; i < size; i++)
{
std::cout << shm_start[i] << " ";
}
std::cout << std::endl;
sleep(1);
}
sharedmem.Detch();
sharedmem.Delete();
return 0;
}
cpp
#include "Shm.hpp"
int main()
{
Shm shmaredmen;
shmaredmen.Get();
shmaredmen.Attach();
sleep(2);
shmaredmen.PrintAttr();
char* shm_start = (char*)shmaredmen.Addr();
int size = shmaredmen.Size();
int index = 0;
while(true)
{
std::cout << "Please Enter@ ";
std::cin >> *shm_start;
// char ch;
// std::cin >> ch;
// shm_start[index++] = ch;
shm_start++;
///index %= size;
}
shmaredmen.Detch();
return 0;
}
System V消息队列
原理
System V消息队列是基于一个共享的队列,可以实现基于有类型数据块级别的进程间通信,这个类型是为了区分是哪个进程放入队列中的。
使用
消息队列的使用有以下细节:
消息队列可能同时存在多份,操作系统要对消息队列进行管理,管理的方式为"先描述,再组织"。
消息队列一定有一个队列头,用一个结构体来描述消息队列。
创建共享内存可使用msgget函数,声明如下所示:

第一个参数key也可以通过ftok生成,这个参数可以确保消息队列的唯一性。第二个参数为创建方式,这个方式与创建共性内存的方式类似。
查看消息队列可使用"ipcs -q"指令,删除消息队列可使用"ipcrm -q [msqid]"指令。删除消息队列也可以使用msgctl函数,这个函数也是个控制函数,如下所示:

消息队列的发送和接收相关函数如下:

System V信号量
相关概念
在了解信号量机制前,我们先来了解以下几个概念:
共享资源:多个执行流,能够同时看到并访问的公共资源。
互斥:任何时刻,只能有一个执行流访问公共资源。
临界资源:被保护的公共资源。
临界区:访问临界资源的代码。
原子性:要么不做,要么做完的两态的状态。
信号量
本质
信号量本质是一个计数器,用来描述临界资源中资源数量的多少,申请信号量的本质是对资源的预定机制。申请资源,首先要申请信号量,若申请成功,就可以访问资源,否则就会阻塞等待。
每一个进程都要申请信号量,也就是每一个进程,都必须看到同一个信号量,所以,信号量本身就是一个共享资源,为此,系统设计信号量时,就会从技术上保证信号量的申请和信号量的释放是原子的(简单判断:一行汇编就是原子的,否则不是原子的)。若信号量的值为1,只能有一个进程访问资源,这时就是互斥,这个信号量只有两种状态,称为二元信号量;若信号量有多个,称为多元信号量。
申请
如果要进行共享资源的保护,就必须先让不同的进程看到同一份计数器资源,申请信号量的函数如下:

其中key用来标注信号量的唯一值,可以用ftok函数生成,semflg为创建信号量的选项,与上面的类似,第二个参数为nsems为要申请信号量的个数(不是信号量的初值)。
删除
删除信号量时信号量控制的方式之一,相关函数如下:

semid为要控制的信号量编号,cmd为控制方式,semnum为要删除的信号量的编号。
初始化
初始化相关操作可使用如下函数:

sembuf结构体如下:
