删除对象后未完成的存储会发生什么情况



考虑以下简单函数(假设大多数编译器优化已关闭),由具有存储缓冲区的 X86 CPU 上不同内核上的两个线程执行:

struct ABC
{
int x;
//other members.
};
void dummy(int index)
{
while(true)
{
auto abc = new ABC;
abc->x = index;
cout << abc->x;
// do some other things.
delete abc;
}
}

在这里,index 是线程的索引;1 由线程 1 传递,2 由线程 2 传递。 因此,thread1 应始终打印 1,线程 2 应始终打印 2。

是否存在将 x 的存储放在存储缓冲区中并在执行删除后提交的情况?或者是否存在隐式内存屏障,可确保在删除之前提交存储?还是一旦遇到删除,任何未完成的存储都会被丢弃?

这变得很重要的情况:

由于 delete 将对象的内存返回到空闲列表(使用 libc),因此线程 2 中的新运算符可能会返回在 thread1 中刚刚释放的一段内存(不仅是虚拟地址,甚至返回的基础物理地址也可以相同)。 如果删除后可以执行未完成的存储,则在 thread2 将 abc->x 设置为 2 之后,thread1 中的一些较旧的未完成存储可能会将其覆盖为 1。

这意味着在上面的程序中,thread2 可以打印 1,这是绝对错误的。线程 1 和线程 2 是完全独立的,从程序员的角度来看,线程之间没有数据共享,他们不必担心任何同步。

我在这里错过了什么?

在单个线程内

CPU必须保留指令按程序顺序为单个线程一次执行一个指令的错觉。 这是 OoO exec 的基本规则。这意味着跟踪程序顺序,并确保加载始终看到与此一致的值,并且最终写入缓存的值也是一致的。

这很像C++的"假设"规则,只是需要保留不同的可观察量。 (与 CPU ISA 不同,C++在法律允许观察其他线程方面非常严格,但编译时和运行时内存重新排序都不能通过重新排序源代码行来解释 1)

此核心的加载会侦听存储缓冲区,如果加载正在重新加载尚未提交的存储,则从中转发数据。

对于任何单个内存位置,请确保其修改顺序与程序顺序匹配,即不将存储重新排序到同一位置。 因此,尘埃落定后的最终值是程序顺序中的最后一个值。 甚至其他线程的观察也会看到该位置的一致修改顺序;这就是为什么std::atomic能够提供每个对象单独存在的修改顺序的保证,如果程序顺序存储了 B 然后 A,则不会对 A 然后 B 进行额外的更改,然后再更改回 A。 ISO C++ 可以保证这一点,因为所有现实世界的 CPU 也保证了这一点。

munmap这样的系统调用是一种特例,但就CPU而言,new/delete(和malloc/free)并不特殊:将一个块放在空闲列表中并让其他代码分配它只是另一种弄乱基于指针的数据结构的情况。 与往常一样,CPU 会跟踪其执行的任何重新排序,以确保负载看到正确的值。


被另一个线程重用

你担心这一点并没有错;正确性在这里不会仅仅基于CPU架构免费发生;一个有缺陷的libc可能会弄错这个,并允许你描述的问题。 @ixSci的回答引用了C++标准的相关部分。 (内存访问的编译时排序也是必要的。 对new/delete的调用也是必要的,但是对于编译器不知道是"纯"的任何非内联函数调用,这总是必须发生;任何函数都可能读取或写入内存,因此它必须同步。

如果内存被放置在可由另一个线程重用的全局空闲列表中,则线程安全分配器将使用足够的同步来在以前使用然后删除内存的代码与刚刚分配内存的另一个线程中的代码之间创建C++发生前关系

因此,存储到此内存块中的任何旧线程都已经对刚刚分配内存的线程可见。 所以他们不会踩到它的商店。 如果新线程将指向此内存的指针传递给第 3 个线程,则最好使用 acq/rel 或消耗/释放同步本身,以确保第 3 个线程看到其存储,而不是仍然从第一个线程存储。


完全取消映射,因此访问该虚拟地址错误

如果free涉及一个munmap,该使用syscall指令来运行更改页表的内核代码(使映射无效,因此加载/存储到它会产生错误),则该映射本身将提供足够的序列化。 现有 CPU 不会重命名权限级别,因此它们不会通过系统调用指令对内核执行无序执行。

操作系统需要围绕修改页表进行足够的内存屏障,尽管在 x86-64 上invlpg已经是一个序列化指令。 (在 x86 术语中,这意味着耗尽 ROB 和存储缓冲区,因此所有先前的指令都完全执行,其结果写回 L1d 缓存(用于存储)。 因此,即使切换到内核模式,它也不可能使用依赖于该 TLB 条目的早期加载/存储重新排序。

(不过,切换到内核模式并不一定会耗尽存储缓冲区;这些存储的物理地址是已知的。 TLB 检查是在执行存储地址 uop 时完成的。 因此,对页表的更改不会影响将它们提交到内存的过程。


脚注 1:内存重新排序不是源重新排序

顺便说一句,内存重新排序不像C++源代码中的重新排序语句或 asm 机器代码中的指令那样工作;内存重新排序是关于其他线程可以观察到的内容,因为从缓存读取的负载和存储最终提交到存储缓冲区远端的缓存。 重新排序源以尝试解释这会破坏代码,违反了 as-if 规则,但内存重新排序会产生这样的效果,同时仍然让线程的操作看到其自己的存储的正确值,例如通过存储转发。 这是因为现实世界的 ISA 没有顺序一致的内存模型;您需要额外的订购才能恢复SC。 例如,即使是有序的 CPU 管道也可以使用可能命中命中的缓存对加载进行重新排序,甚至强序 x86 也允许 StoreLoad 重新排序:它的内存模型基本上是程序顺序加上带有存储转发的存储缓冲区。

(评论中有关于编译时重新排序和源代码排序的讨论;这个问题没有这种误解。

C++ as-if 规则与 CPU 在执行时遵循的思想相同,只是 ISA 的规则控制着对外部可观察量的要求。 没有 ISA 具有像 ISO C++ 那样弱的内存排序规则,例如,它们都保证一致的共享缓存,并且许多 CPU ISA 没有 UB。 (尽管有些人这样做,例如称其为"不可预测"的行为。 更常见的是,在某些寄存器中只是一个不可预测或未定义的结果;用户/主管权限分离要求对可能的行为进行限制,以便用户空间无法运行某些不受支持的指令序列,并可能接管或崩溃整个机器。

有趣的事实:特别是在强排序的 x86 上,存储和加载排序需要比大多数 ISA 更紧密地联系在一起;英特尔将存储缓冲区 + 负载缓冲区的组合称为内存顺序缓冲区,因为它还必须检测负载在架构允许之前提前获取值的情况(LoadLoad 排序),但后来发现该内核无法访问缓存行。 或者在对商店转发进行错误推测的情况下,例如动态预测负载将从未知地址重新加载商店,但事实证明商店是非重叠的。 在任一情况下,CPU 都会将无序后端回退到一致的停用状态。 (这称为管道核弹;此特定原因由machine_clears.memory_ordering性能事件计算。

根据 C++20 (new.delete.dataraces/p1),我们有以下保证:

调用分配或取消分配特定单元的这些函数 的存储应发生在一个总订单中,并且每个这样的 解除分配调用应在 (6.9.2) 下一次分配之前发生(如果 任意)按此顺序。

由于每个delete都发生在同一内存的任何new之前,因此在这些运算符之前排序的内容也会在这些其他调用之前发生。对于您的示例:

abc->x = index;delete abc;之前进行排序,这发生在auto abc = new ABC;之前,并且传递abc->x = index;发生在auto abc = new ABC;之前。这保证了abc->x = index;auto abc = new ABC;之前完成。

相关内容

最新更新