Розуміння std :: atomic :: compare_exchange_weak () у C ++ 11


86
bool compare_exchange_weak (T& expected, T val, ..);

compare_exchange_weak()є одним із примітивів порівняння-обміну, представлених у C ++ 11. Він слабкий у тому сенсі, що повертає false, навіть якщо значення об'єкта дорівнює expected. Це пов’язано з помилковим збоєм на деяких платформах, де для його реалізації використовується послідовність інструкцій (замість однієї, як на x86). На таких платформах перемикання контексту, перезавантаження тієї самої адреси (або рядка кешу) іншим потоком тощо може призвести до помилки примітиву. Це spuriousяк це не значення об'єкта (не дорівнює expected), що не вдається виконати операцію. Натомість це свого роду проблеми з термінами.

Але мене бентежить те, що сказано в C ++ 11 Standard (ISO / IEC 14882),

29.6.5 .. Наслідком помилкових помилок є те, що майже всі випадки використання слабкого порівняння та обміну будуть у циклі.

Чому майже у всіх випадках це повинно бути в циклі ? Чи означає це, що ми будемо петляти, коли він вийде з ладу через помилкові помилки? Якщо це так, чому ми намагаємось використовувати compare_exchange_weak()і писати цикл самі? Ми можемо просто використовувати те, compare_exchange_strong()що, на мою думку, повинно позбутися помилкових для нас невдач. Які поширені випадки використання compare_exchange_weak()?

Ще одне питання, пов’язане. У своїй книзі "C ++ Concurrency In Action" Ентоні каже:

//Because compare_exchange_weak() can fail spuriously, it must typically
//be used in a loop:

bool expected=false;
extern atomic<bool> b; // set somewhere else
while(!b.compare_exchange_weak(expected,true) && !expected);

//In this case, you keep looping as long as expected is still false,
//indicating that the compare_exchange_weak() call failed spuriously.

Чому !expectedіснує умова циклу? Чи існує це для того, щоб запобігти тому, щоб усі потоки деякий час могли голодувати і не робити жодного прогресу?

Редагувати: (одне останнє запитання)

На платформах, де не існує єдиної апаратної інструкції CAS, як слабка, так і сильна версія реалізовані за допомогою LL / SC (наприклад, ARM, PowerPC тощо). То чи є якась різниця між цими двома циклами? Чому, якщо такі є? (Для мене вони повинні мати подібні показники.)

// use LL/SC (or CAS on x86) and ignore/loop on spurious failures
while (!compare_exchange_weak(..))
{ .. }

// use LL/SC (or CAS on x86) and ignore/loop on spurious failures
while (!compare_exchange_strong(..)) 
{ .. }

Я придумую останнє запитання, яке ви всі згадали, що, можливо, різниця в продуктивності всередині циклу. Це також згадується стандартом C ++ 11 (ISO / IEC 14882):

Коли порівняння та обмін у циклі, слабка версія дасть кращу продуктивність на деяких платформах.

Але, як було проаналізовано вище, дві версії циклу повинні давати однакову / подібну продуктивність. Чого я сумую?


4
Без першого питання, у багатьох випадках вам все одно потрібно зациклюватись (використовуєте ви сильну або слабку версію), і слабка версія може мати кращу продуктивність, ніж сильна.
TC

2
Як слабкі, так і сильні CAS реалізуються "за допомогою LL / SC", так само, як і сортування міхурів, і швидке сортування "за допомогою свопу"; тобто в тому сенсі, що це примітивна операція, яка використовується для виконання завдання. Те, що вони обертають навколо LL / SC, дуже різне. Слабкий CAS - це просто LL / SC. Сильна CAS - це LL / SC з купою інших речей.
Sneftel


@TuXiaomi з відповіддю у цьому посиланні, я не можу зрозуміти, чому "слабка версія дасть кращу продуктивність на деяких платформах", як зазначено у Стандарті.
Декінг

@Deqing На інших, порівняння_зміни_слаб може помилятися помилково через переривання або дії інших процесорів або потоків. На цих платформах compare_exchange_strong - це фактично цикл для compare_exchange_weak - якщо він помилково помилився, то повторюється. Це допомагає? Можливо, я помиляюся
Tu Xiaomi

Відповіді:


72

Навіщо робити обмін у циклі?

Зазвичай ви хочете, щоб ваша робота була виконана перед тим, як рухатись далі, таким чином, ви вводите compare_exchange_weakв цикл, щоб він намагався обмінятися, поки не вдасться (тобто повернеться true).

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

Чому використовувати weakзамість strong?

Досить просто: помилковий збій трапляється не часто, тому це не є великим успіхом. На противагу цьому, терпіння такого збою дозволяє набагато ефективніше впровадити weakверсію (порівняно з strong) на деяких платформах: strongзавжди потрібно перевіряти помилкові помилки та маскувати їх. Це дорого.

Таким чином, weakвикористовується, оскільки це набагато швидше, ніж strongна деяких платформах

Коли слід використовувати weakі коли strong?

У посиланні зазначено, коли використовувати weakі коли використовувати strong:

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

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

Чому !expectedв прикладі

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

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

Про ваше останнє запитання

Але, як було проаналізовано вище, дві версії циклу повинні давати однакову / подібну продуктивність. Чого я сумую?

З Вікіпедії :

Реальні реалізації LL / SC не завжди вдаються, якщо немає паралельних оновлень відповідного місця пам'яті. Будь-які виняткові події між двома операціями, такі як перемикання контексту, інше посилання для завантаження або навіть (на багатьох платформах) інше завантаження чи операція магазину, спричинять помилковий збій умовного магазину. Старі реалізації зазнають невдачі, якщо через шину пам’яті транслюються оновлення.

Так, наприклад, LL / SC не вдасться помилково вплинути на перемикач контексту. Тепер сильна версія має свій власний маленький цикл, щоб виявити цю помилкову помилку та замаскувати її, спробувавши ще раз. Зверніть увагу, що цей власний цикл також є більш складним, ніж звичайний цикл CAS, оскільки він повинен розрізняти помилковий збій (і маскувати його) та збій через паралельний доступ (що призводить до повернення зі значенням false). Слабка версія не має такого власного циклу.

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

Також зауважте, що ваш аргумент (LL / SC) - це лише одна можливість реалізувати це. Є більше платформ, які мають навіть різні набори інструкцій. Крім того (і що більш важливо), зверніть увагу, що він std::atomicповинен підтримувати всі операції для всіх можливих типів даних , тому, навіть якщо ви оголосите структуру в десять мільйонів байтів, ви можете використовувати compare_exchangeце. Навіть коли на центральному процесорі, який має CAS, ви не можете CAS десять мільйонів байт, тому компілятор генерує інші інструкції (ймовірно, отримання блокування, а потім неатомне порівняння та обмін, а потім звільнення блокування). А тепер подумайте, скільки речей може статися, міняючи місцями десять мільйонів байт. Отже, хоча помилкова помилка може бути дуже рідкісною для обміну 8 байтами, вона може бути більш поширеною в цьому випадку.

Отже, у двох словах, C ++ дає вам дві семантики, "найкраще" ( weak) та "Я зроблю це точно, незалежно від того, скільки поганого може статися між" ( strong). Те, як вони застосовуються на різних типах даних та платформах, - це зовсім інша тема. Не прив’язуйте свою розумову модель до реалізації на вашій конкретній платформі; стандартна бібліотека призначена для роботи з більшою кількістю архітектур, ніж ви могли знати. Єдиний загальний висновок, який ми можемо зробити, полягає в тому, що гарантувати успіх, як правило, складніше (і, отже, може вимагати додаткової роботи), ніж просто намагатися залишити місце для можливих невдач.


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

3
@Voo: Оновлена ​​відповідь. Тепер підказки з посилання включені. Може існувати алгоритм, який робить різницю. Наприклад, розглянемо семантику "потрібно це оновити": оновлення чогось повинно бути зроблено рівно один раз, тому, коли ми зазнаємо невдачі через одночасну запис, ми знаємо, що це зробив хтось інший, і ми можемо перервати. Якщо ми зазнаємо невдачі через помилковий збій, це ніхто не оновив, тому ми повинні повторити спробу.
гексицид

8
" Чому очікується! У прикладі? Це не потрібно для коректності. Якщо його пропустити, це дасть ту саму семантику." - не так ... якщо сказати перший обмін зазнає невдачі , тому що він знаходить bвже true, потім - з expectedТепер true- без && !expectedнього петлі і намагається інший (дурний) обмін trueі trueякий цілком може «добитися успіху» тривіального порушення з whileциклу, але може проявляти суттєво інша поведінка, якби bтим часом було повернуто до false, і в цьому випадку цикл буде продовжуватися і, зрештою, може b true знову встановитись перед розривом.
Тоні Делрой

@TonyD: Правильно, я повинен це пояснити.
гексицид

Вибачте, хлопці, я додав ще одне останнє запитання;)
Eric Z

17

Чому майже у всіх випадках це повинно бути в циклі ?

Тому що, якщо ви не виконуєте цикл, і це не вдається помилково, ваша програма не зробила нічого корисного - ви не оновили атомний об'єкт і не знаєте, яке його поточне значення (Виправлення: див. Коментар нижче від Камерона). Якщо дзвінок не робить нічого корисного, який сенс це робити?

Чи означає це, що ми будемо петляти, коли він вийде з ладу через помилкові помилки?

Так.

Якщо це так, чому ми намагаємось використовувати compare_exchange_weak()і писати цикл самі? Ми можемо просто використовувати compare_exchange_strong (), який, на мою думку, повинен позбутися помилкових помилок для нас. Які поширені випадки використання compare_exchange_weak ()?

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

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

Чому !expectedіснує умова циклу?

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

Редагувати:

Але, як було проаналізовано вище, дві версії циклу повинні давати однакову / подібну продуктивність. Чого я сумую?

Безумовно, очевидно, що на платформах, де можлива помилкова помилка, реалізація compare_exchange_strongповинна бути більш складною, перевіряти помилкові помилки та повторити спробу.

Слабка форма просто повертається на помилковий збій, вона не намагається повторити.


2
+1 Фактично точний за всіма пунктами (що надзвичайно потрібно Q).
Тоні Делрой

Приблизно you don't know what its current value isв першому пункті, коли має місце помилкова помилка, чи не повинно поточне значення дорівнювати очікуваному значенню в цей момент? В іншому випадку це було б справжньою невдачею.
Eric Z

ІМО, як слабка, так і сильна версія реалізовані за допомогою LL / SC на платформах, на яких не існує жодного примітивного апаратного приміщення CAS. Тож для мене, чому є різниця в продуктивності між while(!compare_exchange_weak(..))і while(!compare_exchange_strong(..))?
Eric Z

Вибачте, хлопці, я додав ще одне останнє запитання.
Eric Z

1
@ Джонатан: Просто чіплятися, але ви робите знати поточне значення , якщо воно не підроблено (звичайно, незалежно від того , що по - , як і раніше поточне значення на той час , коли Ви читаєте змінну є інше питання повністю, але це незалежно від того, слабкий / сильний). Я використовував це, наприклад, для спроби встановити змінну, припускаючи, що її значення дорівнює нулю, і якщо це не вдається (помилково чи ні), продовжуйте намагатися, але лише залежно від того, яке фактичне значення.
Камерон

17

Я намагаюся відповісти на це сам, переглянувши різні Інтернет-ресурси (наприклад, цей і цей ), стандарт C ++ 11, а також відповіді, наведені тут.

Пов'язані запитання об'єднуються (наприклад, " чому! Очікується? " Об'єднується з "навіщо ставити порівняння_мінна_слабка () у цикл? ") І відповіді даються відповідно.


Чому функція compare_exchange_weak () майже в усіх циклах повинна бути у циклі?

Типовий шаблон A

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

expected = current.load();
do desired = function(expected);
while (!current.compare_exchange_weak(expected, desired));

Реальний приклад полягає в тому, що декілька потоків одночасно додають елемент до списку з єдиним зв’язком. Кожен потік спочатку завантажує покажчик head, виділяє новий вузол і додає head до цього нового вузла. Нарешті, він намагається поміняти новий вузол головою.

Інший приклад - реалізація використання mutex std::atomic<bool>. По більшій мірі один потік може увійти в критичну секцію в той час, в залежності від того, який потік першого набору , currentщоб trueі вийти з циклу.

Типовий шаблон B

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

expected = false;
// !expected: if expected is set to true by another thread, it's done!
// Otherwise, it fails spuriously and we should try again.
while (!current.compare_exchange_weak(expected, true) && !expected);

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

Тим не менш, це повинно бути рідкісним використанням compare_exchange_weak()поза циклом. Навпаки, бувають випадки, коли використовується сильна версія. Наприклад,

bool criticalSection_tryEnter(lock)
{
  bool flag = false;
  return lock.compare_exchange_strong(flag, true);
}

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

Голодна нитка?

Одне, що варто згадати, - це те, що трапляється, якщо помилкові збої продовжують траплятися, тим самим голодуючи нитку? Теоретично це може відбуватися на платформах, коли compare_exchange_XXX()це реалізується як послідовність інструкцій (наприклад, LL / SC). Частий доступ до тієї ж лінії кешу між LL і SC призведе до постійних помилкових помилок. Більш реалістичним прикладом є німе планування, де всі паралельні потоки чергуються наступним чином.

Time
 |  thread 1 (LL)
 |  thread 2 (LL)
 |  thread 1 (compare, SC), fails spuriously due to thread 2's LL
 |  thread 1 (LL)
 |  thread 2 (compare, SC), fails spuriously due to thread 1's LL
 |  thread 2 (LL)
 v  ..

Чи може це статися?

Це не відбудеться назавжди, на щастя, завдяки тому, що вимагає C ++ 11:

Реалізації повинні гарантувати, що слабкі операції порівняння та обміну не повертають постійно значення false, якщо або атомний об'єкт не має значення, яке відрізняється від очікуваного, або паралельні модифікації атомного об'єкта.

Чому ми турбуємося за допомогою compare_exchange_weak () і пишемо цикл самі? Ми можемо просто використовувати compare_exchange_strong ().

Це залежить.

Випадок 1: Коли обидва потрібно використовувати всередині циклу. C ++ 11 говорить:

Коли порівняння та обмін у циклі, слабка версія дасть кращу продуктивність на деяких платформах.

На x86 (принаймні в даний час. Можливо, він вдасться до подібної схеми як LL / SC одного дня для продуктивності, коли буде введено більше ядер), слабка і сильна версія по суті однакові, оскільки обидва вони зводяться до єдиної інструкції cmpxchg. На деяких інших платформах, де compare_exchange_XXX()не реалізовано атомно (тут мається на увазі, що жоден апаратний примітив не існує), слабка версія всередині циклу може виграти битву, тому що сильній доведеться обробляти помилкові помилки і повторити спробу відповідно.

Але,

рідко, ми можемо віддати перевагу compare_exchange_strong()більш compare_exchange_weak()навіть в циклі. Наприклад, коли між атомною змінною завантажується багато речей і обмінюється обчислене нове значення (див. function()Вище). Якщо сама атомна змінна не змінюється часто, нам не потрібно повторювати дорогий розрахунок для кожного помилкового збою. Натомість ми можемо сподіватися, що compare_exchange_strong()«поглинемо» такі збої, і ми повторюємо обчислення лише тоді, коли це не вдається через реальну зміну величини.

Випадок 2: Коли compare_exchange_weak() потрібно використовувати лише цикл. С ++ 11 також говорить:

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

Зазвичай це трапляється, коли ви виконуєте цикл лише для усунення помилкових збоїв у слабкій версії. Повторіть спробу, доки обмін не буде успішним або невдалим через одночасну запис.

expected = false;
// !expected: if it fails spuriously, we should try again.
while (!current.compare_exchange_weak(expected, true) && !expected);

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

Нарешті, якщо ви виконуєте цикл для інших речей (наприклад, див. "Типовий шаблон A" вище), тоді є велика ймовірність, що він compare_exchange_strong()також буде введений у цикл, що поверне нас до попереднього випадку.


13

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

void atomicLeftShift(std::atomic<int>* var, int shiftBy)
{
    do {
        int oldVal = std::atomic_load(var);
        int newVal = oldVal << shiftBy;
    } while(!std::compare_exchange_weak(oldVal, newVal));
}

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

  1. Хтось інший змінив змінну, поки я робив ліву зміну. Результати моїх обчислень не слід застосовувати до атомної змінної, оскільки це фактично знищить чужі записи.
  2. Мій процесор відригнув, а слабкий CAS помилково вийшов з ладу.

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

Однак менш швидким є додатковий код, який сильний CAS повинен обертати навколо слабкого CAS, щоб бути сильним. Цей код не робить багато, коли слабкий CAS досягає успіху ... але коли він не вдається, сильному CAS потрібно виконати певну детективну роботу, щоб визначити, чи це був випадок 1 або випадок 2. Ця робота розвідки має форму другого циклу, ефективно всередині мого власного циклу. Дві вкладені петлі. Уявіть собі, що ваш вчитель алгоритмів прямо на вас кидає очей.

І як я вже згадував раніше, мені байдуже про результат цієї детективної роботи! У будь-якому випадку я буду переробляти CAS. Тож використання сильної CAS не приносить мені точно нічого і втрачає невелику, але вимірювану кількість ефективності.

Іншими словами, слабкий CAS використовується для реалізації операцій з атомного оновлення. Сильний CAS використовується, коли ви дбаєте про результат CAS.


0

Я думаю, що більшість відповідей, наведених вище, стосуються "помилкових помилок" як якоїсь проблеми, компромісу щодо коректності роботи.

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

Для мене основна відмінність полягає в тому, як ці дві версії вирішують проблему ABA:

слабка версія буде успішною лише в тому випадку, якщо ніхто не торкнувся лінії кешу між завантаженням і сховищем, тому вона на 100% виявить проблему ABA.

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

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

Але на x86 (сильна впорядкована архітектура) слабка версія і сильна версія однакові, і вони обидва страждають від проблеми ABA.

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

На закінчення - з мобільності та з міркувань продуктивності сильна версія завжди є кращим чи рівним варіантом.

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

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