从新建文件夹开始构建ShadowPlay Engine(5)
本篇序言
从本篇开始,我们要开始构建引擎核心中的系统组件部分,广义上讲其实我们从开始到现在一直都是在构建引擎核心中的系统部分,但严格的定义中系统组件大概有这么几个:内存管理,线程管理,文件管理,时间系统,特殊格式文件处理(比如XML,json文件等)。接下来的文章更新间隔可能会长一些,说不定哪天就断更了。诶嘿。
正如在上一篇文章中我所承诺的那样,接下来我会用大概3至4篇博文的长度来描述本引擎线程管理和内存管理部分,这两个部分同时也是系统组件中不怎么容易理解并且工程量最大的两个模块。我会尽量用通俗易懂的方式描述,希望可以为各位的游戏引擎开发提供帮助。
1. 内存管理(第一部分,理论)
这是我们的引擎开发到目前所面临的最复杂的一个单元,所以,我会在我们的引擎项目之外创建一个新项目用来编码与测试我们的内存管理单元与线程管理单元。等我们的以上两个单元完全没有什么问题时,我们再将它们迁移回我们的引擎项目当中。这两个单元说是很复杂,其实也不怎么复杂(听君一席话,如听一席话),至少比《雷神之锤Ⅲ》的那个求平方根倒数的“WTF”函数要好理解得多。那么让我们开始吧!
还记得上篇文章中我所讲到的我们一直在进行的“危险行为”么?盲目地使用大量的new以及delete而不加管理很容易造成内存泄漏,空指针调用,野指针等问题,这只是其一。其二,直接使用new分配会浪费大量的运行时间,为什么这么说?如果各位有使用虚幻或者unity的内存监视的经验的话应该可以发现小内存的分配与释放是最频繁的,比如游戏内事件对象,纹理,AI-Controller对象或者Shader对象等,这些小内存大致都不超过32千字节(实际上32KB大小的内存分配也不怎么频繁,这里其实是取经验数字)。而new与delete进行分配与释放小内存空间的操作所消耗的时间成本可是非常大的。所以我们为什么不在引擎初始化前就在内存中专门划分一块区域用来分配给这些小内存的相关操作?也就是说,小于某个大小标准的内存分配要求我们可以在我们的专门区域为其分配内存空间,也就是将这部分小内存的操作权从操作系统交到我们引擎的手上,而大于这个标准的我们直接为其分配内存。这样可以在可接受范围内的内存浪费的情况下保证引擎运行的高效性。
其实我上面描述的这个算法就是虚幻引擎3的内存管理办法。当然在细节上,我最终在引擎中的实现和虚幻3是不同的,但是大致思路一样。然而这个分配算法由于其精妙性也导致了它的理解门槛有些高,所以希望各位有一定的计算机组成原理以及操作系统的基础,没有的话也没太大关系,我会对一些概念做详细说明,涉及的相关知识点不是很多,所以还请不用担心。
就像我在上面提到过的,在开发调试阶段我们需要内存监视来告诉我们我们的内存占用、内存对齐情况、内存块分配、内存块释放以及内存块大小等信息。而语言层面自带的内存分配可并没有这方面的接口供我们调用,所以我们就必须要自己组织内存管理的数据结构,保证在使用操作系统提供的API时可以跟踪到具体位置。说的这么危言耸听,但其实很简单,在开发调式模式下,我们的内存管理并不需要遵循我上述说到的快速分配算法,我们主要是为了让游戏开发人员在开发过程中可以通过内存监视追踪到出问题的地方,所以我们可以这样去设计在开发调试阶段的内存管理器:
就像上一篇文章中我们构建渲染链的设想一样,引擎是不知道你会申请分配多少或释放多少内存,对于B/S的管理系统来说服务器使用一个线性Pool以及排队等候就可以解决问题,但游戏引擎的实时性要高于B/S的管理系统,游戏玩家可不希望因为预留线性空间不足导致只扣矿石而不生产单位,从而导致贻误战机。目前比较经济的一个方法也就是使用链表来管理这些内存块。
所以,该怎么做?
为了避开系统的自动分配从而导致很难跟踪内存(虽说各种IDE或者操作系统也提供了一大堆的内存跟踪管理的工具以及插件,不过想要找到自己申请分配的那块内存,不多下点功夫还是很难找的到的,多数游戏开发人员并不想将大量的时间与精力浪费到一连串的16进制数里面,而且游戏引擎是一套工具集,它有义务为开发人员提供更直观的内存监视结果),我们有两条路可走:C语言的malloc或者是汇编。不过由于我们的引擎只能在x64环境下运行(这是当初构建项目时已经设计好的),也就导致了我们无法直接在代码文件中使用嵌入式汇编语句“__asm”。还有,因为本人技术不过关,win32的一些指令到了x64就要重新考虑了。而且malloc的分配后的内存结构也便于理解,所以我们选择malloc以及free,比起new以及delete更加自由也更加基础一些。
而每个链表的结点我们可以这样设计:
这次我依旧使用了使用代理类MemoryBlock,但代理类与被代理对象并不是通过指针相联系了,而是将它们通过一段连续的内存联系起来,因为这次我们的被代理对象就是内存块啊(笑),而且代理类没有义务也没有权限去了解被代理内存中对象的类型,这种联系方式使得内存在释放时也会更有效率一些。
好了,大致的设计构想我们已经总结完毕,接下来让我们考虑一些小细节问题,假设我们最终写成的分配器以及释放器的声明如下所示:
void* memAllocate(unsigned long long _udlLength, bool _bIsArray); // 分配器
void memDeallocate(void* _pBlock, bool _bIsArray); // 释放器
看起来并没有什么问题,很好解释:分配器需要的参数是待分配内存的长度以及是否是数组的条件值,释放器需要的参数是待释放的指针地址以及是否是数组的条件值。也就是说在每次分配内存时我们都需要向里面填入相应的参数,这就是对比new来说一个稍微麻烦的一点了,既然new操作符有这么好的特性,那我们就把它用在我们的分配器上,但有聪明的同学会立马提出质疑:你不是说过不能随便用new以及delete关键字吗?那么,我们是否可以通过重载这两个关键字得到我们想要的效果?幸运的是,C++支持对new进行运算符重载,所以,我们的工作立马就容易得多。我们可以在引擎的作用域内重载new以及delete运算符,然后在重载函数体里调用我们的分配器与释放器。用这种“偷天换日”的方法在不影响游戏开发人员的开发效率下完成引擎对内存的管理工作。
也就是说,我们可以这么去写:
// 负责单个内存块分配工作
void* operator new(unsigned long long _udlLength)
{
return memAllocate(_udlLength, false);
}
// 负责数组分配工作
void* operator new[](unsigned long long _udlLength)
{
return memAllocate(_udlLength, true);
}
// 负责单个内存块释放工作
void operator delete(void* _pBlock)
{
return memDeallocate(_pBlock, false);
}
// 负责数组释放工作
void operator delete[](void* _pBlock)
{
return memDeallocate(_pBlock, true);
}
在后面的第二部分,我们会开始着手构建在调试环境下的内存分配与管理器。
2. 线程管理单元(第一部分)
我们先不着急接着往下进行我们的内存管理部分,在继续之前请容大家与我解决一些小障碍。就像我在构建渲染核心时候说的一样,OpenGL的“初始化——渲染循环——释放”是一个典型的状态机模型,也就是说它是遵循单线程模式的。而且引擎的所有非初始化类型的处理语句都必须运行在渲染循环中以确保实时更新。这看起来并没有什么问题,但问题也正出在这里,过长的处理步骤必然会占用更多的处理器时间,也就是说循环体中的一次循环执行时间会更长,这样必然导致游戏帧数低下,所以我们必须要将某些对实时性要求不高的处理语句从渲染循环中抽离出来,专门为它们开辟一个或者多个线程,充分利用多核处理器的优势(因为现在也没多少人用单核处理器了),在不影响内容展现的同时,达到引擎运行的高效率。
有两种实现多线程的方法:一种是操作系统提供的API(系统层面),另一种是在C++11中开始提供的thread标准库(语言层面),这里我们选择语言层面的实现,举个例子来说明一下两者的差别以及我为什么要选用语言层面的实现:
我们先来看一下Windows启动线程的方法:(摘自Microsoft Docs)
uintptr_t _beginthreadex( // NATIVE CODE
void *security,
unsigned stack_size,
unsigned ( __stdcall *start_address )( void * ),
void *arglist,
unsigned initflag,
unsigned *thrdaddr
);
因为其他的参数目前没有必要去深入,所以挑两个最重要的讲:函数指针start_address以及空指针arglist,start_address也就是我们要在线程中运行的函数,而且Windows在它的参数方面有着严格的定义,即必须为void*,空指针arglist就是传参的,Windows只给我们提供了一个参数的预留位置,如果我们想要将更多的参数传入函数内,我们就必须要用到结构体了,这不失为一种巧妙的方法,然而,在对线程需求量大的时候,越积越多的结构体定义除了降低代码可读性外并没有任何好处。而且,我们传入的是指针,也就是说我们可能要面临着更频繁且更复杂的内存分配,虽说操作系统层面的线程管理更加高效,但以这种牺牲掉内存操作时间来换取线程的高运行效率的做法恐恕本人无法接受。当然,它也有优点,比如它拥有对于安全性的操作,线程控制权等等标准库所没有的功能。
接下来我们再看一下标准库启动线程的方法:
template<class _Func, class..._Args>
std::thread::thread(_Func _f, _Args... _args);
这里也就是标准库的一大强项:支持可变参数模板,也就是说我们完全没必要声明为每一个待处理函数设计声明一个新的结构体,直接将参数传入即可,而且相比Windows最大的一个优点就是支持多平台迁移。但标准库的线程操作简单到过于离奇,所以线程相关的操作问题还得我们自己去考虑。
看到这也许各位会问,那我们就直接调用相关层面的API即可,为什么还要大费周章的创建一个线程管理器?正如字面意思,就是为了方便管理线程,如果各位还是有些不理解,还请随我继续接下来的探索:
标准库中是以类的形式抽象线程的,即我们创建一条新线程就是创建了一个新的对象,但由于标准库启动线程是使用上述的构造函数的,也就是说我们可以在任何时间任何地方更改我们线程对象所负责的线程而不必关心当前负责的线程是否结束,听起来就是一个极富灾难性的操作,而且在分配线程后而不及时收回轻者会造成系统资源浪费,重者则直接导致“Abort() has called”。所以我们很有必要为引擎构建一套线程管理单元。
下图便是我为本引擎设计的线程管理单元的大致结构:
本线程管理单元由四个部分组成:线程管理器(SPThreadManager,继承自引擎的链表数据结构),线程对象(SPThreadObject),线程ID(SPThread)以及互斥量(SPThreadMtx)。而用户创建新线程时得到的只是线程ID,这一点是从Windows那里学来的,就如Windows的HANDLE一样,用户不能直接通过线程ID来访问线程,他们需要本引擎提供的方法以及他们手里掌握的线程ID来对线程进行操作。这样大大增加了引擎对线程的掌控。互斥量作为对线程相关操作限制的开关存在。
先来介绍一下线程ID,虽说用0~32767作为线程ID完全够用,但总感觉缺少了一点专业气息,显得我是一个“new money”(笑),实际上还有一点考虑,我们希望在后续的引擎功能扩展中加入线程的相关限制,而且我们希望这些限制保存在ID里而不是再开辟一至多块内存用来存放布尔值,这时候一个整型数字可就不能再存放这么多信息了。所以我们使用GUID作为我们引擎的ID,幸运的是,Windows提供了这方面的生成API:coCreateGuid。我们将其稍微的包装一下就可以得到我们的线程ID对象了。声明如下:
class SPGlobalID
{
public:
// 构造函数与析构函数,在初始化的时候就生成一个GUID
SPGlobalID();
SPGlobalID(const SPGlobalID&);
~SPGlobalID();
// 用来做比较用的,主要对比的是GUID值
bool operator ==(Shadow::SPGlobalID& _right);
bool operator !=(Shadow::SPGlobalID& _right);
// 获取相关值,只能用作打印或其他不修改数据的用途
const GUID& GetWPId() const;
const std::string& GetSId() const;
// 设置友元函数,用来在调试信息上输出ID
friend std::ostream& operator <<(std::ostream& _outObj, SPGlobalID& _id);
private:
std::string s_ID;
GUID wp_ID;
};
在解决了ID的问题后,我们接下来着手解决线程管理单元,与内存管理以及渲染链一样,引擎除了几个自己用的基础的线程外,根本不知道开发人员在开发游戏或插件时又会申请分配多少个线程,所以,在这里我们还是使用链表来解决问题,在前面讲内存管理的时候我曾说过,频繁的内存分配与释放会消耗大量的CPU时间,而链表则是这其中的常客了。这里我借鉴了一些来自于后端开发者的经验:线程池。也就是说引擎在初始化时会首先创建一定数量的线程对象构成一个线程池,如果分配行为产生,则会首先在线程池中寻找空闲线程对象,线程池内没有空闲线程对象时才会申请一个新的线程对象,这样在一定程度上可以减少CPU时间的浪费,下面是线程管理单元的声明:
// 正如各位所看到的,我使用typedef来对线程对象以及线程ID做了名称定义
typedef std::thread SPThreadObject;
typedef SPGlobalID SPThread;
class SPThreadManager :
protected LinklistManager<SPThreadObject, SPThread>
{
public:
SPThreadManager();
~SPThreadManager();
// 以下两个方法便是向线程管理单元申请分配线程以及释放线程
SPThread ApplyThread();
void TerminateThread(SPThread&);
// 由于我们需要使用可变参数模板,而且这个函数内也并未出现分支判断结构,所以定义在这里
template <class Fp, class ...Args>
void ThreadStart(SPThread _thread, Fp _func, Args... _args)
{
LinkListNode<SPThreadObject, SPThread>* ln_pointer = this->operator[](_thread);
ln_pointer->GetContent() = std::thread(_func, _args...);
}
// 停止相关线程
// 但是我们没有理由强制停止,所以这个方法仅仅是调用了一下joinable而已
void ThreadEnd(SPThread);
private:
// 这里我用标准库的链表来标记已被占用线程以及空闲线程(线程池)
std::vector<SPThread> stdv_originalPool;
std::list<SPThread> stdl_idleThreads;
std::list<SPThread> stdl_occupiedThreads;
};
3. 本篇结语
先写到这里,由于内存管理器部分还有许多待调试的地方未完成,所以文章记录也不能很好地进行下去。只有准备万全,才可拿得出手——这是本人的编程守则,也是为了在毕业设计答辩的时候不至于演示翻车(笑)。本次内容不多,但复杂的地方还是有,也希望大家可以慢慢消化,为后面的理解提供基础,俗话也是这么说的:“老鼠拖木锨,大头在后面“。好的,下次见~
从新建文件夹开始构建ShadowPlay Engine(5)的更多相关文章
- 从新建文件夹开始构建ShadowPlay Engine游戏引擎(3)
本篇序言 各位可能看到博文的名字换了,也就是引擎名字换了,其实是在下想到了一个更棒的名字:皮影戏(ShadowPlay),取这个名字的含义是因为,游戏中的角色(Puppet)不也是由于我们的操作而动起 ...
- 从新建文件夹开始构建ShadowPlay Engine游戏引擎(4)
本篇序言 这次博客更新距离上次的时间间隔变短了好多,因为最近硬是抽出了一大部分时间来进行引擎的开发.而且运气很好的是在写链表这种很"敏感"的的数据结构的时候并没有出现那种灾难性的后 ...
- 从新建文件夹开始构建ShadowPlay Engine游戏引擎(6)
本篇序言 在经历了为期很长时间的调试以及思维纠错后,我们可以开始实现我们的内存管理模块了,我在前面说过如果各位要继续跟着学习的话可能会需要一定的计算机组成原理和操作系统的知识,不过在莽代码的过程中,我 ...
- 从新建文件夹开始构建UtopiaEngine(2)
本篇序言 摸了两个月的鱼,又一次拾起了自己引擎的框架,开始完善引擎系统,如果非要用现实中的什么东西比喻的话,那么我们目前实现的框架连个脚手架都不是.把这项目这样晾着显然不符合本人的风格,而且要作为毕业 ...
- 从新建文件夹开始构建UtopiaEngine(1)
序言 在苦等了半年多之后,我终于开始了向往已久的实时NPR游戏引擎项目--Utopia Engine,这半年多一直为了构建这个引擎在做很多准备:多线程.动态链接库.脚本引擎.立即渲染GUI--统统吃了 ...
- Mac新建文件夹、txt文件、无格式文件
新建文件夹: mkdir test 新建txt touch test.txt 新建无后缀格式文件 touch test 如果要删除文件夹 rm -r -f test
- win10没有新建文件夹
win10没有新建文件夹 win10系统,电脑点击右键没有新建文件夹选项. 工具/原料 win10 系统台式机 方法/步骤 电脑左下角搜素出输入:cmd 出现以下画面 在命令指示符 ...
- Delphi 弹出Windows风格的选择文件夹对话框, 还可以新建文件夹
Delphi 弹出Windows风格的选择文件夹对话框, 还可以新建文件夹 unit Unit2; interface uses Windows, Messages, SysUtils, V ...
- win10无法新建文件夹怎么办 win10右键新建菜单设置方法
有朋友安装了win10系统后发现右键菜单中没有新建项,而平时使用新建 - 文件夹项的机率很高.如何才能恢复桌面右键菜单中的新建项呢? 右键点击桌面空白处,在右键菜单中发现没有新建项: 桌面右键菜单没有 ...
随机推荐
- springMVC-6-restful_CRUD
1.大体框架 POJO层代码 Employee @Data public class Employee { private Integer id; private String lastName; p ...
- 微信小程序云开发-云存储-上传单个视频到云存储并显示到页面上
一.wxml文件 <!-- 上传视频到云存储 --> <button bindtap="chooseVideo" type="primary" ...
- 开源框架是如何使用设计模式的-MyBatis缓存机制之装饰者模式
写在前面 聊一聊MyBatis是如何使用装饰者模式的,顺便回顾下缓存的相关知识,可以看看右侧目录一览内容概述. 装饰者模式 这里就不了它的概念了,总结下就是套娃.利用组合的方式将装饰器组合进来,增强共 ...
- 关于 .NET 与 JAVA 在 JIT 编译上的一些差异
最近因为公司的一些原因,我也开始学习一些 JAVA 的知识.虽然我一直是以 .NET 语言为主的程序员,但是我并不排斥任何其它语言.在此并不讨论 JAVA .NET 的好坏,仅仅是对 .NET 跟 J ...
- 第十篇 -- 学习C++宝典2005版
最近看了C++宝典,看时间是2005的,对于里面的程序自己也进行了编写,由于时间过久,可能有些函数的用法发生了改变,自己也对其进行了修改,用VS2017可以编译通过. 前四章学习内容 CPlusPlu ...
- 深入刨析tomcat 之---第9篇 how tomcat works 第9章,Session的实现 关于request.setContext(context)
writedby 张艳涛,在学第9章session的时候,做了个实验在给的demo代码中添加了 package com.zyt.tomcat.ex09.core; public class Simpl ...
- Linux扩展分区和文件系统
磁盘分区 linux也与windows一样,为了使用全部的磁盘空间,需要先对磁盘分区:如果所有分区的总容量小于磁盘容量,说明磁盘还有未分配空间,这个时候会对磁盘造成浪费.需要增加一个新的分区来将全部空 ...
- 几张图搞懂 NodeJS 的流
假设我们现在要盖一座房子,我们买了一些砖块,厂家正在送货.现在我们有两个选择,一是等所有砖块都到了以后再开始动工:二是到一批砖块就开始动工,砖块到多少我们就用多少. 这两种方式哪种效率更高呢?显然是第 ...
- MySQL 执行优化查询
查询执行的基础 当希望MySQL能够以更高的性能运行查询时,最好的办法就是弄清楚MySQL是如何优化和执行查询的.当向MySQL发送一个请求的时候,MySQL执行过程如图1-1所示: 图1-1 查询执 ...
- 2021字节跳动校招秋招算法面试真题解题报告--leetcode19 删除链表的倒数第 n 个结点,内含7种语言答案
2021字节跳动校招秋招算法面试真题解题报告--leetcode19 删除链表的倒数第 n 个结点,内含7种语言答案 1.题目描述 给你一个链表,删除链表的倒数第 n 个结点,并且返回链表的头结点. ...