关于多线程:在C#中访问变量是一个原子操作吗?

Is accessing a variable in C# an atomic operation?

我一直认为,如果多个线程可以访问一个变量,那么所有对该变量的读取和写入都必须受到同步代码的保护,例如"lock"语句,因为处理器可能在写入过程中切换到另一个线程。

但是,我使用Reflector查看了system.web.security.membership,发现了如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public static class Membership
{
    private static bool s_Initialized = false;
    private static object s_lock = new object();
    private static MembershipProvider s_Provider;

    public static MembershipProvider Provider
    {
        get
        {
            Initialize();
            return s_Provider;
        }
    }

    private static void Initialize()
    {
        if (s_Initialized)
            return;

        lock(s_lock)
        {
            if (s_Initialized)
                return;

            // Perform initialization...
            s_Initialized = true;
        }
    }
}

为什么在锁外读取S_初始化字段?难道另一个线程不能同时写入它吗?变量的读写是原子的吗?


对于确定的答案,请参阅规范。)

cli规范第12.6.6节的分区i指出:"当对某个位置的所有写入访问大小相同时,一致的cli应确保对不大于本机字大小的正确对齐内存位置的读写访问是原子的。"

因此,这证实了S_初始化永远不会是不稳定的,对小于32位的primitve类型的读写是原子的。

特别是,doublelong(Int64UInt64)不能保证在32位平台上是原子的。您可以使用Interlocked类上的方法来保护这些方法。

此外,虽然读和写是原子的,但有一个加、减、递增和递减原语类型的竞争条件,因为它们必须被读取、操作和重写。联锁类允许您使用CompareExchangeIncrement方法来保护它们。

互锁产生一个内存屏障,以防止处理器重新排序读写。在这个例子中,锁创建了唯一需要的屏障。


这是双重检查锁定模式的一种(坏)形式,在C中它不是线程安全的!

此代码中有一个大问题:

已初始化的S_不易挥发。这意味着初始化代码中的写入可以在s_initialized设置为true之后移动,而其他线程可以看到未初始化的代码,即使s_initialized对它们为true也是如此。这不适用于Microsoft的框架实现,因为每个写操作都是不稳定的。

但在Microsoft的实现中,对未初始化数据的读取也可以重新排序(即由CPU预取),因此,如果"初始化"为"真",则读取应初始化的数据可能会导致由于缓存命中而读取旧的未初始化数据(即重新排序读取)。

例如:

1
2
3
4
5
Thread 1 reads s_Provider (which is null)  
Thread 2 initializes the data  
Thread 2 sets s\_Initialized to true  
Thread 1 reads s\_Initialized (which is true now)  
Thread 1 uses the previously read Provider and gets a NullReferenceException

在初始化读取S_之前移动S_提供程序的读取是完全合法的,因为任何地方都没有易失性读取。

如果初始化的s_是不稳定的,则在初始化s_的读取之前,不允许移动s_提供程序的读取,并且在s_初始化设置为true且现在一切正常后,不允许移动提供程序的初始化。

JoeDuffy还写了一篇关于这个问题的文章:双重检查锁上的损坏变体


等等——标题上的问题绝对不是罗里问的真正的问题。

有名无实的问题有一个简单的答案"不",但当你看到真正的问题时,这根本帮不上什么忙——我认为没有人给出过一个简单的答案。

罗里提出的真正的问题会在很晚的时候提出,并且与他给出的例子更为相关。

Why is the s_Initialized field read
outside of the lock?

答案也很简单,尽管与变量访问的原子性完全无关。

因为锁很昂贵,所以在锁外部读取s_初始化字段。

由于S_初始化字段本质上是"写入一次",它将永远不会返回假阳性。

在锁外面读是很经济的。

这是一项低成本活动,很有可能获得利益。

这就是为什么它是在锁外读取的——以避免支付使用锁的费用,除非有指示。

如果锁便宜的话,代码就更简单了,并且省略了第一次检查。

(编辑:罗里的回答很好。是的,布尔读取是非常原子化的。如果有人构建了一个具有非原子布尔值读取的处理器,那么他们将在dailywtf中显示出来。)


正确的答案似乎是,"是的,大多数情况下。"

  • 约翰引用cli规范的答案表明,访问32位处理器上不超过32位的变量是原子的。
  • C规范第5.5节,变量引用原子性的进一步确认:


    Reads and writes of the following data types are atomic: bool, char, byte, sbyte, short, ushort, uint, int, float, and reference types. In addition, reads and writes of enum types with an underlying type in the previous list are also atomic. Reads and writes of other types, including long, ulong, double, and decimal, as well as user-defined types, are not guaranteed to be atomic.

  • 我的示例中的代码是从成员类中改写的,正如ASP.NET团队自己编写的那样,因此始终可以安全地假设它访问s_初始化字段的方式是正确的。现在我们知道为什么了。

  • 编辑:正如ThomasDanecker指出的那样,即使字段的访问是原子的,初始化的s_也应该标记为volatile,以确保锁定不会被处理器重新排序读写而中断。


    初始化功能出现故障。看起来应该更像这样:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    private static void Initialize()
    {
        if(s_initialized)
            return;

        lock(s_lock)
        {
            if(s_Initialized)
                return;
            s_Initialized = true;
        }
    }

    如果不在锁内进行第二次检查,可能会执行两次初始化代码。因此,第一个检查是为了节省您不必要地使用锁的性能,第二个检查是针对线程正在执行初始化代码但尚未设置s_Initialized标志的情况,因此第二个线程将通过第一个检查并等待锁。


    我想你在问,当在锁外读取时,s_Initialized是否处于不稳定状态。简短的答案是否定的。一个简单的分配/读取将归结为一个单一的汇编指令,它在我能想到的每一个处理器上都是原子的。

    我不确定分配给64位变量是什么情况,这取决于处理器,我假设它不是原子的,但它可能在现代32位处理器上,当然在所有64位处理器上。复杂值类型的赋值将不是原子的。


    变量的读写不是原子的。您需要使用同步API来模拟原子读/写。

    要获得关于这方面的出色参考以及更多与并发性相关的问题,请确保您抓取了JoeDuffy最新的奇观的副本。这是一个开膛手!


    "访问C中的变量是原子操作吗?"

    不。它不是一个C事物,也不是一个.NET事物,它是一个处理器事物。

    OJ就是其中的一员,乔·达菲就是要去了解这类信息的人。如果你想知道更多,"连锁"是一个很好的搜索词。

    "撕裂读取"可以发生在字段总和超过指针大小的任何值上。


    You could also decorate s_Initialized with the volatile keyword and forego the use of lock entirely.

    这是不正确的。在第一个线程有机会设置标志之前,您仍然会遇到第二个线程通过检查的问题,这将导致多次执行初始化代码。


    @列昂我明白你的观点——我问过的,然后评论过的,这个问题可以用两种不同的方式来表达。

    为了清楚起见,我想知道在没有任何显式同步代码的情况下,让并发线程读写布尔字段是否安全,即访问布尔(或其他基元类型)变量原子。

    然后,我使用成员代码给出了一个具体的例子,但是这引入了一些干扰,比如双重检查锁定,S_初始化的事实只有一次设置,并且我注释了初始化代码本身。

    我的错。


    对布尔值的If (itisso) {检查是原子性的,但即使它不是原子性的。无需锁定第一张支票。

    如果任何线程完成了初始化,那么它将是真的。一次检查几个线程并不重要。他们都会得到相同的答案,不会有冲突。

    锁内的第二个检查是必需的,因为另一个线程可能先获取了锁,并且已经完成了初始化过程。


    我认为它们是-我不确定您的示例中的锁的位置,除非您同时对S_提供商做了一些事情-那么锁将确保这些调用一起发生。

    //Perform initialization评论是否涵盖了创建S U供应商?例如

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    private static void Initialize()
    {
        if (s_Initialized)
            return;

        lock(s_lock)
        {
            s_Provider = new MembershipProvider ( ... )
            s_Initialized = true;
        }
    }

    否则,静态属性get将返回空值。


    也许联锁提供了一个线索。否则这个我就很好了。

    我早就猜到它们不是原子的了。


    你要问的是,是否多次访问一个方法中的一个字段是原子的——答案是否定的。

    在上面的示例中,初始化例程有故障,因为它可能导致多次初始化。您需要检查锁内和锁外的s_Initialized标志,以防止多个线程在实际执行初始化代码之前读取s_Initialized标志的争用情况。例如。,

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    private static void Initialize()
    {
        if (s_Initialized)
            return;

        lock(s_lock)
        {
            if (s_Initialized)
                return;
            s_Provider = new MembershipProvider ( ... )
            s_Initialized = true;
        }
    }

    要使代码始终在弱顺序的体系结构上工作,必须在编写初始化的s_之前放置一个memorybarrier。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    s_Provider = new MemershipProvider;

    // MUST PUT BARRIER HERE to make sure the memory writes from the assignment
    // and the constructor have been wriitten to memory
    // BEFORE the write to s_Initialized!
    Thread.MemoryBarrier();

    // Now that we've guaranteed that the writes above
    // will be globally first, set the flag
    s_Initialized = true;

    在membershipprovider构造函数中发生的内存写入和对s_provider的写入在弱顺序处理器上初始化s_之前不保证发生。

    在这个过程中,有很多想法是关于某个东西是否是原子的。这不是问题所在。问题是线程的写入对其他线程可见的顺序。在弱顺序体系结构中,写入内存的顺序并不正确,这是真正的问题,而不是变量是否适合数据总线。

    编辑:实际上,我在陈述中混合了平台。在C中,clr规范要求写入按顺序是全局可见的(必要时对每个存储使用昂贵的存储指令)。因此,你不需要有记忆障碍。但是,如果C或C++没有全局可见性顺序的这种保证,并且您的目标平台可能具有弱有序的内存,并且它是多线程的,那么您需要确保构造函数在更新SY初始化之前是全局可见的,这是在锁之外测试的。


    ACK,永远……正如所指出的,这确实是不正确的。它不会阻止第二个线程进入"初始化"代码部分。呸。

    You could also decorate s_Initialized with the volatile keyword and forego the use of lock entirely.