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

时间:2008-08-13 11:41:29

标签: c# multithreading

我已经被提出要相信如果多个线程可以访问变量,那么对该变量的所有读取和写入都必须受同步代码保护,例如“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字段在锁外读取?另一个线程难道不能同时写入它吗? 变量的读写是否原子?

16 个答案:

答案 0 :(得分:35)

要获得最终答案,请转到规范。 :)

CLI规范第12.6.6节的分区I指出:“符合要求的CLI应保证在对位置的所有写入访问都是对原始字大小不大时,对正确对齐的内存位置的读写访问权限是原子的。大小相同。“

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

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

此外,虽然读取和写入是原子的,但存在一种具有加法,减法,递增和递减基本类型的竞争条件,因为它们必须被读取,操作和重写。互锁类允许您使用CompareExchangeIncrement方法保护它们。

互锁会创建一个内存屏障,以防止处理器重新排序读取和写入。在此示例中,锁定创建了唯一必需的障碍。

答案 1 :(得分:34)

这是双重检查锁定模式的一种(坏)形式,它在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是volatile,则在读取s_Initialized之前不允许读取s_Provider,并且在s_Initialized设置为true并且现在一切正常后,也不允许提供者的初始化。

Joe Duffy还写了一篇关于这个问题的文章:Broken variants on double-checked locking

答案 2 :(得分:11)

坚持一下 - 标题中的问题绝对不是罗里提出的真正问题。

这个名义上的问题有一个简单的答案“不” - 但当你看到真实的问题时,这根本没有任何帮助 - 我认为没有人给出一个简单的答案。

Rory提出的真实问题要求很晚,并且与他给出的例子更为相关。

  

为什么读取s_Initialized字段   在锁外?

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

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

由于s_Initialized字段基本上是“一次写入”,因此它永远不会返回误报。

在锁外阅读它是经济的。

这是一项低成本活动,很高有机会获益。

这就是为什么它在锁外阅读 - 为了避免支付使用锁的费用,除非它被指出。

如果锁很便宜,代码会更简单,省略第一次检查。

(编辑:来自rory的好反应如下。是的,布尔读取非常原子。如果有人构建了一个非原子布尔读取的处理器,它们将在DailyWTF中被显示。)

答案 3 :(得分:7)

正确答案似乎是,“是的,主要是。”

  1. John的回答引用了CLI规范,表明在32位处理器上访问不大于32位的变量是原子的。
  2. 进一步确认C#规范第5.5节Atomicity of variable references

      

    以下数据类型的读写是原子的:bool,char,byte,sbyte,short,ushort,uint,int,float和reference类型。此外,在先前列表中具有基础类型的枚举类型的读取和写入也是原子的。其他类型的读写,包括long,ulong,double和decimal,以及用户定义的类型,都不能保证是原子的。

  3. 我的示例中的代码是由ASP.NET团队自己编写的Membership类中的释义,因此可以安全地假设它访问s_Initialized字段的方式是正确的。现在我们知道原因了。

  4. 编辑:正如Thomas Danecker指出的那样,即使该字段的访问是原子的,s_Initialized也应该被标记为 volatile ,以确保处理器重新排序读取时不会破坏锁定写道。

答案 4 :(得分:2)

Initialize功能有问题。看起来应该更像这样:

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

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

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

答案 5 :(得分:1)

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

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

答案 6 :(得分:1)

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

有关此问题以及与并发有关的更多问题的精彩参考,请确保获取Joe Duffy latest spectacle的副本。这是一个开膛手!

答案 7 :(得分:1)

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

不。它不是C#的东西,也不是.net的东西,它是处理器的东西。

OJ就是Joe Duffy可以获得这种信息的人。如果您想了解更多信息,那么“互锁”是一个很好的搜索术语。

“Torn reads”可能出现在其字段总和超过指针大小的任何值上。

答案 8 :(得分:1)

  

您还可以使用volatile关键字修饰s_Initialized并完全放弃使用锁。

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

答案 9 :(得分:1)

@Leon
我明白你的观点 - 我问的方式,然后评论,问题允许它以几种不同的方式进行。

要清楚,我想知道并发线程是否可以安全地读取和写入布尔字段而没有任何显式同步代码,即访问布尔(或其他原始类型)变量原子。

然后我使用了Membership代码给出了一个具体的例子,但是它引入了一些干扰,比如双重检查锁定,s_Initialized只被设置一次,并且我注释掉了初始化代码本身。

我的坏。

答案 10 :(得分:0)

我认为他们是 - 我不确定你的例子中的锁定点,除非你同时对s_Provider做了一些事情 - 然后锁定将确保这些调用一起发生。

//Perform initialization评论是否涵盖创建s_Provider?例如

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

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

否则静态属性-all只会返回null。

答案 11 :(得分:0)

您要问的是,是否多次访问方法中的字段 - 答案是否定的。

在上面的示例中,初始化例程有问题,因为它可能导致多次初始化。您需要检查锁内外的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;
    }
}

答案 12 :(得分:0)

也许Interlocked给出了一个线索。否则this one我很不错。

我猜他们不是原子的。

答案 13 :(得分:0)

要使代码始终在弱有序的体系结构上运行,必须在编写s_Initialized之前放入MemoryBarrier。

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_Initialized之前,不保证写入s_Provider。

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

编辑:实际上,我在我的陈述中混合了平台。在C#中,CLR规范要求写入按顺序全局可见(如果需要,通过为每个商店使用昂贵的存储指令)。因此,您不需要在那里实际拥有内存屏障。但是,如果它是C或C ++,其中不存在全局可见性顺序的这种保证,并且您的目标平台可能具有弱有序内存,并且它是多线程的,那么您需要确保在更新s_Initialized之前构造函数的写入是全局可见的,在锁外测试。

答案 14 :(得分:0)

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

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

锁内的第二次检查是必要的,因为另一个线程可能已经先抓住了锁并完成了初始化过程。

答案 15 :(得分:-1)

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

  

您还可以使用volatile关键字修饰s_Initialized并完全放弃使用锁。