在C#中访问变量是原子操作吗?

时间:2020-03-05 18:39:21  来源:igfitidea点击:

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

但是,我正在使用Reflector查看System.Web.Security.Membership,发现了如下代码:

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_Initialized字段?另一个线程不能同时尝试写入它吗?变量的读写是原子的吗?

解决方案

回答

我以为他们不确定示例中的锁定点,除非我们同时对s_Provider做一些操作,然后锁定将确保这些调用一起发生。

该" //执行初始化"注释是否涵盖了创建s_Provider?例如

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

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

否则,该静态属性get无论如何都将返回null。

回答

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

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

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

如果没有在锁中进行第二次检查,则初始化代码可能会执行两次。因此,第一个检查的性能是为了节省不必要的锁定,而第二个检查的情况是线程正在执行初始化代码,但尚未设置s_Initialized标志,因此第二个线程将通过首先检查并等待锁。

回答

我们要问的是,是否以原子方式多次访问方法中的字段-答案是否定的。

在上面的示例中,初始化例程有问题,因为它可能导致多次初始化。我们需要检查锁内和锁外的s_Initialized标志,以防止出现竞争状态,即多个线程在实际执行初始化代码之前先读取s_Initialized标志。例如。,

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

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

回答

也许互锁提供了一个线索。否则这个我还不错。

我会猜想他们不是原子的。

回答

我认为我们是在询问在锁外读取时s_Initialized是否可能处于不稳定状态。最简洁的答案是不。一个简单的赋值/读取将最终归结为一个汇编指令,这在我能想到的每个处理器上都是原子的。

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

回答

变量的读取和写入不是原子的。我们需要使用同步API来模拟原子读取/写入。

有关此问题以及与并发有关的更多问题的出色参考,请确保我们获取了Joe Duffy最新作品的副本。是开膛手!

回答

"是否可以在原子操作中访问变量?"

没有。它不是Cthing,也不是.net,而是处理器。

OJ发现Joe Duffy是获得此类信息的人。如果我们想了解更多信息,那么"互锁"和"互锁"是一个很好的搜索词。

任何值的字段加起来超过指针大小的值都可能发生"读取读取"。

回答

阿克,没关系...正如所指出的,这确实是不正确的。它不会阻止第二个线程进入"初始化"代码部分。呸。

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

回答

对于确定的答案,请转到规格。 :)

CLI规范的第I部分,第12.6.6节指出:"符合标准的CLI必须保证对所有不超过本机字大小的正确对齐的内存位置的读写访问是原子的,而对某个位置的所有写访问都具有相同的大小。"

这样可以确认s_Initialized永远不会不稳定,并且对小于32位的原始类型的读写是原子的。

特别是,在32位平台上,不能保证double和long(Int64和UInt64)是原子的。我们可以使用Interlocked类上的方法来保护这些方法。

另外,虽然读写是原子的,但由于必须读取,操作和重写原始类型,因此存在一种竞争条件,具有加,减,递增和递减原始类型。互锁的类允许我们使用CompareExchange和Increment方法来保护它们。

互锁会产生内存屏障,以防止处理器对读取和写入进行重新排序。在此示例中,锁创建了唯一需要的屏障。

回答

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

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

回答

正确的答案似乎是:"是的,主要是"。

  • John的参考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团队自己编写的Membership类解释的,因此始终可以安全地假定它访问s_Initialized字段的方式是正确的。现在我们知道为什么了。

编辑:正如Thomas Danecker指出的那样,即使字段的访问是原子的,s_Initialized仍应真正标记为volatile,以确保不会因处理器对读取和写入进行重新排序而破坏锁定。

回答

徘徊-标题中的问题绝对不是Rory提出的真正问题。

名义问题的答案很简单:"否",但是当我们看到真正的问题时,这根本没有帮助。我认为没有人给出简单的答案。

罗里提出的真正问题要晚得多了,并且与他所举的例子更加相关。

Why is the s_Initialized field read
  outside of the lock?

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

s_Initialized字段在锁外部读取,因为锁很昂贵。

由于s_Initialized字段本质上是"写入一次",因此它永远不会返回假肯定。

在锁外阅读它是经济的。

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

这就是为什么在锁之外读取它的原因-除非另有说明,否则避免支付使用锁的费用。

如果锁便宜,那么代码会更简单,并省略该第一检查。

(编辑:随后来自rory的很好的响应。是的,布尔读取是非常原子的。如果有人用非原子布尔读取构建了处理器,那么它们将在DailyWTF中使用。)

回答

@里昂
我以我们提出并评论的方式来理解观点,这个问题可以用两种不同的方式来解决。

明确地说,我想知道并发线程在没有任何显式同步代码的情况下读写布尔值字段是否安全,即正在访问布尔值(或者其他基本类型的)变量atomic。

然后,我使用Membership代码给出了一个具体示例,但是这引入了很多干扰,例如双重检查锁定,s_Initialized仅设置一次,并且注释掉了初始化代码本身。

我的错。

回答

这是双重检查锁定模式的(错误)形式,在C#中不是线程安全的!

这段代码有一个大问题:

s_Initialized不是易失的。这意味着在s_Initialized设置为true之后,初始化代码中的写操作可以移动,并且其他线程也可以看到未初始化的代码,即使它们的s_Initialized为true。这不适用于Microsoft的Framework实施,因为每次写入都是易失性写入。

而且在Microsoft的实现中,未初始化数据的读取可以重新排序(即由cpu预取),因此,如果s_Initialized为true,则由于缓存命中(例如,读取应初始化的数据可能导致读取旧的,未初始化的数据)。读取重新排序)。

例如:

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_Initialized之前将s_Provider的读取移动是完全合法的,因为任何地方都没有易失性读取。

如果s_Initialized是易失性的,则不允许在读取s_Initialized之前移动s_Provider,并且在s_Initialized设置为true后也不允许移动Provider的初始化,现在一切正常。

乔·达菲(Joe Duffy)还写了一篇有关此问题的文章:双重检查锁定中的残破变体