65

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

但是,我正在使用 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 字段在锁之外读取?其他线程不能同时尝试写入它吗?变量的读写是原子的吗?

4

15 回答 15

37

对于明确的答案去规范。:)

CLI 规范的第 I 部分第 12.6.6 节指出:“当对一个位置的所有写访问大小相同时,符合标准的 CLI 应保证对不大于本机字大小的正确对齐的内存位置的读写访问是原子的。”

这证实了 s_Initialized 永远不会不稳定,并且对小于 32 位的原始类型的读写是原子的。

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

此外,虽然读取和写入是原子的,但存在与加法、减法以及递增和递减原始类型的竞争条件,因为它们必须被读取、操作和重写。interlocked 类允许您使用CompareExchangeIncrement方法保护这些。

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

于 2008-08-13T13:24:41.533 回答
34

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

这段代码有一个大问题:

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

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

例如:

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 的读取是完全合法的,因为在任何地方都没有 volatile 读取。

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

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

于 2008-09-16T15:50:29.737 回答
12

等一下——标题中的问题绝对不是 Rory 提出的真正问题。

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

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

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

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

s_Initialized 字段是在锁之外读取的,因为锁很昂贵

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

在锁外阅读它是经济的。

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

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

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

(编辑:来自 rory 的回应很好。是的,布尔读取是非常原子的。如果有人用非原子布尔读取构建了一个处理器,他们就会出现在 DailyWTF 上。)

于 2008-08-15T12:53:51.250 回答
7

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

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

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

  1. 我的示例中的代码是由 ASP.NET 团队自己编写的 Membership 类转述的,因此始终可以安全地假设它访问 s_Initialized 字段的方式是正确的。现在我们知道为什么了。

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

于 2008-08-13T17:22:04.330 回答
2

初始化功能有问题。它应该看起来更像这样:

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

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

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

于 2008-08-13T12:08:29.220 回答
1

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

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

于 2008-08-13T12:34:20.057 回答
1

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

要获得有关此方面的精彩参考以及与并发有关的更多问题,请确保获取 Joe Duffy最新奇观的副本。是开膛手!

于 2008-08-13T12:35:12.897 回答
1

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

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

OJ 认为 Joe Duffy 是获取此类信息的最佳人选。如果您想了解更多信息,“联锁”是一个很好的搜索词。

“撕裂读取”可能发生在其字段加起来超过指针大小的任何值上。

于 2008-08-13T12:39:33.883 回答
1

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

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

于 2008-08-13T13:28:47.013 回答
1

对布尔值的If (itisso) {检查是原子的,但即使不是,也不需要锁定第一次检查。

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

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

于 2013-02-28T01:35:36.470 回答
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;
    }
}

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

于 2008-08-13T12:00:51.343 回答
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;
    }
}
于 2008-08-13T12:16:39.860 回答
0

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

我会猜到它们不是原子的。

于 2008-08-13T12:19:22.663 回答
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;

在弱序处理器上写入 s_Initialized 之前,不能保证在 MembershipProvider 构造函数中发生的内存写入和对 s_Provider 的写入发生。

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

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

于 2012-08-30T22:59:27.060 回答
-1

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

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

于 2008-08-13T12:42:16.720 回答