关于数据竞赛,标准能保证什么



这是关于C#中多线程问题的讨论的延续。

在C++中,如果涉及写操作,那么从多个线程对共享数据的无保护访问是未定义的行为。它在C#中是什么?由于C#的(安全部分)不包含未定义的行为,有什么保证吗?C#似乎也有一种似是而非的规则,但在阅读了标准中提到的部分后,我没有从语言的角度看不到未受保护的数据访问会产生什么后果。

特别是,通过该语言了解哪些优化(包括负载融合和发明)是被禁止的,这很有趣。这一禁令意味着C#中几种流行模式(包括原问题中讨论的模式)的有效性(或缺乏有效性)。

[尽管Microsoft CLR中实际实现的细节非常有趣,但它并不是这个问题的一部分:这里只讨论语言本身(因此是可移植的)提供的保证。]

规范性参考文献非常受欢迎,但我怀疑C#标准在这个主题上有足够的信息。也许语言团队的某个人可以阐明什么是实际的保证,这些保证稍后将被纳入标准,但现在可以依赖。

我怀疑有一些隐含的保证,比如没有指针引用撕裂,因为这很容易导致破坏类型安全性。但我不是这方面的专家。


*通常缩写为UB。Undefined Behavior允许C++编译器生成任何代码,包括格式化硬盘或其他什么,或者在编译时崩溃。

.net运行时保证对某些变量类型的写入是原子

以下数据类型的读取和写入应为原子类型:bool、char、byte、sbyte、short、ushort、uint、int、float和引用类型。此外,对前面列表中具有底层类型的枚举类型的读取和写入也应是原子类型。其他类型的读取和写入,包括long、ulong、double和decimal,以及用户定义的类型,不需要是原子类型。除了为此目的设计的库函数之外,不能保证原子读-修改-写,例如在递增或递减的情况下。

没有提到的是IntPtr,我相信它也保证是原子的。由于引用是原子的,因此保证不会被撕裂。更多信息请参见C#-理论和实践中的C#内存模型

还应保证内存安全,即任何内存访问都将引用有效内存,并且所有内存在使用前都已初始化。非托管资源、不安全代码和stackalloc等情况除外。

关于优化的一般规则是,编译器/抖动可以执行任何优化,只要结果对于单线程程序是相同的。因此,在没有任何同步的情况下,撕裂、融合、重新排序等都是可能的。

因此,只要存在多个线程同时使用同一内存进行除读取以外的任何操作的可能性,请始终使用适当的同步。请注意,ARM的内存排序保证比x86/x64弱,这进一步强调了同步的必要性。

正如@JonasH所提到的,C#规范只保证原子访问大小为32位或更小的值。

但是,假设您可以依赖始终在符合ECMA-335的运行时上实现的C#,那么您也可以依赖该规范。这应该是安全的,因为.Net的所有实现,包括Mono和WASM,都符合ECMA-335(它不是微软独有的规范)。

ECMA-335保证访问本地大小的值,其中包括IntPtr和对象引用,以及64位体系结构上的64位整数。

ECMA-335说: (我的粗体)

12.6.6原子读写

当对一个位置的所有写访问都是相同的大小时,合格的CLI应保证对正确对齐的内存位置的读写访问不大于本机字大小(类型native int的大小)是原子的(见§12.6.2)。原子写入只能更改写入的位。除非使用显式布局控制(参见分区II(控制实例布局))来更改默认行为,否则不大于自然字大小(native int的大小)的数据元素应正确对齐。对象引用应被视为以原生单词大小存储。

[注:内存的原子更新(读-修改-写)不能保证,但作为类库一部分提供的方法除外(请参阅分区IV)。在不支持直接写入小数据项的硬件上进行原子读/修改/写时,需要对"小数据项"(不大于本机字大小的项)进行原子写。结束注释]


您似乎特别询问了代码的原子性

if (SomeEvent != null) SomeEvent(this, args);

无论是C#规范还是.NET规范,这段代码都不能保证是线程安全的。虽然优化JIT编译器可能会生成线程安全的代码,但依赖它是不安全的。

相反,使用更好(更简洁)的代码,这可以保证线程安全。

SomeEvent?.Invoke(this, args);

最新更新