手柄是更好的指针(2018)

2021-04-03 07:05:07

2018年11月28日:我最后添加了一个小型更新关于如何使用每位插槽生成计数器预防碰撞

......其中我谈谈了这些天在Cand C ++中的动态内存管理如何,基本上取代了与'下划线的智能指针'。

在我最后的博客文章中,我提到了点和分配的编程,但正在跳过细节。这是以下博客帖子isabout。

这一切都基于(有时痛苦)的摔跤经验15 +年,具有相当大的C ++代码基础(0.5到大约1mloc),内存通过智能指针管理。最糟糕的情况是数千个小C ++对象,每个小型C ++对象,每个都在自己的Heapallocation中,通过智能指针指向彼此。虽然这些码在内存损坏方面非常强大(SegFaults和损坏发生,因为大多数尝试被Dereferencesmart指针捕获),但这种类型的“对象SpiderWeb代码”也是狗 - Swithwithout,以便优化以来的明显起点。代码已满了roce未命中。其他典型问题是内存碎片和“fakememory泄漏”,因为忘记的智能指针是防止释放undering的内存(我称之为“假泄漏”,因为这种类型的泄漏不能被内存调试工具捕获)。

这里没有任何介绍是特别的新的或聪明的,这是一些简单的想法,在伟大的码码中相当良好地工作,以防止(或者至少检测到早期)C和C ++中的许多共同之处相关问题,甚至可能是适用于较高级别的收集语言,以减少垃圾收集器上的压力。

然而,潜在的设计理念不合适进入Aclassical OOP世界,其中应用于彼此交互的小型自动侵略性。这就是为什么它也非常棘手的opplement在一个大型现有OOP代码库中的想法,其中对象创造和破坏发生在代码上的“分散”。

这里描述的方法非常适用于数据方向建筑,其中中央系统在内存中包装的数据项阵列上工作。

以下大多数以下博客文章是由游戏开发人员编写的,但也应该适用于程序需要一个计划需要几百到几百万个对象(或通常为“数据项”)的其他领域,以及此类项目的位置经常创造和销毁。

将所有内存管理移动到集中系统(如渲染,物理,动画......)中,系统是其内存分配的唯一所有者

将与数组相同类型的组项目,并将数组基本指针视为系统私有

创建项目时,只返回到外部世界的“索引句柄”,而不是指向该项目的指针

在索引句柄中,仅使用阵列索引的需要使用尽可能多的比特,并使用剩余的位进行额外的内存安全检查

当绝对需要时,才将句柄转换为指针,并不在任何地方存储指针

我将在下面详细解释这些点。但是这个想法基本上是普通的'用户级'代码,不会直接呼叫内存分配功能(比如Malloc,new或make_shared / make_unique),并将使用指针减少到绝对最小值(仅作为短暂的参考文献Whendi向内存访问绝对需要一个项目)。最重要的是,指针永远不会是项目底层内存的“所有者”。

相反,直接内存操作在内容相关的系统内尽可能地发生在内存相关的问题上更容易调试和优化。

在这个博客文章中,一个“系统”是一个(通常是相当大的)代码Base的一部分,根据“渲染”,“物理”,“AI”,“字符动画”等等,如“渲染”,“”,“角色动画”等等。这种系统通过明确定义的功能API与其他系统和“用户码”分开,并且在系统数据上发生的工作是在紧密的中央环路中执行的,而不是在代码基础上展开。

系统经常在控制用户代码控制中创建和销毁的项目(但请注意,创建和销毁物品的不同与AlloCateD和释放这些项目使用的内存不同!)。例如,使用顶点缓冲区,纹理,着色器和管道状态对象的渲染systemmight处理。物理系统与刚体,关节和碰撞原语一起工作,动画系统与动画键和曲线一起使用。

将这些项目的内存管理移动到Systemsthem本身是有意义的,因为一般内存分配器没有关于如何处理数据项的系统特定的“域知识”,以及数据项之间的相关性。这允许系统优化MemoryAllocations,在创建AndDestroying项目时执行其他验证检查,并在内存中排列用于最好使用Thecpu的数据缓存。

这个“系统域知识”的一个很好的例子是具有现代3D API的毁坏资源对象:用户代码说明时,资源对象无法立即销毁,因为源仍然可以在等待消耗的命令列表中引用GPU。相反,当用户代码请求其破坏时,渲染系统只标记资源对象,但是当GPU不再使用资源时,才能在稍后的时间内发生职业。

一旦所有内存管理都移动到系统中,系统可以优化偏见分配和内存布局,其额外了解了有关使用HowITEM的知识。一个明显的优化是通过将相同类型的项目分组到数组中来减少总体化分配的数量,并在系统启动时放大这些数组。

系统每次创建新项目时都会跟踪空闲阵列插槽,而不是执行内存分配,而是选择下一个免费插槽。当用户代码不再需要该项目时,系统只需再次将插槽标记执行删除(不是典型池分配器的不同)。

这个池分配很可能比每件商品执行的AMEMORY分配更快,但甚至甚至是阵列中守护者的主要原因(现代一般分配者对于Sallowallocations也很快)。

物品保证在内存中紧密包装,一般分配器有时需要在实际物品内存旁边保留一些管家数据

将“热门物品”保持在连续内存范围内更容易,以便CPU可以更好地利用其数据缓存

也可以将单个项目的数据分成不同阵列中的几个子项,以便甚至更严格的包装和更好的数据缓存使用(AOS VS SOA和彼此中的所有内容),并且所有这些数据布局细节都保持私有,并且是微不足道的改变而不影响“外部代码”

只要系统不需要重新分配阵列,就保证没有内存碎片(尽管这在64位地址空间中的问题较少)

更容易检测到早期内存泄漏,并提供更有用的错误消息:创建一个新项目时,系统可以琐碎地检查对预期上限的当前项目数(例如,游戏可能知道应该永远不会超过1024纹理一次活着,因为所有纹理都通过渲染系统创建,因此系统可以在超出此号码时打印出更有用的警告消息)

保持阵列中的系统项目而不是唯一分配具有TheAdvantage可以通过数组索引来识别项目,而不是重新查询完整指针。这对于内存安全来说非常有用。将内存指针移到外部世界,系统可以将阵列基本指针视为“私人知识”,并且只向公众交出阵列。如果没有基本指针来计算项目的MemoryLocation,即使有犯罪能量,外部代码也无法访问项目的内存。

在许多情况下,系统之外的代码甚至从未直接访问物品的内存,只有系统确实。在这种“理想”的情况下,用户代码永远不会通过指针访问内存,并且永远不会导致内存损坏。

由于只有系统知道数组基本指针,因此它可以自由移动或重新分配项目阵列,而不会使现有的索引句柄无效。

阵列指数需要比完整指针更少的位数,并且可以为它们挑选较小的数据类型,这又允许更紧密的数据结构包装和更好的数据缓存使用(这使得额外的手柄位可用于提高内存安全性来提高内存安全性,更多关于这个问题)

如果用户代码需要直接访问项目的内存,则需要通过“查找函数”来获取指针,该“查找函数”将句柄作为输入和returnsa指针。一旦这样的查找功能存在,上面概述的相当不潜的内存安全场景不再保证,并且用户代码应该遵守一些规则:

指针应该永远不会存储在任何地方,因为下次使用指针时,它可能不再指向相同的项目,甚至可以到有效内存

一个指针应该只在简单的代码块中使用,而不是“跨越”函数调用

每次句柄被转换为指针时,系统都可以保证返回的指针仍然指向与(更详细的情况下)的手柄创建的相同项目,但这保证了指针下方项目的“衰减”加班已被销毁,或者底层媒体可能已经重新分配到不同的位置(这是C ++中的迭代器失效的同名问题。

上面的两个简单规则很容易记忆,并且完全是一个很好的折衷,并且完全将指针暴露于用户代码,并且对于每个单个内存访问,具有(有点昂贵)的手柄对指针转换。

首先,每种类型的句柄都应该得到自己的C / C ++类型,以便在Compiletime中检测到将错误的句柄类型传递给函数(请注意,简单的typedef不足以生成编译器警告,则必须包裹到自己的结构或类 - 但是这可能限于调试编译模式)。

所有运行时内存安全检查发生在将Ahandle转换为指针的函数中。如果句柄只是一个数组索引,它将看起来很屏蔽:

索引对当前项数组大小的索引发生了一个范围检查,这可以防止分割故障和读取或写入分配但不相关的内存区域

检查是否需要发生索引数组项目插槽是包含活动项目(目前不是“免费插槽”),这可以防止自由的简单变体'

最后,项目指针是从私有阵列基指针和公共项目索引计算的

两者都只能发生在系统的一个函数中,这就是为什么存在的两个人的使用规则'存在(不存储指针,不要将指针横穿函数调用)。

在上面的使用后检查中有一个非常大的漏洞:如果我们唯一只要索引句柄后面的数组插槽包含有效项,则保证它是与句柄最初创建的相同项目。可能会发生原始项目被销毁,而Samearray插槽已重复使用新项目。

这是一个手柄中的“免费比特”进来的地方:让我们说我们的守人是16位,但我们只需要1024件物品同时使用1024件物品。寻址1024项只需要10个索引比特,以其他方式为其他方式免费。

如果那些6位包含某种“独特模式”,则可能拖动悬挂访问:

创建项目时,挑选一个自由数组项,并将其索引放入较低的10个句柄比特中。鞋面6位设置为“唯一位模式”

由此产生的16位句柄(10位索引+ 6位'唯一模式')返回到外部世界,同时存储与阵列插槽的相同。

当销毁物品时,存储与阵列插槽的项目句柄被设置为“无效句柄”值(可以为零,只要零永远不会返回到外部世界的有效句柄)

当把手转换为指针时,较低的10位用作查找阵列索引以查找阵列插槽,并且整个16位手柄与当前用阵列插槽存储的句柄进行比较:如果两个手柄都相等,指针有效并指向句柄创建的相同项

否则这是一个悬垂的访问权限,插槽项目已被销毁(在这种情况下,存储的句柄将具有“无效的句柄”值),或者已被销毁并重新使用新项目(在那种情况下,upper 6'唯一模式'位不匹配)

此句柄 - 比较检查句柄到指针工作句柄何时何时才能检测悬挂 - 访问,但它并不是防水,因为阵列索引和“独特模式”的组合将更快地创建Orlater。但它仍然比没有悬空更好保护根本(如原始指针),或“假记忆泄漏”,它会在与智能指针的类似情况下发生。

寻找良好的策略来创建独特的句柄,碰撞很少随意是当然最重要的部分,并且留下了读者; p

显然,尽可能多地使用与独特模式的比特很好,而且尾随阵列插槽也被重用是重要的(例如,Lifo VS FIFO)。它也很好地写一点创建/销毁压力 - 测试句柄冲突,可用于调整特定用例的唯一模式创建。创建和销毁以非常高频的项目创建和销毁的系统,需要比项目创建和破坏的系统更加努力(或简单地处理比特)。

除了整个内存安全方面,手柄对指针有问题的其他情况也很有用:

句柄可以用作跨进程的共享对象标识符(所有您需要的ISSOME类型的'create_item_with_handle()'函数,它不会创建一个新句柄,但将现有的句柄占用作为输入参数)。这对在游戏会话中的服务器和所有客户端之间可以共享句柄的在线游戏,或者在savegame系统中存储引用的其他对象。

有时创建一组相关项目(对于InstanceAnimation键和曲线),它非常有用,并用单个句柄引用整个项目组。在这种情况下,可以使用某种“范围句柄”,其中仅包含索引(第一个项目),而且还可以使用该索引(第一个项目),也可以使用该范围内的项目数。

在某些情况下,如果在编译时间的静态类型检查,则对ItemType的一些句柄位保留一些句柄位也是有用的。

总之,我发现它非常令人惊讶于我在过去的传统'Pointsto对象在堆模型中遇到的许多问题,以及现在如何错过这个模型(以及周围的C ++的那个型号) )。

Sokol-GFX API是C-API的示例,它使用句柄而不是渲染资源对象(缓冲区,图像,着色器,...):

oryol动画扩展模块是一个字符动画系统,它在数组中保存所有数据:

...在上面的帖子中,我有点吞噬了两次为同一个插槽创建相同唯一标签的问题,以及推翻我的一个很好的人,关于一个非常简单,优雅和令人尴尬的“明显”解决方案:

每个阵列插槽都获取自己的生成计数器,当释放Ahdherle时会撞击(当创建句柄时也可能发生,但禁止释放意味着您不需要保留值为“免费插槽”来检测无效句柄)。

要检查句柄是否有效,只需将其唯一标记与其插槽中的CurrentGeneration计数器进行比较。

一旦生成计数器将“溢出”,禁用该阵列插槽,Sothat为此插槽返回NOTAT。 这是一个完美的解决方案,可以避免句柄冲突,但是手柄势力最终用完,因为最终将禁用所有阵列插槽。 Butsince每个插槽都有自己的柜台,这只发生在AllHandle-Bits筋疲力尽之后,而不仅仅是少数唯一的唯一标签位。 因此,使用32位手柄,您可以同时创建40亿个项目,在Most2 ^(32 - num_counter_bits)中。 这也意味着可以减少唯一标签的数量而不会影响“手柄安全”。 还可以重新激活禁用的插槽,一旦它可以在野外的丢失中没有更多的句柄丢失(可能在代码中的特殊位置,例如输入或退出级别)。