Гарантия на модель памяти в режиме двойной проверки

Недавно я встретил следующую публикацию на веб-сайте 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()
     {
        ...
     }
}

Затем сообщение утверждает, что

Если предположить, что Init() - это метод, используемый для инициализации состояния Foo, то приведенный выше код может не работать должным образом из-за модель памяти, не гарантирующая порядок чтения и записи. Как результат, вызов Init() может действительно произойти до переменной экземпляр находится в согласованном состоянии.

Вот мои вопросы:

  • Насколько я понял, модель памяти .NET(по крайней мере с версии 2.0) не требовала объявления instance как volatile, так как lock обеспечивал бы полный забор памяти. Разве это не так, или я был дезинформирован?

  • Является ли переупорядочение чтения/записи только наблюдаемым в отношении нескольких потоков? Я понимал, что в одном потоке побочные эффекты будут в последовательном порядке и что lock на месте помешает любому другому потоку замечать что-то недопустимое. Я тоже вне базы?

Ответ 1

Большая проблема с примером заключается в том, что первая проверка нуля не заблокирована, поэтому экземпляр может быть не нулевым, но до того, как Init был вызван. Это может привести к потокам с использованием экземпляра до того, как был вызван Init.

Поэтому правильная версия должна быть:

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

    return instance;
 }

Ответ 2

Я понял, что модель памяти .NET(по крайней мере с 2.0) не потребовал, чтобы этот экземпляр был объявлен изменчивым, поскольку блокировка обеспечит полный забор памяти. Разве это не так, или я был дезинформировали?

Это требуется. Причина в том, что вы получаете доступ к 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() { /* ... */ }
}

На некотором уровне компилятор С#, компилятор JIT или аппаратное обеспечение могут генерировать последовательность команд, которая оптимизирует переменную temp и вызывает присвоение переменной instance до запуска Init. Фактически, он мог бы назначить instance еще до запуска конструктора. Метод Init делает проблему намного легче выявить, но проблема все еще существует и для конструктора.

Это правильная оптимизация, так как инструкции могут быть переупорядочены внутри блокировки. A lock излучает барьер памяти, но только на вызовы Monitor.Enter и Monitor.Exit.

Теперь, если вы опустите volatile, код, вероятно, по-прежнему будет работать с большинством комбинаций аппаратных средств и реализаций CLI. Причина в том, что аппаратное обеспечение x86 имеет более жесткую модель памяти, а реализация CLR в Microsoft также довольно жесткая. Однако спецификация ECMA по этому вопросу относительно свободна, а это означает, что другая реализация CLI позволяет делать оптимизации, которые Microsoft в настоящее время предпочитает игнорировать. Вы должны кодировать более слабую модель, которая может быть дрожанием CLI, а не аппаратным обеспечением, на которое большинство людей склонны сосредотачиваться. Вот почему volatile по-прежнему требуется.

Не выполняется переупорядочение чтения/записи, наблюдаемое только в отношении нескольких потоки? Я понимал, что в одном потоке сторона эффекты будут в последовательном порядке и что блокировка на месте будет препятствовать тому, чтобы какая-либо другая нить наблюдала что-то неладное. Я тоже вне базы?

Да. Переупорядочение команд входит в игру только тогда, когда более одного потока обращается к одной и той же ячейке памяти. Даже самые слабые модели программной и аппаратной памяти не позволят какой-либо оптимизации, которая изменяет поведение от того, что разработчик планировал при выполнении кода в потоке. В противном случае программа не будет выполняться правильно. Проблема в том, как другие потоки наблюдают за тем, что происходит в этом потоке. Другие потоки могут воспринимать другое поведение по сравнению с исполняемым потоком. Но исполняемый поток всегда воспринимает правильное поведение.

Нет, a lock сам по себе не помешает другим потокам воспринимать другую последовательность событий. Причина в том, что исполняемый поток может выполнять инструкции внутри lock в другом порядке, чем то, что планировал разработчик. Только на входах и выходах замка создаются барьеры памяти. Таким образом, в вашем примере ссылка на новый объект может быть назначена на instance еще до запуска конструктора, даже если вы завернули эти инструкции с помощью lock.

Использование volatile, с другой стороны, оказывает большее влияние на то, как ведет себя код внутри lock по сравнению с начальной проверкой instance в начале метода, несмотря на общую мудрость. Многие считают, что главная проблема заключается в том, что instance может быть устаревшим без изменчивого чтения. Это может иметь место, но большая проблема заключается в том, что без волатильной записи внутри lock другой поток может видеть instance, ссылаясь на экземпляр, для которого конструктор еще не запущен. Неустойчивая запись решает эту проблему, поскольку она не позволяет компилятору перемещать код конструктора после записи в instance. Это большая причина, по которой требуется volatile.

Ответ 3

Если я правильно прочитал код, проблема следующая:

Caller 1 запускает метод, находит экземпляр == null как true, входит в блокировку, находит экземпляр STILL равным null и создает экземпляр.

Вызывается вызов Init(), поток вызывающего 1 приостанавливается, и вызывающий объект 2 вводит метод. Caller 2 находит экземпляр NOT равным нулю и продолжает использовать его, прежде чем вызывающий может инициализировать его.

Ответ 4

С одной стороны, он создает "полный забор", но что касается цитаты, это то, что происходит "внутри этого забора" в "двойном проверенном случае блокировки"... см. объяснение http://blogs.msdn.com/b/cbrumme/archive/2003/05/17/51445.aspx

В нем говорится:

However, we have to assume that a series of stores have taken place during construction 
of ‘a’.  Those stores can be arbitrarily reordered, including the possibility of delaying 
them until after the publishing store which assigns the new object to ‘a’.  At that point, 
there is a small window before the store.release implied by leaving the lock.  Inside that
window, other CPUs can navigate through the reference ‘a’ and see a partially constructed 
instance.

Замените a в приведенном выше предложении instance из вашего примера...

Кроме того, проверьте этот http://blogs.msdn.com/b/brada/archive/2004/05/12/130935.aspx - он объясняет, что volatile достигает в вашем сценарии...

Хорошее объяснение заборов и volatile и как volatile имеет даже разные эффекты в зависимости от процессора, на котором вы запускаете код, см. http://www.albahari.com/threading/part4.aspx и даже больше/лучше, см. http://csharpindepth.com/Articles/General/Singleton.aspx