哈Ha! 在本文中,我将尝试从应用程序程序员的角度告诉您程序/应用程序中的内存管理是什么。 这不是详尽的指南或手册,而只是对现有问题及其解决方法的概述。
为什么这是必要的? 程序是一系列数据处理指令(在最一般的情况下)。 该数据需要以某种方式存储 , 加载 , 传输等。 所有这些操作不会立即发生,因此,它们直接影响最终应用程序的速度。 在工作过程中以最佳方式管理数据的能力将使您能够创建非常简单且非常耗资源的程序。
注意:大部分材料都提供了游戏/游戏引擎的示例(因为这个主题对我个人而言比较有趣),但是,大多数材料都可以应用于编写服务器,用户应用程序,图形包等。

牢记一切都是不可能的。 但是,如果您无法加载它,就会得到肥皂
马上开始
在行业中,大型AAA游戏项目主要是在使用C ++编写的引擎上开发的。 该语言的功能之一是需要手动进行内存管理。 Java / C#等 他们拥有垃圾回收(GarbageCollection / GC)-创建对象的能力,但仍然不能手动释放已用的内存。 此过程简化并加快了开发速度,但也可能引起一些问题:定期触发的垃圾收集器会杀死所有软件实时时间,并给游戏增加令人不快的冻结。
是的,在“ Minecraft”等项目中,GC可能不会引起注意,因为 他们通常不需要计算机的资源,但是“ Red Dead Redemption 2”,“ God of War”,“ Last of Us”之类的游戏在系统性能达到顶峰时几乎“工作”,因此不仅需要大型资源的数量,还在于他们的称职分配。
此外,在具有自动内存分配和垃圾回收的环境中,您可能会遇到缺乏灵活的资源管理方式。 Java将所有实现细节和所有工作细节隐藏在幕后,这已不是什么秘密,因此在输出端,您仅具有用于与系统资源进行交互的已安装接口,但可能不足以解决某些问题。 例如,在每个帧中以不固定数量的内存分配启动算法(这可能是对AI路径的搜索,检查可见性,动画等),不可避免地导致性能的灾难性下降。
代码中的分配方式
在继续讨论之前,我想通过几个示例说明如何直接在C / C ++中使用内存。 通常,用于分配进程内存的标准和最简单的接口由以下操作表示:
// size void* malloc(size_t size); // p void free(void* p);
在这里,您可以添加一些其他功能,这些功能使您可以分配对齐的内存:
// C11 - , * alignment void* aligned_alloc(size_t size, size_t alignment); // Posix - // address (*address = allocated_mem_p) int posix_memalign(void** address, size_t alignment, size_t size);
请注意,不同的平台可能支持不同的功能标准,例如在macOS上可用,而在win上则不可用。
展望未来,可能需要特殊对齐的内存区域,以达到处理器高速缓存行以及使用扩展的寄存器集( SSE , MMX , AVX等)进行计算的目的。
一个玩具程序的示例,该程序分配内存并打印缓冲区值,并将它们解释为有符号整数:
/* main.cpp */ #include <cstdio> #include <cstdlib> int main(int argc, char** argv) { const int N = 10; int* buffer = (int*) malloc(sizeof(int) * N); for(int i = 0; i < N; i++) { printf("%i ", buffer[i]); } free(buffer); return 0; }
在macOS 10.14上,可以使用以下命令集来构建和运行该程序:
$ clang++ main.cpp -o main $ ./main
注意:此后,我并不是很想介绍诸如new / delete之类的C ++操作,因为它们很可能直接用于构造/销毁对象,但是它们使用了诸如malloc / free之类的常用内存操作。
记忆问题
使用计算机的RAM时会出现一些问题。 所有这些,无论是一种还是另一种方式,不仅是由操作系统和软件的功能引起的,而且还由所有这些东西所起作用的烙铁的体系结构引起的。
1.内存量
不幸的是,内存在物理上受到限制。 在PlayStation 4上,这是8 GiB GDDR5,其中3.5 GiB是操作系统保留的供其使用 。 虚拟内存和页面交换无济于事,因为将页面交换到磁盘是非常慢的操作(如果我们谈论游戏,每秒固定N帧)。
还值得注意的是有限的“ 预算 ”-为了在多个平台上运行应用程序而创建的对使用的内存量的一些人为限制。 如果您要为移动平台创建游戏,并且不仅要支持一个设备,而且要支持一整套设备,那么为了提供更广阔的销售市场,就必须限制胃口。 这既可以通过简单地限制RAM的消耗,又可以根据游戏实际开始的小工具来配置此限制来实现。
2.碎片化
在对各种大小的内存进行多次分配的过程中出现的不愉快效果。 结果,您得到的地址空间分为许多不同的部分。 将这些部分组合成更大的单个块将不起作用,因为一部分内存已被占用,我们无法自由移动它。

通过内存块的顺序分配和释放示例进行分段
结果是:我们可以定量但不定性地拥有足够的可用内存。 而下一个请求,例如“为音轨分配空间”,分配器将无法满足,因为根本没有单个大小的内存。
3. CPU缓存

计算机内存层次结构
现代处理器的高速缓存是将主存储器(RAM)与处理器寄存器直接连接的中间链接。 碰巧对内存的读/写访问是一个非常慢的操作(如果我们谈论执行所需的CPU时钟周期数)。 因此,存在某种缓存层次结构(L1,L2,L3等),它允许“根据某种预测”从RAM加载数据,或将其缓慢推入速度较慢的内存中。
将相同类型的对象放在内存中的一行中可以使您“显着”加快处理它们的过程(如果处理顺序发生),因为在这种情况下,更容易预测接下来将需要哪些数据。 所谓“重大”是指有时会提高生产率。 Unity引擎的开发人员已经在GDC的报告中多次谈到了这一点。
4.多线程
确保在多线程环境中安全访问共享内存是创建自己的游戏引擎/游戏/使用多线程以实现更高性能的任何其他应用程序时必须解决的主要问题之一。 现代计算机的排列方式非常简单。 我们既有复杂的缓存结构,又有几个计算器核心。 如果使用不当,所有这些都可能导致进程的共享数据由于多个线程而损坏(如果它们同时尝试在没有访问控制的情况下使用此数据)。 在最简单的情况下,它将如下所示:

我不想深入探讨多线程编程的主题,因为它的许多方面都远远超出了本文甚至整个书的范围。
5. Malloc /免费
分配/释放操作不会立即发生。 在现代操作系统上,如果我们谈论的是Windows / Linux / MacOS,则它们可以很好地实现并在大多数情况下可以快速工作。 但这可能是非常耗时的操作。 这不仅是系统调用,而且取决于实现方式,它可能需要一段时间才能找到合适的内存(首次安装,最佳安装等)或找到插入和/或合并释放区域的位置。
此外,新分配的内存实际上可能不会映射到实际的物理页面上,这在第一次访问时可能也需要一些时间。
这些是实施细节,但是适用性又如何呢? Malloc / new不知道在何处,如何调用或为何调用它们。 他们平均分配1 KiB和100 MiB的内存(在最坏的情况下)……同样糟糕。 直接将使用策略留给程序员或实现程序运行时的那个人使用。
6.内存损坏
正如Wiki所言 ,这是最不可预测的错误之一,它仅在程序执行过程中出现,并且通常是由程序编写错误直接引起的。 但是,这是什么问题呢? 幸运的是,它与计算机的损坏无关。 相反,它显示一种情况,您尝试使用不属于您的内存。 我现在将解释:
- 这可能是尝试读取/写入部分未分配的内存。
- 超越了提供给您的内存块的范围。 这个问题是问题(1)的一种特殊情况,但是更糟糕的是,因为只有当您保留为您显示的页面时,系统才会告诉您您超出了范围。 也就是说,潜在地,这个问题很难解决,因为只有在您不显示虚拟页面限制的情况下,操作系统才能够响应。 您可以破坏进程内存,并从根本不会出现的地方得到一个非常奇怪的错误。
- 释放已经释放(听起来很奇怪)或尚未分配的内存
- 等
在C / C ++中,存在指针算法,您将遇到一两次。 但是,在Java Runtime中,您必须付出很多努力才能得到这种错误(我自己没有尝试过,但是我认为这是可能的,否则生活会太简单了)。
7.内存泄漏
这是在许多编程语言中都会出现的更普遍问题的特例。 标准的C / C ++库提供对OS资源的访问。 它可以是文件,套接字,内存等。 使用后,必须正确关闭资源并
被他占用的记忆应该被释放。 特别是关于内存的释放-程序导致的累积泄漏可能导致“内存不足”错误,这将导致OS无法满足下一个分配请求。 通常,开发人员只是出于某种原因而忘记释放已使用的内存。
这里值得补充有关正确关闭和释放GPU上的资源的信息,因为如果先前的会话未正确完成,早期的驱动程序将不允许继续使用视频卡。 只有重新启动系统才能解决此问题,这是非常可疑的-迫使用户在运行应用程序后重新启动系统。
8.悬空指针
悬空指针是一些术语,描述了指针指向无效值的情况。 在C / C ++程序中使用经典C风格的指针时,很容易出现类似情况。 假设您分配了内存,将地址保存在p指针中,然后释放了内存(请参见代码示例):
// void* p = malloc(size); // ... - // free(p); // p? // *p == ?
指针存储一些值,我们可以将其解释为内存块的地址。 碰巧的是,我们不能说这个存储块是否有效。 根据某些协议,只有程序员才能使用指针进行操作。 从C ++ 11开始,标准库中引入了许多其他“智能指针”指针,这些指针通过使用内部的其他元信息以某种方式削弱了程序员对资源的控制(稍后将对此进行详细介绍)。
作为部分解决方案,您可以使用指针的特殊值,该特殊值将通知我们该地址没有任何内容。 在C中,将NULL宏用作该值的值,在C ++中,将使用nullptr语言关键字。 解决方案是局部的,因为:
- 指针值必须手动设置,以便程序员可以简单地忘记执行它。
- 指针接受的一组值中包括nullptr或仅包含0x0,当通过其通常状态表示对象的特殊状态时,这不好。 这是某种传统,根据协议,操作系统不会为您分配地址以0x0开头的内存。
具有空值的示例代码:
// - p free(p); p = nullptr; // p == nullptr ,
您可以在某种程度上自动执行此过程:
void _free(void* &p) { free(p); p = nullptr; } // - p _free(p); // p == nullptr, //
9.内存类型
RAM是普通的通用随机存取存储器,可通过中央总线访问处理器和外围设备的所有内核。 它的容量各不相同,但大多数情况下我们谈论的是N GB,其中N为1,2,4,8,16,依此类推。 调用malloc / free seek将所需的内存块直接放在计算机的RAM中。
VRAM (视频内存) -PC的视频卡/视频加速器随附的视频内存。 通常,它比RAM小(大约1.2.4 GiB),但是速度很高。 这种类型的内存的分配由视频卡驱动程序处理,大多数情况下,您没有直接访问它的权限。
在PlayStation 4上没有这种分隔,所有RAM在GDDR5上都由单个8 GB表示。 因此,处理器和视频加速器的所有数据都在附近。
游戏引擎中良好的资源管理包括在主RAM和VRAM端均分配有能力的内存。 在这里,当相同的数据不存在时,或者从RAM到VRAM 的数据传输过多,反之亦然时,可能会遇到重复 。
为了说明所有问题,您可以使用PlayStation 4架构示例(图)来查看计算机设备的各个方面。 这是中央处理器,8个内核,L1和L2级别的高速缓存,数据总线,RAM,图形加速器等。 有关完整和详细的描述,请参阅Jason Gregory的“游戏引擎体系结构” 。

PlayStation 4架构
一般方法
没有通用的解决方案。 但是,如果要在应用程序中实现手动分配和内存管理,则应注意一些重点。 这包括容器和专用分配器,内存分配策略,系统/游戏设计,资源管理器等。
分配器的类型
特殊内存分配器的使用基于以下思想:您知道什么大小,什么时候工作以及在什么地方需要内存。 因此,您可以分配必要的内存,以某种方式构造它并使用/重用它。 这是使用特殊分配器的一般想法。 它们是什么(当然,不是全部)可以进一步看到:
线性分配器
表示连续的地址空间缓冲区。 在工作过程中,它允许您分配任意大小的内存部分(以使它们适合缓冲区)。 但是您只能释放所有分配的内存1次。 也就是说,不能释放任意一块内存-它将一直被占用,直到将整个缓冲区标记为干净为止。 这种设计提供了O(1)的分配和释放,从而保证了在任何条件下的速度。

典型用例:在更新进程状态(游戏中的每个帧)的过程中,您可以使用LinearAllocator分配tmp缓冲区以满足任何技术需求:处理输入,使用字符串,在调试模式下解析ConsoleManager命令等。
堆栈分配器
修改线性分配器。 允许您以相反的分配顺序释放内存,换句话说,根据LIFO原理,其行为类似于常规堆栈。 这对于执行加载的数学计算(转换的层次结构),实现脚本子系统的工作,对于事先已知的释放内存的指定过程的任何计算都非常有用。

设计的简单性提供了O(1)内存分配和释放速度。
池分配器
允许您分配相同大小的内存块。 它可以实现为连续地址空间的缓冲区,该缓冲区被划分为预定大小的块。 这些块可以形成一个链表。 而且我们总是知道在下一次分配中要分配哪个块。 此元信息可以存储在块本身中,这对最小块大小(sizeof(void *))施加了限制。 实际上,这并不重要。

由于所有块的大小相同,因此返回哪个块对我们来说都没有关系,因此,所有分配/解除分配操作都可以在O(1)中执行。
帧分配器
线性分配器,但仅参考当前帧-允许您执行tmp内存分配,然后在更改帧时自动释放所有内容。 应该单独选择它,因为这是运行时游戏框架中的一些全局且唯一的实体,因此它的大小非常可观,例如几十个MiB,这在加载资源和处理它们时将非常有用。
双帧分配器
它是一个双帧分配器,但具有一些功能。 它允许您在当前帧中分配内存,并在当前帧和下一帧中使用它。 也就是说,仅在N + 1帧后才会释放在N帧中分配的内存。 这是通过将活动帧切换为每帧末尾突出显示来实现的。

但是,这种分配器与前面的分配器一样,对在分配给它的内存中创建的对象的生存期施加了许多限制。 因此,您应该意识到,在帧的末尾,数据仅变得无效,并且重复访问它们可能会导致严重的问题。
静态分配器
这种类型的分配器从例如在程序启动阶段获得的或在功能帧中的堆栈上捕获的缓冲区分配内存。 按类型,它绝对可以是任何分配器:线性,池,堆栈。 为什么称为静态 ? 在程序编译阶段应该知道捕获的内存缓冲区的大小。 这施加了一个很大的限制:该分配器可用的内存量不能在操作期间更改。 但是有什么好处呢? 使用的缓冲区将被自动捕获,然后释放(完成工作或退出功能时)。 这不会加载堆,使您免于碎片,使您可以快速分配内存。
如果需要将字符串分成子字符串并对它们进行一些处理,则可以使用此分配器查看代码示例:

还应注意的是,从理论上讲,使用堆栈中的内存效率要高得多,因为 将当前函数的框架以高概率堆叠在处理器缓存中。
所有这些分配器都以某种方式解决了碎片,内存不足,接收和释放所需大小的块的速度,对象的生存期以及它们所占用的内存的问题。
还应注意的是,正确的接口设计方法将使您可以创建一种分配器层次结构 ,例如:池从帧分配器分配内存,而帧分配又从线性分配器分配内存。 可以根据您的任务和需求进一步延续类似的结构。

我看到了类似的用于创建层次结构的界面,如下所示:
class IAllocator { public: virtual void* alloc(size_t size) = 0; virtual void* alloc(size_t size, size_t alignment) = 0; virtual void free (void* &p) = 0; }
malloc/free , . , , . / , .
Smart pointer — C++ ++11 ( boost, ). -, , - , . .
? :
- (/)
:
Unique pointer
1 ( ).
unique pointer , . , .. 1 / .
uniquePtr1 uniquePtr2, uniquePtr1 , . 1 .

Shared pointer
(reference counting). , , . , , , .

. -, , . . -, - .
Weak pointer
. , . 这是什么意思? shared pointer. , shared pointer , . , shared pointer weak pointer. , (shared) , weak pointer shared pointer. — weak pointer , , , .

shared, weak pointer meta-data . - , .. , O(N) overhead , N — - . , . , . .
: . , shared pointer, , ( ) - - - . . meta-info , , . 一个例子:
/* */ /* , shared pointer */ Array<TSharedPtr<Object>> objects; objects.add(newShared<Object>(...)); ... objects.add(newShared<Object>(...));
/* ( meta-info ) */ Array<Object> objects; objects.emplace(...); ... objects.emplace(...);
. . 关于它进一步。
Unique id
, . (id/identificator), , , -. :
, id. , , , id.
, ( , )
id , , id.
. , id, .
: id, , id, .
id , (Vulkan, OpenGL), (Godot, CryEngine). EntityID CryEngine .
, id : . , ( ), , .
/* */ class ID { uint32 index; uint32 generation; }
/* - / */ class ObjectManager { public: ID create(...); void destroy(ID); void update(ID id, ...); private: Array<uint32> generations; Array<Objects> objects; }
ID , ID . :
generation = generations[id.index]; if (generation == id.generation) then /* */ else /* , */
id generation 1 id ids.
C++ , . std, , . :
- Linked list —
- Array — /
- Queue —
- Stack —
- Map —
- Set —
? memory corruption. / , , , , .
, , . , , / .
, , . , ( ) . , malloc/free , , .
? , (/ ), , , . , , , .

ryEngine Sandbox:
, Unreal, Unity, CryEngine ., , . , , , — , .
Pre-allocating
, / .
: malloc/free . , "run out of memory", . . , (, , .).
. . , - . , malloc/free, : , , .
. : , , , .. .
: , , , . open-source , , . , , — malloc/free.
GDC CD Project Red , , "The Witcher: Blood and Wine" () . , , , , .

Naughty Dog , "Uncharted 4: A Thief's End" , (, ) .

结论
, , , . , . / , , - .. , (, ).