10 进程间通信
- 自本章节开始,开发环境将切换成
Ubuntu24.04和neovim,后续可能会使用Cmake
10.1 什么是进程间通信
- "进程间通信",即
IPC,"Interprocess communication" - 进程并不是完全独立的!
- 我们在之前的章节中学习的这么内容,很大一部分都是为了进程间的独立性而设计的,例如说页表,虚拟地址空间,以及通过页表共享动态库等等内容,全部都是围绕进程间独立性完成的
- 进程间通信,即字面意思,就是进程与进程之间可以通过某种方式进行通信,以互换或传输数据,或者以传输信号的形式实现控制进程
- 我们杀死一个进程使用的信号本质上也是使用的进程间通信
10.2 为什么需要进程间通信
-
打个比方,现在你需要设计一个游戏商店平台,需要满足游戏购买与下载,本地游戏管理,游戏社区访问等多个模块,你会怎么设计?
-
如果没有进程间通信,那么这个游戏平台就只能使用一个进程
-
但如果有进程间通信,你就可以以模块化设计多个程序,例如将游戏购买与下载,本地游戏管理等独立开来
-
同时,如果一个进程崩溃,也不会影响其他进程的正常运行
-
游戏下载肯定需要网络服务,而本地游戏管理则不需要,就可以让各个程序的接口引入不至于那么冗余
-
同时,进程间通信其实还可以用于网络服务的访问,因为本质上下载内容这个过程,或者说传输数据这个过程,就是商店平台的某个服务器的某个进程向用户的商店平台这个进程进行通信,即进程间通信(不过网络通信本质虽然是进程间通信,但我们一般和本地进程间通信分开处理,本章节主要聊本地进程间通信)
-
进程间通信的目的:
- 数据传输
- 资源共享
- 通知事件(例如当前进程终止时要通知父进程)
- 进程控制
10.3 进程间通信的本质
-
进程间通信的本质是:两个进程同时看到同一片资源
-
之所以这么说,是因为进程间本身是独立的,只有当进程之间构成共享某个资源的关系时,才能通过这个资源间接实现通信,类似于"时光胶囊"一样,写入者将某个东西写入到一片资源中,未来某个时刻,会有一个读取者把东西拿回来
10.4 怎么使用进程间通信
10.4.1 基于文件的管道通信
10.4.1.1 原理
- 其实管道通信的原理是非常简单的
- 进程间通信一定满足满足两进程共享同一片资源
- 我们不妨猜一猜,有什么资源是现阶段我们所熟知的,同时也是两个进程可共享的,可被读写的?
- 文件!文件就是一个典型的可被读写可被共享的资源!
- 所以我们可以让两个进程同时打开一个文件,这样它们就可以相互通信了
- 但!这个文件一定是磁盘中的文件吗?
- 要知道磁盘速度很慢,同时还要做查找文件这个麻烦的步骤,所以管道通信一般直接用内存级文件,内存可就相当快了,同时还不需要查找文件位置
- 所以,管道的本质,是内存级文件!
10.4.1.2 pipe()
pipe()是一个系统调用接口
#include <unistd.h>
int pipe(int pipefd[2]);
-
参数方面:
pipefd是一个int数组,其中两个元素都是用于管理同一个内存级文件的,同时pipefd是输出型参数pipefd[0]用于读该内存级文件pipefd[1]用于写该内存级文件
-
返回值方面:
- 返回
0:成功 - 返回
-1:失败,并设置errno
- 返回
10.4.1.3 匿名管道模拟实现
-
我们可以先写一个匿名管道来暂时感受一下管道具体是什么
-
这里我写了一个匿名管道,因为管道本质是内存级文件,既然是文件,就一定会被进程的
fd_array进行管理,而fork()子进程会拷贝一份task_struct,以至于子进程和父进程的fd_array会同时管理同一片文件,包括这个管道 -
现在我们要求把父进程的内容通过管道传递给子进程
#include <iostream>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>
void f(int pipefd[2])
{
pid_t pid = fork();
//parent
if(pid > 0)
{
//parent仅写入,所以关闭读取用的fd
close(pipefd[0]);
const char* buf = "oldking is H";
write(pipefd[1], buf, strlen(buf));
close(pipefd[1]);
waitpid(pid, 0, 0);
exit(0);
}
//child
else if(pid == 0)
{
//child仅读取,所以关闭写入用的fd
close(pipefd[1]);
char buf[10];
int length = 0;
while((length = read(pipefd[0], buf, 9)) != 0)
{
buf[length] = '\0';
std::cout << buf;
}
std::cout << std::endl;
close(pipefd[0]);
exit(0);
}
//err
else
{
exit(1);
}
}
int main()
{
int pipefd[2] = {0, 0};
if(pipe(pipefd) == -1)
{
exit(1);
}
f(pipefd);
return 0;
}
-
这个小程序有一个比较有意思的问题,即子进程的
read()在有进程作为写入进程指向这个内存级文件时,read()会一直处于阻塞状态,因为read()认为还有内容可能会被写进该控空间,所以它会等待内容写入 -
即,如果我们不关闭父进程的写入
fd的话,子进程将会因为read()而一直处于阻塞状态
10.4.1.4 匿名管道的特性与类别
-
特性:
- 匿名管道只能用于父子间/有血缘关系的进程间通信
- 管道文件自带同步机制(主要是OS在做控制)
- 管道是面向字节流的,只能以文字数据传输
- 管道是单项通信的
- 管道的生命周期是跟随进程的,当一个管道没有被进程所指向,那么管道将会自动被销毁
-
类别(类别的不同将会导致同步机制不同):
- 写端慢,读端快:读端如果读不到内容,将会被阻塞
- 写端快,读端慢:写端写的速度大于读端读的速度,很可能导致管道被填充满,此时写端将会被阻塞
- 写端关闭,读端没关闭:因为写端关闭了,管道没有内容输入,等到读端读完所有数据,此时也没有写端写内容,
read()将会返回EOF - 读端关闭,写端没关闭:当写端试图向管道写东西的时候,因为没有读端读内容,因此写内容这个步骤就是完全没有意义的,此时
OS会传递信号杀死试图往该管道写内容的进程(抛出信号SIGPIPE)
10.4.1.4 基于双匿名管道的进程池
- 注意:这部分代码并不完善
// main.cpp
//includes
#include <iostream>
#include <unistd.h>
#include <sys/wait.h>
//#include <vector>
//#include <map>
#include "ProcessPool.hpp"
// 子进程主要代码部分
int child_part(int pwfd, int prfd)
{
char msg[1024] = { 0 };
while (true)
{
int msglen = read(prfd, msg, sizeof(msg) - 1);
msg[msglen] = '\0';
std::string msgstr = msg;
if (msgstr == std::string("hello oldking!"))
{
write(pwfd, "oldking is very H!", 18);
}
else
{
write(pwfd, "msg err!", 8);
}
}
}
// 向某个子进程发送消息
std::string send_msg(process_pool& pp, int childpid, const std::string& msg)
{
char retstr[1024] = { 0 };
int writelen = write(pp.getpipe(childpid).first, msg.c_str(), msg.length());
std::cout << "father writelen: " << writelen << std::flush << std::endl;
int readlen = read(pp.getpipe(childpid).second, retstr, sizeof(retstr) - 1);
std::cout << "father readlen: " << readlen << std::flush << std::endl;
retstr[readlen] = '\0';
return std::string(retstr);
}
int main()
{
int child_cnt = 4;
//std::cin >> child_cnt;
process_pool pp;
std::function<int(int, int)> fcp = child_part;
while (child_cnt--)
{
pp.insert(fcp);
}
int cnt = 1;
while (cnt <= 2)
{
for (auto& it : pp)
{
sleep(1);
std::string retstr = send_msg(pp, it, std::string("hello oldking!"));
std::cout << "father said: " << "hello oldking!" << std::endl;
std::cout << "childpid:" << it << " said " << retstr << std::endl;
std::cout << "cnt: " << cnt << std::flush << std::endl;
}
cnt++;
}
pp.erase(*(pp.begin() + 1));
while (cnt <= 4)
{
for (auto& it : pp)
{
sleep(1);
std::string retstr = send_msg(pp, it, std::string("hello oldking!"));
std::cout << "father said: " << "hello oldking!" << std::endl;
std::cout << "childpid:" << it << " said " << retstr << std::endl;
std::cout << "cnt: " << cnt << std::flush << std::endl;
}
cnt++;
}
return 0;
}
// ProcessPool.hpp
#pragma once
// includes
#include <map>
#include <vector>
//#include <string>
#include <unistd.h>
#include <sys/wait.h>
#include <iostream>
#include <functional>
#include <algorithm>
// process pool subject
class process_pool
{
public:
// 迭代器方面可以直接利用vector的现有的迭代器
typedef std::vector<int>::iterator iterator;
// init process_pool
process_pool()
: _size(0)
{}
// 该对象用于自动执行close()
class child_part
{
public:
child_part(std::function<int(int, int)> func)
:_func(std::move(func))
{}
int start(int pwret, int prmes)
{
int ret = _func(pwret, prmes);
close(pwret);
close(prmes);
return ret;
}
private:
std::function<int(int, int)> _func;
};
// 新增一个子进程(需要用户提供子进程代码,即child_part)
// 其实这里的设计还是存在缺陷,可以使用隐式类型转换构造cp减少开销,但懒得写了
int insert(child_part cp)
{
// 创建管道
int pipefdmes[2] = { 0 };
int pipefdret[2] = { 0 };
if (pipe(pipefdmes) == -1)
{
std::cerr << "pipemes err" << std::endl;
exit(1);
}
if (pipe(pipefdret) == -1)
{
std::cerr << "piperet err" << std::endl;
exit(1);
}
int pidnum = fork();
if (pidnum == 0)
{
//child
//关闭消息子进程消息管道的写端和返回管道的读端
close(pipefdmes[1]);
close(pipefdret[0]);
// 启动子进程部分
int ret = cp.start(pipefdret[1], pipefdmes[0]);
exit(ret);
}
else if (pidnum > 0)
{
//parent
//关闭消息父进程消息管道的读端和返回管道的写端
close(pipefdmes[0]);
close(pipefdret[1]);
// 更改用于管理的成员
_m_pp[pidnum] = std::pair<int, int>(pipefdmes[1], pipefdret[0]); // write pipe & read pipe
_v_pp.push_back(pidnum);
_size++;
}
else
{
//err
std::cerr << "fork err" << std::endl;
exit(1);
}
return pidnum;
}
int erase(int pidnum)
{
if (_m_pp.find(pidnum) == _m_pp.end())
{
return -1;
}
// 关闭管道,删除该子进程相关信息
close(_m_pp[pidnum].first);
close(_m_pp[pidnum].second);
_m_pp.erase(pidnum);
_v_pp.erase(std::find(_v_pp.begin(), _v_pp.end(), pidnum));
_size--;
return 0;
}
// begin()和end()直接套用vector
iterator begin()
{
return _v_pp.begin();
}
iterator end()
{
return _v_pp.end();
}
int size() const
{
return _size;
}
// 获取某个子进程的管道
std::pair<int, int> getpipe(int childpid)
{
if (_m_pp.find(childpid) == _m_pp.end())
{
return std::pair<int, int>(-1, -1);
}
return _m_pp[childpid];
}
// 析构
~process_pool()
{
// 这里不能直接用范围for遍历_v_pp,而需要从后往前删,直接遍历并删除的话可能会导致遍历失效
// 因为控制遍历的是当前的迭代器,而删除当前节点可能会导致迭代器失效
while (!_v_pp.empty())
{
erase(_v_pp.back());
}
}
private:
std::map<int, std::pair<int, int>> _m_pp; // 用于快速定位某个子进程的管道
int _size; // 容量控制
public:
std::vector<int> _v_pp; // 用于获取子进程pid
};
- 运行示例:
oldking@iZwz9b2bj2gor4d8h3rlx0Z:~/code/code_25_4_8_IPC$ ./execProessPool
father writelen: 14
father readlen: 18
father said: hello oldking!
childpid:653573 said oldking is very H!
cnt: 1
father writelen: 14
father readlen: 18
father said: hello oldking!
childpid:653574 said oldking is very H!
cnt: 1
father writelen: 14
father readlen: 18
father said: hello oldking!
childpid:653575 said oldking is very H!
cnt: 1
father writelen: 14
father readlen: 18
father said: hello oldking!
childpid:653576 said oldking is very H!
cnt: 1
father writelen: 14
father readlen: 18
father said: hello oldking!
childpid:653573 said oldking is very H!
cnt: 2
father writelen: 14
father readlen: 18
father said: hello oldking!
childpid:653574 said oldking is very H!
cnt: 2
father writelen: 14
father readlen: 18
father said: hello oldking!
childpid:653575 said oldking is very H!
cnt: 2
father writelen: 14
father readlen: 18
father said: hello oldking!
childpid:653576 said oldking is very H!
cnt: 2
father writelen: 14
father readlen: 18
father said: hello oldking!
childpid:653573 said oldking is very H!
cnt: 3
father writelen: 14
father readlen: 18
father said: hello oldking!
childpid:653575 said oldking is very H!
cnt: 3
father writelen: 14
father readlen: 18
father said: hello oldking!
childpid:653576 said oldking is very H!
cnt: 3
father writelen: 14
father readlen: 18
father said: hello oldking!
childpid:653573 said oldking is very H!
cnt: 4
father writelen: 14
father readlen: 18
father said: hello oldking!
childpid:653575 said oldking is very H!
cnt: 4
father writelen: 14
father readlen: 18
father said: hello oldking!
childpid:653576 said oldking is very H!
cnt: 4
10.4.1.5 文件描述符问题
- 如果你尝试输出子进程和父进程的文件描述符的话,你会发现一个惊人的问题
- 这里我在
main()的结尾输出一下文件描述符,在子进程部分的结尾输出一下文件描述符
// 贴一下子进程部分和main()函数的代码
// 子进程部分
// 子进程部分
int child_part(int pwfd, int prfd)
{
char msg[1024] = { 0 };
while (true)
{
int msglen = read(prfd, msg, sizeof(msg) - 1);
msg[msglen] = '\0';
std::string msgstr = msg;
if (msgstr == std::string("hello oldking!"))
{
write(pwfd, "oldking is very H!", 18);
}
// 如果父进程写端退出,此时子进程read()不再阻塞,然后打印pid,fd信息并退出
else if (msglen == 0)
{
std::cout << "childpid:" << getpid() << " read:" << prfd << " write:" << pwfd << std::endl;
return 0;
}
else
{
write(pwfd, "msg err!", 8);
}
}
}
// main()
int main()
{
int child_cnt = 4;
//std::cin >> child_cnt;
process_pool pp;
std::function<int(int, int)> fcp = child_part;
while (child_cnt--)
{
pp.insert(fcp);
}
int cnt = 1;
while (cnt <= 2)
{
for (auto& it : pp)
{
sleep(1);
std::string retstr = send_msg(pp, it, std::string("hello oldking!"));
std::cout << "father said: " << "hello oldking!" << std::endl;
std::cout << "childpid:" << it << " said " << retstr << std::endl;
std::cout << "cnt: " << cnt << std::flush << std::endl;
}
cnt++;
}
pp.erase(*(pp.begin() + 1));
while (cnt <= 4)
{
for (auto& it : pp)
{
sleep(1);
std::string retstr = send_msg(pp, it, std::string("hello oldking!"));
std::cout << "father said: " << "hello oldking!" << std::endl;
std::cout << "childpid:" << it << " said " << retstr << std::endl;
std::cout << "cnt: " << cnt << std::flush << std::endl;
}
cnt++;
}
for (auto& it : pp)
{
// 打印父进程的pid和fd信息
std::cout << "pid:" << it << " " << "write:" << pp.getpipe(it).first << " " << "read:" << pp.getpipe(it).second << std::endl;
}
return 0;
}
- 前面所有的输出信息我们就不看了,和之前的一样(这里有疑点的),我们只看最后的信息
...
pid:655758 write:4 read:5
pid:655760 write:8 read:9
pid:655761 write:10 read:11
childpid:655761 read:3 write:12
childpid:655760 read:3 write:10
childpid:655759 read:3 write:8
childpid:655758 read:3 write:6
-
记得吗,我在测试代码中,中途删掉了一个子进程,于是最后父进程没有遍历到被删除的子进程,于是没有输出该子进程的相关信息,这很好理解
-
但这里有两个疑点:
- 我改了一下子进程部分的代码,让子进程在退出的时候会返回其信息,而测试代码中又中途删除了一个子进程,但此时子进程本应该在中途输出的信息却在最后才出现!
- 子进程的读端写端的
fd为什么这么奇怪??
-
提问:当父进程在创建管道的时候最先创建的是哪个管道的哪一端?
-
答:是
pipemes的读端 -
而关闭
pipemes的读端的只有父进程!于是父进程每次在创建子进程后,都会关闭pipemes的读端,在第二次创建子进程时,相同的fd仍然又会被分配给pipemes的读端,而因为子进程的pipemes的读端不会被关闭,所以fd_array被拷贝到子进程后后就会保留pipemes的读端fd,就造就了子进程所有读端的fd都一样的现象 -
而子进程写端的问题也是这个原因,我们可以看一下这个表(下划线表示该
fd用于当前创建的子进程与父进程的沟通)
| 时机 | 父进程fd_array使用情况 | 当前创建的子进程fd_array使用情况 |
|---|---|---|
| 创建完第一个子进程后 | 0,1,2,4,5 | 0,1,2,3,6 |
| 创建完第二个子进程后 | 0,1,2,4,5,6,7 | 0,1,2,3,4,5,8 |
| 创建完第三个子进程后 | 0,1,2,4,5,6,7,8,9 | 0,1,2,3,4,5,6,7,10 |
| 创建完第三个子进程后 | ... | ... |
-
究其原因是创建后面的子进程会拷贝父进程的
fd_array,使得后面的子进程会有一部分fd指向兄弟进程,所以导致了子进程写端fd不统一的问题,同时也会导致erase()一个子进程的时候,由于还有兄弟进程指向其管道,而导致子进程的read()无法从阻塞状态脱离并返回0,所以在erase()的时候实际并没有让子进程退出,所以被erase()的子进程并不会及时输出其信息,而是等到父进程释放之后变成孤儿进程之后才会被bash释放 -
修改方案:我们可以关闭无关管道的
fd
int insert(child_part cp)
{
int pipefdmes[2] = { 0 };
int pipefdret[2] = { 0 };
if (pipe(pipefdmes) == -1)
{
std::cerr << "pipemes err" << std::endl;
exit(1);
}
if (pipe(pipefdret) == -1)
{
std::cerr << "piperet err" << std::endl;
exit(1);
}
int pidnum = fork();
if (pidnum == 0)
{
//child
//关闭消息子进程消息管道的写端和返回管道的读端
close(pipefdmes[1]);
close(pipefdret[0]);
//关闭其他无用管道的写端和读端
for (auto& it : _v_pp)
{
if (it != getpid())
{
close(_m_pp[it].first);
close(_m_pp[it].second);
}
}
int ret = cp.start(pipefdret[1], pipefdmes[0]);
exit(ret);
}
else if (pidnum > 0)
{
//parent
//关闭消息父进程消息管道的读端和返回管道的写端
close(pipefdmes[0]);
close(pipefdret[1]);
_m_pp[pidnum] = std::pair<int, int>(pipefdmes[1], pipefdret[0]); // write pipe & read pipe
_v_pp.push_back(pidnum);
_size++;
}
else
{
//err
std::cerr << "fork err" << std::endl;
exit(1);
}
return pidnum;
}
- 此时就正常退出了(提前退出的就没有截下来了)
...
pid:668931 write:4 read:5
pid:668933 write:8 read:9
pid:668934 write:10 read:11
childpid:668934 read:3 write:12
childpid:668933 read:3 write:10
childpid:668931 read:3 write:6
10.4.1.6 关于进程池的应用场景和进程池思想
-
在我设计的进程池中,允许用户为多个不同的子进程设计不同的代码部分,意味着同一个进程池的不同进程之间的任务逻辑可能会有不同
-
而在进程池实际的应用中,其一般用在同类任务并且任务量极其巨大的情境中,我这么设计其实是有问题的,或者说偏离了进程池而更像是"工作队列系统"
-
因为我们的进程池是提前创建好的,所以我们可以很快速地分发各个任务到各个子进程,子进程可以及时处理任务而不是像循环一样需要等待当前任务处理完毕才能处理下一个任务
-
比方说我将一个任务给子进程a,子进程a在处理任务的时候,父进程就可以把相同类型的下一个任务给子进程b,所以在使用进程池的时候,父进程只需要发任务就行,而子进程就是血汗工厂的工人一样任劳任怨接受上级发来的任务
-
注意:以下内容仅为进程池思想讨论,实际情况会有出入
-
所以为什么我们总说大型游戏吃
CPU单核,因为游戏的执行逻辑极为复杂,即便是基本的底层逻辑都十分复杂,所以一般游戏的主进程都用于处理基本逻辑,例:"键盘鼠标等硬件IO","场景更新","渲染调用"等等,这个黑心老板光是让底下工人干活就能让自己累死累活了,还要对接大量用户的交流,更是雪上加霜 -
而这些复杂任务逻辑如果交给子进程,又会使代码变得更加复杂,同时因为多加了一层,所以效率也好不到哪去,所以很多老游戏对于多核的使用根本不好办
-
但有一类任务就很喜欢使用进程池,或者说没有进程池就没有现代的大型
3a游戏,即"图像渲染" -
这类任务往往数量巨大,任务逻辑并不复杂,且任务类型单一,即"处理像素生成"
-
所以主进程会将大量该任务交给大量子进程,与此同时就诞生了新的硬件类型,即显卡,
GPU -
甚至说
GPU本质其实就是CPU,只不过它处理的任务更加单一而已,它只负责处理图像,同时因为图像处理量一般极高,GPU的核心数会远高于CPU的核心数,例如4090的CUDA核心数为16000+,而i9 13900k的CPU核心数仅为24个 -
当然,
GPU虽然核心数多,但其单个核心的性能是远不及CPU的 -
如果你把图像处理交给
CPU,那么进程调度的开销都会非常高 -
所以我们简单得出结论:
GPU利用进程池(其实并不是),用于专门处理图像这样的单一场景CPU则可以应对更加复杂的逻辑,不仅局限于图像处理
PS:虽然上述内容我描述
GPU使用了进程池技术,但实际上并不是,而是使用"线程级并行模型",这个技术在思想上和进程池差不多,但实际实现差距极大,同时CPU并不负责分发任务给成百上千个关于图像处理的进程,分发图像处理任务的地方也是在GPU中,CPU只负责调用相关图像接口,或者说CPU并不是图像相关子进程的调度者,它是更加高级的存在
10.4.1.7 进程池在OS中的本质与进程池改进
-
上小节我们提到我的进程池当前其实更像是任务队列,究其原因是我们没有了解进程池的本质,在上小节的叙述中,我们了解到进程池实际上就是提前创建好一堆相同的进程,等到主进程需要处理一大堆任务,同时还不想使用简单的循环实现时,我们就可以使用进程池中存在的一大堆子进程处理这些任务
-
所以,这个进程池为什么会让效率提高呢?命名一个单核
CPU每时间片只能处理一个进程啊?! -
我们知道,
CPU通过快速切换当前处理的进程以达到多个进程一起运行的假象,对于每个进程来说,获取CPU资源是公平的 -
但!者仅局限于进程而言,用户使用的是程序而非进程,一个程序可以有很多个进程,也就像这里的进程池一样,一旦一个程序拥有多个进程,就会提高该程序在
OS所管理的进程的占比!例如OS当前管理了101个进程,然后你使用进程池,创建了99个子进程,此时OS一共管理了200个进程,加上父进程一起,这个程序整整占了OS所管理的进程的一半!意味着该程序甚至能获取将近一半的CPU资源! -
所以,进程池思想,本质上是在掠夺其他进程的时间片!掠夺
CPU资源以达到快速处理任务的目的 -
如果你不使用进程池,而是使用循环,那么你这个程序就得等待前面
100个进程处理完才能轮到你获取CPU资源,你只占了1/101的CPU资源! -
所以!你甚至可以手搓一个进程炸弹,用来无限掠夺
CPU资源,直到系统完全卡死(不要在自己的电脑尝试!!!) -
所以我对之前的进程池做了一些改进(依然不够完善,如果一定要到能正儿八经使用的程度,即去和回两个管道都能使用的程度,需要等到正儿八经聊并发的时候说)
// main.cpp
//includes
#include <iostream>
#include <unistd.h>
#include <sys/wait.h>
//#include <vector>
//#include <map>
#include "ProcessPool.hpp"
int func1(int wret)
{
char buff[12] = "using func1";
write(wret, buff, sizeof(buff) - 1);
return 0;
}
int func2(int wret)
{
char buff[12] = "using func2";
write(wret, buff, sizeof(buff) - 1);
return 0;
}
int func3(int wret)
{
char buff[12] = "using func3";
write(wret, buff, sizeof(buff) - 1);
return 0;
}
int main()
{
process_pool pp;
pp.fcpushback(func1);
pp.fcpushback(func2);
pp.fcpushback(func3);
pp.run();
for (int i = 0; i < 3; i++)
{
sleep(1);
pp.send(i);
}
pp.stop();
return 0;
}
// ProcessPool.hpp
#pragma once
// includes
#include <climits>
#include <map>
#include <vector>
//#include <string>
#include <unistd.h>
#include <sys/wait.h>
#include <iostream>
#include <functional>
#include <algorithm>
#include "task.hpp"
// process pool subject
class process_pool
{
public:
private:
int start(int rmes, int wret)
{
int* readcode = new int;
while (true)
{
if (read(rmes, readcode, sizeof(int)) == 0)
break;
fclist[*readcode](wret);
}
close(_m_pp[getpid()].first);
close(_m_pp[getpid()].second);
free(readcode);
return 0;
}
int insert()
{
int pipefdmes[2] = { 0 };
int pipefdret[2] = { 0 };
if (pipe(pipefdmes) == -1)
{
std::cerr << "pipemes err" << std::endl;
exit(1);
}
if (pipe(pipefdret) == -1)
{
std::cerr << "piperet err" << std::endl;
exit(1);
}
int pidnum = fork();
if (pidnum == 0)
{
//child
//关闭消息子进程消息管道的写端和返回管道的读端
close(pipefdmes[1]);
close(pipefdret[0]);
//关闭其他无用管道的写端和读端
for (auto& it : _v_pp)
{
if (it != getpid())
{
close(_m_pp[it].first);
close(_m_pp[it].second);
}
}
int ret = start(pipefdmes[0], pipefdret[1]);
exit(ret);
}
else if (pidnum > 0)
{
//parent
//关闭消息父进程消息管道的读端和返回管道的写端
close(pipefdmes[0]);
close(pipefdret[1]);
_m_pp[pidnum] = std::pair<int, int>(pipefdmes[1], pipefdret[0]); // write pipe & read pipe
_v_pp.push_back(pidnum);
_work_cnt[pipefdmes[1]] = 0;
_size++;
}
else
{
//err
std::cerr << "fork err" << std::endl;
exit(1);
}
return pidnum;
}
int erase(int pidnum)
{
if (_m_pp.find(pidnum) == _m_pp.end())
{
return -1;
}
close(_m_pp[pidnum].first);
close(_m_pp[pidnum].second);
_work_cnt.erase(_m_pp[pidnum].first);
_m_pp.erase(pidnum);
_v_pp.erase(std::find(_v_pp.begin(), _v_pp.end(), pidnum));
_size--;
// 回收僵尸进程
std::cout << "erase pid: " << waitpid(pidnum, NULL, 0) << std::endl;
return 0;
}
void create(int cnt)
{
while (cnt--)
{
insert();
}
}
std::pair<int, int> getpipe(int childpid)
{
if (_m_pp.find(childpid) == _m_pp.end())
{
return std::pair<int, int>(-1, -1);
}
return _m_pp[childpid];
}
public:
// 以下是开放的出来的接口
typedef std::vector<int>::iterator iterator;
// init process_pool
process_pool()
: _m_pp(std::map<int, std::pair<int, int>>()),
_size(0),
_v_pp(std::vector<int>())
{
}
// 用于创建和启动进程池
int run(int cnt = 3)
{
if (fclist.size() == 0)
return -1;
if (cnt <= 0)
return -1;
if (_size == 0)
return -1;
create(cnt);
return 3;
}
// 向子进程发送消息
void send(int sendcode)
{
if (_size == 0)
{
return;
}
if (fclist.size() == 0)
{
return;
}
int min = INT_MAX;
int minfd = 0;
for (auto& it : _work_cnt)
{
if (it.second < min)
{
min = it.second;
minfd = it.first;
}
write(minfd, &sendcode, sizeof(sendcode));
std::cout << "write to fd: " << minfd << std::endl;
it.second++;
}
}
// 用于暂停并销毁进程池
int stop()
{
int cnt = 0;
while (!_v_pp.empty())
{
if (erase(_v_pp.back()) != 0)
cnt++;
}
return cnt;
}
iterator begin()
{
return _v_pp.begin();
}
iterator end()
{
return _v_pp.end();
}
int size() const
{
return _size;
}
~process_pool()
{
top();
}
// 获取函数表的大小
int getfcsize()
{
return fclist.size();
}
// 向函数表新增函数
void fcpushback(std::function<int(int)> func)
{
fclist.push_back(func);
}
private:
std::map<int, std::pair<int, int>> _m_pp;
int _size;
std::vector<int> _v_pp;
// 任务等级,防止多次派发任务给同一个进程
std::map<int, int> _work_cnt;
// 函数表,也可以称为任务表
task fclist;
};
// task.hpp
// 用于管理一堆任务
#pragma once
//#include <iostream>
#include <functional>
#include <unistd.h>
#include <vector>
class task
{
public:
task()
{}
void push_back(std::function<int(int)> func)
{
func_list.push_back(func);
}
int size() { return func_list.size(); }
std::function<int(int)> operator[](size_t pos)
{
return func_list[pos];
}
~task()
{}
private:
std::vector<std::function<int(int)>> func_list;
};
- 执行结果:
oldking@iZwz9b2bj2gor4d8h3rlx0Z:~/code/code_25_4_8_IPC$ ./execProessPool
write to fd: 4
write to fd: 6
write to fd: 8
erase pid: 291165
erase pid: 291164
erase pid: 291163
10.4.1.8 当前进程池的问题与并发的概念
-
从代码看,当前进程池的问题也很显著,虽然设计上基于双匿名通道,但子进程的写端我们并没有用到,父进程也没有接收子进程信息,为什么呢?
-
即,暂时的,我们没法做到同时满足以下两个条件:
- 父进程只需要输出任务代码,子进程或的任务代码并执行任务,同时要求父进程可以在一个子进程正在运行的时候向另一个子进程输出任务代码(该条件其实就要求父进程不能因为子进程而被阻塞)
- 父进程需要用某种方式接收子进程返回的数据,同时要为了满足第一条,所以父进程还不能被阻塞
-
而这里我们引入一个全新的思想,即并发
-
并发,简单来说就是使用进程池或者其他什么方式,让多个进程在单核心同步推进,当然,这里的并发并不等于抢占时间片,抢占时间片只是实现并发的一种形式,即"抢占式调度"
-
而另一个概念,并行,则是真正意义上的多个进程同时运行,即每个进程可以使用不同的
CPU核心,不过这个设计多核问题,我们以后再谈 -
所以说,我们设计的进程池其实就是实现并发的一种形式
-
当然,这里的进程池设计上还有些其他问题,比方说对于各个成员,并没有做很好的封装,可读性很差
10.4.1.9 命名管道的通信
-
命名管道的通信区别于匿名管道:
- 命名管道需要手动创建(创建方式和匿名管道的区别还是挺大的)和关闭,命名管道不会因为没有
fd指向它而被销毁 - 命名管道顾名思义,是有名字的
- 命名管道可以允许任意进程读写而不要求血缘关系
- 命名管道需要手动创建(创建方式和匿名管道的区别还是挺大的)和关闭,命名管道不会因为没有
-
命名管道的创建,使用函数
mkfifo()用于在内核申请一个命名管道(本质依旧是一个内存级文件)
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);
int mkfifo(const char *pathname, mode_t mode);
-
相关参数:
pathname: 管道名称mode: 权限- 返回值: 成功返回
0,失败返回-1并设置errno
-
其他接口与注意事项:
- 既然使用
mkfifo()需要使用权限,就意味着该内存级文件依然受到用户权限系统的管理,没权限的进程不能访问该命名管道 - 同样的,既然要使用权限,就意味着要注意一个叫
umask的东西,要改掩码的话要用umask() - 因为命名管道需要手动关闭,所以建议在创建一个管道之前,先试图将可能存在的同名管道关闭,然后创建管道,当然,如果这个管道以后不用了,也别忘了关闭它
- 既然使用
-
关闭一个命名管道,我们可以使用
unlink(),这个接口同样可以用于删除链接之类的(当然unlink也也可以删除文件就是了,因为unlink本质是删除文件名和文件属性和数据的连接关系,当一个文件的属性和数据没有和文件名有链接关系,就自动被删除了,当然,更准确来说是文件名和inode的映射关系)
#include <unistd.h>
int unlink(const char *pathname);
- 相关参数:
pathname: 要删除链接的文件名- 返回值: 成功返回
0, 失败返回-1并设置errno
10.4.1.10 命名管道上手实操
// in.cpp
// #include <iostream>
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#include <sys/types.h>
int main()
{
umask(0);
// open named pipe
int wrpipefd = open("named_pipe", O_WRONLY, 0666);
// open the file who need to read
int srcfd = open("srcfile", O_RDONLY, 0666);
// read from file
char str[1024];
int readlen = 0;
while((readlen = read(srcfd, str, sizeof(str) - 1)) > 0)
{
str[readlen] = '\0';
write(wrpipefd , str, readlen);
readlen = 0;
}
close(srcfd);
close(wrpipefd);
return 0;
}
// out.cpp
// include
#include <iostream>
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#include <sys/types.h>
int main()
{
umask(0);
unlink("named_pipe");
// make named pipe
if(mkfifo("named_pipe", 0666) == -1)
{
std::cerr << "mkfifo err" << std::endl;
exit(1);
}
// open named pipe
int rdpipefd = open("named_pipe", O_RDONLY, 0666);
// open the file who need to read
int dstfd = open("dstfile", O_WRONLY, 0666);
// read from file
char str[1024];
int readlen = 0;
while((readlen = read(rdpipefd, str, sizeof(str) - 1)) > 0)
{
str[readlen] = '\0';
write(dstfd , str, readlen);
readlen = 0;
}
unlink("named_pipe");
close(dstfd);
close(rdpipefd);
return 0;
}
srcfile
i miss u, my girl, i haven't seen you for a long time, how are you doing?
dstfile
(空文件)
bash1(先运行exeout,否则无法创建文件,注意,运行完之后会发现exeout处于阻塞状态,因为其open()在等待exein创建写入端)(即open()且读取一个文件的逻辑是,如果这个文件还没有写入端,则open()会阻塞)
oldking@iZwz9b2bj2gor4d8h3rlx0Z:~/code/code_25_4_16_IPC$ ls
dstfile in.cpp out.cpp srcfile
oldking@iZwz9b2bj2gor4d8h3rlx0Z:~/code/code_25_4_16_IPC$ g++ out.cpp -o exeout
oldking@iZwz9b2bj2gor4d8h3rlx0Z:~/code/code_25_4_16_IPC$ ls
dstfile exeout in.cpp out.cpp srcfile
oldking@iZwz9b2bj2gor4d8h3rlx0Z:~/code/code_25_4_16_IPC$ ./exeout
oldking@iZwz9b2bj2gor4d8h3rlx0Z:~/code/code_25_4_16_IPC$ (阻塞)
bash2(再运行exein)
oldking@iZwz9b2bj2gor4d8h3rlx0Z:~/code/code_25_4_16_IPC$ ls
dstfile exeout in.cpp named_pipe out.cpp srcfile
oldking@iZwz9b2bj2gor4d8h3rlx0Z:~/code/code_25_4_16_IPC$ ll
total 44
drwxrwxr-x 2 oldking oldking 4096 Apr 17 16:55 ./
drwxrwxr-x 5 oldking oldking 4096 Apr 16 19:11 ../
-rw-rw-r-- 1 oldking oldking 1 Apr 17 16:49 dstfile
-rwxrwxr-x 1 oldking oldking 16888 Apr 17 16:55 exeout*
-rw-rw-r-- 1 oldking oldking 535 Apr 17 01:07 in.cpp
prw-rw-rw- 1 oldking oldking 0 Apr 17 16:55 named_pipe|
-rw-rw-r-- 1 oldking oldking 676 Apr 17 16:15 out.cpp
-rw-rw-r-- 1 oldking oldking 74 Apr 17 16:49 srcfile
oldking@iZwz9b2bj2gor4d8h3rlx0Z:~/code/code_25_4_16_IPC$ g++ in.cpp -o
exein
oldking@iZwz9b2bj2gor4d8h3rlx0Z:~/code/code_25_4_16_IPC$ ./exein
oldking@iZwz9b2bj2gor4d8h3rlx0Z:~/code/code_25_4_16_IPC$ cat dstfile
i miss u, my girl, i haven't seen you for a long time, how are you doing?
oldking@iZwz9b2bj2gor4d8h3rlx0Z:~/code/code_25_4_16_IPC$ ls
dstfile exein exeout in.cpp out.cpp srcfile
- 当然,我们也可以在
bash中创建管道文件
oldking@iZwz9b2bj2gor4d8h3rlx0Z:~/code/code_25_4_18_IPC$ mkfifo fifo
oldking@iZwz9b2bj2gor4d8h3rlx0Z:~/code/code_25_4_18_IPC$ ll -a
total 8
drwxrwxr-x 2 oldking oldking 4096 Apr 18 23:16 ./
drwxrwxr-x 7 oldking oldking 4096 Apr 18 23:16 ../
prw-rw-r-- 1 oldking oldking 0 Apr 18 23:16 fifo|
10.4.1.11 命名管道的问题及其本质
-
由此,你能发现什么问题?
-
即命名管道其实并不是一个严格意义上的内存级文件,因为你甚至能在文件系统中看到他
-
实际上命名管道的定义是这样的:即命名管道的运作逻辑将文件系统作为储存数据的媒介,而将内存作为传输数据的媒介
-
所以本质上和你手动搞一个文件,手动打开,手动传输,手动关闭没啥区别,吗?
-
并不是,要满足管道这种可能会有高负载场景的信息传递介质,管道文件的实际位置在磁盘的可能性并不大
-
事实上我们在文件系统看到的管道文件,仅仅是管道的名字和
inode的映射关系,inode指向的属性和内容全都不在磁盘,而是在内存,这也是为什么一旦我们unlink这个管道,该管道就会释放的原因 -
除此之外,这个管道文件和匿名管道还有本质区别,即管道文件需要通过文件系统打开,和正常打开文件无异,仅仅只是其内容和数据全部在内存,在用户视角看来,管道文件其实更像是普通文件一点,而非匿名管道(主要是匿名管道没有名字,也没有路径,而打开一个管道文件必须通过文件名和路径)
-
也正是因为命名管道拥有文件名和路径,才可以做到让两个进程打开同一个管道文件,这是利用了文件的唯一性
10.4.1.12 使用单命名管道实现两进程通信
- 其实和之前的上手实操很像啦,看看就好
// in.cpp
#include <iostream>
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#include <sys/types.h>
#include <string>
int main()
{
umask(0);
// open named pipe
int wrpipefd = open("named_pipe", O_WRONLY, 0666);
// open the file who need to read
// no file to open because of std::cin
// read from file
std::string str;
while(true)
{
std::cout << "please enter somthing >: ";
std::getline(std::cin, str);
if(str.length() == 0)
continue;
if(str == "exit")
break;
write(wrpipefd, str.c_str(), str.length());
str.clear();
}
close(wrpipefd);
return 0;
}
// out.cpp
#include <iostream>
#include <fcntl.h>
#include <ostream>
#include <sys/stat.h>
#include <unistd.h>
#include <sys/types.h>
int main()
{
umask(0);
unlink("named_pipe");
// make named pipe
if(mkfifo("named_pipe", 0666) == -1)
{
std::cerr << "mkfifo err" << std::endl;
exit(1);
}
// open named pipe
int rdpipefd = open("named_pipe", O_RDONLY, 0666);
// open the file who need to read
// no file to open because of std::out
// read from file
char str[1024];
int readlen = 0;
while((readlen = read(rdpipefd, str, sizeof(str) - 1)) > 0)
{
str[readlen] = '\0';
std::cout << "exein say: " << str << std::endl;
std::flush(std::cout);
readlen = 0;
}
unlink("named_pipe");
close(rdpipefd);
return 0;
}
- 效果:
10.4.1.13 命名管道的相关修正以及命名管道的生命周期问题
-
当然,事实上这个示例写得并不完善(现在是一个月后的我在回顾该篇内容了),有几个问题:
- 这个
out端其实一开始就不需要删除一次named_pipe,因为已经存在同名管道的概率其实很小,同时养成在代码结尾unlink管道的好习惯,就可以避免这样的问题 - 其实有一种情况我们没有考虑到,即如果
out端读取失败了会怎么样?!或者说in端被用户或者是系统强制杀死了,out会怎样?他还会继续阻塞等待新的进程打开这个管道吗? - 代码设计上其实还有一些比较困惑的地方
- 这个
-
第一个问题,其实已经做出了解答了,就不多赘述
-
而第二个问题就很有意思了,如果
in端被杀死了,事实上out端并不会等待新的in端,有这样一个机制,即如果一个进程被强制杀死,那么这个进程打开的所有管道文件都会被强制关闭 -
那如果这个管道文件关闭了,那
out端还能从管道文件里读到东西吗?显然不能!此时out端的read()会返回0,然后out端的循环会被终止 -
第三个问题,其实是设计上的问题,虽然我写的两个可执行文件都可以解释命名管道的工作逻辑,但其实这么写是"不够"恰当的
-
换句话说,如果我们写一个服务端,写一个用户端,用户端将字符写给服务端,服务端再打印字符,这样可能会更好理解一些
-
这里我重新写一下
// server
#include <iostream>
#include <ostream>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#include <string>
int main()
{
umask(0);
if(mkfifo("named_pipe", 0666) != 0)
{
std::cerr << "mkfifo err" << std::endl;
}
int pipe_fd = open("named_pipe", O_RDONLY);
if(pipe_fd < 0)
{
std::cerr << "open err" << std::endl;
exit(2);
}
char str[1024];
while(true)
{
int lenth = read(pipe_fd, str, sizeof(str) - 1);
str[lenth] = 0;
if(lenth > 0)
{
std::cout << "client say: " << std::string(str) << std::endl;
std::flush(std::cout);
}
else if(lenth == 0)
{
std::cout << "no client" << std::endl;
sleep(1);
break;
}
else
{
std::cerr << "?" << std::endl;
}
}
close(pipe_fd);
unlink("named_pipe");
return 0;
}
// client
#include <iostream>
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string>
int main()
{
int pipe_fd = open("named_pipe", O_WRONLY);
std::string str;
while(true)
{
std::cout << "please input > " ;
getline(std::cin, str);
if(str != "quit")
write(pipe_fd, str.c_str(), str.length());
else
break;
}
close(pipe_fd);
return 0;
}
-
你可以看到,如果用户端主动退出,命名管道是不会留下来的,因为
server的代码逻辑中会清除named_pipe -
如果在
server中不清除named_pipe会发生什么?现在我们注释一下unlink("named_pipe");再试试
- 我们进行了两次
client的退出,一次正常退出,一次非正常退出,在这两次退出后,命名管道依然存在,这也就应证了命名管道的生命周期并不跟随fd的生命周期,而是跟随文件系统
10.4.1.14 管道机制深究
-
我们知道,匿名管道会因为没有写端而销毁
-
那么命名管道也会因为没有写端而销毁吗?显然不是的
-
首先我们要聊聊为什么匿名管道会因为没有写端而销毁,或者说为什么读写端会影响到匿名管道的存亡
-
因为一个进程唯一能访问匿名管道的方式,就是通过匿名管道的
fd,如果一个匿名管道管道没有fd指向它,那么它的存在将会毫无意义,因为哪怕你想找它,它也会因为没名字而无法被找到 -
但命名管道则不同,命名管道它是有名字的,所以哪怕没有
fd指向命名管道,命名管道也依然会存在 -
换句话说,匿名管道的生命周期是取决于进程的
fd的 -
那么命名管道的生命周期取决于什么?是取决于文件系统,如果进程调用
unlink向文件系统声明要杀死该命名管道,这个命名管道才会被杀死,或者说一个进程非正常退出,那么该命名管道也会因为进程的非正常退出而被文件系统杀死 -
那么,
fd究竟能不能影响到命名管道呢?其实会有一定影响,对于命名管道而言,fd控制的是它的读写行为(或者说数据流行为),如果一个命名管道和所有写端脱离链接,那么该命名管道链接的所有读端的read()都会返回0,但是,命名管道并没有被销毁!假如你重新链接一个写端,那么读端的read()就不会返回0了(这句话要取决于你的实际代码,如果你的读端是循环读且循环控制不是read()的返回值的话,效果就是这样,如果你的读端关于读的循环控制是由read()的返回值控制的话,那么就不会有这种效果)
10.4.2 System V IPC
10.4.2.1 什么是System V IPC
System V是一套标准,准确来说是Unix众多版本中的一支,而System V IPC则是该标准中,专门用于进程间通信机制的API标准,而其虽然是Unix这种古老玩意的产物,但该标准依旧被Linux接受和采纳了,证明该标准是非常有用的,是Linux众多用于进程间通信的手段之一
10.4.2.2 shm,msg,sem
-
System V IPC是一个大框架,而shm,msg,sem是System V IPC下的子机制 -
这三个机制都是完全独立的,不会有依赖影响的情况
shm: 共享内存机制msg: 消息队列机制sem: 信号量机制
10.4.2.3 shm机制
-
我们知道,进程间通信一定需要不同进程看向同一片空间,
System V IPC下的shm的做法也是如此 -
我们知道,管道使用的是内存级文件,但因为该内存级文件的大小实际上收到限制(也就是该文件是有内存上限的)以至于管道只能用于处理少量的数据,如果要用于处理大量数据,就需要分批次传递数据
-
区别于管道使用的内存级文件的形式,
shm的运作机制则完全不受文件限制,因为shm机制直接申请内存块,而不是内存级文件,然后申请到的内存块作为共享内存给进程管理(就和共享库差不多的逻辑),并且,通过页表进行物理地址和虚拟地址间的映射之后,会直接交给vm_area_struct管理!但是,这是对于进程而言的! -
我们知道,一个操作系统中,肯定会有情况,使得一个操作系统中运作着好几个共享内存,同时共享内存还会存在很多种属性和运行状态,甚至说引用计数等等,那么势必对于操作系统而言,我们需要管理这么一批共享内存,于是,在操作系统中,一定会存在一个结构,用于管理这些共享内存,并且一定会放进某种数据结构中组织起来!
-
我们一般称共享内存机制为
shm
10.4.2.2 shm的安全性问题
-
提问:如果我们将
shm交给vm_area_struct管理,不会有安全问题吗?该进程是否可以像访问其他vm_area_struct一样访问这个用于管理shm的vm_area_struct(以下将vm_area_struct称为VMA)?如果真的是这样,难道不会造成安全性问题吗? -
首先,虽然
OS提供了一系列接口用于访问shm,但如果用户直接通过指针之类的访问该空间,一样是可以被允许的!因为其被VMA管理, 这就意味着,如果将shm交给不同进程管理,就需要信任所有共享该shm的进程! -
注意:既然会有安全问题,意味着
shm也拥有权限管理这么一说,毕竟要限制用户的权利,避免其随便访问内存 -
这也就意味着,如果你将
shm的权限设置为0666,那么所有进程都可以自由地访问该shm,这就麻烦大了,我们称这种问题为"裸内存暴露" -
所以,切记:不要将共享内存的权限设置为
0666,不要暴露给other!
10.4.2.3 shm的相关接口
-
int shmget(key_t key, size_t size, int shmflg):这个接口用于新建并获取一个shm,相关参数与设计思想如下:key:这是shm的唯一标识符size:需要申请的shm的大小,如果key对应的shm已经存在,那么这个参数就不会有任何作用shmflg:这个参数是一个位图,用于控制接口的逻辑,常用的关键字为:IPC_CREAT,IPC_EXCL,IPC_PRIVATEIPC_CREAT:如果只使用该参数,则表示,如果不存在key对应的shm,则会新创建一个shm,并返回valid,如果已经存在key对应的shm,就不会新建,同时返回valid(返回值会提到的)IPC_EXCL:这个参数只能搭配IPC_CREAT使用,一般这样使用:IPC_EXCL | IPC_CREAT,一旦携带IPC_EXCL,当已经存在了key对应的shm时,不会返回valid,而是返回-1并设置errnoIPC_PRIVATE:这个要搭配fork使用,大概意思是,如果该shm被标记为IPC_PRIVATE,则仅关联该shm的进程包括其子进程才有权限使用该shm,对于其他进程而言,该shm是不可见的,子进程会忽略key唯一的约束,直接和父进程共享同一片shm
- 返回值:如果该接口调用成功,就会返回一个叫
shmid的东西,失败则会设置errno并返回-1,从设计上来说,返回值可以有两个功能性目的,即用于判断shm是否可用和作为唯一标识符用于访问shm
-
key_t ftok(const char *pathname, int proj_id):该接口用于获取一个key值,这里通过算法,如果pathname和proj_id固定,那么获取到的key也会是固定的,而proj_id的作用是,如果真的出现了key值冲突的情况,可以通过修改proj_id来变更key值 -
void *shmat(int shmid, const void *_Nullable shmaddr, int shmflg):共享内存最重要的是要共享,但shmget()并不会映射共享的物理地址到进程的虚拟地址上,shmget()仅负责创建一个shm,而映射的工作交给shmat()来做,这里了解两个单词attach和detach,attach的意思是"关联,附加",detach的意思是"分离,脱钩",这个接口可以关联当前进程和指定的shmshmid:你想要关联的shm对应的shmidshmaddr:这个参数的作用很特殊,一般来说,我们说共享内存映射到的虚拟地址空间的部分是共享区,一般是从共享区的头开始,划分一片连续的空间,但这个参数允许从其他固定位置划分空间,这是为了满足一些特定开发的要求,但一般情况下其实用不到,因为我们知道,一般虚拟地址空间的划分在编译链接的时候就划分好了,虚拟地址空间一般是不受用户手动控制的,这个东西相当于给了一部分空间让用户手动控制,这其实是比较危险的事情,万一选到了非共享区就完蛋了,所以一般设置为NULLshmflg:区别于shmget的shmflg,这里的shmflg用处不小,它的作用是,以特定权限关联shm到进程,权限这点我们也提到过,有的shmflg的是只读的,所以就不能随便关联,一般是0(即读写,作为默认权限),SHM_RDONLY(只读),SHM_EXEC(执行,这里执行存在的原因其实很简单,共享库也是一种共享内存,而共享库里的东西就是可执行的)- 返回值:返回映射空间中起始的虚拟地址,有点像是
malloc()的地址一样
-
int shmctl(int shmid, int op, struct shmid_ds *buf):很特殊,shm的删除是集成在一个叫做shmtcl的接口里的,这个接口用于控制一个shm,包括但不限于删除,查找其属性,设置其属性等,这个接口的设计真的是极为复杂,我自己认为挺抽象的,可能是阅历还不够吧shmid:你要执行操作的shmidop:这个表示你需要这个接口执行的内容,常用的有IPC_RMID(删除),IPC_SET(设置属性信息),IPC_STAT(读取属性信息)- 返回值:返回
0表示成功,返回-1表示失败并设置errno buf:struct shmid_ds的定义是
struct shmid_ds { struct ipc_perm shm_perm; /* Ownership and permissions */ size_t shm_segsz; /* Size of segment (bytes) */ time_t shm_atime; /* Last attach time */ time_t shm_dtime; /* Last detach time */ time_t shm_ctime; /* Creation time/time of last modification via shmctl() */ pid_t shm_cpid; /* PID of creator */ pid_t shm_lpid; /* PID of last shmat(2)/shmdt(2) */ shmatt_t shm_nattch; /* No. of current attaches */ ... };这个参数的使用是最为复杂的,当你要获取
shm的属性信息的时候,这个参数的作用是用于返回属性信息,当你要设置属性信息的时候,这个参数的作用是告诉接口你要设置的属性信息,当要删除shm的时候,这个参数没有任何作用,要传一个NULL进去,QAQ -
int shmdt(const void *shmaddr):用于取消一个进程对于一个shm的attachshmaddr:你要detach的shm的起始虚拟地址- 返回值:成功返回
0,失败返回-1并设置errno
10.4.2.4 shm的机制详谈
10.4.2.5 shm相关接口设计问题详谈
-
key的问题:我们知道,对于一个进程而言,打开命名管道文件后的fd是其关于该进程的唯一标识符,而这个fd是操作系统分配给你的,不是在用户层面分配的,而对于shm的唯一标识符,是由用户自己分配的?!为什么要这样设计?这就需要扯到一个问题了,如果没有这个东西,会造成什么影响?对于一个命名管道文件,如果没有fd,进程依然可以找到该文件,意味着对于进程而言,管道文件的fd的职能仅仅只是作为进程关联一个文件的标示,代表着该文件被进程打开,同时允许该进程对该文件做读/写,但key的职责不仅仅是关联进程和该空间(或者说做虚拟物理内存映射),还有,这个shm是没有名字的!你该如何找到该shm都成问题!所以key既包含了类似于命名管道fd的职能,同时还包含着"文件路径+文件名"的职能!而在操作系统的角度,key是全局的,同时"文件路径+文件名"也是全局的!换句话说,因为上述原因,所以key需要用户自己定义(也有一些情况是用户交给某些接口来定义的,目的是保证key不会发生冲突,也就是我们上一小节了解过的ftok()) -
shmid的问题:我们已经用key作为shm的唯一标识符了,为什么访问shm还需要用到这个叫shmid的东西呢?我们假设一种情况,进程a申请了一个shm,进程b也申请了一个shm,key值与进程a一样,那么此时就意味着他们两个共享了一片内存,此时如果a释放了这个shm,而正好进程c也用同样的key申请了shm,在没有shmid的情况下,你会发现进程a的操作毫无任何问题,因为原来key值的shm被删除了,所以进程c也是正常申请shm,如果我们将key用作访问shm的桥梁,此时进程b本来应该和进程a共享的内存,直接就被动转而和进程c共享内存了,这显然不是我们想看到的,这种情况我们称作悬挂引用,为了解决这种问题,就需要shmid,还是刚刚的例子,a使用0x1234申请shm返回shmid = 5,b使用0x1234申请shm返回shmid = 5,此时a删除shm,c使用0x1234申请shm返回shmid=6,此时进程b关联的shmid=5的shm就会被标记为删除,虽然b直到取消对shm的去你内存映射之前依旧可以正常访问该shm,但此时你会发现内存中包含两个shm,它们的key值都是0x1234,于是我们需要防止c申请到b的shm,并且还要将两个shm做区分,于是不得不采用shmid -
设计
ftok()的理由:我们知道key这个东西就是一串大于0的整形数字,但事实是,一个普通的整型数字其实是没有任何意义的,这就会导致如果两个进程之间要相互通信,那么设计这两个进程的人就需要同时记得一个毫无意义的key,这其实是一件很麻烦的事情,并且,如果没有这个接口,那么同一个公司的人,在写代码的时候,就很容易出现key撞车的情况,比如就直接写成办公区的门牌号这种,所以我们需要一个为key赋予意义的接口,同时还需要降低key撞车可能性,因此ftok应运而生,因为你传进去的是一个路径,这个路径本身是有意义的,记忆也很方便,同时因为第二个参数proj_id的存在,假设使用了ftok依旧撞车了,还是可以通过proj_id来更改key值,虽然这就不完全满足我们说的有意义这个条件了,不过至少不会撞车了
10.4.2.6 shm的优劣
-
shm的优势其实非常明显,当我们申请完一个shm之后,因为其本身直接和一个进程的虚拟地址挂钩,所以使用shm的时候几乎是零成本,直接拿着地址用就行,就像是使用一个melloc()的空间一样,而管道,因为其直接和文件系统挂钩,和进程本身的关联程度并不大,所以一般要使用系统调用,让OS帮我们访问,这就势必会造成一定程度上的CPU资源浪费,或者更直白的说,就是更慢! -
但劣势也是非常明显的,因为一个
shm可以开辟得非常大,同时shm并没有类似于管道的阻塞机制,虽然访问上非常方便和自由,这就意味着如果一个进程的任务仅仅只是一个简单的单一功能,那么处在非阻塞状态下其实是会浪费一部分CPU资源的,是非常搓的设计 -
当然,管道其实也是非常快的,如果我们不追求极致的效率,且需要开辟大空间进行高密度信息的传输的话,那么可以使用 管道 +
shm的设计,管道用于传输信号,代表传输开始和传输结束,以此控制进程阻塞,而shm则专注于信息传输和访问,这种方式还可以保证读端不会读一半这种读到无效信息的情况(这是一个非常大的优势)
10.4.2.7 使用 "管道" + shm 实现进程间通信
- 借用命名管道机制实现
shm进程间通信
// define.h
#pragma once
// fifo.hpp pipe.hpp
#define FIFO_CLIENT 0
#define FIFO_SERVER 1
// pipe.hpp
#define PIPE_RD_POLL 0
#define PIPE_RD_BLOCK 1
// fifo.hpp
// 切记,这里一定得是单个字符,否则可能会出现读一半的问题
#define SEND_BEGIN "0"
#define SEND_OVER "1"
// shm.hpp
#define GSIZE 1024
#define THROWERR(x) \
do \
{ \
perror(x), \
exit(errno); \
}while(0)
// global.h
#pragma once
#include <string>
namespace oldking
{
std::string gpath = ".";
int gproj_id = 0;
int gsize = 1024;
}
// shm.hpp
#pragma once
#include <sys/ipc.h>
#include <sys/shm.h>
#include <string>
#include <iostream>
#include "define.h"
namespace oldking
{
class Shm
{
public:
Shm(int flag)
: _shmid(0)
, _key(0)
, _path("")
, _size(0)
, _shmaddr(NULL)
, _creater(flag)
{
}
void create(int flag, std::string path = ".", int proj_id = 0, size_t size = 1024)
{
_key = ftok(path.c_str(), proj_id);
_size = size;
std::cout << "getkey: " << _key <<std::endl;
int shmid = shmget(_key, _size, flag);
std::cout << "key: " << _key << " _size: " << _size << std::endl;
if(shmid == -1)
{
THROWERR("shmget");
}
std::cout << "shm获取成功" << std::endl;
_size = size;
_shmid = shmid;
_path = path;
std::cout << "_shmid: " << _shmid << " _path: " << _path << std::endl;
}
void* attach(int flg)
{
if(_shmid == 0)
return NULL;
_shmaddr = shmat(_shmid, NULL, flg);
std::cout << "at成功 " << "addr: " << _shmaddr << std::endl;
return _shmaddr;
}
void detach()
{
if(_shmaddr == NULL)
return ;
if(shmdt(_shmaddr) == -1)
{
THROWERR("shmdt");
}
std::cout << "dt成功" << std::endl;
}
void del()
{
detach();
if(_creater == FIFO_CLIENT)
return;
if(_shmid == 0)
return ;
if(shmctl(_shmid, IPC_RMID, NULL) == -1)
{
THROWERR("shmctl");
}
std::cout << "shm销毁成功" << std::endl;
}
~Shm()
{
del();
_shmid = 0;
_key = 0;
_path = "";
_size = 0;
_shmaddr = NULL;
}
void* getaddr()
{
return _shmaddr;
}
int getkey()
{
return _key;
}
int getshmid()
{
return _shmid;
}
private:
int _shmid;
key_t _key;
std::string _path;
size_t _size;
void* _shmaddr;
int _creater;
};
}
// pipe.hpp
#pragma once
#include "define.h"
#include <string>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <iostream>
namespace oldking
{
class pipe
{
public:
pipe(const int creater, const std::string pathname = "./namedpipe")
: _creater(-1)
, _pathname(pathname)
, _fd(-1)
{
if(creater != FIFO_CLIENT && creater != FIFO_SERVER)
{
THROWERR("creater");
}
_creater = creater;
}
bool creat()
{
if(_creater == FIFO_SERVER)
{
if(mkfifo(_pathname.c_str(), 0666) != 0)
{
THROWERR("mkfifo");
}
}
int fd = open(_pathname.c_str(), (_creater == FIFO_SERVER ? O_RDONLY : O_WRONLY));
if(fd < 0)
{
THROWERR("open");
}
_fd = fd;
return true;
}
std::string take_over(const int flag)
{
std::cout << "pipe:take_over:_fd: " << _fd << std::endl;
if(_fd == -1)
return std::string();
if(_creater == FIFO_CLIENT)
{
THROWERR("pipe:take_over");
}
if(flag == PIPE_RD_POLL)
{
// 设置轮询状态
int op = fcntl(_fd, F_GETFL);
fcntl(_fd, F_SETFL, op | O_NONBLOCK);
}
else if(flag == PIPE_RD_BLOCK)
{
// 设置阻塞状态
int op = fcntl(_fd, F_GETFL);
fcntl(_fd, F_SETFL, op & (~O_NONBLOCK));
}
else
{
THROWERR("pipe:take_over");
}
char arr[1024];
int length = read(_fd, arr, sizeof(arr) - 1);
if(length == -1)
{
THROWERR("read");
}
arr[length] = '\0';
return std::string(arr);
}
int send(const std::string msg)
{
if(_fd == -1)
return -1;
if(_creater == FIFO_SERVER)
{
THROWERR("send");
}
int ret = write(_fd, msg.c_str(), msg.length());
if(ret == -1)
{
THROWERR("write");
}
return ret;
}
void del()
{
if(_fd == -1)
return ;
if(_creater == FIFO_SERVER)
{
close(_fd);
_fd = -1;
unlink(_pathname.c_str());
}
else if(_creater == FIFO_CLIENT)
{
close(_fd);
_fd = -1;
}
else
{
THROWERR("del");
}
}
~pipe()
{
del();
_creater = -1;
_pathname = "";
}
private:
int _creater;
std::string _pathname;
int _fd;
};
}
// fifo.hpp
#pragma once
#include <iostream>
#include "global.h"
#include "shm.hpp"
#include "pipe.hpp"
#include "define.h"
#include <sys/ipc.h>
#include <sys/shm.h>
#include <stdio.h>
#include <string.h>
namespace oldking
{
class fifo
{
public:
fifo(const int flag, const std::string path = oldking::gpath, const int proj_id = oldking::gproj_id, const int size = oldking::gsize)
: _shm(flag)
, _notice_pipe(flag)
, _creater(flag)
{
if(flag == FIFO_CLIENT)
{
_shm.create(0 | 0666, path, proj_id, size);
_shm.attach(0);
}
else if(flag == FIFO_SERVER)
{
_shm.create(IPC_CREAT | 0666, path, proj_id, size);
_shm.attach(SHM_RDONLY);
}
else
{
THROWERR("flag");
}
_notice_pipe.creat();
}
int send(const std::string msg)
{
if(_creater == FIFO_SERVER)
{
THROWERR("send");
}
if(msg.length() >= (size_t)oldking::gsize)
{
THROWERR("send");
}
if(_notice_pipe.send(SEND_BEGIN) == -1)
return -1;
memcpy((char*)_shm.getaddr(), msg.c_str(), msg.length());
if(_notice_pipe.send(SEND_OVER) == -1)
{
THROWERR("send");
}
return msg.length();
}
std::string take_over()
{
if(_creater == FIFO_CLIENT)
{
THROWERR("fifo:take_over");
}
std::string first_rd_msg = _notice_pipe.take_over(PIPE_RD_BLOCK);
std::cout << "first notice: " << first_rd_msg << std::endl;
if(first_rd_msg == std::string())
return std::string();
else if(first_rd_msg == std::string(SEND_BEGIN) + std::string(SEND_OVER))
{
return std::string((char*)_shm.getaddr());
}
else if(first_rd_msg != std::string(SEND_BEGIN))
{
THROWERR("fifo:take_over");
}
std::string second_rd_msg = _notice_pipe.take_over(PIPE_RD_BLOCK);
std::cout << "second notice: " << second_rd_msg << std::endl;
if(second_rd_msg == std::string(SEND_OVER))
{
return std::string((char*)_shm.getaddr());
}
else
{
THROWERR("fifo:take_over");
}
}
~fifo()
{
_creater = -1;
}
private:
oldking::Shm _shm;
oldking::pipe _notice_pipe;
int _creater;
};
}
// client.cpp
#include "fifo.hpp"
#include <iostream>
#include <string>
int main()
{
oldking::fifo f(FIFO_CLIENT);
std::string str;
for(int i = 0; i < 5; i++)
{
getline(std::cin, str);
f.send(str);
str = "";
}
return 0;
}
// server.cpp
#include <iostream>
#include <string>
#include "fifo.hpp"
int main()
{
oldking::fifo f(FIFO_SERVER);
for(int i = 0; i < 5; i++)
{
std::cout << f.take_over() << std::endl;
}
return 0;
}
-
当然,这里这个利用命名管道进行同步的机制,个人认为其实没什么必要,或者说,一旦要实现得比较完美,其实比较困难,不如用其他比较合适得方案
-
我们简单梳理一下,管道是用来传输信号的,于是我
define了两个宏出来,实际值分别为0和1,这么做的原因(或者说不使用字符串的原因)其实是,如果使用字符串,就可能会出现读端读信号读一半的情况,比方说写hello和world,读端可能会读出来hellowor这种,其实是不太严谨的,所以我这里使用了单个字符传输,就是为了规避这种情况(其实应该用char类型的,这样会更极致一些) -
换句话说,真正写完之后才发现这种方案还是略搓的,如果更极致的话,可以读端搞一个缓冲区,为写端为实际信息的两边添加检测字符,比方说,写实际内容之前,先写一段
114514表示开始写内容了,然后写实际内容,最后写1919810代表写完了,这样只需要读端检查缓冲区是否有这两个字符就行了 -
当然直接在前和后直接写标志信息肯定是不行的,我们可以直接搞一个类似于以下的结构体
struct msg
{
char begin[10];
char str[gsize - 10 - 10 - size];
size_t size;
char end[10];
};
-
因为比起一个字符一个字符地查找标志信息,直接对该结构体进行比较会更快,因为对结构体比较这个操作对于
CPU而言是非常快的,CPU会直接对内存块比较而不是对单个字符比较,CPU这种"并行比较"一定是比单个字符的这种"串行比较"快得多的(这种并行比较方式涉及到底层硬件设计,这是"物理意义"上的压制) -
当然,我们也可以使用其他数据结构进行优化,老实说我这里并不是特别清楚
10.4.2.8 消息队列 msg
-
和
shm类似,msg也是一种用于进程间沟通的,处于system V下的一个标准,它的使用方式和shm的使用方式有些类似,这里我们先不谈相关接口,我们来讲讲msg的机制问题 -
关于
msg,咱不会实战,因为system V中的msg其实已经比较过时了
10.4.2.9 msg的机制和意义
-
假如说,进程
a需要向进程b发送一个消息,以让进程b获取自己的任务信息(即a通过消息向进程b发布任务),而当进程b还没完成任务时,进程a又发了个消息,紧接着a再次发了个消息 -
此时,进程
a与进程b沟通的"公共资源"内存在着进程a的两条消息,如果这份公共资源是管道或是shm的话,那么还可能需要封装一下消息,以实现消息的完整传输,否则可能会读到无用信息,所以我们其实迫切需要一种封装程度非常不错的沟通机制,以实现允许多条消息等待被读取的功能,此时msg就这样诞生了 -
msg本质上是一个链表,是和数据结构中的队列非常类似的东西但其单个节点中的内容有所不同 -
单个
msg节点中的内容类似于以下情况
struct node
{
int type;
char text[size];
size_t size;
struct node* next;
};
-
你能看到,其中有一个比较奇怪的成员变量,即
type,这个type就是字面意思,代表"类型" -
这意味着,每一条消息还具有类型这个属性,这个类型的具体可以实现的功能完全由用户设计,比方说我可以将优先级概念塞进
type中以实现接收端优先执行高优先级任务,抑或是将消息创建者身份塞进type中以实现两进程可以互相拿自己需要的信息(msg不像是管道一样的单向通信,msg允许两进程间双向通信,实现双向通信中两进程各自拿取各自需要的消息这一功能就必须要借助type),甚至说,你可以将以上的两个功能同时塞进type中,用一个位图实现,也是可以的 -
有以上的设计,我们其实就将"一条消息"给复杂化了,用于满足"解耦生产者-消费者"模型(意思是生产者/发送端无需等待生产者/接收端完成任务,而是可以立即返回),同时实现复杂信息的通信
-
以下是将消息创建者身份塞进
type中以实现进程间双向通信
-
同时,到这里你应该也已经发现了,
msg其实并非像shm一样通过页表映射关联进一个进程的空间,反倒像是一个匿名管道一样 -
事实上
msg这个结构,的确是由OS进行维护的,这也意味着一定会有结构体管理一堆msg,也就是"先描述,再组织",就不多提了
10.4.2.10 msg相关接口的使用
int msgget(key_t key, int msgflg): 和shmget()的使用一模一样,也可以搭配ftok()使用int msgctl(int msqid, int cmd, struct msqid_ds *buf): 也和shmctl()的使用一模一样,区别在于struct msqid_ds的成员结构可能和描述shm的属性的结构不同int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg): 这里其实就已经体现了msg和shm的不同了,msg因为不需要使用页表映射机制,所以不需要进行attach和detach,这里的msgsnd是用于发消息的,msqid是msgget返回的队列id,msgp是需要发送的内容,msgsz是需要发送内容的大小,msgflg是一个位图,用于控制是否阻塞和一点点其他的功能,返回值方面,成功返回0,失败返回-1并设置errnossize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg): 同理,msgrcv是用于接收消息的,msqid是msgget返回的队列id,msgp是接收的buf,msgsz是消息大小,msgtyp是需要接收的类型,msgflg用于处理是否阻塞和截断key_t ftok(const char *pathname, int proj_id):shm部分聊过了,不赘述了
10.4.2.11 锁与原子性与进程
-
我们知道,在信息在一个读写的时候,"保证我们写入和读取的信息都是有效信息"这件事,其实是非常重要的,就比方我们之前有聊到过的,在有效信息前后添加标记字符,抑或是封装有效信息到结构的做法,都是在保证信息的有效
-
但实际上,这还远远不够,我们知道,传输一段消息,这个消息可以抽象地看作五个状态:
- 写入公共资源前
- 正在写入公共资源时
- 在公共资源中等待读取时
- 正在从公共资源读取时
- 完成从公共资源读取后
-
以上我们保证了哪些状态的安全?
- 公共资源的权限保证了"在公共资源中等待读取时"信息的安全,防止非法进程读写公共资源
- "封装有效信息到结构"的做法保证了"写入公共资源前"和"完成从公共资源读取后"信息的完整读取
-
那么,"正在写入公共资源时"和"正在从公共资源读取时"如何保证安全?
-
我们来打个比方
-
我家楼下有两个KFC,但因为平时单量不大所以共享了同一个宅急送的配送员,同时为了保证送到的餐一定是热乎的,有规定配送员只能一单一单配送
-
但碰巧今天是疯狂星期四,所以单量激增,结果在某个时刻出现了非常戏剧性的一幕,两个KFC的前台同时叫配送员取餐,于是配送员难绷了
-
如果配送员同时接收这两个单,意味着没法保证送到的餐一定是热乎的,毕竟配送员不能分裂成两个一起送餐
-
所以,我们迫切需要一项新的规定
-
规定:当多个KFC共享一个配送员的情况出现时,先做好的单优先分配给配送员,配送员立马开始送餐,如果有其他店面也做好了餐,得先由店面自己保温,等配送员送完这单再来送另一单
-
我们称店面(进程)暂时留住餐(消息)的这个动作为加锁的结果,当然,这是加锁的结果
-
我们称配送员(公共资源)配送完了单,开始处于空闲状态称为资源释放
-
而加锁,其实是保护店面(进程)将餐(信息)交给配送员(公共资源)这个步骤,也就是保护了"正在写入公共资源时"的安全,使进程能够安心写内容而不受其他进程的打扰
-
换句话说,加锁的意义在于,保证该进程的读写公共资源部分的代码在执行时,不会受到其他与该公共资源关联的进程的影响
-
我们称一个进程中,已经受到了加锁保护的代码块为临界区代码,反之为非临界区,更严谨一些,应该是当该进程读写公共资源部分的代码,可能会与其他进程读写公共资源部分的代码发生冲突时,该部分代码称为临界区代码(并不一定会加锁,因为加锁是为了解决临界区冲突的手段),我们称这种冲突为互斥,需要被保护的公共资源称作临界资源
-
我们称一个进程为一个执行流,任何时刻,只允许一个执行流访问公共资源,即为互斥
-
这里我们再提一个概念,叫做"原子性"
-
简单来说,原子性的意义就是:要么做,要么不做,不存在其他的任何情况
-
比方说,一个进程关联了一份公共资源,此时只会存在两种情况,要么这个进程对公共资源进行了一些操作,要么这个进程压根不动这份公共资源,不会存在比方说多个进程访问这份公共资源的情况,或者换句话说,这个进程要么不访问公共资源,要么直接做完你要对这个公共资源执行的任务,且无法被中断,不可被中断,不可被打扰,意味着执行操作的过程中发生了什么,外界是完全不知道的,外界只知道你开始做操作了,然后做完操作使得公共资源空闲了,我们不是说加锁是为了保证没有其他进程访问公共资源以实现信息安全嘛,一旦引入原子性,其他进程连看都看不到公共资源里面发生了什么,更别提试图修改了
-
而加锁,就是为了保证进程在访问公共资源时的原子性
-
但此时又会产生一个问题了,既然锁保护的是公共资源,就意味着锁本身也是公共的?!
-
那此时怎么保证锁的安全呢?此时就必须要保证申请锁的过程也是原子性的,这里的原子性是对于锁而言的,意味着锁只有被一个进程申请走和没被申请两种状态,不存在被两个进程同时申请的情况,申请的时候也不会被打扰
-
注意了,加锁是结果,至于具体是通过什么机制加的锁,是在什么东西层面加的锁,还是要根据实际情况来看
10.4.2.12 信号量机制 sem
-
比方说
sem,这个东西就是一个锁,我们来简单聊一下sem的机制吧 -
这个
sem从本质上说,就是一个计数器,里面有一个count,需要搭配着其他IPC结构使用 -
假设我们申请了一个
shm,然后我们通过地址将shm拆分成多个小块使用,那么理论上说,只要共享这shm的进程访问的是不同的小块,就可以保证该公共资源的原子性 -
于是我们就需要
sem,我们将sem看作是一个许可证机制,或者说,将sem看成一个很大的表格,每个格子代表着shm的一个小块,假设该进程需要使用一个小块,就需要申请一个许可证,当拿到了许可证才可以使用,拿到许可证之后,就在sem的格子上画勾,将计数器自减1,当一个进程结束访问了,就把它之前所在位置的格子上的勾擦掉,计数器自增1 -
能不能拿到许可证这件事,全部是由
sem在管理的,换句话说如果所有的格子上都有勾,计数器为0,sem会开一个队列记住这些想用公共资源但还没用上的进程,等到有资源空闲再让他们使用,这也就意味着,每一个小的公共资源都是原子的,当然哈,进程拿到了资源的许可没问题,至于它使不使用,sem不在乎,换句话说,进程完全可以申请但不使用,占着茅坑不拉屎 -
不过既然计数器这个东西事关进程能否访问公共资源,这也就意味着计数器自增和自减这两个动作都得是原子的,不过这就是后话了,我们聊不了这么深
-
我们称计数器的自增操作为"V操作",代表进程归还资源,称计数器的自减操作为"P操作",代表进程借走资源
-
值得一提的是,因为锁这个东西需要被不同进程看到,所以锁本身也是被共享的,意味着锁本身也是需要被保护的,这里不深挖
-
还有一个比较有意思的特例,如果我们不想拆分
shm,此时sem就可以被设置为单个格子,此时shm这个大块就是原子的了,只存在被使用和没被使用两种状态,即0和1两种状态,我们称这种信号量为"二元信号量" -
当然,以上是单个信号量的构成
10.4.2.13 sem相关接口
-
int semget(key_t key, int nsems, int semflg):key: 不赘述,shm部分聊过了nsems: 表示sem的数量,要注意,这个参数是帮助我们批量申请信号量的,然后所有申请到的信号量会被数组维护起来,我们称这个维护着一堆信号量的数组为信号量集semflg: 创建机制和权限,不赘述- 返回值: 成功返回
semid失败返回-1并设置errno
-
int semctl(int semid, int semnum, int cmd, ...): 用于控制信号量,包括但不限于初始化,删除,获取状态等semid: 不赘述semnum: 要操作的信号量编号,换句话说就是信号量集的下标cmd: 具体要做的操作,包括SETVAL设置单个信号量值,GETVAL,获取单个信号量值,IPC_RMID,删除整个信号量集,GETALL/SETALL,获取/设置整个集的所有值- 返回值: 含义取决于
cmd - 可变参数: 你可以传各种东西进去,如果
cmd是SETVAL,那么这个地方可以填一个int类型的值,表示每个sem中有多少个小块,如果cmd是GETVAL/IPC_RMID,那么不用填东西,如果cmd是GETALL/SETALL,这里需要填unsigned short *的地址,表示一个数组,"获取"/"设置"各个信号量的值(各个sem的小块数量,当然也可以理解为该公共资源可被并发使用的进程数量)
-
int semop(int semid, struct sembuf *sops, size_t nsops): 表示对信号量集中的一个或多个信号量执行的"P"/"V"操作semid: 不赘述nsops: 表示你要操作信号量的多少个小块sops: 可以是一个数组,表示要对信号量集的什么位置,执行什么操作,有没有特殊标志,关于struct sembuf,里头有三个成员unsigned short sem_num: 选择信号量集的第几个信号量(下标从0开始)short sem_op: 要执行的操作,-1是"P"操作,1是"V"操作,0表示等待信号量变为0short sem_flg:0,表示默认的阻塞等待,IPC_NOWAIT,表示非阻塞模式
10.4.2.14 当今环境下sem的没落
-
讲真,现在对于
system V的sem的使用已经是少之又少了,我做了一下分析:- 该接口在创建信号量和初始化信号量时,并不是原子的,而是要分两个接口进行创建,我觉得可能会在特定的情况下出现问题
- 该信号量接口接口的设计并不是特别优秀,我们之前有提到过,信号量类似于一个计数器,我以为的计数器是类似于STL中的优先级队列一样,底层可以是各种不同的结构,而Linux中的信号量是完完全全独立开的计数器,意味着"计数器增/减"到"资源占用/释放"这一过程也不会是原子的,老实说我认为用模板类设计成底层可以基于任何IPC结构的信号量会更加完美一些
-
事实也确实如此,这个
sem是上世纪设计的,对于现代来说还是太难用了,接口设计复杂,语义不直观,没法支持自定义底层,而且更加致命的,它不够原子,况且性能也不咋地 -
而现代
sem已经可以支持泛型资源类型了,全面接受面向对象改造,使用上会方便很多,同时因为封装了,也会更原子一些,更安全一些,比方说CPP的Folly库的Semaphore
- 如有问题或者有想分享的见解欢迎留言讨论