目录
为什么snprintf用的是sizeof,write用的是strlen,read用的是sizeof(buffer)-1?
5.2、使用命名管道进行client&server两个进程间通信
[七、System V消息队列](#七、System V消息队列)
[九、内核中的systemV IPC](#九、内核中的systemV IPC)
一、进程间通信介绍
1.1、为什么要进行进程间通信?
数据传输:⼀个进程需要将它的数据发送给另⼀个进程
资源共享:多个进程之间共享同样的资源。
通知事件:⼀个进程需要向另⼀个或⼀组进程发送消息,通知它(它们)发⽣了某种事件(如进
程终⽌时要通知⽗进程)。
进程控制:有些进程希望完全控制另⼀个进程的执⾏(如Debug进程),此时控制进程希望能够
拦截另⼀个进程的所有陷⼊和异常,并能够及时知道它的状态改变。
1.2、怎么通信?
进程间通信的本质是让不同的进程看到同一份资源(进程间通信的前提条件),之后才有进程间通信。这份资源不是由进程提供的,而是由OS的系统调用即设计的统一通信接口提供的。
1.3、什么是进程间通信?
进程间通信(Inter-Process Communication,IPC)是指操作系统中不同进程之间交换数据或信息的机制。由于进程拥有独立的地址空间,无法直接访问彼此的资源,因此需要借助IPC技术实现协作。
1.4、分类
管道
匿名管道pipe 、 命名管道
System V IPC
System V 消息队列 、 System V 共享内存 、 System V 信号量
POSIX IPC
消息队列 、 共享内存 、 信号量 、 互斥量 、 条件变量 、 读写锁
二、管道
什么是管道
管道是Unix中最古⽼的进程间通信的形式。
我们把从⼀个进程连接到另⼀个进程的⼀个数据流称为⼀个"管道"
三、匿名管道
3.1、背景
最开始人们要实现进程间的通信,首先想到的不是创建新的方式,而是想的能不能基于已有技术实现。
发现可通过文件实现:一般文件的缓冲区会刷新到磁盘,将缓冲区作为进程间通信的中间资源即可实现进程间通信

一个进程拥有自己的文件描述符表,文件描述符表中的fd指向不同的文件,每一个都打开一个struct_file,而struct_file会有自己的inode属性,通过路径基于找到inode属性,inode通过向block的映射找到数据内容,而struct_file会存在一个缓冲区用于和磁盘之间IO数据内容,
struct file 中的 f_op 是一个 指向文件操作函数表的指针,类型为 const struct file_operations *(简称 ops)。 它定义了对该文件描述符的所有操作行为,使得 不同类型的文件(普通文件、管道、套接字、设备节点等) 可以通过统一的 read()/write()/ioctl() 等系统调用接口,由各自的专用函数实现。
当创建子进程后,父进程的内核数据结构会被拷贝进子进程
例如文件描述符表以及struct_file都会拷贝一次。但是子进程的strcut_file指向的inode属性,ops以及缓冲区是不变的。那么此时父子进程就可以通过缓冲区来进行通信了,这就相当于是一个管道,连接起两个进程。
struct_file会存在一个引用计数,当其中一个进程释放时,这个struct_file不一定释放,看引用计数是否为0

而管道是不需要刷盘的,这个匿名管道是一个内核级的文件
3.2、原理
在文件描述符层面来看:

首先父进程通过系统调用接口创建一个内存级的管道文件
include <unistd.h>
功能 : 创建⼀⽆名管道
原型 int pipe ( int fd[ 2 ]);
参数 fd:⽂件描述符数组 , 其中 fd[ 0 ] 表⽰读端 , fd[ 1 ] 表⽰写端 。返回值: 成功返回 0 ,失败返回错误代码

此时父进程的文件描述符表中用两个fd分别指向管道文件的读端和写端
之后创建子进程,子进程的内核数据结构拷贝自父进程,因此子进程也会用相同的fd指向这个管道文件
通过系统调用关闭父进程的读端和子进程的写端,之后进行通信
在内核角度来看:

为什么称作匿名管道?
因为这个管道文件是内存级的,也就是我们不需要认为去找到这个文件,也就是不需要记录这个文件的路径,也就没有对应的文件名称
怎么保证两个进程打开的是同一个管道文件?
子进程的文件描述符表继承自父进程,因此指向的文件一样
3.3、代码
先正确创建管道文件
cpp
#include<iostream>
#include<unistd.h>
int main()
{
int fds[2] = {0};
int n = pipe(fds);
if(n < 0)
{
std::cerr << "pipe failed !" << std::endl;
return 1;
}
//检测是否创建正确
std::cout <<"fds[0]: "<< fds[0] << std::endl;
std::cout <<"fds[1]: "<< fds[1] << std::endl;
return 0;
}

创建成功fds[0]就是读端,fds[1]就是写端

现在封装读写函数,让子进程去写,父进程读
来测试是否父子进程可以通过匿名管道来进行通信,用一个变量cnt来观察:
cpp
#include<iostream>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<stdio.h>
#include<string.h>
void ChildWrite(int wfd)
{
char buffer[1024];
int cnt = 0;
while(true)
{
snprintf(buffer, sizeof(buffer), "I am child , pid: %d, cnt: %d", getpid(), cnt++);
write(wfd, buffer, strlen(buffer));
sleep(1);
}
}
void FatherRead(int rfd)
{
char buffer[1024];
while(true)
{
ssize_t n = read(rfd, buffer, sizeof(buffer) - 1);
if(n > 0)
{
buffer[n] = 0;//这也是为什么要sizeof(buffer)-1,就是预留一个给'\0'
std::cout << "Child say: " << buffer << std::endl;
}
}
}
int main()
{
int fds[2] = {0};
int n = pipe(fds);
if(n < 0)
{
std::cerr << "pipe failed !" << std::endl;
return 1;
}
// //检测是否创建正确
// std::cout <<"fds[0]: "<< fds[0] << std::endl;
// std::cout <<"fds[1]: "<< fds[1] << std::endl;
pid_t id = fork();
if(id < 0)
{
std::cerr << "fork failed!" << std::endl;
return 1;
}
if(id == 0)//子进程
{
//先关掉子进程管道文件的读端
close(fds[0]);
//封装的写函数
ChildWrite(fds[1]);
//最后关掉写端
close(fds[1]);
exit(0);
}
//这里是父进程
close(fds[1]);
FatherRead(fds[0]);
close(fds[0]);
//父进程接收子进程退出信息
waitpid(id, nullptr, 0);
return 0;
}
为什么snprintf用的是sizeof,write用的是strlen,read用的是sizeof(buffer)-1?
-
snprintf 使用 sizeof (buffer)
snprintf(buffer, sizeof(buffer), ...)
中,sizeof(buffer)
用于指定缓冲区的最大容量(1024 字节)- 这是为了防止字符串长度超过缓冲区大小导致的缓冲区溢出
snprintf
会保证最终生成的字符串(包括结尾的\0
)不会超过这个长度
-
write 使用 strlen (buffer)
write(wfd, buffer, strlen(buffer))
中,strlen(buffer)
用于获取实际字符串长度- 因为
snprintf
已经在字符串末尾添加了\0
结束符,而strlen
会计算到\0
之前的有效字符数 - 这里只需要写入有效字符串内容,不需要把
\0
也写入管道
-
read 使用 sizeof (buffer)-1
read(rfd, buffer, sizeof(buffer)-1)
中,sizeof(buffer)-1
是为了预留一个字节给\0,保证字符串处理安全
- 读取数据后,代码会手动添加
buffer[n] = 0
来确保字符串正确终止 - 这样处理可以避免当读取到最大长度数据时,没有空间存放结束符的问题
- n为实际读到的个数,要是有1023个字节,那么就在最后一个加'\0',n此时为1023;要是不足1023个,buffer[n]也在实际读到的字符串后买你添加了'\0'
现在来看测试结果

可以看到父进程可以收到子进程写的内容,并且不会存在写实拷贝,因为cnt变量在子进程中变化了之后父进程也随之变化;所以管道实现了进程间的通信
3.4、五种特性和四种通信情况
五种特性
匿名管道只能给具有血缘关系的进程进行进程间通信(例如父子进程)
管道文件自带同步机制(比如上例的写是慢于读的,读完之后会等待子进程写,自同步)
管道文件是面向字节流的
看现象:
假设读快于写,也就是上述代码

现在看写快于读呢?在读的代码加上sleep
可见每次读到的内容不固定。在每一次读的时候写了很多次,当缓冲区写满之后子进程不再写,等待父进程读缓冲区,之后再写。最重要的是父进程怎么读,读的内容的多少和你怎么写没有关系,而是和父进程的代码中指定每次读取的内容是多少直接关联,比如这里buffer是1024,若是指定其他大小则读取的内容会变化
所以粗略来说,管道文件是直接面向字节流的


管道是单向通信的(属于半双工通信的一种,也就是同时只允许一方发送,另一方此时不能发送只能接收)
管道文件的生命周期是随着进程的,当父子进程都结束之后,它们的管道文件也结束
四种通信情况
写得慢读得快,读的一端就要阻塞 。而阻塞实际上是进程在等待,比如上述代码中当缓冲区中都读完时,代码就会阻塞在FatherRead的read接口调用处,等待子进程写入内容之后再读
写得快读得慢,写端阻塞。当缓冲区写满之后,写端就要等待读端读取内容之后缓冲区有空间继续写
写关也就是写的时候突然退出不写了,读端就会读到0,表示文件结尾
看现象:



读关,也就是读直接退出,写正常写。操作系统判定此时的写无用,因为写就是给其他进程读的,没有读的,写就没有意义,此时子进程会被杀死,返回退出码(无意义)以及退出信息(标明杀死进程的原因)
看现象
写端正常写

读端break退出

main函数里面打印退出码以及退出信息


退出信息是13,kill -l 查看是什么
这是进程尝试向一个已关闭的管道(Pipe)或套接字(Socket)写入数据时触发的信号。

3.5、管道的容量
将上面代码每次写入一个字节并且用cnt计数,看看cnt最后的值
最后用cnt的值除去1024,得出的结果以kb为单位就是管道的容量
注意不能让父进程读,但是也不能直接关闭读端,否则操作系统会杀死子进程;所以让父进程一直休眠sleep(10000);子进程一直往管道里面写,因为此时父进程休眠,所以不会读,那么管道就一直被写没有读,也就是其中数据一直增加
最后cnt到65535结束,因为cnt初始为0,所以总共写了65536个字节,也就是64kb
64kb一般为管道的容量大小



3.6、管道写入的原子性

管道写入的原子性就是每次读取时若是管道正在被写并且此时还没有PIPE_BUF个字节,那么就不能被读取,必须要写满PIPE_BUF
若是在写入时,管道里面已经存在很多内容了(之前写的),那么此时可以读取,读之前写的
四、进程池(基于匿名管道)
基于匿名管道进程池就是父进程有很多个子进程并且每个之间都存在管道,父进程可以给每个子进程分配任务,同样是写入管道和从管道读取的方式
具体地:这里父进程为写端,各个子进程为读端。将父进程到一个管道到管道连接的子进程称为一个信道,将这个信道用类channel描述起来,同样地,要进行信道的管理还需要组织,此时用一个类channelmanager里面的属性vector<channel>管理。后面用一个类ProcessPool来封装各个通信的方法
文件名为ProcessPool.hpp(意思是头文件和具体的实现可以一起编写)
第一阶段:
分配子进程时同时指定Work也就是每个子进程的工作,当创建一个子进程之后此时需要建立信道(ChannelInsert),要构建channel的实例,在channelmanager的封装的Insert里面用emplace_back(),在实例化类的同时插入进vector进行管理
同时在ProcessPool里面封装Debug方法,也就是打印此时的信道的写端fd,连接的子进程pid
cpp
#ifndef _PROCESS_POOL_HPP_
#define _PROCESS_POOL_HPP_
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <string.h>
#include <vector>
// 单个信道的描述
class Channel
{
public:
Channel(int wfd, int id) : _wfd(wfd), _id(id)
{
_name = "channel-" + std::to_string(id) + "-" + std::to_string(_wfd);
}
~Channel() {}
std::string GetName() { return _name; }
private:
int _wfd;
pid_t _id;
std::string _name;
};
// 对多个信道的描述
class ChannelManager
{
public:
ChannelManager() {}
void ChannelInsert(int wfd, pid_t id)
{
_channels.emplace_back(wfd, id); // 先调用构造函数构造对象之后进行插入channels
}
void PrintChannels()
{
for(auto& ch : _channels)
{
std::cout << ch.GetName() << std::endl;
}
}
~ChannelManager() {}
private:
std::vector<Channel> _channels;
};
int gdefaultnum = 5;
// 进程池
class ProcessPool
{
public:
ProcessPool(int processnum) : _processnum(processnum)
{
}
// 子进程的工作
void Work(int rfd, pid_t id)
{
while(true)
{
std::cout << "我是一个子进程, 我的rfd : " << rfd << std::endl;
sleep(5);
}
}
bool Create()
{
for (int i = 0; i < _processnum; i++)
{
// 创建管道
int pipefd[2] = {0};
int n = pipe(pipefd);
if (n < 0)
return false;
pid_t id = fork();
if (id < 0)
return false;
else if (id == 0)
{
close(pipefd[1]);
Work(pipefd[0], id);
close(pipefd[0]);
exit(0);
}
else
{
close(pipefd[0]);
// 走到此处已经创建了一个管道和子进程,就要开始添加信道
_cm.ChannelInsert(pipefd[1], id);
close(pipefd[1]);
}
}
return true;
}
void Debug()
{
_cm.PrintChannels();
}
~ProcessPool() {}
private:
ChannelManager _cm;
int _processnum;
};
#endif

测试结果:
rfd一直为3后面解释

第二阶段:
开始给子进程分配任务,当然也是通过进程池来封装;由于此时调用在外部,所以不能确定选择哪个信道也就是子进程,所以要进行子进程的选择:
选择也要有策略,若是一直选择某些信道而其他的不用,那么就会造成负载不均衡 。负载均衡的选择方法:轮询(即遍历选择)、随机、channel添加负载指标;这里采用轮询的方式.
发送任务的实现在Channel类里面,因为信道的类记录了创建对应子进程时的wfd,可以进行写
具体代码实现(只含变化处):
cpp
//Channel内
//发送任务
void Send(int code)
{
ssize_t n = write(_wfd, &code, sizeof(code));
(void)n;//这样写防止编译报错------定义了n但是没有使用
}
//ChannelManager内
ChannelManager() :_next(0)
{}
Channel& Select()
{
Channel& c = _channels[_next++];
_next %= _channels.size();//防止越界
return c;
}
private:
std::vector<Channel> _channels;
int _next;//标明下一次轮询的下标
//ProcessPool内
// 子进程的工作
void Work(int rfd)
{
while(true)
{
int code = 0;
ssize_t n = read(rfd, &code, sizeof(code));
if(n > 0)
{
if(n != sizeof(code))//虽然读了但是不完整,重新读
{
continue;
}
std::cout << "读取成功!" << std::endl;
std::cout << "子进程[" << getpid() << "]读取到的任务码是 : " << code << std::endl;
}
else if(n == 0)
{
std::cout << "写关, 子进程退出" << std::endl;
break;
}
else
{
std :: cout << "读取错误, 子进程退出" << std::endl;
break;
}
}
}
不能关闭写端,否则在for循环之后全部写关,读不到数据
bool Create()
{
for (int i = 0; i < _processnum; i++)
{
// 创建管道
int pipefd[2] = {0};
int n = pipe(pipefd);
if (n < 0)
return false;
pid_t id = fork();
if (id < 0)
return false;
else if (id == 0)
{
close(pipefd[1]);
Work(pipefd[0]);
close(pipefd[0]);
exit(0);
}
else
{
close(pipefd[0]);
// 走到此处已经创建了一个管道和子进程,就要开始添加信道
_cm.ChannelInsert(pipefd[1], id);
//close(pipefd[1]);
}
}
return true;
}
void TaskPush(int taskcode)
{
//轮询方式选择子进程
auto& c = _cm.Select();
std::cout << "选择的子进程 : " << c.GetName() << std::endl;
//发送任务码
c.Send(taskcode);
std::cout << "发送了任务码 : " << taskcode << std::endl;
}

测试结果
可以看到一直在轮询使用信道

第三阶段:
将执行的具体任务写出来,用一个Task.hpp里面的TaskManager来封装,其私有成员变量为一个容纳任务的数组(这里的任务实际上就是函数,因此可以用函数指针来表示这个任务的类型),这个类里面有任务的注册、任务码的生成以及任务的执行。
注册是在进程池启动时的动作,因此在ProcessPool的构造函数里面调用注册;
之前的任务码是通过main函数传参,现在不一样了,先将ProcessPool里面的TaskPush函数名改为Run,并且在此函数里面调用任务管理类的任务码生成函数,选择一个任务;
最后子进程Work函数里面读取Send发来任务码之后开始执行任务码对应的任务(实际上就是下标)
代码变化:
cpp
//Task.hpp文件
#pragma once
#include<iostream>
#include<vector>
#include<ctime>
//void(*)() : *表示为指针类型,()表示没有任何参数,void表示函数无返回值
//将void(*)() 重命名为task_T
//task_t 可以指向任何无参数无返回值的函数
typedef void (*task_t) ();
//任务
void LogView()
{
std::cout << "这是一个查看日志的任务" << std::endl;
}
void Download()
{
std::cout << "这是一个下载的任务" << std::endl;
}
void Delete()
{
std::cout << "这是一个删除的任务" << std::endl;
}
class TaskManager
{
public:
TaskManager()
{
srand((unsigned int)time(nullptr));//生成随机数的种子
}
//注册------实际上就是入数组进行组织
void TaskRegister(task_t t)
{
_tasks.push_back(t);
}
//生成任务码
int Taskcode()
{
int taskcode = rand() % _tasks.size();//不越界
return taskcode;
}
//执行任务
void Execute(int taskcode)
{
_tasks[taskcode]();//函数名接上()才是调用这个函数
}
~TaskManager(){}
private:
std::vector<task_t> _tasks;
};
cpp
#include"Task.hpp"
//ProcessPool类内:
ProcessPool(int processnum) : _processnum(processnum)
{
_tm.TaskRegister(LogView);
_tm.TaskRegister(Download);
_tm.TaskRegister(Delete);
}
//Work内
int taskcode = 0;
ssize_t n = read(rfd, &taskcode, sizeof(taskcode));
if(n > 0)
{
if(n != sizeof(taskcode))//虽然读了但是不完整,重新读
{
continue;
}
std::cout << "读取成功!" << std::endl;
std::cout << "子进程[" << getpid() << "]读取到的任务码是 : " << taskcode << std::endl;
//执行任务
std::cout << "执行任务 : " << std::endl;
_tm.Execute(taskcode);
}
void Run()
{
//选择任务码
int taskcode = _tm.Taskcode();
//轮询方式选择子进程
auto& c = _cm.Select();
std::cout << "选择的子进程 : " << c.GetName() << std::endl;
//发送任务码
c.Send(taskcode);
std::cout << "发送了任务码 : " << taskcode << std::endl;
}
private:
ChannelManager _cm;
int _processnum;
TaskManager _tm;

执行结果

子进程一直在Work里面的read等待,主程序(父进程)结束,此时子进程才退出(从五种特性之一可知父进程退出,那么父进程管道的写端就没了,此时子进程读到返回值为0,break之后exit退出),所以会存在孤儿进程。因此要使得子进程先于父进程之前退出;同时退出了没有等待进程,也会有僵尸进程
此图说明Work结束之后子进程会推出
第四阶段:
结束进程池:首先要避免孤儿进程就要使得子进程先结束,按照代码逻辑,当子进程连接管道的写端关闭时,就会break之后exit,那么我们实现时只需要写关闭每个管道的写端即可,因为在关闭写端时顺便避免了孤儿进程;之后子进程退出之后父进程需要接收其退出信息,避免僵尸进程,所以也要封装等待进程的函数。
变化代码
cpp
channel类内
int Getwfd() { return _wfd; }
pid_t Getid() { return _id; }
ChannelManager类内
void CloseChannelWfd()
{
for(auto& ch : _channels)
{
close(ch.Getwfd());
}
std::cout << "父进程写端全部关闭" << std::endl;
}
void WaitSubProcess()
{
for(auto& ch : _channels)
{
pid_t id = ch.Getid();
waitpid(id, nullptr, 0);
}
std::cout << "子进程退出信息接收完毕" << std::endl;
}
ProcessPool类内
void Stop()
{
//关闭写端 , 即子进程退出
_cm.CloseChannelWfd();
//结束子进程退出信息
_cm.WaitSubProcess();
}

运行结果:

进程池总的代码:
cpp
//ProcessPool.hpp:
#ifndef _PROCESS_POOL_HPP_
#define _PROCESS_POOL_HPP_
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <string.h>
#include <vector>
#include"Task.hpp"
// 单个信道的描述
class Channel
{
public:
Channel(int wfd, int id) : _wfd(wfd), _id(id)
{
_name = "channel-" + std::to_string(id) + "-" + std::to_string(_wfd);
}
//发送任务
void Send(int taskcode)
{
ssize_t n = write(_wfd, &taskcode, sizeof(taskcode));
(void)n;//这样写防止编译报错------定义了n但是没有使用
}
std::string GetName() { return _name; }
int Getwfd() { return _wfd; }
pid_t Getid() { return _id; }
~Channel() {}
private:
int _wfd;
pid_t _id;
std::string _name;
};
// 对多个信道的描述
class ChannelManager
{
public:
ChannelManager() :_next(0)
{}
void ChannelInsert(int wfd, pid_t id)
{
_channels.emplace_back(wfd, id); // 先调用构造函数构造对象之后进行插入channels
}
void PrintChannels()
{
for(auto& ch : _channels)
{
std::cout << ch.GetName() << std::endl;
}
}
Channel& Select()
{
Channel& c = _channels[_next++];
_next %= _channels.size();//防止越界
return c;
}
void CloseChannelWfd()
{
for(auto& ch : _channels)
{
close(ch.Getwfd());
}
std::cout << "父进程写端全部关闭" << std::endl;
}
void WaitSubProcess()
{
for(auto& ch : _channels)
{
pid_t id = ch.Getid();
waitpid(id, nullptr, 0);
}
std::cout << "子进程退出信息接收完毕" << std::endl;
}
~ChannelManager() {}
private:
std::vector<Channel> _channels;
int _next;//标明下一次轮询的下标
};
int gdefaultnum = 5;
// 进程池
class ProcessPool
{
public:
ProcessPool(int processnum) : _processnum(processnum)
{
_tm.TaskRegister(LogView);
_tm.TaskRegister(Download);
_tm.TaskRegister(Delete);
}
// 子进程的工作
void Work(int rfd)
{
while(true)
{
int taskcode = 0;
ssize_t n = read(rfd, &taskcode, sizeof(taskcode));
if(n > 0)
{
if(n != sizeof(taskcode))//虽然读了但是不完整,重新读
{
continue;
}
std::cout << "读取成功!" << std::endl;
std::cout << "子进程[" << getpid() << "]读取到的任务码是 : " << taskcode << std::endl;
//执行任务
std::cout << "执行任务 : " << std::endl;
_tm.Execute(taskcode);
}
else if(n == 0)
{
std::cout << "写关, 子进程退出" << std::endl;
break;
}
else
{
std :: cout << "读取错误, 子进程退出" << std::endl;
break;
}
}
}
bool Create()
{
for (int i = 0; i < _processnum; i++)
{
// 创建管道
int pipefd[2] = {0};
int n = pipe(pipefd);
if (n < 0)
return false;
pid_t id = fork();
if (id < 0)
return false;
else if (id == 0)
{
close(pipefd[1]);
Work(pipefd[0]);
close(pipefd[0]);
exit(0);
}
else
{
close(pipefd[0]);
// 走到此处已经创建了一个管道和子进程,就要开始添加信道
_cm.ChannelInsert(pipefd[1], id);
//close(pipefd[1]);
}
}
return true;
}
void Debug()
{
_cm.PrintChannels();
}
void Run()
{
//选择任务码
int taskcode = _tm.Taskcode();
//轮询方式选择子进程
auto& c = _cm.Select();
std::cout << "选择的子进程 : " << c.GetName() << std::endl;
//发送任务码
c.Send(taskcode);
std::cout << "发送了任务码 : " << taskcode << std::endl;
}
void Stop()
{
//关闭写端 , 即子进程退出
_cm.CloseChannelWfd();
//结束子进程退出信息
_cm.WaitSubProcess();
}
~ProcessPool() {}
private:
ChannelManager _cm;
int _processnum;
TaskManager _tm;
};
#endif
//Task.hpp文件
#pragma once
#include<iostream>
#include<vector>
#include<ctime>
//void(*)() : *表示为指针类型,()表示没有任何参数,void表示函数无返回值
//将void(*)() 重命名为task_T
//task_t 可以指向任何无参数无返回值的函数
typedef void (*task_t) ();
//任务
void LogView()
{
std::cout << "这是一个查看日志的任务" << std::endl;
}
void Download()
{
std::cout << "这是一个下载的任务" << std::endl;
}
void Delete()
{
std::cout << "这是一个删除的任务" << std::endl;
}
class TaskManager
{
public:
TaskManager()
{
srand((unsigned int)time(nullptr));//生成随机数的种子
}
//注册------实际上就是入数组进行组织
void TaskRegister(task_t t)
{
_tasks.push_back(t);
}
//生成任务码
int Taskcode()
{
int taskcode = rand() % _tasks.size();//不越界
return taskcode;
}
//执行任务
void Execute(int taskcode)
{
_tasks[taskcode]();//函数名接上()才是调用这个函数
}
~TaskManager(){}
private:
std::vector<task_t> _tasks;
};
残留问题:这样写Stop:每一次close之后直接wait

结果:

在执行完十次任务之后卡住;
原因:第一次创建子进程,管道1的写端为4,读端为3;第二次创建子进程,子进程拷贝父进程,管道2除了有正常分配的写端5,读端3之外(因为父进程每次创建完子进程之后读端3都关闭所以每次创建的子进程读端都是3),还有拷贝至父进程的写端4,这个写端连接到管道1;依次类推,管道1最终会连接5个写端(父进程一个,四个子进程每个一个)
那么在进行上面的Stop时,范围for先关闭最开始插入到vector里面的管道,那么就关闭了父进程的写端4,正常来说此时子进程读端检测到写端关闭,OS会结束子进程,wait收到退出信息;但是因为这个管道1还有其他子进程的读端连接(管道写端也有引用计数),所以此时的子进程没有退出,而是处于等待写端写内容的状态,那么程序就会在这里卡住

解决方案:
1、倒着关闭管道的读端
从最后一个管道开始关闭,因为最后一个管道只有一个写端(后面没有子进程),那么关闭这个写端之后,子进程正常退出。因此这个子进程连接到前面的一个子进程的写端就会关闭(管道随着进程存亡而存亡),那么之后关闭倒数第二个写端时,此时的管道只连接了一个写端就是父进程的,那么就可以close之后子进程正常退出了。因为最后一个子进程还连接了之前每一个管道的写端,那么最后一个子进程关闭之后上面的管道的写端都会减1。

2、真的让每一个管道的写端只有父进程一个
如何实现呢?就是在创建子进程时,关闭子进程的所有写端(除了此时创建新管道申请的写端,还有拷贝至父进程的连接到其他子进程管道的写端)。这要在创建的时候关闭,因为子进程拷贝至父进程,所以子进程也有自己的_cm中的vector<Channel> _channels,在子进程关闭时,也就是在执行if(id==0)这个代码块时,父进程的插入_cm中的vector还没执行,也就是此时的子进程的_cm中的vector的管道的写端都是之前的,没有此时创建管道的,因为此时父进程还没插入pipe[1],那么关闭就不会关闭父进程的正常应插入的。并且父进程在插入之后想相当于更改了,那么就会发生写实拷贝,父进程的vector不会受到影响


子进程的操作(包括关闭写端、修改_channels
副本)与父进程完全隔离,原因是:
- 进程地址空间独立,
_channels
是副本而非共享; - 文件描述符是进程私有资源,子进程关闭的是自己持有的描述符;
- 内核通过引用计数管理管道,子进程关闭写端不会影响父进程持有的写端有效性
五、命名管道

两个进程没有血缘关系,不能使用类似于因父子继承而共享同一份资源的匿名管道;因此需要命名管道来实现这两个进程间的通信。
5.1、没有血缘关系的进程如何实现共享资源?
首先当两个进程访问同一个文件时,由于OS不会做费时费空间的工作,所以OS不会将这同一个文件打开两次,做重复的工作。那么实际上这两个进程打开文件需要各自的文件描述符表以及struct_file管理打开文件的相关信息,但是由于struct_file内的inode以及文件内核缓冲区分别和文件属性内容挂钩,所以这两个进程使用同一份的inode、文件内核缓冲区等。
这样就让不同的进程看到了同一份资源。因此没有血缘关系的进程间通信共享资源的方式是打开唯一路径下的同一个文件实现的。因为路径具有唯一性所以不会出错,并且打开的文件是通过文件名实现共享的,因此这个文件肯定有名字,所以这个文件就是命名管道
创建命名管道的方式是mkfifo,命名管道文件是一种特殊的文件,因为这个文件不像普通文件需要刷盘做持久化工作,所以它是内存级的文件。这种文件开头是p

简单看一个例子来说明命名管道可以进行进程间通信
先cat < fifo阻塞等待数据输入,再使用echo将内容重定向进入fifo,此时cat就会打印

5.2、使用命名管道进行client&server两个进程间通信
首先需要三个文件,一个是server.cc这个文件用来创建命名管道,并且打开这个命名管道文件阻塞等待;client.cc用来打开命名管道文件进行写入;因为打开的是同一份文件因此需要记录文件的路径和名称,所以使用common.hpp记录管道文件的路径和名称
5.2.1、命名管道创建方式、删除方式以及各自的返回值
pathname:指明文件创建到哪个路径以及文件名
mode:设置文件的权限
创建
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);
返回值
On success mkfifo() and mkfifoat() return 0. In the case of an error, -1 is returned (in which case, errno is set appropriately).
删除
#include <unistd.h>
int unlink(const char *pathname);
返回值
On success, zero is returned. On error, -1 is returned, and errno is set appropriately.

5.2.2、打开管道文件准备读写
cpp
#include <iostream>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#include "common.hpp"
int main()
{
// 创建命名管道文件
umask(0);
int n1 = mkfifo(FIFO_FILE, 0666);
if (n1 == 0)
{
std::cout << "mkfifo success." << std::endl;
}
else
{
std::cerr << "mkfifo fail." << std::endl;
}
//打开管道文件
int fd = open(FIFO_FILE, O_RDONLY);
if(fd == -1)
{
std::cerr << "open fifo fail." << std::endl;
}
else
{
std::cout << "open fifo success." << std::endl;
}
//进行通信:从管道文件中读取
while(true)
{
char buffer[1024];
int n = read(fd, buffer, sizeof(buffer) - 1);
if(n > 0)
{
buffer[n] = 0;//保证字符串安全
std::cout << "read fifo success." << std::endl;
std::cout << "cilent say# " << buffer << std::endl;
}
else if(n == 0)
{
std::cout << "cilent quit! me too." << std::endl;
break;
}
else
{
std::cerr << "read fifo fail" << std::endl;
break;
}
}
//关闭管道文件
close(fd);
// 删除命名管道
int n2 = unlink(FIFO_FILE);
if (n2 == 0)
{
std::cout << "unlink fifo success" << std::endl;
}
else
{
std::cerr << "unlink fifo fail" << std::endl;
}
return 0;
}
cpp
#include <iostream>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#include <string>
#include "common.hpp"
int main()
{
//打开管道文件准备写
int fd = open(FIFO_FILE, O_WRONLY);
if(fd == -1)
{
std::cerr << "open fifo fail." << std::endl;
}
else
{
std::cout << "open fifo success." << std::endl;
}
//写入内容
while(true)
{
std::string message;
std::cout << "Please enter# " << std::endl;
std::getline(std::cin, message);
int cnt = 1;
pid_t id = getpid();
message += ", message number: " + std::to_string(cnt) + ", pid: [" + std::to_string(id) + "]";
int n = write(fd, message.c_str(), message.size());
if(n == -1)
{
std::cerr << "write fail" << std::endl;
}
else
{
std::cout << "write success" << std::endl;
}
}
//关闭管道文件
close(fd);
return 0;
}
Makefile以及common.hpp
cpp
.PHONY:all
all:client server
client:client.cc
g++ -o $@ $^ -std=c++11
server:server.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -f client server
cpp
#pragma once
#define FIFO_FILE "fifo"
make,先运行server,发现阻塞在open处;这是因为写的一端还没有执行时,读的一端不会打开管道文件,因为此时打开没有意义

现在执行client


可以发现server打开的管道文件
现在进行通信,输入数据:

若是退出client这个进程,读端会读到0得知写端进程退出,此时读无意义,也退出

5.3、封装代码
将管道文件的创建以及删除封装在文件commom.hpp里面的NamedPipe里面,将打开管道文件以及读写以及文件的关闭封装在common.hpp里面的PipeOperator里面。这样在server以及client简化的代码,并且只需要调用方法就可以实现进程间通信
cpp
#pragma once
#include <iostream>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#include <string>
// #define FIFO_FILE "fifo"
#define PATH "."
#define NAME "fifo"
#define ERR_EXIT(m) \
do \
{ \
perror(m); \
exit(EXIT_FAILURE); \
} while (0)
class NamedPipe
{
public:
NamedPipe(const std::string path, const std::string name)
: _path(path), _name(name)
{
// 创建命名管道文件
_pn = _path + "/" + _name;
umask(0);
int n = mkfifo(_pn.c_str(), 0666);
if (n == 0)
{
std::cout << "mkfifo success." << std::endl;
}
else
{
// std::cerr << "mkfifo fail." << std::endl;
ERR_EXIT("mkfifo");
}
}
~NamedPipe()
{
// 删除命名管道
int n = unlink(_pn.c_str());
if (n == 0)
{
std::cout << "unlink fifo success" << std::endl;
}
else
{
// std::cerr << "unlink fifo fail" << std::endl;
ERR_EXIT("unlink");
}
}
private:
std::string _path;
std::string _name;
std::string _pn;
};
class PipeOperator
{
public:
PipeOperator(const std::string path, const std::string name)
: _path(path), _name(name), _fd(-1)
{
_pn = _path + "/" + _name;
}
void OpenForRead()
{
// 打开管道文件
_fd = open(_pn.c_str(), O_RDONLY);
if (_fd == -1)
{
// std::cerr << "open fifo fail." << std::endl;
ERR_EXIT("open");
}
else
{
std::cout << "open fifo success." << std::endl;
}
}
void OpenForWrite()
{
// 打开管道文件准备写
_fd = open(_pn.c_str(), O_WRONLY);
if (_fd == -1)
{
// std::cerr << "open fifo fail." << std::endl;
ERR_EXIT("open");
}
else
{
std::cout << "open fifo success." << std::endl;
}
}
void Read()
{
// 进行通信:从管道文件中读取
while (true)
{
char buffer[1024];
int n = read(_fd, buffer, sizeof(buffer) - 1);
if (n > 0)
{
buffer[n] = 0; // 保证字符串安全
std::cout << "read fifo success." << std::endl;
std::cout << "cilent say# " << buffer << std::endl;
}
else if (n == 0)
{
std::cout << "cilent quit! me too." << std::endl;
break;
}
else
{
// std::cerr << "read fifo fail" << std::endl;
ERR_EXIT("read");
break;
}
}
}
void Write()
{
// 写入内容
int cnt = 1;
while (true)
{
std::string message;
std::cout << "Please enter# ";
std::getline(std::cin, message);
pid_t id = getpid();
message += ", message number: " + std::to_string(cnt++) + ", pid: [" + std::to_string(id) + "]";
int n = write(_fd, message.c_str(), message.size());
if (n == -1)
{
// std::cerr << "write fail" << std::endl;
ERR_EXIT("write");
}
else
{
std::cout << "write success" << std::endl;
}
}
}
void Close()
{
if (_fd > 0)
close(_fd);
}
~PipeOperator() {}
private:
std::string _path;
std::string _name;
std::string _pn;
int _fd;
};
cpp
#include "common.hpp"
int main()
{
//创建管道
// NamedPipe namedpipe("/", NAME);
NamedPipe namedpipe(".", NAME);
//打开文件准备读
PipeOperator reader(PATH,NAME);
reader.OpenForRead();
//读
reader.Read();
//关掉
reader.Close();
return 0;
}
cpp
#include "common.hpp"
int main()
{
//打开文件准备写
PipeOperator writer(PATH,NAME);
writer.OpenForWrite();
//写
writer.Write();
//关掉
writer.Close();
return 0;
}
新增知识点
#define ERR_EXIT(m) \
do \
{ \
perror(m); \
exit(EXIT_FAILURE); \
} while (0)
do-while(0)
结构 :确保宏在任何场景下(如单独使用、配合if
等语句)都能正确展开,避免语法错误。perror(m)
:打印错误信息。m
是用户指定的提示字符串,随后会自动附加系统错误原因(基于errno
变量)。exit(EXIT_FAILURE)
:终止程序并返回失败状态码(EXIT_FAILURE
通常定义为1
)。
结合上面代码给个示例,假设现在在根目录下创建管道文件,显然创建不了,因为没有权限;那么就会出错,看出错信息,echo $?打印最近进程的错误码
'
5.3、用命名管道实现文件拷贝
首先建立一个文件file,随便输入一些内容进去,之后创建一个管道文件,将文件file里面的内容写到管道文件中;之后创建一个file.bak文件,从管道文件中读取file写入的内容;实现文件拷贝
file
cpp
迎接我的狮子子牙吧!!!
肩挑凡事,拳握初心。
卑鄙是卑鄙者的通行证,高尚是高尚者的墓志铭!!!
cpp
// 将文件内容写入管道文件
#include <iostream>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#define ERR_EXIT(m) \
do \
{ \
perror(m); \
exit(EXIT_FAILURE); \
} while (0)
int main()
{
// 先创建管道文件
umask(0);
int n = mkfifo("pipe", 0666);
if (n < 0) ERR_EXIT("mkfifo");
// 打开一个文件读内容到管道
int outfd = open("file", O_RDONLY);
if(outfd < 0) ERR_EXIT("open");
// 打开管道文件将file文件内容写入管道
int infd = open("pipe", O_WRONLY);
if(infd < 0) ERR_EXIT("open");
//开始读写
char buffer[1024];
int num = 0;
while((num = read(outfd, buffer, sizeof buffer)) > 0)
{
write(infd, buffer, num);// 读到多少内容就写多少内容
}
//关闭文件
close(outfd);
close(infd);
return 0;
}
cpp
// 将管道文件中的内容拷贝至另一个文件
#include <iostream>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#define ERR_EXIT(m) \
do \
{ \
perror(m); \
exit(EXIT_FAILURE); \
} while (0)
int main()
{
umask(0);
// 打开管道文件从中读取内容
int outfd = open("pipe", O_RDONLY);
if(outfd < 0) ERR_EXIT("open");
// 将管道文件中内容写入另一个拷贝文件
int infd = open("file.bak", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if(infd < 0) ERR_EXIT("open");
//开始读写
char buffer[1024];
int num = 0;
while((num = read(outfd, buffer, sizeof buffer)) > 0)
{
write(infd, buffer, num);// 读到多少内容就写多少内容
}
//关闭文件
close(outfd);
close(infd);
//读完删除管道文件
unlink("pipe");
return 0;
}
因为打开file文件时,没有指明O_CREAT,所以这个文件是手动创建的;
先运行inPipe,创建管道文件,并且写入内容,运行终端会阻塞,此时写入了管道,管道处于等待被读的状态

接着运行outPipe,创建file.bak文件,成功从管道文件中读取并且写入,此时终端不再处于阻塞状态,并且file.bak里面内容和file一样

六、systemV共享内存
systemV是一种标准,Linux为了支持这种标准,专门设计了一个IPC通信模块,我们学习通信的接口设计,原理,接口以及和其他进程间通信方式的相似性
IPC本质就是让不同的进程看到同一份资源也就是可以共享资源
6.1、共享内存的原理
共享内存共享的区域在物理内存中,这个很好理解,因为进程的工作取实际数据都是在物理内存取的;
那么是如何共享的呢?进程的PCB中的栈区和堆区之间存在一个共享区域,这个区域中就是为了映射共享内存的,前面学习到的动态库也在这里。那么就是不同的进程拿到PCB中共享区的虚拟地址通过页表映射到物理内存的共享内存实现同一份资源的共享
这些操作是OS做的,我们如何进行内存共享呢?通过调用OS提供的系统接口来让OS执行相关操作
释放共享内存,首先free掉PCB中的虚拟地址,之后OS系统检测到物理内存中的共享内存没有进程相关联就释放这块内存

不过可能同时有很多个进程需要进行通信,此时OS有多个共享内存必须存在。这就要进行共享内存的管理了,先描述再组织,也就是每一个共享内存都有一个自己的内核数据结构来进行描述,之后通过某种数据结构进行组织。
这样之后进程和共享内存之间的联系就成了两个内核数据结构之间的关系(PCB和描述共享内存的内核数据结构)
当一个共享内存需要释放时,由于这块共享内存被多个进程共享,因此描述这块共享内存的内核数据结构中存在以恶搞引用计数,当一个进程结束,引用计数减去一,当计数为零时释放内存
6.2、使用共享内存的接口
申请共享内存接口:
int shmget(key_t key, size_t size, int shmflg);
头文件:
#include <sys/ipc.h>
#include <sys/shm.h>
返回值:
On success, a valid shared memory identifier is returned. On error, -1 is returned, and errno is set to indicate the error
介绍每个参数的作用
1、size表示指定申请的共享内存的大小
2、shmflg是一个标志位,通过传入宏来表示具体含义;有两个可传宏:IPC_CREAT 、IPC_EXCL
若是只传入IPC_CREAT,当这块内存不存在时新申请,存在时返回这个共享内存
IPC_EXCL不能单独使用,要配合IPC_CREAT一起使用(传入IPC_CREAT | IPC_EXCL),当两者一起使用时,共享内存不存在则新申请,存在则出错返回
也就是说两者一起使用是用于新申请,因为只要成功返回一定是新的共享内存;只用IPC_CREAT时侧重于获取已经申请好的共享内存
3、key标识共享内存的唯一性
我们要如何判断一个共享内存是否已经存在或者如何让不同的进程拿到同一个共享内存呢?
肯定不能像之前文件描述符那样,打开一个文件返回一个fd的方式来新申请一个共享内存,其他进程通过返回的标识符来访问共享内存,因为若是按照这种方式,描述符也是数据,将数据从一个进程传到另一个进程就已经实现了进程间通信,不需要共享内存了。但是这显然不行
需要用到key,并且这个key是用户传入给系统的,OS拿着这个key去申请内存。这样一来其他的进程想要和使用某个key的进程通信也可以传入一个key去找同一块内存,这也是为什么要用户传入
这样也回答了开始的两个问题,若是key值代表的共享内存存在,那么就会key值冲突;key值标识共享内存的唯一性,那么不同的进程就可以通过key去访问同一块内存了
类比之前的命名管道,通过命名管道通信的进程也是拿着唯一路径去看到同一份资源;这个key和那里的唯一路径作用相似
key值是用一种算法生成的:需要调用接口 key_t ftok(const char *pathname, int proj_id);
头文件:
#include <sys/types.h>
#include <sys/ipc.h>
返回值:
On success, the generated key_t value is returned. On failure -1 is returned, with errno indicating the error as for the stat(2) sys‐
tem call.
pathname:传入一个路径;proj_id:项目编号
当然这两个参数可以随便传入,因为生成的只要是一个唯一key就行了;当冲突时,改变传入的参数直到不冲突时即可
6.3、实现使用共享内存进行通信
在comm.hpp里面搭建信道,也就是管理共享内存。封装一个类shm,里面包含申请共享内存,删除,获取等等
server.cc和client.cc作为通信双方进程,server.cc用于创建,client用于获取
6.3.1、创建
cpp
#include <iostream>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <string>
#define ERR_EXIT(m) \
do \
{ \
perror(m); \
exit(EXIT_FAILURE); \
} while (0)
// 用于创建共享内存
const int gdefaultid = -1;
const int gsize = 4096;
// 用于构建唯一key值
std::string pathname = ".";
const int proj_id = 0x4444; // 随便给值只要保证构建的key值唯一即可,若是不唯一传不同的pathname和proj_id
class shm
{
public:
shm() : _shmid(gdefaultid), _size(gsize)
{
}
void create()
{
// 先生成一个key值
key_t k = ftok(pathname.c_str(), proj_id);
if(k == -1)
{
ERR_EXIT("ftok");
}
printf("key success : 0x%x\n", k);
// key值构建成功,开始申请共享内存
_shmid = shmget(k, _size, IPC_CREAT | IPC_EXCL);
if(_shmid == -1)
{
ERR_EXIT("shmget");
}
printf("shmget sucess , shmid : %d\n", _shmid);
}
~shm()
{
}
private:
int _shmid;
int _size;
};
在server.cc端调用

观察运行结果:

第一次运行输出正确信息,ipcm -s查看共享内存的情况
第二次为什么输出File exits,首先这是错误信息,echo $? 退出码为1,这是因为这一块的共享内存已经存在,而再次调用shmget同时使用IPC_CREAT和IPC_EXCL这两个选项时,当key值对应的共享内存存在时,返回错误
并且就算进程退出,这块共享内存依旧存在,输出一个结论:
共享内存的生命周期随内核,而不是进程,就算进程结束了,ipc资源依旧占用内存,需要手动删除
6.3.2、删除
删除的方式有两种一种为指令,一种为代码
指令就是ipcrm -m 接shmid,为什么不使用key值呢?因为key值是给内核去使用的,去给共享内存区分唯一性的,但是指令可以说是用户级别的,使用shmid更合理

代码删除:使用接口shmclt
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
- 第一个参数:
int shmid
------ 共享内存段的唯一标识
shmid
是 共享内存段的 ID(标识符) ,它是共享内存段的 "唯一身份凭证",用于指定shmctl
要操作的目标共享内存。第二个参数:
int cmd
------ 要执行的控制命令
cmd
是shmctl
的核心参数 ,用于指定对shmid
对应的共享内存段要执行的 "具体操作"。不同的cmd
会决定shmctl
的行为,且部分cmd
会要求第三个参数buf
配合使用。
- 第三个参数:
struct shmid_ds *buf
------ 共享内存的属性数据结构
buf
是一个指向struct shmid_ds
结构体 的指针,该结构体用于 存储或修改共享内存段的属性信息 ,其作用完全由第二个参数cmd
决定(是 "数据的载体")。
当作用删除时第二个参数为**IPC_RMID,此时第三个参数无意义置为null即可
**
在shm封装为一个函数Destroy
cpp
// 删除共享内存
void Destroy()
{
if(_shmid == -1)
{
printf("nothing to delete\n");
return;
}
else
{
int n = shmctl(_shmid, IPC_RMID, NULL);
if(n == -1)
{
ERR_EXIT("shmctl");
}
printf("shmctl->IPC_RMID success\n");
}
}


6.3.3、进程与共享内存关联
进程关联共享内存需要使用接口shmat,at就是attach的意思
void *shmat(int shmid, const void *shmaddr, int shmflg);
第一个参数指定关联的贡献内存的shmid;第二个参数指明共享内存映射到与之关联的进程虚拟地址空间的哪个起始位置 ,因为起始位置加上偏移量就可以确定这块共享内存,所以只需要知道起始地址即可,第二个参数就是设置共享内存在进程地址空间的起始地址,一半不需要我们设置,因为这是OS的工作,设置为NULL即可
第三个参数
shmflg
是一个标志位参数,用于指定共享内存的附加方式和权限, 不需要用设置为0即可返回值为关联之后分配的进程的虚拟地址的起始地址
现在封装一个Attach函数用于关联;接着封装一个StartAddr函数用于获取分配的起始地址,当然虚拟起始地址可以设置为shm的属性
cpp
// 实现进程与共享内存关联
void Attach()
{
_startaddr = shmat(_shmid, NULL, 0);
if((long long)_startaddr < 0)
{
ERR_EXIT("shmat");
}
printf("shmat success\n");
}
// 打印虚拟起始地址
void* StartAddrPrint()
{
printf("VirtualAddr : %p\n", _startaddr);
return _startaddr;
}


运行结果:

还记得之前没有删除上次的共享内存但是两次运行server的报错结果,就是File exists。意思就是起始共享内存在某方面也是当作文件来看待的,所以应该也存在权限,这里报错就是权限不允许,那么我们要设置权限,也就是在申请创建时,设置权限


ipcs -m 观察共享内存的关联情况,用脚本while :; do ipcs -m ; sleep 3 ; done ; 循环查看共享内存情况

可见nattch开始0,也就是申请了共享内存但是没有关联进程,之后shmat,nattch变为1,之后shmctl,共享内存删除,也就没有了
6.3.4、client的获取以及关联共享内存
server.cc弄完之后,开始client.cc,客户端不需要构建共享内存,只需要使用服务端申请的共享内存,使它们看到同一份资源。那么就要封装一个Get函数,此时shmget第二个参数不一样,其他都和申请时的Creat函数相同,那么就可以申请的获取这两个函数合并为一个private函数,只需要传不同参数就行;
首先看未与Create合并的Get函数
cpp
// 获取共享内存
void Get()
{
// 先生成一个key值
key_t k = ftok(pathname.c_str(), proj_id);
if (k == -1)
{
ERR_EXIT("ftok");
}
printf("key success : 0x%x\n", k);
// key值构建成功,开始申请共享内存
_shmid = shmget(k, _size, IPC_CREAT);
if (_shmid == -1)
{
ERR_EXIT("shmget");
}
printf("shmget success , shmid : %d\n", _shmid);
}
client端

运行结果:
先运行server申请共享内存,之后运行client获取

合并:

当然client端也需要进行attach这样两个不同的进程关联同一块内存块之后就可以通信了
client端

运行结果:

循环脚本观察nattch情况

可以看到先是没有关联的进程,之后server端attach之后nattch变为1,再之后client关联之后nattch变为2;后面Destroyserver先解掉关联,nattch变为1,client现在可以说还是单方面关联着的,后面运行完之后nattch消失
6.3.5、简化代码
将申请或者获取共享内存在shm的构造函数内部调用因为这是必须要做的,但是要指明是申请还是获取,所以新增一个成员变量,_usertype,后面析构函数也是看usertype来确定要不要删除共享内存,因为只有server端才需要删除。这时可以将Create、Get、Destroy函数全部私有化,因为只需要类内部调用
将key值也设置为成员变量_key,这个成员变量在构造函数时构建,因此需要传入参数pathname、proj_id
cpp
#include <iostream>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <string>
// 出错打印错误信息并且退出
#define ERR_EXIT(m) \
do \
{ \
perror(m); \
exit(EXIT_FAILURE); \
} while (0)
// 用于创建共享内存
const int gdefaultid = -1;
const int gsize = 4096;
// 指明用户类型
#define CREATER "creater"
#define USER "user"
// 用于构建唯一key值
std::string pathname = ".";
const int proj_id = 0x4444; // 随便给值只要保证构建的key值唯一即可,若是不唯一传不同的pathname和proj_id
class shm
{
private:
void CreateOrGet(int flag)
{
_shmid = shmget(_key, _size, flag);
if (_shmid == -1)
{
ERR_EXIT("shmget");
}
printf("shmget success , shmid : %d\n", _shmid);
}
// 申请共享内存
void Create()
{
CreateOrGet(IPC_CREAT | IPC_EXCL | 0666);
}
// 获取共享内存
void Get()
{
CreateOrGet(IPC_CREAT);
}
// 实现进程与共享内存关联
void Attach()
{
_startaddr = shmat(_shmid, NULL, 0);
if ((long long)_startaddr < 0)
{
ERR_EXIT("shmat");
}
printf("shmat success\n");
}
// 删除共享内存
void Destroy()
{
if (_shmid == -1)
{
printf("nothing to delete\n");
return;
}
else
{
int n = shmctl(_shmid, IPC_RMID, NULL);
if (n == -1)
{
ERR_EXIT("shmctl");
}
printf("shmctl->IPC_RMID success\n");
}
}
public:
shm(const std::string& usertype, const std::string pathname, int proj_id)
: _shmid(gdefaultid),
_size(gsize),
_startaddr(NULL),
_usertype(usertype)
{
// 生成一个key值
_key = ftok(pathname.c_str(), proj_id);
if (_key == -1)
{
ERR_EXIT("ftok");
}
printf("key success : 0x%x\n", _key);
// key值构建成功,开始申请共享内存
// 在构造函数里面申请或者获取共享内存并且关联共享内存和进程
if (_usertype == CREATER)
{
Create();
}
else if (_usertype == USER)
{
Get();
}
else
{}
Attach();
}
// 打印虚拟起始地址
void *VirtualAddr()
{
printf("VirtualAddr : %p\n", _startaddr);
return _startaddr;
}
int Size()
{
printf("size : %d\n", _size);
return _size;
}
~shm()
{
if(_usertype == CREATER)
{
Destroy();
}
}
private:
int _shmid;
int _size;
key_t _key;
void *_startaddr;
const std::string _usertype;
};
6.3.6、使用共享内存开始通信以及优缺点
server端

client端


可以看到,使用共享内存通信的进程不需要任何的系统调用,这是因为共享内存的通信是通过进程的内核数据结构和物理内存之间映射完成的,而堆栈之间的共享区属于用户区,可以直接使用
而像管道通信,进程是通过内核文件缓冲区进行通信的,而这个缓冲区是在操作系统中的,所以需要系统接口调用,类似read或者write
所以使用共享内存通信优点之一就是很快(进程通信中最快的方式),映射之后读写直接被通信对方进程看到;优点之二就是不需要系统调用获取或者写入内容
但是随之而来的也有缺点就是没有同步机制,也就是数据不一致,使用共享内存数据不安全,没有对数据的保护机制
上面的打印也可以看出,在client没有写时,server就一直在打印,根本没有等待client;下面举个例子也可以看出:想要一次读入AA,或者BB这样成对的字符,但是做不到


可以看出一旦有数据就读了,并不是等待一次写完之后在读(例如读AA、AABB、AABBCC),没有同步机制,数据不安全
如何解决读写数据同步的问题?
通过命名管道来等待和唤醒,控制读写的时机;让没有写时的读端一直等待,直到写完一次之后写端唤醒读端才开始读
将之前写的命名管道的主体部分拷贝过来,将Read修改为Wait,Write修改为Wakeup


server和client代码
server端在client没有写时会阻塞在Wait等待,直到client写成功了server才会读

client在获取共享内存之前先休眠防止这个共享内存是client申请的,因为client可能走在前面,它们是并发运行的
每次写之前先休眠,因为一旦唤醒成功,这个循环函数又会开始下一次循环,而此时server端可能还在读,那么就达不到预想的效果,所以每次写之前先等待,等server上一次读完了再开始写新的数据

运行结果


6.3.7、共享内存的大小
这里申请的是4096,是正确的。共享内存只能申请4kb的整数倍。当然申请不是整数倍的也行,但是操作系统会给你补成整数倍,但是能用的就只是你申请的空间,也就是说操作系统补全的浪费了
例如申请4097,那么操作系统会补为4kb*2,但是浪费了4095,只能用4097个字节
6.3.8、解关联shmdt
其实共享内存需要接关联,有绑定就可以解掉。所以在删除共享内存之前需要解关联,而且这个关联实际上就是开篇提及的删除共享内存时描述共享内存内核数据结构的关联进程的数量。那么有几个进程和一个共享内存关联,删除共享内存之前就要解关联shmdt
int shmdt(const void *shmaddr);
On success, shmdt() returns 0; on error -1 is returned, and errno is
set to indicate the cause of the error.
优化代码



6.3.9、描述共享内存的内核数据结构
在shmctl中可以看见

这两个内核数据结构都是描述共享内存属性的
可以打印出几个属性看看


6.3.10、完整代码
cpp
#include <iostream>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <string>
#include "common.hpp"
// 用于创建共享内存
const int gdefaultid = -1;
const int gsize = 4096;
// 指明用户类型
#define CREATER "creater"
#define USER "user"
// 用于构建唯一key值
std::string pathname = ".";
const int proj_id = 0x4444; // 随便给值只要保证构建的key值唯一即可,若是不唯一传不同的pathname和proj_id
class shm
{
private:
void CreateOrGet(int flag)
{
_shmid = shmget(_key, _size, flag);
if (_shmid == -1)
{
ERR_EXIT("shmget");
}
printf("shmget success , shmid : %d\n", _shmid);
}
// 申请共享内存
void Create()
{
CreateOrGet(IPC_CREAT | IPC_EXCL | 0666);
}
// 获取共享内存
void Get()
{
CreateOrGet(IPC_CREAT);
}
// 实现进程与共享内存关联
void Attach()
{
_startaddr = shmat(_shmid, NULL, 0);
if ((long long)_startaddr < 0)
{
ERR_EXIT("shmat");
}
printf("shmat success\n");
}
// 删除共享内存
void Destroy()
{
if (_shmid == -1)
{
printf("nothing to delete\n");
return;
}
else
{
int n = shmctl(_shmid, IPC_RMID, NULL);
if (n == -1)
{
ERR_EXIT("shmctl");
}
printf("shmctl->IPC_RMID success\n");
}
}
public:
shm(const std::string &usertype, const std::string pathname, int proj_id)
: _shmid(gdefaultid),
_size(gsize),
_startaddr(NULL),
_usertype(usertype)
{
// 生成一个key值
_key = ftok(pathname.c_str(), proj_id);
if (_key == -1)
{
ERR_EXIT("ftok");
}
printf("key success : 0x%x\n", _key);
// key值构建成功,开始申请共享内存
// 在构造函数里面申请或者获取共享内存并且关联共享内存和进程
if (_usertype == CREATER)
{
Create();
}
else if (_usertype == USER)
{
Get();
}
else
{
}
Attach();
}
// 打印虚拟起始地址
void *VirtualAddr()
{
printf("VirtualAddr : %p\n", _startaddr);
return _startaddr;
}
int Size()
{
printf("size : %d\n", _size);
return _size;
}
void Attr()
{
struct shmid_ds ds;
int n = shmctl(_shmid, IPC_STAT, &ds); // ds:输出型参数
printf("shm_segsz: %ld\n", ds.shm_segsz);
printf("key: 0x%x\n", ds.shm_perm.__key);
}
~shm()
{
int n = shmdt(_startaddr);
if (n == 0)
{
printf("shmdt success\n");
}
else
{
ERR_EXIT("shmdt");
}
if (_usertype == CREATER)
{
Destroy();
}
}
private:
int _shmid;
int _size;
key_t _key;
void *_startaddr;
const std::string _usertype;
};
七、System V消息队列
7.1、消息队列相关概念
两个进程通过队列进行通信;IPC本质上就是使得不同进程看到同一份资源,这里将资源维护成队列,这个队列存在于在操作系统内核中

结论一:消息队列提供了一种一个进程给另一个进程发送有类型数据块的方式
也就是说通信的单位不是面向字节流的,而是面向数据块的。进程A发送数据块到消息队列,进程B可以拿到这个数据块,并且两个进程是通过一个消息队列通信,A可以发送,B也可以发送,但是是一个队列,为了区分数据块该由谁接收,每个数据块应该带有类型,比如A发送的为类型1,那么B就只接受类型1的数据块,过滤类型2的数据块,也就是自己发哦是那个的
结论二:消息队列需要被操作系统进行管理
因为内核中可能存在很多消息队列,因此需要统一描述组织来管理。每个数据块都有一个节点结构体来描述其相关属性,而这些描述结构体都会被msgid_ds结构体所管理起来
结论三:通信的进程通过传递唯一值的key来看到同一份资源,也就是拿到同一个消息队列
7.2、消息队列的接口
消息队列的接口使用和共享内存相似
1、创建或获取消息队列
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
int msgget(key_t key, int msgflg);
第一个参数为我们构建的key和共享内存一样;第二个参数传IPC_CREAT、IPC_EXCL使用方式和共享内存一样;成功会返回这个消息队列的msqid
RETURN VALUE
If successful, the return value will be the message queue identifier (a nonnegative integer), otherwise -1 with errno indicating the
error.
2、删除或者打印消息队列相关属性
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
第一个参数传消息队列的id,第二个参数传IPC_RMID表示删除,此时第三个参数传NULL即可;当第二个参数传IPC_STAT时,第三个参数传我们自己创建的结构体msqid_ds对象的地址,为一个输出型参数,然后可以通过这个对象打印这个消息队列相关属性(和共享内存一样)
RETURN VALUE
On success, IPC_STAT, IPC_SET, and IPC_RMID return 0 . A successful IPC_INFO or MSG_INFO operation returns the index of the highest
used entry in the kernel's internal array recording information about all message queues. (This information can be used with repeated
MSG_STAT or MSG_STAT_ANY operations to obtain information about all queues on the system.) A successful MSG_STAT or MSG_STAT_ANY op?
eration returns the identifier of the queue whose index was given in msqid.
On error, -1 is returned with errno indicating the error.
3、传递和接收数据块的接口
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
第一个参数为选择通信媒介的消息队列;
第二个参数为指向传递的有类型数据块的指针,这个数据块是用结构体描述的
struct msgbuf {
long mtype; /* message type, must be > 0 */
char mtext[1]; /* message data */
};mtype为数据块类型,这个值必须大于0;第二个为传递数据的内容,大小不一定为1,可以手动改变
第三个参数为传递数据内容的大小,也就是mtext的大小
第四个参数置为0
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp,
int msgflg);第一个参数为选择通信媒介的消息队列;
第二个参数是用于接收的数据块
第三个参数为数据内容大小
第四个参数指定接收的数据块类型
第五个参数置为0即可
RETURN VALUE
On failure both functions return -1 with errno indicating the error, otherwise msgsnd() returns 0 and msgrcv() returns the number of bytes actually copied into the mtext array.
失败两个都返回-1,msgsnd成功返回0;msgrcv成功返回实际接收的字节数
7.3、消息队列的生命周期
消息队列的生命周期也是随内核的,也就是说删除消息队列要手动删除,使用指令ipcrm -p
查看消息队列使用指令ipcs -p
八、信号量
8.1、预备知识
共享资源:多个执行流(进程),看到的同一份公共资源
临界资源:被保护起来的共享资源
临界区:访问临界资源的代码
同步:多个执行流访问同一个资源时,具有一定的顺序性
互斥:任何时候只允许一个执行流访问这一个资源
原子性:要么做要么不做,在这里可以说是访问一个资源的进程在访问时做完自己的任务
回顾共享内存,说资源不安全是因为数据不一致,没有同步,而数据的传输是我们写的代码决定的,也就是说假设没有临界区代码,那么这个资源没有安不安全这一说法,因为没有访问它,所以保护公共资源实际上就是对临界区进行保护;所谓对共享资源进行保护实际上就是对访问共享资源的代码进行保护
怎么保护?信号量就可以进行保护,比如利用互斥保护

利用加锁保证临界区是互斥的,也就是一次只有一个进程来访问资源;这样就避免了读写数据不同步的危险
但是锁也是共享资源,因为多个执行流都用到它了。那么怎么保证锁是安全的?所以要确保申请锁的时候锁是原子性的
8.2、信号量概念
临界资源可以被分为很多小块资源
信号量可以理解为一个计数器,记录一块临界资源中资源的数量是多少,所有进程访问临界资源中的一小块,就要先申请信号量。进程访问前先申请信号量,可以说信号量本质上是一种对资源预定机制
当信号量大于零时,进程访问信号量,那么就可以得到小块资源,此时信号量--,表示自己记录的临界资源中的资源数量有一块被占用,其他进程可访问的资源少了一块。当这个进程访问资源完毕时,信号量++表示多出来了一块资源可供其他进程访问;当一个进程申请信号量而信号量是等于0时,这个进程会被阻塞挂起
由于临界资源中有很多资源,所以可以有很多进程并发访问,不会影响
这样就完成了对资源的保护
细节1:信号量本身就是共享资源,那么如何保证信号量的安全?需要保证信号量++或者--时是原子性的,--就是P操作,++就是V操作。利用PV完成对资源的预定
细节2:信号量若是只有01两态,那么这个信号量叫做二元信号量。也就是这个信号量对应的共享资源只能给一个进程访问,其他资源不能访问,也就是互斥
信号量实际上不是一个整形数,而是一个结构体,因为一个数对于多进程来说,由于进程是具有独立性的,所以多个进程拿到一个整形时,当一个进程对这个整形改变,其他的进程因为写实拷贝的原因不会受到影响,所以是一个结构体
里面有一个计数器count,被锁保护;当count大于零时--,此时进程可以得到一个资源;当count等于0时,阻塞后进程被挂起到等待队列进程等待可用资源

8.3、信号量与通信的关系
1、可以说信号量本身就是一种通信,因为每个进程要访问资源前都要先申请信号量,也就是每个进程都要看到同一个信号量
2、通信不一定是数据的传输,还有别的。通知、同步互斥也算通信。比如临界资源中满了,信号量计数器为0,此时信号量会通知多余的进程等待,等待出现可用资源,其他进程才可以使用资源,这里信号量就用自己通知了其他进程
8.4、信号量接口和系统调用
1、申请信号量
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
int semget(key_t key, int nsems, int semflg);
nsems表示一次申请的信号量个数,semflag传IPC_CREAT......和共享内存一样
RETURN VALUE
If successful, the return value will be the semaphore set identifier (a nonnegative integer), otherwise, -1 is returned, with errno
indicating the error.
成功返回一个信号量集的id,也就是将一次申请的信号量全部用一个id表示,失败-1
2、信号量的加减操作
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
int semop(int semid, struct sembuf *sops, size_t nsops);
第一个参数为信号量集id
第二个参数传一个结构体
unsigned short sem_num; /* semaphore number */
short sem_op; /* semaphore operation */
short sem_flg; /* operation flags */第一个成员表示你要操作的信号量在信号量集里面是哪个,用下标的形式指出;第二个参数表示操作类型,-1表示--,1表示++;第三个参数一半置为0
第三个参数表示一次semop操作的信号量个数
RETURN VALUE
If successful, semop() and semtimedop() return 0; otherwise they return -1 with errno indicating the error.
3、信号量删除、设置初始值、打印描述信号量的属性
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
int semctl(int semid, int semnum, int cmd, ...);
删除时,cmd为IPC_RMID
设置初始值:cmd为SETVAL,semnum指明初始哪个信号量,此时最后一个参数传一个结构体,设置初始值
打印属性的时候,cmd传IPC_STAT最后一个参数传可输出参数ds
RETURN VALUE
On failure, semctl() returns -1 with errno indicating the error.
Otherwise, the system call returns a nonnegative value depending on cmd as follows:
GETNCNT the value of semncnt.
GETPID the value of sempid.
GETVAL the value of semval.
GETZCNT the value of semzcnt.
IPC_INFO the index of the highest used entry in the kernel's internal array recording information about all semaphore sets. (This
information can be used with repeated SEM_STAT or SEM_STAT_ANY operations to obtain information about all semaphore sets on
the system.)
SEM_INFO as for IPC_INFO.
SEM_STAT the identifier of the semaphore set whose index was given in semid.
SEM_STAT_ANY
as for SEM_STAT.
All other cmd values return 0 on success.
ipcs -p 打印有哪些信号量集
九、内核中的systemV IPC
共享内存、消息队列以及信号量都是用key来唯一区分的,那么在内核中这三种被当作了同一种资源,之前学习了内核如何描述的,现在看看在内核中是如何组织的

内核存在一个ipc_ids,里面有一个柔性数组,这个数组指向的就是三种通信方式内核数据结构的第一个元素,也就是kern_ipc_perm,数组的下标就是申请的通信方式的id,例如shmid、msgid;结构体第一个元素的地址其实就是这个结构体的地址,将来想要找到某种结构体,例如msg_queue,则直接拿着数组里面的地址加上这种同行方式的类型强转,找到结构体之后访问一些属性。这些其实就是用C语言实现了一种多态
内核源代码
ipc_ids中的entries就是指向柔性数组的指针。这个数组中存放的是每种通信方式的内核数据结构的指针(强转为kern_ipc_prem)
共享内存shmid_kernel中存在一个文件指针,这也是为为什么共享内存建立完毕之后通信时不需要进程系统调用的原因,具体地:首先进程通过shmget获得内核中地shmid_kernel,共享内存中地资源会被映射到一个文件缓冲区,并且由shmid_kernel中地文件指针指向地struct_file描述;进程PCB中存在一个struct file* vm_file,shmat时这个指针会指向对应共享内存映射到的文件缓冲区的所描述的struct_file。那么之后进程只需要和文件打交道即可,这就也是不需要进行系统调用的原因

