5

休闲条款取自 jetbrains.net 在阅读了这篇文章和网络上的其他一些文章后,我仍然不明白在第一个线程进入锁之后如何返回 null。有人理解它可以请帮助我并以更人性化的方式解释它吗?

“考虑以下代码:

public class Foo
{
  private static Foo instance;
  private static readonly object padlock = new object();

  public static Foo Get()
  {
    if (instance == null)
    {
      lock (padlock)
      {
        if (instance == null)
        {
          instance = new Foo();
        }
      }
    }
    return instance;
  }
};

鉴于上述代码,初始化 Foo 实例的写入可能会延迟到实例值的写入,从而产生实例返回处于未初始化状态的对象的可能性。

为了避免这种情况,必须将实例值设为易失性。"

4

2 回答 2

16

回归null不是问题。问题是新实例可能处于另一个线程感知的部分构造状态。考虑这个声明Foo

class Foo
{
  public int variable1;
  public int variable2;

  public Foo()
  {
    variable1 = 1;
    variable2 = 2;
  }
}

以下是 C# 编译器、JIT 编译器或硬件如何优化代码。1

if (instance == null)
{
  lock (padlock)
  {
    if (instance == null)
    {
      instance = alloc Foo;
      instance.variable1 = 1; // inlined ctor
      instance.variable2 = 2; // inlined ctor
    }
  }
}
return instance;

首先,请注意构造函数是内联的(因为它很简单)。现在,希望很容易看到instance在其组成字段在构造函数中初始化之前被分配了引用。这是一个有效的策略,因为读写可以自由地上下浮动,只要它们不越过边界lock或改变逻辑流;他们没有。所以另一个线程可以在它完全初始化之前看到instance != null并尝试使用它。

volatile修复了这个问题,因为它将读取视为获取栅栏并将写入视为释放栅栏

  • 获取栅栏:不允许其他读写在栅栏之前移动的内存屏障。
  • release-fence:一个内存屏障,不允许其他读写在栅栏之后移动。

因此,如果我们标记instance为,volatile则释放栅栏将阻止上述优化。以下是带有屏障注释的代码的外观。我使用 ↑ 箭头表示释放栅栏,使用 ↓ 箭头表示获取栅栏。请注意,不允许任何东西向下飘过 ↑ 箭头或向上飘过 ↓ 箭头。把箭头想象成把一切都推开。

var local = instance;
↓ // volatile read barrier
if (local == null)
{
  var lockread = padlock;
  ↑ // lock full barrier
  lock (lockread)
  ↓ // lock full barrier
  {
    local = instance;
    ↓ // volatile read barrier
    if (local == null)
    {
      var ref = alloc Foo;
      ref.variable1 = 1; // inlined ctor
      ref.variable2 = 2; // inlined ctor
      ↑ // volatile write barrier
      instance = ref;
    }
  ↑ // lock full barrier
  }
  ↓ // lock full barrier
}
local = instance;
↓ // volatile read barrier
return local;

对 的 组成变量的写入Foo仍然可以重新排序,但请注意,内存屏障现在阻止它们在分配给 之后发生instance。使用箭头作为指导,想象允许和不允许的各种不同的优化策略。请记住,不允许读取写入通过 ↑ 箭头或向上通过 ↓ 箭头。

Thread.VolatileWrite也可以解决这个问题,并且可以在没有volatile关键字的语言中使用,如 VB.NET。如果你看看是如何VolatileWrite实现的,你会看到这一点。

public static void VolatileWrite(ref object address, object value)
{
  Thread.MemoryBarrier();
  address = value;
}

现在这乍一看似乎违反直觉。毕竟,内存屏障是在赋值之前放置的。将任务提交到您要求的主存储器怎么样?在分配之后放置障碍不是更正确吗?如果这是你的直觉告诉你的,那就错了。您会看到内存屏障并不是严格意义上的“新读取”或“提交写入”。这都是关于指令排序的。这是迄今为止我看到的最大的混乱来源。

可能还需要提到的是,它Thread.MemoryBarrier实际上会产生一个完整的屏障。因此,如果我将上面的符号与箭头一起使用,那么它看起来像这样。

public static void VolatileWrite(ref object address, object value)
{
  ↑ // full barrier
  ↓ // full barrier
  address = value;
}

因此,从技术上讲,调用VolatileWrite比写入volatile字段所做的更多。请记住,这volatile在 VB.NET 中是不允许的,但VolatileWrite它是 BCL 的一部分,因此可以在其他语言中使用。


1这种优化主要是理论上的。ECMA 规范在技术上确实允许这样做,但 ECMA 规范的 Microsoft CLI 实现将所有写入视为已经具有释放栅栏语义。不过,CLI 的另一个实现可能仍然可以执行此优化。

于 2012-04-23T13:29:38.057 回答
3

Bill Pugh写了几篇关于这个主题的文章,并且是关于这个主题的参考。

一个值得注意的参考是,“双重检查锁定被破坏”声明。

粗略地说,问题出在这里:

在多核 VM 中,一个线程的写入可能对其他线程不可见,直到达到同步屏障(或内存栅栏)。您可以阅读“内存屏障:软件黑客的硬件视图”,这是一篇关于这个问题的非常好的文章。

A因此,如果一个线程用一个字段初始化一个对象,并将该对象的引用存储在另一个对象a的字段中,我们在内存中有两个“单元格”:和。对两个内存位置的更改可能不会同时对其他线程可见,除非线程通过内存栅栏强制更改可见性。refBaref

在 java 中,可以使用synchronized. 这是昂贵的,另一种方法是声明一个字段,volatile在这种情况下,对该单元格的更改始终对所有线程可见。

但是,Java 4 和 5 之间 volatile 的语义发生了变化。在 Java 4 中,您需要将a和都定义ref为 volatile,以便在我描述的示例中进行双重检查。

这并不直观,大多数人只会将其设置ref为 volatile。所以他们改变了这一点,在 Java 5+ 中,如果一个 volatile 字段被修改(ref),它会触发其他字段被修改()的同步a

编辑:我现在才看到您要求 C#,而不是 Java ...我留下我的答案,因为它可能仍然有用。

于 2012-04-23T13:09:42.753 回答