双重锁定中的记忆模型保证

dle*_*lev 12 .net multithreading

我最近在Resharper网站上看到了以下帖子.这是对双重检查锁定的讨论,并具有以下代码:

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

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

     private void Init()
     {
        ...
     }
}
Run Code Online (Sandbox Code Playgroud)

该帖子然后声称

如果我们假设Init()是用于初始化Foo状态的方法,那么由于内存模型不能保证读写顺序,上述代码可能无法按预期运行.因此,对Init()的调用实际上可能在变量实例处于一致状态之前发生.

这是我的问题:

  1. 我的理解是,.NET内存模型(至少从2.0开始)并没有要求instance被声明为volatile,因为lock它将提供一个完整的内存栅栏.是不是这样,还是我被误导了?

  2. 对于多个线程,是不是只对可观察的读/写重新排序?我的理解是,在一个线程上,副作用将是一致的顺序,并且lock就地防止任何其他线程观察到某些东西是不对的.我也在这里吗?

Tim*_*oyd 24

该示例的一个大问题是第一个空检查未锁定,因此实例可能不是null,而是调用Init 之前.这可能导致在调用Init之前使用实例的线程.

因此,正确的版本应该是:

public static Foo GetValue()
{
    if (instance == null)
    {
        lock (padlock)
        {
            if (instance == null)
            {
                var foo = new Foo();
                foo.Init();
                instance = foo;
            }
        }
    }

    return instance;
 }
Run Code Online (Sandbox Code Playgroud)

  • 我应该为回答这款手机获得额外积分:-) (3认同)
  • 这非常尖锐.我自己错过了.你不是我为了完整性而在答案中添加正确版本的代码,是吗? (2认同)
  • @Steven正确.欢呼为编辑 - 赞赏.从我的手机很难!:) (2认同)

Bri*_*eon 16

据我所知,.NET内存模型(至少从2.0开始)并没有要求将该实例声明为volatile,因为lock会提供一个完整的内存区域.是不是这样,还是我被误导了?

这是必需的.原因是因为你在instance外面访问lock.让我们假设你省略volatile并且已经修复了这样的初始化问题.

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

    public static Foo GetValue()
    {
        if (instance == null)
        {
            lock (padlock)
            {
                if (instance == null)
                {
                    var temp = new Foo();
                    temp.Init();
                    instance = temp;
                }
            }
        }
        return instance;
     }

     private void Init() { /* ... */ }
}
Run Code Online (Sandbox Code Playgroud)

在某种程度上,C#编译器,JIT编译器或硬件可以发出一个指令序列,该序列优化掉temp变量并使instance变量在Init运行之前被分配.实际上,它instance甚至可以在构造函数运行之前进行分配.该Init方法使问题更容易发现,但问题仍然存在于构造函数中.

这是一个有效的优化,因为指令可以在锁中自由重新排序.A lock会释放内存屏障,但仅限于Monitor.EnterMonitor.Exit调用.

现在,如果省略volatile代码,则可能仍然可以使用大多数硬件和CLI实现组合.原因是因为x86硬件具有更紧密的内存模型,而且微软的CLR实现也非常紧张.但是,关于此主题的ECMA规范相对宽松,这意味着CLI的另一个实现可以自由地进行Microsoft当前选择忽略的优化.您必须为较弱的模型编写代码,这可能是CLI抖动,而不是大多数人倾向于关注的硬件.这就是为什么volatile仍然需要.

对于多个线程,是不是只对可观察的读/写重新排序?

是.只有当多个线程访问同一个内存位置时,指令重新排序才会起作用.即使是最弱的软件和硬件内存模型也不允许任何类型的优化,这些优化会改变开发人员在线程上执行代码时的行为.否则,没有程序会正确执行.问题在于其他线程如何观察该线程中发生的事情.其他线程可能会感觉到与执行线程的行为不同的行为.但是,执行线程始终会感知到正确的行为.

我的理解是,在单个线程上,副作用将是一致的顺序,并且锁定到位将阻止任何其他线程观察到某些东西是不对的.我也在这里吗?

不,一个lock人本身不会阻止其他线程感知不同的事件序列.原因是执行线程可能以lock不同于开发人员预期的顺序执行其中的指令.只有在锁的入口和出口点才会创建内存屏障.因此,在您的示例中,instance即使您已使用a包装这些指令,也可以在构造函数运行之前分配对新对象的引用lock.

使用volatile,而另一方面,对如何在代码里面有更大的影响lock相比,在初步核实,作为的行为instance在共同尽管智慧的方法的开头.很多人认为主要问题是如果instance没有易变的读数可能会陈旧.情况可能就是这样,但更大的问题是,如果没有lock另一个线程内部的易失性写入,可能会看到instance引用构造函数尚未运行的实例.volatile写入解决了这个问题,因为它阻止编译器在写入后移动构造函数代码instance.这volatile仍然是需要的重要原因.

  • 很好的答案. (3认同)