Потреба у нестабільному модифікаторі при перевірці блокування в .NET


85

У кількох текстах сказано, що при впровадженні подвійно перевіреної блокування в .NET до поля, яке ви блокуєте, повинен застосовуватися мінливий модифікатор. Але чому саме? Розглядаючи наступний приклад:

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
Ганс Пасант

1
На жаль, у цій статті Джон двічі посилається на "нестабільне", і жодне посилання безпосередньо не посилається на приклади коду, які він дав.
Dan Esparza,

Дивіться цю статтю, щоб зрозуміти проблему: igoro.com/archive/volatile-keyword-in-c-memory-model-explained В основному для JIT теоретично можливо використовувати регістр ЦП для змінної екземпляра - особливо якщо вам потрібен трохи додаткового коду. Таким чином, виконання оператора if двічі потенційно може повернути одне і те ж значення незалежно від того, чи змінюється воно в іншому потоці. Насправді відповідь трохи складна Заява блокування може відповідати за покращення ситуації, а може і не відповідати за неї (продовження)
user2685937

(див. попередній коментар продовжено) - Ось, що я думаю, що насправді відбувається - В основному будь-який код, який робить щось складніше, ніж читання або встановлення змінної, може викликати JIT, щоб сказати, забудьте про спроби оптимізувати це, просто завантажимо і збережемо в пам'ять, тому що якщо функція називається JIT, потенційно потрібно буде зберігати та перезавантажувати регістр, якщо вона кожного разу зберігала його там, а не просто писала і читала кожен раз безпосередньо з пам'яті. Звідки я знаю, що замок не є нічим особливим? Подивіться на посилання, яке я розмістив у попередньому коментарі Ігоря (продовження наступного коментаря)
user2685937

. Це все одно не призведе до виходу коду, оскільки змінна екземпляра була виведена з циклу. Додавання до циклу while простого локального набору змінних все ще виводило змінну з циклу - тепер щось більш складне, наприклад, якби оператори, виклик методу або так, навіть виклик блокування завадило б оптимізації і, отже, змусило її працювати. Тому будь-який складний код часто змушує прямий доступ до змінних, а не дозволяє JIT оптимізувати. (продовження наступного коментаря)
user2685937

Відповіді:


59

Летючі непотрібні. Ну, як би **

volatileвикористовується для створення бар'єру пам'яті * між читанням і записом змінної.
lock, коли використовується, створює бар'єри пам'яті навколо блоку всередині lock, на додаток до обмеження доступу до блоку одним потоком.
Бар'єри пам'яті роблять це таким чином, що кожен потік читає найбільш поточне значення змінної (а не локальне значення, кешоване в якомусь регістрі), і що компілятор не впорядковує оператори. Використовувати volatileнепотрібно **, оскільки ви вже отримали замок.

Джозеф Альбахарі пояснює ці речі краще, ніж я коли-небудь міг.

І обов’язково ознайомтесь з посібником Джона Скіта з реалізації синглтона в C #


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

** моя відповідь правильна, лише якщо ви використовуєте CLR на процесорах x86 та x64. Це може бути правдою в інших моделях пам'яті, таких як Mono (та інші реалізації), Itanium64 та майбутньому обладнанні. Саме на це посилається Джон у своїй статті в "gotchas" про подвійне перевірене блокування.

Виконання одного з {позначення змінної як 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 / багатопотоковість-введення-пам'яті-огорожі.aspx


Джон Скіт насправді каже, що для створення належного баріера пам'яті необхідний мінливий модифікатор, тоді як автор першого посилання каже, що блокування (Monitor.Enter) було б достатньо. Хто насправді правий ???
Костянтин

@Konstantin здається, що Джон мав на увазі модель пам'яті на процесорах Itanium 64, тому в такому випадку може знадобитися використання енергозалежних. Однак нестабільність непотрібна на процесорах x86 та x64. Трохи оновлю більше.
дан

Якщо lock дійсно створює бар'єр пам'яті, і якщо barier пам'яті дійсно стосується як порядку вказівок, так і кеш-пам’яті, то він повинен працювати на всіх процесорах. У будь-якому випадку це так дивно, що така основна річ викликає стільки плутанини ...
Костянтин

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;
}

А тепер уявіть виклик конструктора за допомогою нового оператора:

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 #. Ото, Java робить гарантію , що результат newповністю инициализирован ..
user2864740

Я знаю, що реалізація MS .net розміщує бар'єр пам'яті в кінці конструтора ... але краще бути в безпеці, ніж шкодувати.
Мігель Анджело

7

Думаю, насправді ніхто не відповів на запитання , тому спробую.

Нестійкі та перші if (instance == null)не є "необхідними". Блокування зробить цей код потокобезпечним.

Тож питання: чому б ви додали перше if (instance == null)?

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

Отже, перша нульова перевірка додана як дуже швидкий спосіб дізнатись, чи потрібен вам замок. Якщо вам не потрібно створювати синглтон, ви можете повністю уникнути блокування.

Але ви не можете перевірити, чи є посилання нульовим, не заблокувавши його якимось чином, оскільки через кешування процесора інший потік може його змінити, і ви прочитаєте "застаріле" значення, яке призведе до того, що ви введете блокування без потреби. Але ви намагаєтесь уникнути замку!

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

Вам все ще потрібен внутрішній замок, оскільки енергонезалежний захищає вас лише під час одноразового доступу до змінної - ви не можете перевірити та встановити її безпечно, не використовуючи блокування.

Тепер, чи насправді це корисно?

Ну, я б сказав "у більшості випадків ні".

Якщо Singleton.Instance може спричинити неефективність через замки, то чому ви дзвоните йому так часто, що це може бути значною проблемою ? Вся суть синглтона в тому, що існує лише один, тому ваш код може читати та кешувати посилання на синглтон один раз.

Єдиний випадок, коли я можу придумати, де це кешування неможливе, - це коли у вас велика кількість потоків (наприклад, сервер, який використовує новий потік для обробки кожного запиту, може створювати мільйони дуже короткочасних потоків, кожен з яких який повинен був би зателефонувати Сінглтону. Екземпляр один раз).

Тож я підозрюю, що подвійне перевірене блокування - це механізм, який має реальне місце у дуже конкретних випадках, що критично важливі для продуктивності, і тоді всі ламали голову про "це правильний спосіб зробити", насправді не думаючи, що це робить і чи є буде фактично необхідним у тому випадку, якщо вони його використовують.


6
Це десь між помилкою та пропуском суті. volatileне має нічого спільного із семантикою блокування при перевіреному блокуванні, це пов’язано з моделлю пам’яті та когерентністю кешу. Його метою є переконатись, що один потік не отримує значення, яке все ще ініціюється іншим потоком, чого шаблон блокування подвійної перевірки за своєю суттю не запобігає. У Java вам обов’язково потрібно volatileключове слово; в .NET це неясно, оскільки це неправильно згідно з ECMA, але правильно згідно з часом роботи. У будь-якому випадку, lockоднозначно про це не піклується.
Ааронаут

А? Я не можу зрозуміти, де ваше твердження не погоджується з тим, що я сказав, і не сказав, що нестабільність якимось чином пов'язана із семантикою блокування.
Джейсон Вільямс,

6
Ваша відповідь, як і декілька інших тверджень у цьому ланцюжку, стверджує, що lockробить код потокобезпечним. Ця частина відповідає дійсності, але схема подвійної перевірки блокування може зробити її небезпечною . Це те, що, здається, вам не вистачає. Ця відповідь здається здивованою щодо значення та мети блокування подвійної перевірки, не вирішуючи при цьому жодних проблем безпеки потоків volatile.
Aaronaught

1
Як це може зробити небезпечним, якщо instanceпозначено volatile?
UserControl

5

Вам слід використовувати летючі елементи з шаблоном подвійної перевірки блокування.

Більшість людей вказують на цю статтю як на доказ того, що вам не потрібна мінливість: https://msdn.microsoft.com/en-us/magazine/cc163715.aspx#S10

Але їм не вдається прочитати до кінця: " Заключне слово попередження - я лише здогадуюсь про модель пам'яті x86 за спостережуваною поведінкою на існуючих процесорах. Таким чином, техніки низького блокування також є тендітними, оскільки обладнання та компілятори з часом можуть стати більш агресивними Ось декілька стратегій, щоб мінімізувати вплив цієї крихкості на ваш код. По-перше, по можливості уникайте методів низького блокування. (...) Нарешті, припустимо найслабшу модель пам'яті з можливих, використовуючи летючі декларації, замість того, щоб покладатися на неявні гарантії . "

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

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

Підсумовуючи, це "може" працювати для вас без мінливості на даний момент, але не варто писати правильний код і використовувати методи volatile або volatileread / write. Статті, в яких пропонується робити інакше, іноді не враховують деякі можливі ризики оптимізації JIT / компілятора, які можуть вплинути на ваш код, а також майбутні оптимізації, які можуть статися, які можуть зламати ваш код. Також, як згадувались припущення в останній статті, попередні припущення про роботу без мінливості вже можуть не мати ARM.


1
Гарна відповідь. Єдина правильна відповідь на це питання - проста "Ні". Відповідно до цього прийнята відповідь є неправильною.
Dennis Kassel

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 мінливий модифікатор справді не потрібен у цій ситуації. Однак у слабших моделях пам'яті записи, зроблені в конструкторі ліниво ініційованого об'єкта, можуть затримуватися після запису в поле, тому інші потоки можуть читати пошкоджений ненульовий екземпляр у першому операторі if.


1
У самому низу цієї статті уважно прочитайте, особливо останнє речення, автор стверджує: "Заключне слово попередження - я лише здогадуюсь про модель пам'яті x86 за спостережуваною поведінкою на існуючих процесорах. Таким чином, техніки низького блокування також є крихкими, оскільки апаратне забезпечення та компілятори з часом можуть стати більш агресивними. Ось кілька стратегій, щоб мінімізувати вплив цієї неміцності на ваш код. По-перше, по можливості уникайте методів низького блокування. (...) Нарешті, припустимо найслабшу з можливих моделей пам'яті, використання нестабільних декларацій замість покладання на неявні гарантії ".
user2685937

1
Якщо вам потрібно більш переконливо, прочитайте цю статтю про те, що специфікація ECMA буде використана для інших платформ: msdn.microsoft.com/en-us/magazine/jj863136.aspx Якщо вам потрібна подальша переконливість, прочитайте цю нову статтю про те, що можна внести оптимізації які заважають йому працювати без мінливості: msdn.microsoft.com/en-us/magazine/jj883956.aspx У підсумку він може "працювати" для вас без мінливості на даний момент, але не варто писати правильний код і використовувати volatile або volatileread / write методи.
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 ) говорить, що в цьому випадку мінливість необхідна для належного бар'єру пам'яті. Марк, ти можеш це прокоментувати?
Костянтин

Ах, я не помітив перевіреного замка; просто: не роби цього
;-p

Я насправді вважаю, що подвійна перевірка блокування - це добре для продуктивності. Крім того, якщо необхідно зробити поле нестабільним, поки до нього можна отримати доступ у замку, тоді подвійний замок не набагато гірший за будь-який інший замок ...
Костянтин

Але чи це так добре, як про окремий клас підходить Джон?
Марк Гравелл

-3

Це досить хороший допис про використання енергонезалежних з подвійним перевіреним блокуванням:

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

У Java, якщо метою є захист змінної, вам не потрібно блокувати, якщо вона позначена як нестабільна


3
Цікаво, але не обов’язково дуже корисно. Модель пам'яті JVM і модель пам'яті CLR - це не одне і те ж.
bcat
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.