boxing/unboxing C# 中的结构能否提供与原子相同的效果?

Can boxing/unboxing a struct in C# give the same effect of it being atomic?

根据 C# 规范,是否可以保证 foo.Bar 具有与原子相同的效果(即从不同线程读取 foo.Bar 在写入时永远不会看到部分更新的结构通过不同的线程)?

我一直认为它确实如此。如果确实如此,我想知道规范是否保证。

    public class Foo<T> where T : struct
    {
        private object bar;

        public T Bar
        {
            get { return (T) bar; }
            set { bar = value; }
        }
    }

    // var foo = new Foo<Baz>();

编辑:@vesan 这不是 Atomic Assignment of Reference Sized Structs 的副本。这个问题询问装箱和拆箱的效果,而另一个问题是关于结构中的单个引用类型(不涉及装箱/拆箱)。这两个问题之间唯一的相似之处是结构和原子这两个词(你真的读过这个问题了吗?)。

EDIT2:这是基于 Raymond Chen 的回答的原子版本:

public class Atomic<T> where T : struct
{
    private object m_Value = default(T);

    public T Value
    {
        get { return (T) m_Value; }
        set { Thread.VolatileWrite(ref m_Value, value); }
    }
}

EDIT3:4 年后重温此内容。事实证明,CLR2.0+ 的内存模型表明 All writes have the effect of volatile write: https://blogs.msdn.microsoft.com/pedram/2007/12/28/clr-2-0-memory-model/

因此这个问题的答案应该是 "It is atomic if hardware does not reorder writes",而不是 Raymond 的答案。 JIT 和编译器无法重新排序写入,因此基于雷蒙德答案的 "atomic version" 是多余的。在弱内存模型架构上,硬件可能会重新排序写入,因此您需要适当的 acquire/release 语义。

EDIT4:同样,这个问题归结为 CLR 与 CLI (ECMA),后者定义了一个非常弱的内存模型,而前者实现了一个强大的内存模型。虽然不能保证运行时会执行此操作,但答案仍然有效。然而,由于绝大多数代码过去和现在都是为 CLR 编写的,我怀疑任何试图创建新运行时的人都会采取更简单的方法并以牺牲性能为代价实现强大的内存模型(这只是我自己的观点)。

不,结果不是原子的。虽然对引用的更新确实是原子的,但它不是同步。可以在框对象内的数据可见之前更新引用。

让我们把事情分开。盒装类型 T 基本上是这样的:

class BoxedT
{
    T t;
    public BoxedT(T value) { t = value; }
    public static implicit operator T(BoxedT boxed) { return boxed.t; }
}

(不完全是,但对于本次讨论的目的来说已经足够接近了。)

当你写作时

bar = value;

这是shorthand

bar = new BoxedT(value);

好的,现在让我们拆开这个作业。涉及多个步骤。

  1. BoxedT分配内存。
  2. 使用 value 的副本初始化 BoxedT.t 成员。
  3. bar 中保存对 BoxedT 的引用。

步骤 3 的原子性意味着当您从 bar 读取时,您将获得旧值或新值,而不是两者的混合。但它不能保证同步。特别是,操作 3 可能在操作 2 之前对其他处理器可见。

假设 bar 的更新对另一个处理器可见,但 BoxedT.t 的初始化不可见。当该处理器尝试通过读取 Boxed.t 值来拆箱 BoxedT 时,不能保证读取在步骤 2 中写入的 t 的完整值。它可能只读取部分值,另一部分包含 default(T).

这基本上与双重检查锁定模式相同的问题,但更糟糕的是因为你根本没有锁!解决方案是使用释放语义更新 bar,以便在更新 bar 之前将所有先前的存储提交到内存。根据 C# 4 语言规范第 10.5.3 节,这可以通过将 bar 标记为 volatile 来完成。 (这也意味着来自 bar 的所有读取都将获得语义,这可能是也可能不是您想要的。)