为什么写入24位结构体不是原子性的(而写入32位结构体似乎是)?

14

我是一个爱折腾的人 - 毫无疑问。因此(除此之外很少),最近我进行了一个小实验,以确认我的怀疑:写入struct不是原子操作,这意味着试图强制执行某些约束条件的所谓“不可变”值类型可能在其目标上假设失败。

我撰写了一篇博客文章,其中使用以下类型作为示例:

struct SolidStruct
{
    public SolidStruct(int value)
    {
        X = Y = Z = value;
    }

    public readonly int X;
    public readonly int Y;
    public readonly int Z;
}

尽管上述代码看起来似乎永远不可能出现 X != YY != Z,但实际上,如果一个值正在被“中途赋值”,同时又被另一个线程复制到另一个位置,这种情况就会发生。

好的,这只是一个奇思妙想且没有多大意义。但我有一个想法:我的64位CPU应该能够原子地复制64位,对吗?那么如果我摆脱了Z,只用XY呢?那只有64位;应该可以一步就覆盖它们。

果然,成功了。(我知道你们中的一些人可能正在皱眉头,想着,“是啊,当然。这怎么会有趣呢?”让我自high娱一下。)当然,我不知道这是否得到了保证,考虑到我的系统。我对寄存器、缓存未命中等几乎一无所知。 (我只是背诵听到过的术语而已);所以这一切对我来说都是黑匣子。

接下来,我尝试了一下——仍然只是出于直觉——一个由2个short字段组成的32位结构体。这似乎也表现出了“原子可赋值”的特性。但是,然后我尝试用3个byte字段来创建一个24位结构:结果不行。

突然间,结构体又出现了“中途赋值”复制的问题。

用2个byte字段降到16位:又可原子化了!

有人能解释一下这是为什么吗?我听说过“位打包”,“缓存线跨越”,“对齐”等,但我不知道所有这些术语的含义,也不知道它们在这里是否相关。但是,我感觉有一个模式,只是说不清楚是什么;如果有人能解释一下,那就太好了。


2
CLI规范对32位目标上的1、2和4字节内存访问以及64位目标上的8字节内存访问做出了原子性保证,但前提是变量被正确地对齐。这并不包括结构体,使用StructLayout.Pack可能会破坏它们。 - Hans Passant
对于那些在搜索多线程问题时找到这篇文章的人,你不应该依赖于 CPU 的原子性来保证线程安全。 - Justin Morgan
4个回答

15
您要查找的模式是CPU的本地字长。
从历史上看,x86系列处理器使用16位值(以前是8位值)作为本地元素大小。因此,您的CPU可以原子性地处理这些值:设置这些值只需一条指令。
随着时间的推移,本地元素大小增加到32位,后来变成了64位。在每种情况下,都添加了一条指令来处理特定数量的位数。但是,为了向后兼容,旧指令仍然保留,所以您的64位处理器可以处理所有先前的本地大小。
由于您的结构元素存储在连续的内存中(没有填充,即空间),运行时可以利用这个知识仅对这些大小的元素执行单个指令。简而言之,这创造了您看到的效果,因为CPU一次只能执行一条指令(尽管我不确定多核系统是否可以保证真正的原子性)。
但是,本地元素大小从未为24位。因此,没有单个指令可写入24位,因此需要多条指令,这样就失去了原子性。

太棒了,你每天都可以学到新东西!有时候甚至是很多东西。感谢这个历史解释。 - Dan Tao
2
@Dan:你可能已经通过使用24位结构进行实验弄清楚了,但这不是你应该依赖的东西。最好进行适当的锁定,因为虽然运行时停止使用此优化的可能性很小,但适当的锁定可以保护你,以防你的结构体大小需要更改。 - Michael Madsen

6

C#标准(ISO 23270:2006ECMA-334)对原子性的要求如下:
12.5 变量引用的原子性 以下数据类型的读写应该是原子的:bool、char、byte、sbyte、short、ushort、uint、int、float和引用类型。此外,具有前面列表中基础类型的枚举类型的读写也应该是原子的。其他类型的读写,包括long、ulong、double和decimal,以及用户定义的类型,不需要是原子的。我强调)除了专门设计用于此目的的库函数外,没有保证原子读取-修改-写入,例如增量或减量。
你的例子 X = Y = Z = value 是三个单独赋值操作的简写,每个操作都由12.5定义为原子。这三个操作的顺序(将value分配给Z,将Z分配给Y,将Y分配给X不能保证是原子的。

由于语言规范没有强制要求原子性,因此X = Y = Z = value;的操作可能是一个原子操作,但它是否是取决于许多因素:

  • 编译器编写者的喜好
  • 在构建时选择了哪些代码生成优化选项(如果有的话)
  • 负责将程序集的IL转换为机器语言的JIT编译器的详细信息。在Mono中运行的相同IL可能会表现出不同的行为,例如与.Net 4.0(甚至可能与早期版本的.Net不同)。
  • 程序集所运行的特定CPU。

还应注意的是,即使是单个机器指令也不一定保证是原子操作-许多指令是可中断的。

此外,我们在访问CLI标准(ISO 23217:2006)时发现第12.6.6节:

12.6.6 原子读写 当所有对某个位置的写访问大小相同时,符合 CLI 标准的实现必须保证对正确对齐的内存位置(不大于本机字长即 native int 类型大小)的读写访问是原子的(参见 §12.6.2)。原子写操作不会影响除被写入的位以外的其他位。除非使用显式布局控制(参见第 II 部分(控制实例布局))来改变默认行为,否则不大于本机字长的数据元素(即 native int 大小)将被正确对齐。对象引用将被视为存储在本机字长中。

[注意: 除了类库提供的用于此目的的方法(参见第 IV 部分),没有关于内存原子更新(读-修改-写)的保证。 (我加重了语气)在硬件不支持直接写入小数据项时,要求对“小数据项”(不大于本机字长的项目)进行原子读/修改/写入。 结束注意]

[注意: 当本机 int 的大小为 32 位时,即使某些实现在数据对齐到 8 字节边界时执行原子操作,也没有保证对 8 字节数据的原子访问。 结束注意]


总的来说,这是非常有帮助和信息量丰富的答案... 我只有一个评论,除非我弄错了,否则我在问题中描述的缺乏原子性与 X = Y = Z = value 的赋值无关。它实际上是由于在后台线程上将赋值复制到本地变量的同时("copy out"),在主线程上将赋值复制到静态字段("copy in")所导致的结果。如果我选词不当,请原谅(可能);无论如何,这是有道理的吗? - Dan Tao
1
@Dan Tao:正确。multipart赋值x = y = z = value的原子性只有在另一个线程中有东西写入value时才会成为问题。由于上面示例中value是一个函数参数,它是一个局部变量,不可被其他线程访问,因此在您特定的示例中,这不是一个问题。value在multipart赋值期间不会改变。而且,由于这个赋值是在构造函数中完成的,所以没有其他人能看到这个实例,所以在赋值过程中观察者看到属性处于不一致状态的问题也不存在。 - dthorpe
这让我们回到ISO 23270第12.5节(请参见上文):“读取和写入用户定义的[值]类型不需要是原子的。” - Nicholas Carey

4

x86 CPU操作以8、16、32或64位为单位进行;操纵其他大小需要多个操作。


好的,这可能是一个愚蠢的问题,但是:如果打算在高性能场景中使用值类型,那么将其“填充”到这些位数之一(带有可丢弃的byte字段)是否有意义? - Dan Tao
2
在x86上,64位读/写操作在跨越缓存行边界时不具有原子性。 - Hans Passant

4
编译器和x86 CPU将仅移动结构定义的确切字节数。没有x86指令可以一次移动24位,但是有用于8、16、32和64位数据的单指令移动。
如果您向24位结构添加另一个字节字段(使其成为32位结构),则应该看到原子性返回。
一些编译器允许您在结构体上定义填充以使它们表现得像本机寄存器大小的数据。如果您填充24位结构,则编译器将添加另一个字节来“舍入”大小为32位,以便整个结构可以在一个原子指令中移动。缺点是您的结构将始终占用更多的内存空间(30%)。
请注意,内存中结构的对齐也对原子性至关重要。如果多字节结构不从对齐地址开始,则可能跨越CPU高速缓存中的多个缓存行。读取或写入此数据将需要多个时钟周期和多个读/写操作,即使操作码是单个移动指令。因此,即使对于数据未对齐的情况,单指令移动也可能不是原子的。x86保证原生大小的读/写在对齐边界上具有原子性,即使在多核系统中也是如此。
使用x86 LOCK前缀可以通过多步移动实现内存原子性。但是,应避免使用此方法,因为在多核系统中可能非常昂贵(LOCK不仅会阻止其他核心访问内存,还会锁定系统总线以进行操作的持续时间,这可能会影响磁盘I/O和视频操作。 LOCK还可能强制其他内核清除其本地缓存)。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接