Летючі проти замкнених проти замків


671

Скажімо, у класі є public int counterполе, до якого звертаються декілька потоків. Це intлише збільшується або зменшується.

Для збільшення цього поля, який підхід слід використовувати і чому?

  • lock(this.locker) this.counter++;,
  • Interlocked.Increment(ref this.counter);,
  • Змініть модифікатор доступу counterна public volatile.

Тепер, коли я виявив volatile, я видалив багато lockтверджень і використання Interlocked. Але чи є причина цього не робити?


Прочитайте нитку в довідці C # . Він висвітлює всі ваші запитання. Кожен з трьох має різні цілі та побічні ефекти.
spoulson

1
simple-talk.com/blogs/2012/01/24/… ви можете бачити використання масиву в масивах, я не розумію його повністю, але це ще одне посилання на те, що це робить.
eran otzap

50
Це як би сказати: "Я виявив, що спринклерна система ніколи не активується, тому я збираюся її зняти і замінити на димові сигнали". Причина цього не робити - це неймовірно небезпечно і майже не приносить користі . Якщо у вас є час витратити на зміну коду, тоді знайдіть спосіб зробити його менш багатопоточним ! Не знайдіть способу зробити багатопотоковий код більш небезпечним і легко порушеним!
Ерік Ліпперт

1
У моєму будинку є спринклери та димові сигналізатори. При збільшенні лічильника на один потік і при читанні його на іншому здається, що вам потрібен і замок (або переблокований), і мінливе ключове слово. Правда?
yoyo

2
@yoyo Ні, вам не потрібно обох.
Девід Шварц

Відповіді:


859

Найгірше (насправді не вийде)

Змініть модифікатор доступу counterнаpublic volatile

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

Якщо це не так volatile , а CPU A збільшує значення, тоді CPU B може фактично не бачити це збільшення значення до деякого часу, що може спричинити проблеми.

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

Другий кращий:

lock(this.locker) this.counter++;

Це можна зробити безпечно (за умови, що ви пам’ятаєте про те, lockде ви маєте доступ this.counter). Це не дозволяє іншим потокам виконувати будь-який інший код, який захищається locker. Використання блокувань також запобігає проблемам із упорядкуванням багатопроцесорних процесорів, як зазначено вище, що чудово.

Проблема полягає в тому, що блокування відбувається повільно, і якщо ви повторно користуєтесь lockerіншим місцем, яке не пов’язане насправді, ви можете в остаточному підсумку блокувати інші потоки без будь-якої причини.

Найкраще

Interlocked.Increment(ref this.counter);

Це безпечно, оскільки це ефективно робить читання, збільшення та запис у "один хіт", який неможливо перервати. Через це він не вплине на будь-який інший код, і вам також не потрібно пам'ятати, щоб заблокувати його. Це також дуже швидко (як каже MSDN, на сучасних процесорах це часто буквально одна інструкція процесора).

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

InterlockedNotes:

  1. БЕЗПЕЧНІ МЕТОДИ ТОЧНО БЕЗПЕЧНІ НА БУДЬ-ЯКІЙ ЧАСТИНИ АБО ЦПУ.
  2. Заблоковані методи застосовують повну огорожу навколо інструкцій, які вони виконують, тому переупорядкування не відбувається.
  3. Методи блокування не потребують або навіть не підтримують доступ до мінливого поля , оскільки мінливий розміщується на половині огорожі навколо операцій над заданим полем, а перемикається - використовує повний паркан.

Виноска: те, що леткі, насправді добре.

Оскільки volatileце не перешкоджає таким різновидам багатопотокових запитань, для чого це? Хороший приклад - це те, що у вас є два потоки, той, який завжди пише в змінну (скажімо queueLength), і той, який завжди читається з тієї самої змінної.

Якщо queueLengthвін не мінливий, нитка A може записати п'ять разів, але потік B може бачити ці записи затримкою (або навіть потенційно в неправильному порядку).

Рішенням може бути блокування, але ви також можете використовувати непостійні в цій ситуації. Це забезпечило б, що нитка B завжди побачить найновішу річ, яку написала нитка A. Однак зауважте, що ця логіка працює лише в тому випадку, якщо у вас є письменники, які ніколи не читають, і читачі, які ніколи не пишуть, і якщо річ, яку ви пишете, має атомне значення. Як тільки ви зробите одне читання-зміна-запис, вам потрібно перейти до операцій із замкненими блоками або скористатися блокуванням.


29
"Я не зовсім впевнений ... якщо вам також потрібно поєднувати непостійне з приростом". Вони не можуть поєднуватися з AFAIK, оскільки ми не можемо пройти мінливу за посиланням. Чудова відповідь до речі.
Хосам Алі

41
Дякую багато! Ваша виноска на тему "Що таке летюча речовина насправді добре" - це те, що я шукав і підтвердив, як я хочу використовувати леткі.
Жак Бош

7
Іншими словами, якщо var оголошено мінливим, компілятор припускатиме, що значення var не буде залишатися однаковим (тобто мінливим) щоразу, коли ваш код натикається на нього. Отже, у циклі, такому як: while (m_Var) {} і m_Var встановлено значення false в іншому потоці, компілятор не просто перевірить, що вже є в реєстрі, який раніше був завантажений зі значенням m_Var, але зчитує значення з m_Var знову. Однак це не означає, що якщо не оголосити непостійним, цикл триватиме нескінченно - зазначення непостійних гарантує лише те, що він не буде, якщо m_Var встановлено на false в іншому потоці.
Zach Saw

35
@Zach Saw: У моделі пам’яті для C ++, мінливим є те, як ви її описали (в основному корисно для пам’яті, відображеної на пристрої, і не багато іншого). Під моделлю пам'яті для CLR (це питання позначено C #) є те, що мінливі будуть вставляти бар'єри пам'яті навколо зчитування та запису в це місце зберігання. Бар'єри пам’яті (і спеціальні заблоковані варіанти деяких інструкцій по збірці) - ви говорите процесору не перевпорядковувати речі, і вони досить важливі ...
Orion Edwards

19
@ZachSaw: Мінливе поле в C # заважає компілятору C # і компілятору jit не робити певних оптимізацій, які б кешували значення. Він також дає певні гарантії щодо того, який порядок читання та запису може спостерігатися у кількох потоках. Як деталь реалізації, це може зробити це шляхом введення бар'єрів пам'яті для читання і запису. Точна гарантована семантика описана в специфікації; зауважте, що специфікація не гарантує того, що всі потоки будуть дотримуватися послідовного впорядкування всіх записів і читань .
Ерік Ліпперт

147

EDIT: Як зазначалося в коментарях, ці дні я із задоволенням використовую Interlockedдля випадків єдиної змінної, коли це, очевидно, добре. Коли це ускладнюється, я все одно повернусь до блокування ...

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

Особисто я майже завжди просто замикаюсь - легше вийти правильно таким чином, який, очевидно, правильний, ніж волатильність або Interlocked.Increment. Наскільки я переживаю, безблокова мультиварка призначена для справжніх експертів з нитки, серед яких я не один. Якщо Джо Даффі і його команда створюють приємні бібліотеки, які будуть паралельно налаштовувати речі, не замикаючись, як щось, що я будую, це чудово, і я використовую це в серцебитті, але коли я займаюся ниткою, я намагаюся не ускладнювати.


16
+1 за те, що я зараз забуду про кодування без блокування.
Xaqron

5
коди без блокування, безумовно, не є справді безблоковими, оскільки вони блокуються на певному етапі - будь то на (FSB) шині чи на рівні інтерКПУ, все одно слід сплатити штраф. Однак блокування на цих нижчих рівнях, як правило, швидше, доки ви не насичуєте пропускну здатність місця, де відбувається блокування.
Zach Saw

2
З Interlocked немає нічого поганого, це саме те, що ви шукаєте, і швидше, ніж повний замок ()
Jaap

5
@Jaap: Так, у ці дні я б використовував замок для справжнього єдиного лічильника. Я просто не хотів би починати возитися, намагаючись розробити взаємодію між кількома оновленнями без змін блокування змінних.
Джон Скіт

6
@ZachSaw: у вашому другому коментарі йдеться про те, що операції з блокованими "блокуваннями" на якомусь етапі; термін "замок" загалом означає, що одне завдання може підтримувати виключний контроль над ресурсом протягом необмеженого проміжку часу; Основна перевага програмування без блокування полягає в тому, що це дозволяє уникнути небезпеки того, що ресурс стане непридатним в результаті отримання власної задачі. Синхронізація шини, що використовується блокованим класом, не є просто "загалом швидшою" - для більшості систем вона має обмежений найгірший час, тоді як блокування - ні.
supercat

44

" volatile" не замінює Interlocked.Increment! Він просто гарантує, що змінна не кешується, а використовується безпосередньо.

Збільшення змінної вимагає фактично трьох операцій:

  1. читати
  2. приріст
  3. писати

Interlocked.Increment виконує всі три частини як одну атомну операцію.


4
Іншим способом було сказано, що зміни, що перемикаються, є повністю огородженими і як такі є атомними. Летючі елементи є лише частково огородженими і як такі не гарантуються, щоб бути безпечними нитками.
JoeGeeky

1
На насправді, volatileце НЕ переконайтеся , що змінна не кешируєтся. Він просто встановлює обмеження щодо кешування. Наприклад, він все ще може кешуватися у речах кешу L2 процесора, оскільки вони зроблені когерентними в апаратному забезпеченні. Це все ще можна зробити префектом. Записи все ще можуть бути розміщені в кеші тощо. (На що, на мою думку, ставився Зак.)
Девід Шварц

42

Те, що ви шукаєте, або замок або замкнений приріст.

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

напр

while (m_Var)
{ }

якщо m_Var встановлено на false в іншому потоці, але він не оголошений як мінливий, компілятор може зробити його нескінченним циклом (але це не означає, що це завжди буде), змусивши його перевірити реєстр процесора (наприклад, EAX, тому що це було у що входив m_Var з самого початку) замість того, щоб видавати ще одне з прочитаних у пам'яті m_Var (це може бути кешоване - ми не знаємо і не байдуже, і це сенс когерентності кешу x86 / x64). Усі повідомлення, які раніше згадували про упорядкування інструкцій, просто показують, що вони не розуміють архітектури x86 / x64. Летючі робить НЕвидавати бар'єри для читання / запису, як маються на увазі в попередніх публікаціях, які говорять: "це запобігає переупорядкуванню" Насправді, завдяки протоколу MESI, ми гарантуємо, що результат, який ми читаємо, завжди однаковий у всіх процесорах, незалежно від того, чи були фактичні результати вилучені у фізичну пам'ять чи просто перебувають у кеші локального процесора. Я не буду надто заглиблюватися в деталі цього, але будьте впевнені, що якщо це піде не так, Intel / AMD, ймовірно, видасть відкликання процесора! Це також означає, що нам не потрібно піклуватися про невиконання замовлення і т. Д. Результати завжди гарантовано вийдуть на пенсію для порядку - інакше ми забиті!

За допомогою Interlocked Increment процесор повинен вийти, отримати значення з вказаної адреси, потім збільшити та записати його назад - все це, маючи ексклюзивне право власності на всю лінію кешу (lock xadd), щоб переконатися, що жоден інший процесор не може змінити його значення.

З мінливими, ви все одно закінчите лише з 1 інструкцією (припустимо, що JIT є ефективним як слід) - включаючи dword ptr [m_Var]. Однак процесор (cpuA) не вимагає ексклюзивного права власності на кеш-лінію, роблячи все це з заблокованою версією. Як ви можете уявити, це означає, що інші процесори можуть записувати оновлене значення назад в m_Var після того, як cpuA його прочитав. Отже, замість того, щоб тепер збільшити значення вдвічі, ви закінчите лише один раз.

Сподіваюся, це вирішить це питання.

Для отримання додаткової інформації див. "Розуміння впливу методів низьких блокувань у багатопотокових програмах" - http://msdn.microsoft.com/en-au/magazine/cc163715.aspx

ps Що спонукало до цієї дуже пізньої відповіді? Усі відповіді були настільки неправдивими (особливо той, що позначений як відповідь) у своєму поясненні, що я просто повинен був прояснити це для всіх, хто читав це. знизує плечима

pps Я припускаю, що ціль x86 / x64, а не IA64 (у неї є інша модель пам'яті). Зауважте, що специфікація ECMA Microsoft накручена тим, що вона визначає найслабшу модель пам’яті замість найсильнішої (завжди краще вказати проти найсильнішої моделі пам’яті, щоб вона відповідала всім платформам - інакше код, який би працював 24–7 на x86 / x64 може взагалі не працювати на IA64, хоча Intel реалізує аналогічно потужну модель пам'яті для IA64) - Microsoft визнала це самі - http://blogs.msdn.com/b/cbrumme/archive/2003/05/17/51445.aspx .


3
Цікаво. Ви можете посилатися на це? Я б із задоволенням проголосував за це, але для публікації агресивною мовою через 3 роки після високоголосної відповіді, що відповідає ресурсам, які я прочитав, знадобиться трохи більш відчутне підтвердження.
Стівен Еверс

Якщо ви можете вказати, на яку частину ви хочете посилатися, я би радий викопати десь якісь речі (я дуже сумніваюся, що я передав будь-які секрети торговельних операторів x86 / x64, тому вони повинні бути легко доступними у вікі, Intel PRM (посібники з програмістами), блоги MSFT, MSDN або щось подібне) ...
Zach Saw

2
Чому хтось хотів би не допустити кешування процесора, це не в мене. Вся нерухомість (безумовно, не незначна за розміром і вартістю), призначена для виконання когерентності кешу, повністю витрачається, якщо це так ... Якщо тільки вам не потрібна когерентність кешу, як-от відеокарта, пристрій PCI тощо, ви б не встановили рядок кеш-пам'яті для переробки.
Zach Saw

4
Так, все, що ви говорите, це якщо не 100%, то хоча б 99% на оцінці. Цей сайт (здебільшого) дуже корисний, коли ви поспішаєте розвиватися на роботі, але, на жаль, точності відповідей, що відповідають голосу (гра), немає. Таким чином, в основному за допомогою поточного потоку ви можете відчути, що розуміє читачів, а не те, що воно є насправді. Іноді верхні відповіді - це просто чистий безглуздість - міфи свого роду. І на жаль, це те, що породжує людей, які стикаються з прочитаним, вирішуючи проблему. Це зрозуміло, хоча ніхто не може все знати.
користувач1416420

1
@BenVoigt Я міг би продовжити і відповісти про всі архітектури .NET працює, але це займе кілька сторінок і точно не підходить для SO. Набагато краще навчати людей на основі найбільш широко використовуваної .NET базової апаратної пам’ятки, ніж тієї, яка є довільною. І своїми коментарями "скрізь" я виправляв помилки, які люди робили, припускаючи, що прошивання / виправлення недійсного кешу тощо. Вони робили припущення щодо базового обладнання, не вказуючи, яке саме обладнання.
Zach Saw

16

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

Я б сказав, що ви завжди повинні віддавати перевагу цьому замку та приросту.

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

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

http://www.ddj.com/hpc-high-performance-computing/210604448


11

Блокування (...) працює, але може блокувати потік і може спричинити тупиковий стан, якщо інший код використовує ті самі блокування несумісним чином.

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

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


8

Я зробив тест, щоб побачити, як насправді працює теорія: kennethxu.blogspot.com/2009/05/interlocked-vs-monitor-performance.html . Мій тест був більш орієнтований на CompareExchnage, але результат для збільшення збільшився. Заблокований не потрібен швидше в середовищі з декількома процесорами. Ось результат тесту Increment на 2-річному 16-серверному процесорі. Майте на увазі, що тест також передбачає безпечне зчитування після збільшення, що характерно для реального світу.

D:\>InterlockVsMonitor.exe 16
Using 16 threads:
          InterlockAtomic.RunIncrement         (ns):   8355 Average,   8302 Minimal,   8409 Maxmial
    MonitorVolatileAtomic.RunIncrement         (ns):   7077 Average,   6843 Minimal,   7243 Maxmial

D:\>InterlockVsMonitor.exe 4
Using 4 threads:
          InterlockAtomic.RunIncrement         (ns):   4319 Average,   4319 Minimal,   4321 Maxmial
    MonitorVolatileAtomic.RunIncrement         (ns):    933 Average,    802 Minimal,   1018 Maxmial

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

@Zach, як тут дискусія йшла про сценарій збільшення лічильника в безпечному порядку. Який ще сценарій використання був у вас на думці чи як би ви його протестували? Дякуємо за коментар BTW.
Кеннет Сю

Справа в тому, що це штучне випробування. Ви не збираєтесь забивати ту саму локацію, що часто в будь-якому реальному світі. Якщо ви є, то добре, що у вас є вузькі місця FSB (як показано на скриньках вашого сервера). У будь-якому випадку, подивіться на мою відповідь у вашому блозі.
Zach Saw

2
Оглянувши це ще раз. Якщо справжнє вузьке вузьке місце з FSB, реалізація монітора повинна дотримуватися того самого вузького місця. Справжня різниця полягає в тому, що Interlocked робить зайняті очікування та повторення спроби, що стає справжньою проблемою з високим підрахунком продуктивності. Принаймні сподіваюся, що мій коментар викликає увагу, що Interlocked - це не завжди правильний вибір для підрахунку. Те, що люди шукають альтернативи, добре пояснило це. Вам потрібен довгий додаток gee.cs.oswego.edu/dl/jsr166/dist/jsr166edocs/jsr166e/…
Кеннет Сю

8

3

Я хотів би додати до згадані в інших відповідях різниці між volatile, Interlockedі lock:

Летнє ключове слово можна застосувати до полів таких типів :

  • Довідкові типи.
  • Типи вказівників (у небезпечному контексті). Зауважте, що хоча сам вказівник може бути мінливим, об'єкт, на який він вказує, не може. Іншими словами, ви не можете визначити "покажчик" "мінливим".
  • Прості типи , такі як sbyte, byte, short, ushort, int, uint, char, float, і bool.
  • Перерахування з одним з наступних базових типів: byte, sbyte, short, USHORT, intабо uint.
  • Параметри загального типу, відомі як еталонні типи.
  • IntPtrі UIntPtr.

Інші типи , включаючи doubleта longне можуть бути позначені "мінливими", оскільки читання та запис у поля цих типів не може бути гарантовано атомним. Для захисту багатопотокового доступу до цих типів полів використовуйте Interlockedчлени класу або захистіть доступ за допомогою lockоператора.

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