Необходимость в модификаторе volatile при блокировке с двойной проверкой в ​​.NET

85

В нескольких текстах говорится, что при реализации блокировки с двойной проверкой в ​​.NET к блокируемому полю должен применяться модификатор volatile. Но почему именно? Рассмотрим следующий пример:

public sealed class Singleton
{
   private static volatile Singleton instance;
   private static object syncRoot = new Object();

   private Singleton() {}

   public static Singleton Instance
   {
      get 
      {
         if (instance == null) 
         {
            lock (syncRoot) 
            {
               if (instance == null) 
                  instance = new Singleton();
            }
         }

         return instance;
      }
   }
}

почему «lock (syncRoot)» не обеспечивает необходимой согласованности памяти? Разве не правда, что после оператора «lock» и чтение, и запись будут непостоянными, и поэтому необходимая согласованность будет достигнута?

Константин
источник
2
Это уже много раз пережевывали. yoda.arachsys.com/csharp/singleton.html
Hans Passant
1
К сожалению, в этой статье Джон дважды ссылается на слово «volatile», и ни одна из этих ссылок не относится напрямую к приведенным им примерам кода.
Дэн Эспарса
См. Эту статью, чтобы понять проблему: igoro.com/archive/volatile-keyword-in-c-memory-model-explained. В принципе, JIT ТЕОРЕТИЧЕСКИ возможно использовать регистр ЦП для переменной экземпляра, особенно если вам нужен там немного лишнего кода. Таким образом, выполнение оператора if дважды потенциально может вернуть одно и то же значение независимо от его изменения в другом потоке. На самом деле ответ немного сложен: оператор блокировки может или не может нести ответственность за улучшение ситуации (продолжение)
user2685937
(см. предыдущий комментарий, продолжение) - Вот что, я думаю, действительно происходит - В основном любой код, который делает что-то более сложное, чем чтение или установка переменной, может вызвать JIT, чтобы сказать: забудьте попытаться оптимизировать это, давайте просто загрузим и сохраним в память, потому что если функция вызывается, JIT потенциально может потребоваться сохранить и перезагрузить регистр, если он сохранял его там каждый раз, а не просто записывать и читать каждый раз прямо из памяти. Как я узнаю, что в блокировке нет ничего особенного? Посмотрите ссылку, которую я разместил в предыдущем комментарии от Игоря (продолжение следующего комментария)
user2685937
(см. 2 комментария выше) - я протестировал код Игоря, и когда он создает новый поток, я добавил блокировку вокруг него и даже сделал его зацикленным. Это все равно не приведет к выходу кода, потому что переменная экземпляра была выведена из цикла. Добавление в цикл while простой набор локальных переменных по-прежнему выводил переменную из цикла - теперь что-нибудь более сложное, например, операторы if, вызов метода или да, даже вызов блокировки предотвратят оптимизацию и, таким образом, заставят ее работать. Таким образом, любой сложный код часто требует прямого доступа к переменным, а не позволяет JIT оптимизировать. (продолжение следующего комментария)
user2685937

Ответы:

60

Неустойчивый не нужен. Ну вроде **

volatileиспользуется для создания барьера памяти * между чтением и записью переменной.
lockпри использовании вызывает создание барьеров памяти вокруг блока внутри lock, в дополнение к ограничению доступа к блоку одним потоком.
Барьеры памяти делают так, чтобы каждый поток считывал самое текущее значение переменной (а не локальное значение, кэшированное в каком-либо регистре), и что компилятор не меняет порядок операторов. Использование volatileне требуется **, потому что у вас уже есть блокировка.

Джозеф Альбахари объясняет этот материал лучше, чем я когда-либо мог.

И обязательно ознакомьтесь с руководством Джона Скита по реализации синглтона на C #.


update :
* volatileвызывает чтение переменной VolatileReads, а запись VolatileWrites, которые на x86 и x64 в CLR реализованы с расширением MemoryBarrier. В других системах они могут быть более мелкими.

** мой ответ правильный, только если вы используете CLR на процессорах x86 и x64. Это может быть верно для других моделей памяти, например, для Mono (и других реализаций), Itanium64 и будущего оборудования. Это то, что имеет в виду Джон в своей статье «Попытки» для блокировки с двойной проверкой.

Выполнение одного из следующих действий: {отметка переменной как volatile, ее чтение Thread.VolatileReadили вставка вызова Thread.MemoryBarrier} может потребоваться для правильной работы кода в ситуации слабой модели памяти.

Насколько я понимаю, в CLR (даже на IA64) записи никогда не переупорядочиваются (записи всегда имеют семантику выпуска). Однако в IA64 операции чтения могут быть переупорядочены так, чтобы они выполнялись перед записью, если они не помечены как изменчивые. К сожалению, у меня нет доступа к оборудованию IA64, с которым можно поиграть, поэтому все, что я говорю об этом, будет лишь домыслом.

Я также нашел эти статьи полезными:
http://www.codeproject.com/KB/tips/MemoryBarrier.aspx
статья
ванса Моррисона (все ссылки на это, в нем говорится о блокировке с двойной проверкой) статья Криса Брумма (все ссылки на это )
Джо Даффи: сломанные варианты блокировки с двойной проверкой

Серия статей Луиса Абреу о многопоточности также дает хороший обзор концепций
http://msmvps.com/blogs/luisabreu/archive/2009/06/29/multithreading-load-and-store-reordering.aspx
http: // msmvps. com / blogs / luisabreu / archive / 2009/07/03 / multithreading-introduction-memory-fences.aspx

дан
источник
Джон Скит фактически говорит, что модификатор volatile необходим для создания надлежащего барьера памяти, в то время как автор первой ссылки говорит, что блокировки (Monitor.Enter) будет достаточно. Кто на самом деле прав ???
Константин
@Konstantin, похоже, Джон имел в виду модель памяти на процессорах Itanium 64, поэтому в этом случае может потребоваться использование volatile. Однако в процессорах x86 и x64 volatile не требуется. Я обновлю немного позже.
дан
Если блокировка действительно создает барьер памяти и если проблема с памятью действительно связана как с порядком инструкций, так и с аннулированием кеша, то она должна работать на всех процессорах. В любом случае это так странно, что такая простая вещь вызывает столько путаницы ...
Константин
2
Мне этот ответ кажется неправильным. Если не volatileбыло необходимости на любой платформе , то это будет означать , что JIT не может оптимизировать загрузку памяти , object s1 = syncRoot; object s2 = syncRoot;чтобы object s1 = syncRoot; object s2 = s1;на этой платформе. Мне это кажется маловероятным.
user541686
1
Даже если CLR не будет переупорядочивать записи (я сомневаюсь, что это правда, с помощью этого можно сделать много очень хороших оптимизаций), это все равно будет ошибкой, пока мы можем встроить вызов конструктора и создать объект на месте (мы могли видеть наполовину инициализированный объект). Независимо от какой-либо модели памяти, используемой базовым процессором! По словам Эрика Липперта, CLR на Intel по крайней мере вводит мембрану после конструкторов, которая отрицает эту оптимизацию, но это не требуется спецификацией, и я бы не стал рассчитывать на то же самое, например, на ARM ..
Voo,
34

Есть способ реализовать это без volatileполя. Я объясню это ...

Я думаю, что опасно переупорядочивание доступа к памяти внутри блокировки, так что вы можете получить не полностью инициализированный экземпляр вне блокировки. Чтобы этого избежать, я делаю так:

public sealed class Singleton
{
   private static Singleton instance;
   private static object syncRoot = new Object();

   private Singleton() {}

   public static Singleton Instance
   {
      get 
      {
         // very fast test, without implicit memory barriers or locks
         if (instance == null)
         {
            lock (syncRoot)
            {
               if (instance == null)
               {
                    var temp = new Singleton();

                    // ensures that the instance is well initialized,
                    // and only then, it assigns the static variable.
                    System.Threading.Thread.MemoryBarrier();
                    instance = temp;
               }
            }
         }

         return instance;
      }
   }
}

Понимание кода

Представьте, что внутри конструктора класса Singleton есть какой-то код инициализации. Если эти инструкции переупорядочиваются после того, как в поле установлен адрес нового объекта, то у вас есть неполный экземпляр ... представьте, что у класса есть этот код:

private int _value;
public int Value { get { return this._value; } }

private Singleton()
{
    this._value = 1;
}

Теперь представьте вызов конструктора с помощью оператора new:

instance = new Singleton();

Это можно расширить до следующих операций:

ptr = allocate memory for Singleton;
set ptr._value to 1;
set Singleton.instance to ptr;

Что, если я переупорядочу эти инструкции следующим образом:

ptr = allocate memory for Singleton;
set Singleton.instance to ptr;
set ptr._value to 1;

Есть ли разница? НЕТ, если вы думаете об одном потоке. ДА, если вы думаете о нескольких потоках ... что, если поток прерывается сразу после set instance to ptr:

ptr = allocate memory for Singleton;
set Singleton.instance to ptr;
-- thread interruped here, this can happen inside a lock --
set ptr._value to 1; -- Singleton.instance is not completelly initialized

Это то, чего избегает барьер памяти, не позволяя переупорядочивать доступ к памяти:

ptr = allocate memory for Singleton;
set temp to ptr; // temp is a local variable (that is important)
set ptr._value to 1;
-- memory barrier... cannot reorder writes after this point, or reads before it --
-- Singleton.instance is still null --
set Singleton.instance to temp;

Удачного кодирования!

Мигель Анджело
источник
1
Если среда CLR разрешает доступ к объекту до его инициализации, это дыра в безопасности. Представьте себе привилегированный класс, единственный открытый конструктор которого устанавливает SecureMode = 1, а затем методы экземпляра которого это проверяют. Если вы можете вызвать эти методы экземпляра без запущенного конструктора, вы можете выйти из модели безопасности и нарушить песочницу.
MichaelGG
1
@MichaelGG: в описанном вами случае, если этот класс будет поддерживать несколько потоков для доступа к нему, то это проблема. Если вызов конструктора встроен в джиттер, то ЦП может переупорядочить инструкции таким образом, чтобы сохраненная ссылка указывала на не полностью инициализированный экземпляр. Это не проблема безопасности CLR, потому что ее можно избежать, это ответственность программиста за использование: заблокированных, барьеров памяти, блокировок и / или изменчивых полей внутри конструктора такого класса.
Мигель Анджело
2
Барьер внутри корпуса не исправляет. Если CLR назначает ссылку на вновь выделенный объект до того, как ctor завершил работу, и не вставляет мембрану, то другой поток может выполнить метод экземпляра для полуинициализированного объекта.
MichaelGG
Это «альтернативный шаблон», который предлагает ReSharper 2016/2017 в случае DCL на C #. OTOH, Java действительно гарантирует, что результат newполностью инициализирован ..
user2864740
Я знаю, что реализация MS .net ставит барьер памяти в конце конструктора ... но лучше перестраховаться.
Мигель Анджело
7

Я не думаю, что кто-то действительно ответил на вопрос , поэтому я попробую.

Летучие и первые if (instance == null)не «нужны». Блокировка сделает этот код потокобезопасным.

Возникает вопрос: зачем добавлять первое if (instance == null)?

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

Таким образом, первая нулевая проверка добавляется как действительно быстрый способ узнать, нужна ли вам блокировка. Если вам не нужно создавать синглтон, вы можете полностью избежать блокировки.

Но вы не можете проверить, является ли ссылка нулевой, не заблокировав ее каким-либо образом, потому что из-за кэширования процессора другой поток может ее изменить, и вы прочитаете «устаревшее» значение, которое приведет к ненужному вводу блокировки. Но вы пытаетесь избежать блокировки!

Таким образом, вы делаете синглтон изменчивым, чтобы гарантировать, что вы читаете последнее значение, без необходимости использовать блокировку.

Вам по-прежнему нужна внутренняя блокировка, потому что volatile защищает вас только во время однократного доступа к переменной - вы не можете безопасно проверить и установить ее без использования блокировки.

Так вот, действительно ли это полезно?

Я бы сказал «в большинстве случаев нет».

Если Singleton.Instance может вызвать неэффективность из-за блокировок, то почему вы вызываете его так часто, что это может стать серьезной проблемой ? Весь смысл синглтона в том, что он только один, поэтому ваш код может прочитать и кэшировать ссылку на синглтон один раз.

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

Поэтому я подозреваю, что блокировка с двойной проверкой - это механизм, который имеет реальное место в очень специфических случаях, критичных к производительности, и затем все забрались на «это правильный способ сделать это», не задумываясь, что он делает и действительно ли будет действительно необходимо в случае, если они его используют.

Джейсон Уильямс
источник
6
Это что-то среднее между неправильным и упущенным. volatileне имеет ничего общего с семантикой блокировки при блокировке с двойной проверкой, это имеет отношение к модели памяти и согласованности кеша. Его цель - гарантировать, что один поток не получит значение, которое все еще инициализируется другим потоком, что не предотвращает шаблон блокировки с двойной проверкой. В Java вам обязательно понадобится volatileключевое слово; в .NET это непонятно, потому что это неправильно согласно ECMA, но верно согласно времени выполнения. В любом случае, lockбезусловно , никак не заботиться о нем.
Aaronaught
А? Я не вижу, где ваше утверждение не согласуется с тем, что я сказал, и я не сказал, что volatile каким-либо образом связан с семантикой блокировки.
Джейсон Уильямс
6
Ваш ответ, как и несколько других утверждений в этой цепочке, утверждает, что lockделает код потокобезопасным. Это правда, но схема блокировки с двойной проверкой может сделать ее небезопасной . Это то, чего вам, кажется, не хватает. Этот ответ, кажется, беспокоит смысл и цель блокировки с двойной проверкой, не обращая внимания на проблемы безопасности потоков, которые являются причиной volatile.
Aaronaught
1
Как сделать небезопасным, если instanceон отмечен значком volatile?
UserControl
5

Вы должны использовать volatile с шаблоном блокировки двойной проверки.

Большинство людей указывают на эту статью как на доказательство того, что вам не нужны volatile: https://msdn.microsoft.com/en-us/magazine/cc163715.aspx#S10

Но они не дочитают до конца: " Последнее слово предупреждения - я только предполагаю модель памяти x86 по наблюдаемому поведению на существующих процессорах. Таким образом, методы низкой блокировки также хрупки, потому что оборудование и компиляторы могут стать более агрессивными со временем. . Вот несколько стратегий, позволяющих минимизировать влияние этой хрупкости на ваш код. Во-первых, по возможности избегайте методов с низким уровнем блокировки. (...) Наконец, предположите, что модель памяти является самой слабой из возможных, используя изменчивые объявления вместо того, чтобы полагаться на неявные гарантии . "

Если вам нужно больше убедительности, прочтите эту статью о спецификации ECMA, которая будет использоваться для других платформ: msdn.microsoft.com/en-us/magazine/jj863136.aspx

Если вам нужно еще больше убедить, прочтите эту новую статью о том, что в нее могут быть внесены оптимизации, которые не позволят ей работать без volatile: msdn.microsoft.com/en-us/magazine/jj883956.aspx

Таким образом, на данный момент он «может» работать для вас без volatile, но не шансов, что он напишет правильный код и использует методы volatile или volatileread / write. В статьях, которые предлагают поступить иначе, иногда не учитываются некоторые возможные риски оптимизации JIT / компилятора, которые могут повлиять на ваш код, а также мы используем будущие оптимизации, которые могут произойти, что может привести к поломке вашего кода. Также, как упоминалось в предыдущей статье, предыдущие предположения о работе без volatile уже могут не работать на ARM.

user2685937
источник
1
Хороший ответ. Единственно правильный ответ на этот вопрос - просто «Нет». Согласно этому принятый ответ неверен.
Деннис Кассель
3

AFAIK (и - отнеситесь к этому с осторожностью, я не делаю много параллельных вещей) нет. Блокировка просто дает вам синхронизацию между несколькими соперниками (потоками).

volatile, с другой стороны, сообщает вашей машине, что нужно переоценивать значение каждый раз, чтобы вы не наткнулись на кешированное (и неправильное) значение.

См. Http://msdn.microsoft.com/en-us/library/ms998558.aspx и обратите внимание на следующую цитату:

Кроме того, переменная объявляется изменчивой, чтобы гарантировать, что присвоение переменной экземпляра завершится до того, как переменная экземпляра станет доступной.

Описание изменчивого: http://msdn.microsoft.com/en-us/library/x13ttww7%28VS.71%29.aspx

Бенджамин Подшун
источник
2
«Блокировка» также обеспечивает барьер памяти, такой же (или лучше чем) энергозависимый.
Хенк Холтерман,
2

Думаю, я нашел то, что искал. Подробности в этой статье - http://msdn.microsoft.com/en-us/magazine/cc163715.aspx#S10 .

Подводя итог - в .NET модификатор volatile в этой ситуации действительно не нужен. Однако в более слабых моделях памяти записи, сделанные в конструкторе лениво инициированного объекта, могут быть отложены после записи в поле, поэтому другие потоки могут прочитать поврежденный ненулевой экземпляр в первом операторе if.

Константин
источник
1
В самом низу статьи прочтите внимательно, особенно последнее предложение, которое автор заявляет: «Последнее предупреждение - я только предполагаю модель памяти x86 на основании наблюдаемого поведения на существующих процессорах. Таким образом, методы низкой блокировки также хрупки, потому что оборудование и компиляторы со временем могут стать более агрессивными. Вот несколько стратегий, позволяющих свести к минимуму влияние этой хрупкости на ваш код. Во-первых, по возможности избегайте техник низкой блокировки. (...) Наконец, предположим, что модель памяти самая слабая, использование изменчивых объявлений вместо того, чтобы полагаться на неявные гарантии ".
user2685937
1
Если вам нужно больше убедительности, прочтите эту статью о спецификации ECMA, которая будет использоваться для других платформ: msdn.microsoft.com/en-us/magazine/jj863136.aspx. Если вам нужны дополнительные убедительные доказательства, прочтите эту новую статью, в которой могут быть внесены оптимизации которые мешают ему работать без изменчивости: msdn.microsoft.com/en-us/magazine/jj883956.aspx Таким образом, он «может» работать для вас без изменчивости на данный момент, но не шансов, что он напишет правильный код и либо использует volatile или volatile методы чтения / записи.
user2685937
1

lockДостаточно. Сама спецификация языка MS (3.0) упоминает этот точный сценарий в §8.12 без какого-либо упоминания volatile:

Лучшим подходом является синхронизация доступа к статическим данным путем блокировки частного статического объекта. Например:

class Cache
{
    private static object synchronizationObject = new object();
    public static void Add(object x) {
        lock (Cache.synchronizationObject) {
          ...
        }
    }
    public static void Remove(object x) {
        lock (Cache.synchronizationObject) {
          ...
        }
    }
}
Марк Гравелл
источник
Джон Скит в своей статье ( yoda.arachsys.com/csharp/singleton.html ) говорит, что в этом случае для создания надлежащего барьера памяти необходим volatile. Марк, ты можешь это прокомментировать?
Константин
Ах, я не заметил дважды проверенный замок; просто: не делай этого ;-p
Марк
Я действительно считаю, что блокировка с двойной проверкой - это хорошо с точки зрения производительности. Также, если необходимо сделать поле нестабильным, пока к нему доступ осуществляется внутри блокировки, тогда блокировка с двойной проверкой не намного хуже, чем любая другая блокировка ...
Константин
Но так ли он хорош, как упоминает Джон?
Marc Gravell
-3

Это довольно хороший пост об использовании volatile с двойной проверкой блокировки:

http://tech.puredanger.com/2007/06/15/double-checked-locking/

В Java, если целью является защита переменной, вам не нужно блокировать, если она помечена как изменчивая.

Марк Поуп
источник
3
Интересно, но не обязательно очень полезно. Модель памяти JVM и модель памяти CLR - это не одно и то же.
bcat