Коли слід використовувати ключове ключове слово в C #?


299

Чи може хтось дати хороше пояснення мінливого ключового слова в C #? Які проблеми воно вирішує, а які - ні? У яких випадках врятує мене використання блокування?


6
Чому ви хочете заощадити на використанні блокування? Безперервні блокування додають вашій програмі кілька наносекунд. Ви справді не можете дозволити собі кілька наносекунд?
Ерік Ліпперт

Відповіді:


273

Я не думаю, що краще відповісти на це, ніж Ерік Ліпперт (наголос в оригіналі):

У C # "мінливий" означає не лише "переконайтесь, що компілятор і тремтіння не виконують жодної переупорядкування коду або реєструють кешування оптимізації цієї змінної". Це також означає "сказати процесорам робити все, що потрібно, щоб переконатися, що я читаю останнє значення, навіть якщо це означає зупинку інших процесорів і змушення їх синхронізувати основну пам'ять зі своїми кешами".

Власне, останній шматочок - брехня. Справжня семантика мінливих читань і записів значно складніша, ніж я окреслив тут; насправді вони насправді не гарантують, що кожен процесор зупиняє те, що він робить, і оновлює кеші в / з основної пам'яті. Швидше, вони дають більш слабкі гарантії щодо того, як доступ до пам'яті до і після читання і запису може спостерігатися, щоб вони були впорядковані один щодо одного . Окремі операції, такі як створення нової нитки, введення блокування або використання одного з методів сімейства Interlocked, дають більш гарантії щодо спостереження за замовленням. Якщо ви хочете отримати більше деталей, прочитайте розділи 3.10 та 10.5.3 специфікації C # 4.0.

Чесно кажучи, я заважаю тобі коли-небудь робити мінливе поле . Летючі поля - знак того, що ви робите щось прямо-таки божевільне: ви намагаєтесь прочитати та записати одне і те ж значення на двох різних потоках, не ставлячи замок на місце. Блокування гарантує, що пам'ять, яку читають чи змінюють пам'ять всередині блоку, є послідовною, блокування гарантує, що лише один потік отримує доступ до заданого фрагменту пам'яті одночасно тощо. Кількість ситуацій, в яких блокування занадто повільне, дуже мала, і ймовірність того, що ви збираєтеся помилитися з кодом, оскільки ви не розумієте точну модель пам'яті, дуже велика. Я не намагаюся записати код з низьким рівнем блокування, за винятком самих тривіальних звичаїв операцій Interlocked. Я залишаю використання "летючого" справжнім експертам.

Для подальшого читання див:


29
Я б проголосував за це, якби міг. Там багато цікавої інформації, але вона насправді не відповідає на його запитання. Він запитує про використання мінливого ключового слова, оскільки це стосується блокування. Протягом певного часу (до 2,0 RT) ключове ключове слово потрібно було використовувати для належного захисту статичного потоку поля, якщо польовий екземпляр мав у конструкторі який-небудь код ініціалізації (див. Відповідь AndrewTek). У виробничих середовищах ще багато 1,1 RT-коду, і розробники, які його підтримують, повинні знати, чому саме це ключове слово існує та чи його безпечно видалити.
Пасха Пасха

3
@PaulEaster той факт, що його можна використовувати для блокування, перевіреного doulbe (як правило, в одиночному шаблоні), не означає, що це повинно . Покладатися на модель пам'яті .NET, мабуть, є поганою практикою - замість цього слід покластися на модель ECMA. Наприклад, ви можете хотіти одного разу перенести порт на моно, який може мати іншу модель. Я також повинен розуміти, що різні апаратні архітектури можуть змінити щось. Для отримання додаткової інформації див: stackoverflow.com/a/7230679/67824 . Про кращі альтернативи одиночному (для всіх версій .NET) дивіться: csharpindepth.com/articles/general/singleton.aspx
Шнайдер

6
Іншими словами, правильна відповідь на питання полягає в тому, що: якщо ваш код працює під час виконання 2.0 або пізніше, мінливе ключове слово майже ніколи не потрібне і при необгрунтованому використанні приносить більше шкоди, ніж користі. Але в більш ранніх версіях часу виконання він необхідний для правильного подвійного блокування перевірки на статичні поля.
Павло Пасха

3
чи означає це, що блокування та мінливі змінні взаємно виключаються в наступному сенсі: якщо я застосував блокування навколо якоїсь змінної, більше не потрібно оголошувати цю змінну як мінливу?
giorgim

4
@Giorgi так - бар'єри пам’яті, гарантовані, volatileбудуть існувати в силу блокування
Охад Шнайдер

54

Якщо ви хочете трохи детальніше ознайомитись з тим, що робить мінливе ключове слово, врахуйте наступну програму (я використовую DevStudio 2005):

#include <iostream>
void main()
{
  int j = 0;
  for (int i = 0 ; i < 100 ; ++i)
  {
    j += i;
  }
  for (volatile int i = 0 ; i < 100 ; ++i)
  {
    j += i;
  }
  std::cout << j;
}

Використовуючи стандартні оптимізовані (випущені) настройки компілятора, компілятор створює такий асемблер (IA32):

void main()
{
00401000  push        ecx  
  int j = 0;
00401001  xor         ecx,ecx 
  for (int i = 0 ; i < 100 ; ++i)
00401003  xor         eax,eax 
00401005  mov         edx,1 
0040100A  lea         ebx,[ebx] 
  {
    j += i;
00401010  add         ecx,eax 
00401012  add         eax,edx 
00401014  cmp         eax,64h 
00401017  jl          main+10h (401010h) 
  }
  for (volatile int i = 0 ; i < 100 ; ++i)
00401019  mov         dword ptr [esp],0 
00401020  mov         eax,dword ptr [esp] 
00401023  cmp         eax,64h 
00401026  jge         main+3Eh (40103Eh) 
00401028  jmp         main+30h (401030h) 
0040102A  lea         ebx,[ebx] 
  {
    j += i;
00401030  add         ecx,dword ptr [esp] 
00401033  add         dword ptr [esp],edx 
00401036  mov         eax,dword ptr [esp] 
00401039  cmp         eax,64h 
0040103C  jl          main+30h (401030h) 
  }
  std::cout << j;
0040103E  push        ecx  
0040103F  mov         ecx,dword ptr [__imp_std::cout (40203Ch)] 
00401045  call        dword ptr [__imp_std::basic_ostream<char,std::char_traits<char> >::operator<< (402038h)] 
}
0040104B  xor         eax,eax 
0040104D  pop         ecx  
0040104E  ret              

Дивлячись на вихід, компілятор вирішив використовувати регістр ecx для зберігання значення змінної j. Для енергонезалежного циклу (перший) компілятор призначив i в регістр eax. Досить прямолінійний. Хоча є кілька цікавих бітів - інструкція lea ebx, [ebx] - це фактично багатобайтова команда nop, завдяки чому цикл переходить на 16-байтну адресу пам'яті. Інший - використання edx для збільшення лічильника циклу замість використання інструкції inc eax. Інструкція додати reg, reg має нижчу затримку на декілька ядер IA32 порівняно з інструкцією inc reg, але ніколи не має більш високої затримки.

Тепер для петлі з лічильником летючої петлі. Лічильник зберігається у [esp], а мінливе ключове слово повідомляє компілятору, що значення завжди слід читати з / записувати в пам'ять і ніколи не призначати реєстру. Компілятор навіть йде так далеко, що не виконує завантаження / приріст / зберігання, як три різних кроки (load eax, inc eax, save eax) при оновленні значення лічильника, натомість пам'ять безпосередньо змінюється в одній інструкції (add mem , рег.). Спосіб створення коду забезпечує значення лічильника циклу завжди актуальним у контексті одного ядра процесора. Жодна операція з даними не може призвести до пошкодження або втрати даних (отже, не використовувати навантаження / інк / магазин, оскільки значення може змінюватися під час inc, таким чином, втрачається в магазині). Оскільки переривання можна обслуговувати лише після завершення поточної інструкції,

Після того, як ви введете в систему другий процесор, мінливе ключове слово не захистить даних, які одночасно оновлюються іншим процесором. У наведеному вище прикладі вам знадобляться дані, щоб їх не було, щоб отримати потенційну корупцію. Ефірне ключове слово не запобіжить потенційній корупції, якщо з даними не вдасться обробляти атомно, наприклад, якщо лічильник циклу був типовим довгим (64 біт), то для оновлення значення знадобиться дві 32-бітні операції в середині яка може статися переривання та змінити дані.

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

Ефірне ключове слово було задумане для використання в операціях вводу-виводу, де IO постійно змінювався, але мав постійну адресу, наприклад пристрій UART, нанесений на пам'ять, і компілятор не повинен повторно використовувати перше значення, прочитане з адреси.

Якщо ви обробляєте великі дані або маєте декілька процесорів, то для правильного доступу до даних вам знадобиться система блокування вищого рівня (ОС).


Це C ++, але принцип застосовується до C #.
Skizz

6
Ерік Ліпперт пише, що летючі в C ++ лише перешкоджають компілятору проводити деякі оптимізації, тоді як в C # volatile додатково здійснює деякий зв'язок між іншими ядрами / процесорами, щоб забезпечити зчитування останнього значення.
Пітер Хубер

44

Якщо ви використовуєте .NET 1.1, непостійне ключове слово потрібне при подвійному перевіреному блокуванні. Чому? Оскільки до .NET 2.0 наступний сценарій може викликати доступ другого потоку до ненульового, але ще не повністю сконструйованого об'єкта:

  1. Нитка 1 запитує, чи є змінною нульовою. //if(this.foo == null)
  2. Нитка 1 визначає, що змінна є нульовою, тому вводить замок. //lock(this.bar)
  3. Нитка 1 запитує ПРОТИ, якщо змінна є нульовою. //if(this.foo == null)
  4. Нитка 1 все ще визначає, що змінна є нульовою, тому вона викликає конструктор і присвоює значення змінній. //this.foo = новий Foo ();

До .NET 2.0, цьому.foo можна було призначити новий екземпляр Foo до того, як конструктор закінчився. У цьому випадку може вступити другий потік (під час виклику потоку 1 до конструктора Foo) і зазнати наступного:

  1. Тема 2 запитує, чи змінна нуль. //if(this.foo == null)
  2. Нитка 2 визначає, що змінна НЕ є нульовою, тому намагається її використовувати. //this.foo.MakeFoo ()

До .NET 2.0 ви можете оголосити this.foo як мінливий, щоб подолати цю проблему. Оскільки .NET 2.0, вам більше не потрібно використовувати мінливе ключове слово, щоб виконати подвійне перевірене блокування.

У Вікіпедії є хороша стаття про подвійне перевірене блокування та коротко зачіпає цю тему: http://en.wikipedia.org/wiki/Double-cont_locking


2
це саме те, що я бачу у застарілому коді і цікавився цим. саме тому я розпочав глибше дослідження. Дякую!
Пітер Порфі

1
Я не розумію, як би Thread 2 призначив значення foo? Чи не this.barблокується нитка 1, і тому лише нитка 1 зможе ініціалізувати foo у певний момент часу? Я маю на увазі, ви перевіряєте це значення після того, як блокування буде відпущене знову, коли в будь-якому випадку воно повинно мати нове значення з потоку 1.
gilmishal

24

Іноді компілятор оптимізує поле та використовує реєстр для його зберігання. Якщо потік 1 робить запис у поле, а інший потік отримує доступ до нього, оскільки оновлення зберігалося в реєстрі (а не пам'яті), 2-й потік отримав би несвіжі дані.

Ви можете подумати про мінливе ключове слово, як сказати компілятору "Я хочу, щоб ти зберігав це значення в пам'яті". Це гарантує, що другий потік отримує останнє значення.


21

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


13

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

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


3

Я вважаю цю статтю Джойдіпа Канджілала дуже корисною!

When you mark an object or a variable as volatile, it becomes a candidate for volatile reads and writes. It should be noted that in C# all memory writes are volatile irrespective of whether you are writing data to a volatile or a non-volatile object. However, the ambiguity happens when you are reading data. When you are reading data that is non-volatile, the executing thread may or may not always get the latest value. If the object is volatile, the thread always gets the most up-to-date value

Я просто залишлю це для довідки


0

Іноді компілятор змінює порядок висловлювань у коді, щоб оптимізувати його. Зазвичай це не проблема в однопотоковому середовищі, але це може бути проблемою в багатопотоковому середовищі. Дивіться наступний приклад:

 private static int _flag = 0;
 private static int _value = 0;

 var t1 = Task.Run(() =>
 {
     _value = 10; /* compiler could switch these lines */
     _flag = 5;
 });

 var t2 = Task.Run(() =>
 {
     if (_flag == 5)
     {
         Console.WriteLine("Value: {0}", _value);
     }
 });

Якщо ви запускаєте t1 і t2, ви не очікуєте, що в результаті не буде результату або "Значення: 10". Можливо, компілятор перемикає лінію всередині функції t1. Якщо t2 тоді виконується, може бути, що _flag має значення 5, а _value 0. Очікувана логіка може бути порушена.

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

private static volatile int _flag = 0;

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


2
Ваш приклад справді поганий. Програміст ніколи не повинен очікувати значення _flag у завданні t2, виходячи з того, що код t1 записується першим. Написано перше! = Виконується першим. Не має значення, чи компілятор ДЕ перемикає ці два рядки в t1. Навіть якщо компілятор не перемикав ці висловлювання, ваш Console.WriteLne в іншій гілці все одно може виконуватись, навіть з мінливим ключовим словом на _flag.
Jakotheshadows

@jakotheshadows, ви праві, я відредагував свою відповідь. Моя основна ідея полягала в тому, щоб показати, що очікувана логіка може бути порушена, коли ми будемо одночасно запускати t1 і t2
Аліаксей Манюк

0

Отже, підсумовуючи все це, правильна відповідь на питання: Якщо ваш код працює в режимі виконання 2.0 або пізнішого періоду, мінливе ключове слово майже ніколи не потрібне та приносить більше шкоди, ніж користі, якщо його використовувати не потрібно. IE Ніколи не використовуйте його. АЛЕ в попередніх версіях часу виконання, це необхідно для правильного подвійного блокування перевірки на статичні поля. Зокрема статичні поля, клас яких має код ініціалізації статичного класу.


Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.