目录:
- 前言
- 进程间通信的目的
- 进程间通信的方式
- 管道
- 1.匿名管道
- 简单示例1 - 消息传输
- 五个特性
- 四种场景
- 简单示例2 - 进程控制
- 对管道的深入理解
- 2.命名管道
- 简单示例3 -- 不相关进程间通信
- system V
- 共享内存
- 简单示例4 - 通知事件+消息传输
- 总结
前言
打怪升级:第69天 |
---|
进程间通信的目的
数据传输:一个进程需要将它的数据发送给另一个进程
资源共享:多个进程之间共享同样的资源。
通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
进程间通信的方式
- 管道pipe:管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。
- 命名管道FIFO:有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。
- 消息队列MessageQueue:消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
- 共享存储SharedMemory:共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步和通信。
- 信号量Semaphore:信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
- 套接字Socket:套解口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同及其间的进程通信。
- 信号 ( sinal ) : 信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
该部分内容来源:进程间通讯的7种方式
本文主要讲解管道、命名管道以及共享内存三个部分,希望可以给朋友们提供帮助。
管道
1.匿名管道
管道,通常指匿名管道,是 UNIX 系统IPC最古老的形式,下文中所说的管道和匿名管道是同一个,都是管道。
它可以在两个相关联的进程之间创建一个管道(Pipe),进程可以通过管道进行单向通信。
在 pipe 中,数据是按照字节流的形式传输的,即发送进程将数据写入管道的一端,接收进程从管道的另一端读取数据。管道可以看作是一条单向的、先进先出(FIFO)的队列,进程可以在队列的两端进行操作:写入数据的进程称为管道的写入端,读取数据的进程称为管道的读取端。
- 命令
指令含义:查看我们当前所在路径,并将路径信息通过 管道 传递给 wc 指令,统计行数。
管道,可以给多个指令建立联系,使得多个指令可以进行数据传输,
上方的指令我想大家都是用过的,那么下面我们就来了解一下在语言层面通过系统调用建立匿名管道的方法。
- 系统调用
管道也称为匿名管道,创建匿名管道的方法就是使用系统调用 pipe,参数很简单 :一个整形数组,或者说一个整形指针。
函数的声明提示我们 pipefd数组有两个元素,其中这两个元素是两个fd(文件描述符),其中pipefd[0]表示读端,pipefd[1]表示写端。
从这里我们就可以看出:管道是属于文件系统的,那么我们有了文件描述符,之后的操作就和对文件的操作一样。
简单示例1 - 消息传输
使用 pipe 进行进程间通信的步骤如下:
- 调用 pipe 函数创建一个管道,并返回两个文件描述符:一个用于读取数据,一个用于写入数据。
- 创建一个子进程,它可以通过继承父进程的文件描述符来访问管道。
- 在父进程中关闭管道的读取端(如果不需要读取数据)或关闭管道的写入端(如果不需要写入数据)。
- 在父进程和子进程中分别使用管道的读取端和写入端进行通信。
- 在通信完成后,关闭管道的另一端以释放资源。
下面是一个简单的示例程序,展示了如何使用 pipe 进行进程间通信:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include<cstring>
int main()
{
int fd[2];
pid_t pid;
if (pipe(fd) < 0)
{
perror("pipe error");
exit(EXIT_FAILURE);
}
pid = fork();
if (pid < 0)
{
perror("fork error");
exit(EXIT_FAILURE);
}
else if (pid == 0) /* 子进程 */
{
close(fd[0]); /* 关闭管道的读取端 */
const char* ptrs= "Hello, parent!";
write(fd[1], ptrs, strlen(ptrs)); /* 向管道写入数据 */
close(fd[1]); /* 关闭管道的写入端 */
exit(EXIT_SUCCESS);
}
else /* 父进程 */
{
char buffer[1024];
close(fd[1]); /* 关闭管道的写入端 */
read(fd[0], buffer, sizeof(buffer)); /* 从管道读取数据 */
printf("Message from child: %s\n", buffer);
close(fd[0]); /* 关闭管道的读取端 */
exit(EXIT_SUCCESS);
}
}
运行结果:
上述程序创建了一个管道,然后创建了一个子进程。在子进程中向管道写入一条消息,并在父进程中读取该消息并打印输出。值得注意的是,在使用管道进行通信时需要注意错误处理、关闭文件描述符等问题,以免出现资源泄漏或其他错误。
五个特性
- 单向通信 – 一种特殊的半双工 – 一方只能写,一方只能读
- 管道本质上是文件,因为文件的生命周期随进程,所以管道的生命周期随进程
- 管道只能用于具有血缘关系的进程间的通信 – 父子、兄弟 – 管道继承
- 在管道通信中,读取的次数与写入的次数不是严格匹配的,读写次数没有强相关 – 字节流
- 具有一定的协同能力,使 read 和 write 能够按照一定的步骤进行通信 – 同步与互斥
- 当要写入的数据不大于PIPE_BUF时,Linux将保证写入的原子性。
注:管道一般专指匿名管道,匿名管道只能用于具有血缘关系间的进程通信;
下方我们会遇到 FIFO,一般称它为 命名管道,可以实现两个不相关进程间的通信。
四种场景
- read 快于 write:如果我们读取完毕管道中的数据,如果对方不发,我们会进行等待;
- write 快于 read:read会一次读取write多次写入的数据,如果管道写满就不能写入;
- 关闭管道的read:OS判定write后面的写入无效,OS不会容忍低效率、无意义的事情占用CPU资源,会杀死write的进程 – 信号13:SIGPIPE;
- 关闭管道的write:read读取完管道中的数据,再次读取取得数据为0,退出。
简单示例2 - 进程控制
一个父进程同时对多个子进程发布任务。
- mypipe.hpp
#include<iostream>
using namespace std;
#include<vector>
struct Person
{
int _wfd; // 写文件描述符
int _cid; // 子进程id
};
void PrintLog()
{
cout << "我正在执行打印日志的任务..." << endl;
}
void LoadVideo()
{
cout << "我正在执行加载视频的任务..." << endl;
}
void WaitNetwork()
{
cout << "我正在等待网络资源" << endl;
}
typedef void(*FUNC)(); // 定义一个函数指针类型
class Task
{
public:
Task()
{
_taskArr.push_back(PrintLog);
_taskArr.push_back(LoadVideo);
_taskArr.push_back(WaitNetwork);
}
public:
vector<FUNC> _taskArr; // 任务列表
};
void Menu()
{
cout << "***********************" << endl;
cout << "*1.打印日志 2.加载视频*" << endl;
cout << "*3.等待网络 0.退出 *" << endl;
cout << "***********************" << endl;
}
- test.cc
#include"mypipe.hpp"
#include<cstdlib>
#include<cassert>
#include<sys/types.h>
#include<sys/wait.h>
#include<unistd.h>
const int cpCnt = 5; // 创建子进程个数
void GetCommend(Task task)
{
while(true)
{
int commend;
ssize_t rCnt = read(0, &commend, sizeof(commend));
if(rCnt != sizeof(int) || commend == 0) break; // 读取到的应该是一个整数并且收到执行任务的指令
cout << "我是子进程,我的pid是:" << getpid() <<", ";
task._taskArr[commend-1]();
}
}
void CtrlProess(Person* p, const Task task)
{
int commend;
int i=0;
do
{
Menu();
cin >> commend;
if(commend < 0 || commend > task._taskArr.size()) continue; // 保证选项合理
write(p[i]._wfd, &commend, sizeof(commend));
i = (i + 1) % cpCnt; // 各个子进程依次执行任务
sleep(1);
} while(commend);
cout << "父进程退出" << endl;
}
void Creat(Person* p, const Task& task)
{
for(int i=0; i<cpCnt; ++i)
{
// 创建管道
int pipe_fd[2];
assert(pipe(pipe_fd) != -1);
//创建子进程
pid_t id = fork();
assert(id != -1);
if(id == 0) // child
{
// 关闭不需要的fd
//close(pipe_fd[1]); // 无法形成干净的管道
for(int i = pipe_fd[0]+1; i<=pipe_fd[1]; ++i)
close(i); // 如果有疑问可以分别画出子进程1,2,3的files结构体
dup2(pipe_fd[0], 0); // 将文件标准输入重定向到该管道
// 读取执行
GetCommend(task);
// 子进程退出
close(pipe_fd[0]);
exit(0);
}
// parent
//关闭不需要的fd
close(pipe_fd[0]);
p[i]._wfd = pipe_fd[1];
p[i]._cid = id;
}
}
void WaitProc(Person* p)
{
for(int i=0; i<cpCnt; ++i)
{
close(p[i]._wfd);
waitpid(p[i]._cid, nullptr, 0); // 等待子进程退出
}
cout << "等待子进程成功" << endl;
}
// 一个父进程与多个子进程通过管道 -- 5 -- 父进程写 子进程读
int main()
{
//创建管道与进程 -- 并且保存子进程与写文件信息
Person p[cpCnt];
Task task;
Creat(p, task);
//开始通信
CtrlProess(p, task);
// 关闭管道并且回收子进程资源
WaitProc(p);
return 0;
}
对管道的深入理解
上方的代码我们可以不过多关注,但是有一个地方确是我们需要注意的:不需要的管道的关闭。
父进程同时管理多个子进程,按照我们的预期应该如上图所示,那么我们的代码是否可以达到我们的目的呢?
分析原因:
我以为 vs 实际上
解决方法有三
- 方案1
关闭写端 和 等待子进程分开来写,当我们关闭了所有写端后,子进程会从后往前一次退出并进入僵尸状态。
- 方案2
我们可以从后往前倒着关闭匿名管道,这样类似于方案1.
- 方案3
前两种方法虽说都可以达到我们的目的,但是,从根本上来说并没有达到所谓管道的条件 – 一方写来一方读
因此,我们可以在创建子进程的时候就将不必要存在的fd全部关闭掉,这样一来才能形成一个个干净的管道。
2.命名管道
对于匿名管道我们知道它只能用于具有血缘关系的进程直接通信,至于原因:因为它没有名字,除了一个家族内可以通过fd找到它,其他进程并不知道它的存在;
因此,为了实现毫不相干的进程之间的通信,我们有了FIFO,也称命名管道。
命名管道与管道的区别就是它拥有自己的名字,不同进程只要知道它的位置都可以找到它。
- 指令
mkfifo filename
创建匿名管道,可以直接使用ls查看。
命名管道的删除可以使用 unlink命令,
同时,命名管道说到底还是文件,因此它的删除可以直接使用 rm。
- 系统调用
参数一:命名管道路径名,创建文件的权限
这里就和文件类似,mode为八进制,分别为读、写、执行。和管道不同的是:管道的生命周期是随进程的,但是命名管道我们可以提前手动创建与删除,OS不会自动帮我们回收,
因此,在使用完命名管道后我们需要手动删除它。
参数:文件路径
简单示例3 – 不相关进程间通信
- msg.h // 存储一些公共信息
#include<iostream>
using namespace std;
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include<cassert>
#include<cstring>
#define LOG "fifo"
#define MODE 0666
#define NUM 1024
- user.cc // 用户端输入
#include"msg.h"
int main()
{
// 打开文件 -- 只写
int wfd = open(LOG, O_WRONLY);
assert(wfd != -1);
// 写入数据
while(true)
{
char inquire[NUM] = {0};
cout << "请输入消息# ";
fgets(inquire, sizeof(inquire), stdin);
inquire[strlen(inquire)-1] = 0; // 删除最后一个的 \n
if(strcasecmp(inquire, "quit") == 0) break;
// 发送消息
write(wfd, inquire, strlen(inquire));
}
cout << "user quit success." << endl;
close(wfd);
return 0;
}
- server.cc // 服务端回显
#include"msg.h"
// 两个不同进程间的通信
int main()
{
// 1.1 创建命名管道
umask(0);
int fifoRet = mkfifo(LOG, MODE);
assert(fifoRet != -1);
(void)fifoRet; // 防止由于fifoRet只定义未使用而报警告
cout << "create fifo success" << endl;
// 打开文件 -- 只读
int rfd = open(LOG, O_RDONLY);
assert(rfd != -1);
//1.2 读取数据 -- 进行反馈
cout << "open fifo success" << endl;
while(true)
{
char buf[NUM] = {0};
ssize_t rCnt = read(rfd, buf, NUM - 1);
if(rCnt == 0)
{
cout << "user quit, me too." << endl;
break;
}
cout << "user# " << buf << endl;
}
//2.2关闭写端
close(rfd);
// 2.2 删除命名管道 -- nulink
assert(unlink(LOG)==0);
return 0;
}
有很重要的一点需要我们注意:当server端启动后一开始只打印了“创建成功”,直到user端也启动后才打印出“打开成功”,
也就是说:当只有server端是server是无法单独打开 fifo的,这点和pipe一样:OS不允许任何无效或低效率的事情发生,因为管道是用来传输数据的,但是当只有读端或者写端时无法进行通信,因此OS会将该进程阻塞,直到有其他进程以另一种方式打开管道才会给它们分配CPU资源。
system V
system V通信的三种方式:
System V 消息队列
System V 共享内存
System V 信号量
- 指令
- 查看IPC通信结构:ipcs
- 删除IPC通信结构:ipcrm -m/q/s id
共享内存
- 系统调用
- 创建共享内存 -> shm – shared memory
参数一:随机key值 – 由ftok函数生成
参数二:共享内存大小
参数三:shm打开方式以及shm权限设置参数三的常用选项:IPC_CREAT , IPC_EXCL,IPC_RMID
IPC_CRETA:创建共享内存,如果存在(有相同的key)就使用已存在的,不存在就创建新的。
IPC_EXCL:创建共享内存时必须是新的,如果之前就存在就报错。(绝不将就)
ICP_RMID:根据共享内存的id号删除共享内存,在shmctl函数中使用。
返回值:创建成功返回 共享内存标识符 – shmid,创建失败返回-1.并且设置erron
ftok()函数使用给定路径名命名的文件的标识(它必须引用一个现有的、可访问的文件)
有效的8位proj_id(必须是非零的)来生成key_t类型的System V IPC密钥
简单说:文件名和proj_id 这两个参数都是可以随便给的,它们的作用就是生成一个新的key(类似于生成随机数)来唯一标识shm。
返回值:生成成功返回 key,生成失败返回-1并设置erron。
shmat:shm attach,触摸shm,和shm建立连接,返回值为shm的首地址,有了地址我们就可以将它当做数组来使用。
参数一:shmid
参数二:可以直接设为nullptr
参数三:可以直接设为0
shmat返回值:连接成功返回 shm地址 – shmaddr,失败返回-1,设置erron;shmdt:shm detach ,脱离shm,与shm取消链接
参数一:shm地址
shmdt返回值:取消关联成功返回 0,失败返回-1,设置erron。
参数一:shmid
参数二:IPC_RMID ,删除shm
参数三:直接设为nullptr
返回值:删除成功返回0,失败返回-1并设置erron。
补充知识点:共享内存的映射在进程地址空间的共享区(堆栈之间),如果大家了解过动静态库的话就知道,动态库也是链接在这部分的。
简单示例4 - 通知事件+消息传输
- server.cc
#include"msg.hpp"
int main()
{
// 和shm建立联系
Shm s(SERVER);
// 使用FIFO控制读取
// 打开读端
int rfd = open(FIFONAME, O_RDONLY);
// 开始通信
int cnt = 10;
while(cnt)
{
size_t rsize = read(rfd, &cnt, sizeof cnt);
if(rsize == 4)
{
printf("user# %s\n", s._address);
}
}
return 0;
}
- user.cc
#include"msg.hpp"
int main()
{
// 1. 和shm建立联系
Shm s(USER);
// 2. 使用FIFO通知server可以进行读取
//2.1 创建fifo
mkfifo(FIFONAME, MODE);
// 打开写端
int wfd = open(FIFONAME, O_WRONLY);
// 3. 开始通信
int cnt = 10;
while(cnt--)
{
printf("输入: ");
fflush(stdout);
fscanf(stdin, "%[^\n]s", s._address); // 遇到非换行符字符全部读取 -- 也就是遇到换行符就结束
getchar(); // “吃掉” 缓冲区中的换行符
write(wfd, &cnt, sizeof cnt); // 每次录入信息后写入一个整数,通知server可以进行读取
}
return 0;
}
- msg.hpp
#include<iostream>
using namespace std;
#include<cassert>
#include<cstring>
#include<string>
#include<unistd.h>
#include<sys/ipc.h>
#include<sys/shm.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#define SIZE 4096
#define LOG "."
#define NUM 0x1234
#define MODE 0666
#define SERVER 0 // 服务标识
#define USER 1 // 用户标识
#define FIFONAME "f.fifo"
class Shm{
public:
Shm(int name)
:_name(name)
{
// 创建shm
int key = GetKey();
if(_name == SERVER)
_shmid = CreateShm(key, SIZE);
else
_shmid = FindShm(key, SIZE);
cout << "create shm success." << endl;
// 建立连接
_address = Attach(_shmid);
cout << "attach shm success." << endl;
}
~Shm()
{
// 取消链接
Detach(_address);
// server删除shm
if(_name == SERVER)
{
DelShm(_shmid);
cout << "free shm success." << endl;
}
}
void ToHex(int n)
{
if(n)
{
ToHex(n / 16);
cout << n % 16;
}
}
int GetKey()
{
int key = ftok(LOG, NUM);
assert(key != -1);
return key;
}
private:
int GetShm(int key, int size, int cmd)
{
int shmid = shmget(key, size, cmd);
assert(shmid != -1);
return shmid;
}
// 创建共享内存 -- 返回shmid
int CreateShm(int key, int size)
{
umask(0);
return GetShm(key, size, IPC_CREAT | IPC_EXCL | MODE); // IPC_EXCL:我只要新的,否则我就报错
}
int FindShm(int key, int size)
{
return GetShm(key, size, IPC_CREAT); // 我用其他人的也可以
}
char* Attach(int shmid)
{
char* shmadd = (char*)shmat(shmid, nullptr, 0);
return shmadd;
}
void Detach(char* shmadd)
{
assert(shmdt(shmadd) != -1);
}
void DelShm(int shmid)
{
assert(shmctl(shmid, IPC_RMID, nullptr) != -1);
}
public:
int _name; // 用户区分
int _shmid; // shm标识
char* _address; // shm地址
};
总结
- 匿名管道只能用于具有血缘关系的进程之间通信;
- 在命令行中通过管道连接的各个指令是兄弟关系,它们的父进程都为bash;
- 管道与命名管道都属于文件系统,数据缓冲区的大小随文件缓冲区,数据的流动只在文件缓冲区中进行;
- 命名管道的大小永远是0,文件中的数据不会刷新到磁盘(仅仅作为通信的中间体,没有必要保存数据);
- 由于共享内存创建之后就不需要依赖于OS,因此消息传输是最快的,而同时,因为消息传输时不经过OS的控制,所以通信过程是不安全的 – 没有同步与互斥(两个用户同时进行写入,造成数据覆盖,信息丢失)。
- 两种管道如果只存在读端或者写端会被OS强制关闭,shm则不会。
- 内存中的数据是以块为单位存储的,一块的大小为 4KB(4096B),因此当我们申请4096B时OS会给我们分配4096B,
但是如果我们申请4097B时OS就会给我们分配 2 * 4096B的空间,不过即使给我们分配了这么多,我们也只能使用4097B。 - 共享内存映射在进程地址空间的共享区。