为啥写入 24 位结构不是原子的(当写入 32 位结构时)?
Posted
技术标签:
【中文标题】为啥写入 24 位结构不是原子的(当写入 32 位结构时)?【英文标题】:Why is writing to a 24-bit struct not atomic (when writing to a 32-bit struct appears to be)?为什么写入 24 位结构不是原子的(当写入 32 位结构时)? 【发布时间】:2011-06-23 19:59:12 【问题描述】:我是个修补匠——这一点毫无疑问。出于这个原因(除此之外几乎没有),我最近做了一个小实验来证实我的怀疑,即写入 struct
不是原子操作,这意味着所谓的“不可变" 试图强制执行某些约束的值类型可能会在其目标上失败。
我写了a blog post about this,使用以下类型作为说明:
struct SolidStruct
public SolidStruct(int value)
X = Y = Z = value;
public readonly int X;
public readonly int Y;
public readonly int Z;
虽然上述 看起来 像 X != Y
或 Y != Z
永远不可能为真的类型,但实际上如果值为 " mid-assignment”,同时它被一个单独的线程复制到另一个位置。
好的,很重要。好奇心等等。但后来我有了这样的预感:我的 64 位 CPU 应该实际上能够以原子方式复制 64 位,对吗?那么如果我摆脱了Z
并坚持使用X
和Y
怎么办?那只有 64 位;应该可以一步覆盖那些。
果然奏效了。(我意识到你们中的一些人现在可能正在皱起眉头,想,是啊,呃。这有什么有趣的?幽默我。)当然,我不知道我的系统是否能保证这一点。我对寄存器、缓存未命中等几乎一无所知(我实际上只是在重复我听过的术语而没有理解它们的含义);所以目前这对我来说都是一个黑匣子。
我再次尝试的下一个尝试是使用 2 个short
字段的由 32 位组成的结构。这似乎也表现出“原子可分配性”。但是然后我尝试了一个 24 位结构,使用 3 个 byte
字段:不行。
突然间,该结构似乎再次容易受到“中间分配”副本的影响。
低至 16 位,带有 2 个 byte
字段:又是原子的!
有人可以向我解释这是为什么吗?我听说过“位打包”、“缓存行跨界”、“对齐”等——但同样,我真的不知道这意味着什么,也不知道它是否与这里相关。但是我感觉就像我看到了一种模式,但无法准确地说出它是什么;清晰度将不胜感激。
【问题讨论】:
CLI 规范保证 32 位目标上的 1、2 和 4 字节内存访问的原子性,64 位目标上的 8 字节内存访问,但前提是变量正确对齐。这不包括结构,您可以使用 StructLayout.Pack 将它们搞砸。 对于在搜索多线程问题时发现此问题的任何人,您不应依赖 CPU 原子性来确保线程安全。 【参考方案1】:您正在寻找的模式是 CPU 的本机字长。
从历史上看,x86 系列原生使用 16 位值(在此之前,使用 8 位值)。因此,您的 CPU 可以自动处理这些值:设置这些值只需一条指令。
随着时间的推移,原生元素大小增加到 32 位,后来增加到 64 位。在每种情况下,都添加了一条指令来处理这个特定数量的位。但是,为了向后兼容,旧指令仍然保留,因此您的 64 位处理器可以使用所有以前的本机大小。
由于您的 struct 元素存储在连续的内存中(没有填充,即空白空间),运行时可以利用此知识仅对这些大小的元素执行单条指令。简而言之,这会产生您所看到的效果,因为 CPU 一次只能执行一条指令(尽管我不确定在多核系统上是否可以保证真正的原子性)。
但是,本机元素大小从来不是 24 位。因此,没有一条指令可以写入 24 位,因此需要多条指令,这样就失去了原子性。
【讨论】:
太棒了——你每天都能学到新东西!有时很多东西。感谢历史解释。 @Dan:由于您对 24 位结构的实验,您可能已经明白了这一点,但这不是您应该依赖的。最好进行适当的锁定,因为虽然运行时不太可能停止使用这种优化,但适当的锁定可以保护您,以防结构的大小需要更改。【参考方案2】:C# 标准(ISO 23270:2006、ECMA-334)在原子性方面有这样的说法:
12.5 变量引用的原子性 以下数据类型的读取和写入应是原子的:bool、char、byte、sbyte、short、ushort、 uint、int、float 和引用类型。此外,具有基础类型的枚举类型的读写 在前面的列表中也应该是原子的。 其他类型的读写,包括long、ulong、double、 和十进制,以及用户定义的类型,不需要是原子的。(强调我的)除了设计的库函数 为此,不保证原子读-修改-写,例如在增量或 递减。您的示例
X = Y = Z = value
是 3 个单独的赋值操作的简写,每个赋值操作都在 12.5 中定义为原子操作。 3 个操作的顺序(将value
分配给Z
,将Z
分配给Y
,将Y
分配给X
)保证不是原子的。
由于语言规范不要求原子性,而X = Y = Z = value;
可能是原子操作,是否是原子操作取决于一大堆因素:
人们可能还会注意到,即使是一条机器指令也不一定是原子操作——许多指令是可中断的。
进一步,访问 CLI 标准 (ISO 23217:2006),我们找到第 12.6.6 节:
12.6.6 原子读写 符合标准的 CLI 应保证正确的读写访问 对齐的内存位置不大于本机字大小(类型的大小
native int
) 是原子的(参见 §12.6.2),当对一个位置的所有写访问都是 相同的大小。原子写入不应改变除已写入的位之外的任何位。除非 显式布局控制(参见 Partition II (Controlling Instance Layout))用于 改变默认行为,数据元素不大于自然字长(native int
) 的大小应正确对齐。应处理对象引用 就好像它们以原生字长存储一样。[注意:不保证 关于内存的原子更新(读-修改-写),除了提供的方法 该目的作为类库的一部分(参见第 IV 部分)。(强调我的) “小数据项”的原子写入(不大于本机字大小的项) 需要在不支持直接的硬件上进行原子读取/修改/写入 写入小数据项。 结束说明]
[注意: 当大小为 本机 int 是 32 位,即使某些实现可能执行原子 数据在 8 字节边界上对齐时的操作。 结束说明]
【讨论】:
一般来说非常有帮助且内容丰富的答案...我只有一条评论,除非我弄错了,否则我在问题中描述的缺乏原子性是不是 与X = Y = Z = value
分配相关。它是“复制出”(分配给后台线程上的局部变量)同时作为“复制入”(分配给主线程上的静态字段)的结果)。如果这些术语选择不当(可能),请原谅我;无论如何,这有意义吗?
@Dan Tao:正确。多部分赋值 x = y = z = value 的原子性只有在同时在另一个线程中写入 value 时才会成为问题。由于上面示例中的 value 是函数参数,因此它是局部变量,任何其他线程都无法访问,因此在您的特定示例中这不是问题。在多部分分配期间该值不会改变。而且由于这个赋值是在构造函数中进行的,所以没有其他人可以看到这个实例,所以观察者在赋值期间看到属性处于不一致状态是没有问题的。
这让您回到 ISO 23270 第 12.5 节(见上文):“读取和写入...用户定义的 [值] 类型,不必是原子的。”【参考方案3】:
x86 CPU 操作以 8、16、32 或 64 位进行;操作其他尺寸需要多次操作。
【讨论】:
好的,这可能是一个愚蠢的问题,但是:如果它是有意的,那么将一个值类型“填充”到这些位数之一(带有一次性byte
字段)是否有意义用于高性能场景?
64 位读/写在 x86 上不是原子的,它们可以跨越缓存线边界。【参考方案4】:
编译器和 x86 CPU 会小心地只移动结构定义的字节数。没有 x86 指令可以在一次操作中移动 24 位,但是对于 8、16、32 和 64 位数据,有单条指令移动。
如果您将另一个字节字段添加到您的 24 位结构(使其成为 32 位结构),您应该会看到您的原子性返回。
一些编译器允许您在结构上定义填充以使它们的行为类似于本机寄存器大小的数据。如果填充 24 位结构,编译器将添加另一个字节以将大小“四舍五入”为 32 位,以便整个结构可以在一个原子指令中移动。缺点是你的结构总是会多占用 30% 的内存空间。
请注意,内存中结构的对齐对于原子性也很重要。如果多字节结构不是从对齐地址开始,它可能会跨越 CPU 缓存中的多个缓存行。即使操作码是单个移动指令,读取或写入此数据也需要多个时钟周期和多次读取/写入。因此,如果数据未对齐,即使单个指令移动也可能不是原子的。 x86 确实保证了在对齐边界上的原生大小读/写的原子性,即使在多核系统中也是如此。
可以使用 x86 LOCK 前缀通过多步移动实现内存原子性。然而,这应该避免,因为它在多核系统中可能非常昂贵(LOCK 不仅阻止其他内核访问内存,它还在操作期间锁定系统总线,这可能会影响磁盘 I/O 和视频操作。LOCK 可能还强制其他核心清除其本地缓存)
【讨论】:
以上是关于为啥写入 24 位结构不是原子的(当写入 32 位结构时)?的主要内容,如果未能解决你的问题,请参考以下文章