首页 > 解决方案 > 理论上,在动态分配中仅使用意外重用内存范围来同步线程是否(反常地)合法?

问题描述

在 C++ 中,动态内存(de)分配(malloc-free/new-delete)显然可以重复获得相同的内存范围,即按顺序释放并再次分配。在多线程 C++ 中,这可能发生在多个线程中。

显然这样的重用不应该是用户的问题,他不必关心它;这是“数据竞赛[new.delete.dataraces]中指定的方式

为了确定数据竞争的存在,operator new 的库版本,全局 operator new 的用户替换版本,C 标准库函数aligned_alloc、calloc 和 malloc,operator delete 的库版本,operator delete 的用户替换版本, C 标准库函数 free 和 C 标准库函数 realloc 不得引入数据竞争 ([res.on.data.races])。对分配或解除分配特定存储单元的这些函数的调用应以单个总顺序发生,并且每个此类解除分配调用应在此顺序的下一次分配(如果有)之前发生。

最后一句话很有意思:不仅内存范围的重用不会引起冲突,而且必须存在先发生(HB)关系。虽然没有说实现必须创建 HB 关系,虽然 HB 需求通常是对用户的需求,但由于需求是有条件的并且基于实现创建的特殊情况(标准库代码),它似乎很清楚,它只能解释为对实现的要求。

这意味着在后续分配返回先前释放的内存位置的特殊情况下,与释放它的代码存在隐含的 HB 关系。

这是否真的意味着执行解除分配的线程所执行的任何内存操作的可见性很少能通过执行内存分配的代码得到保证?

这看起来像是一个非常奇怪的实现负担,只有极其奇怪的代码才能使用,这些代码会记住已删除地址的副本,以防其他线程取回这些相同的地址(同时不创建任何同步,例如通过宽松的原子指针表示上的 RMW 操作)。

真正的实现是否注意真正提供这种可见性?内存分配函数是否被视为“黑盒”函数,还是编译器的获取(释放)操作?

额外的精度

这是一个潜在的问题案例:编译器可以提供属性来指定函数的语义属性(如 GCC Common Function Attributes),这些属性标识只能影响某些内存范围的函数(在当前的“模块”中,这将是编程领域不变量:用户、标准库、其他库...)。我承认我没有弄清楚细节,但它似乎直观可行,并且这些概念似乎对优化很有用。

如果实现必须跨分配提供此类保证,则注释将无效。

标签: c++multithreadinglanguage-lawyerdynamic-memory-allocationmemory-model

解决方案


围绕此类分配无法保证任意内存操作的可见性。

每个取消/分配都需要相对于其他分配进行排序,并且返回与先前分配相同的内存的分配是明确排序的。但这并不意味着对中间发生的其他操作进行排序。也就是说,它并不意味着所有其他操作的顺序一致性。

现在,可以看到其他操作,例如在释放之前发生的操作。但是释放之前的所有内容都没有。

该声明的重点是要明确指出,如果您确实获得了与先前分配相同的内存地址,那么导致您到达那里的顺序肯定涉及涉及该地址的内存分配和释放。

规格有时必须相当迂腐。

这看起来像是一个非常奇怪的负担

并不真地。可见性可能仅在内存释放/分配方面需要,但即使是可见性也不会令人惊讶或繁重。

毕竟,堆是共享资源。因此,作为共享资源,对它的访问往往被锁定在某种互斥锁后面。大多数互斥锁确保所有锁定/解锁的完全可见性。这很重要,因为管理堆需要编写堆管理数据,这些数据必须对尝试分配它的其他线程可见。所以你需要一个相当广泛的内存屏障来完成你作为线程可访问分配器的工作。

并不是有人将通用内存分配器误认为是一种快速操作。

因此,如果一个实现确实提供了完整的可见性,那可能是因为它需要管理堆内存,而其他获得可见性的内存访问只是顺其自然。

这是一个潜在的问题案例:编译器可以提供属性来指定函数的语义属性(如 GCC 通用函数属性),这些属性标识只能影响某些内存范围的函数(在当前的“模块”中,这将是一个编程领域)不变量:用户、标准库、其他库...)。

这样的函数不能调用编译器提供的通用内存分配器。根据定义,这样的函数需要严格控制它使用的内存,而基本的内存分配器并没有给你这个。因此,如果它需要动态分配,就必须使用专门的分配器,而这样的分配器可以按照它想要的任何规则来运行。


推荐阅读