Я хочу написати переносний код (Intel, ARM, PowerPC ...), який вирішує варіант класичної проблеми:
Initially: X=Y=0
Thread A:
X=1
if(!Y){ do something }
Thread B:
Y=1
if(!X){ do something }
в якій мета - уникнути ситуації, в якій роблять обидві ниткиsomething . (Добре, якщо жодна річ не працює; це не механізм, який виконується точно один раз.) Будь ласка, виправте мене, якщо ви бачите деякі недоліки в моїх міркуваннях нижче.
Я знаю, що я можу досягти поставленої мети за допомогою memory_order_seq_cstатомних stores та loads таким чином:
std::atomic<int> x{0},y{0};
void thread_a(){
x.store(1);
if(!y.load()) foo();
}
void thread_b(){
y.store(1);
if(!x.load()) bar();
}
що досягає мети, оскільки повинен бути якийсь єдиний загальний порядок
{x.store(1), y.store(1), y.load(), x.load()}подій, який повинен узгоджуватися з програмними "краями" програми:
x.store(1)"в TO є раніше"y.load()y.store(1)"в TO є раніше"x.load()
а якщо foo()зателефонували, то у нас є додаткові переваги:
y.load()"значення читає раніше"y.store(1)
а якщо bar()зателефонували, то у нас є додаткові переваги:
x.load()"значення читає раніше"x.store(1)
і всі ці краї, поєднані разом, утворювали б цикл:
x.store(1)"в TO є перед" y.load()"читає значення перед" y.store(1)"в TO є перед" x.load()"читає значення раніше"x.store(true)
що порушує той факт, що замовлення не мають циклів.
Я навмисно використовую нестандартні терміни "в TO є перед" і "читає значення раніше", на відміну від стандартних термінів типу happens-before, тому що я хочу попросити відгуку про правильність свого припущення про те, що ці краї дійсно мають на увазі happens-beforeвідношення, можуть бути об'єднані разом в єдине графік, і цикл у такому комбінованому графіку заборонений. Я не впевнений у цьому. Я знаю, що цей код створює правильні бар'єри для Intel gcc & clang та ARM gcc
Тепер моя справжня проблема трохи складніша, тому що я не маю контролю над "X" - вона прихована за деякими макросами, шаблонами тощо і може бути слабкішою за seq_cst
Я навіть не знаю, чи "X" - це одна змінна чи якась інша концепція (наприклад, легкий семафор або мютекс). Все, що я знаю, це те, що у мене є два макроси set()та check()такі, що check()повертається true"після" іншою ниткою set(). (Це є також відомо , що setі checkпотокобезпечна і не може створювати дані гонки UB.)
Отже, концептуально set()це дещо схоже на "X = 1" і check()схоже на "X", але я не маю прямого доступу до атомів, якщо такі є.
void thread_a(){
set();
if(!y.load()) foo();
}
void thread_b(){
y.store(1);
if(!check()) bar();
}
Я переживаю, що це set()може бути внутрішньо реалізовано як x.store(1,std::memory_order_release)і / або check()може бути x.load(std::memory_order_acquire). Або гіпотетично, std::mutexщо одна нитка розблоковується, а інша - try_locking; у стандарті ISO std::mutexгарантується лише замовлення на придбання та випуск, а не seq_cst.
Якщо це так, то check(), якщо тіло можна "переупорядкувати" раніше y.store(true)( Дивіться відповідь Алекса, де вони демонструють, що це відбувається на PowerPC ).
Це було б дуже погано, оскільки зараз така послідовність подій можлива:
thread_b()спочатку завантажує старе значенняx(0)thread_a()виконує все, в тому числіfoo()thread_b()виконує все, в тому числіbar()
Тож, foo()і bar()дзвонили, чого мені довелося уникати. Які мої варіанти запобігти цьому?
Варіант А
Спробуйте застосувати бар'єр для зберігання. На практиці цього можна досягти, std::atomic_thread_fence(std::memory_order_seq_cst);- як пояснив Алекс в іншій відповіді, всі перевірені укладачі випустили повний паркан:
- x86_64: MFENCE
- PowerPC: hwsync
- Ітануїм: mf
- ARMv7 / ARMv8: dmb ish
- MIPS64: синхронізація
Проблема такого підходу полягає в тому, що я не зміг знайти жодної гарантії в правилах C ++, яка std::atomic_thread_fence(std::memory_order_seq_cst)повинна перетворюватися на повний бар'єр пам'яті. Насправді, концепція atomic_thread_fences в C ++, схоже, знаходиться на іншому рівні абстракції, ніж концепція складання бар'єрів пам'яті і стосується більше таких речей, як "яка атомна операція синхронізується з тим". Чи є якісь теоретичні докази того, що внизу реалізація досягає мети?
void thread_a(){
set();
std::atomic_thread_fence(std::memory_order_seq_cst)
if(!y.load()) foo();
}
void thread_b(){
y.store(true);
std::atomic_thread_fence(std::memory_order_seq_cst)
if(!check()) bar();
}
Варіант В
Використовуйте управління, яке ми маємо над Y, щоб досягти синхронізації, використовуючи операції читання-зміна-запис пам'яті_порядок_acq_rel на Y:
void thread_a(){
set();
if(!y.fetch_add(0,std::memory_order_acq_rel)) foo();
}
void thread_b(){
y.exchange(1,std::memory_order_acq_rel);
if(!check()) bar();
}
Ідея тут полягає в тому, що доступ до однієї атомної ( y) повинен бути єдиним порядком, з яким згодні всі спостерігачі, так що або fetch_addперед, exchangeабо навпаки.
Якщо fetch_addдо exchangeцього, частина "випуску" fetch_addсинхронізується з частиною "придбати", exchangeі, таким чином, всі побічні ефекти set()повинні бути видимими для виконання коду check(), тому bar()не буде викликано.
В іншому випадку exchangeє раніше fetch_add, тоді fetch_addбуде бачити, 1а не дзвонити foo(). Отже, назвати і те, foo()і неможливо bar(). Чи правильно це міркування?
Варіант С
Використовуйте фіктивну атоміку, щоб ввести "краї", які запобігають катастрофі. Розглянемо наступний підхід:
void thread_a(){
std::atomic<int> dummy1{};
set();
dummy1.store(13);
if(!y.load()) foo();
}
void thread_b(){
std::atomic<int> dummy2{};
y.store(1);
dummy2.load();
if(!check()) bar();
}
Якщо ви думаєте, що проблема тут atomicлокальна, то уявіть, як перемістити їх до глобальної сфери, в наступних міркуваннях це не має для мене значення, і я навмисно написав код таким чином, щоб викрити, наскільки смішним це манекен1 і манекен2 повністю розділені.
Чому на Землі це може працювати? Ну, має бути якийсь єдиний загальний порядок, {dummy1.store(13), y.load(), y.store(1), dummy2.load()}який повинен відповідати програмним "краям":
dummy1.store(13)"в TO є раніше"y.load()y.store(1)"в TO є раніше"dummy2.load()
(Сподіваємось, що магазин seq_cst + завантаження сподівається утворювати C ++ еквівалент повного бар'єру пам'яті, включаючи StoreLoad, як це робиться в asm для реальних ISA, включаючи навіть AArch64, де не потрібні окремі інструкції щодо бар'єру.)
Тепер ми маємо розглянути два випадки: або y.store(1)до, y.load()або після загального порядку.
Якщо y.store(1)до y.load()цього, foo()то не дзвонить, і ми в безпеці.
Якщо y.load()до цього y.store(1), то, поєднавши його з двома ребрами, які ми вже маємо в програмному порядку, виводимо, що:
dummy1.store(13)"в TO є раніше"dummy2.load()
Тепер, dummy1.store(13)це операція випуску, яка випускає ефекти set(), і dummy2.load()це операція придбання, тому check()слід бачити ефекти, set()і таким чином bar()не буде викликатися, і ми в безпеці.
Чи правильно тут думати, що check()побачать результати set()? Чи можу я поєднувати "ребра" різних видів ("програмний порядок", відомий як послідовно раніше, "загальний порядок", "до виходу", "після придбання") так? У мене є серйозні сумніви з цього приводу: правила C ++, схоже, говорять про "синхронізує-з" відносини між магазином і завантаженням на одному місці - тут такої ситуації немає.
Зауважте, що нас хвилює лише той випадок, коли, dumm1.storeяк відомо (за допомогою інших міркувань), це було dummy2.loadв загальному порядку seq_cst. Отже, якби вони отримували доступ до однієї і тієї ж змінної, навантаження побачило б збережене значення і синхронізувалося з ним.
(Обґрунтування перешкод для пам’яті / упорядкування перетворень для реалізацій, де атомні навантаження та сховища збираються принаймні в один бік бар'єри пам’яті (а операції seq_cst не можуть змінити порядок: наприклад, сховище seq_cst не може передати навантаження seq_cst) полягає в тому, що будь-які навантаження / магазини після, dummy2.loadбезумовно, стають видимими для інших потоків після y.store . І аналогічно для іншої теми , ... раніше y.load.)
Ви можете грати з моєю реалізацією параметрів A, B, C за адресою https://godbolt.org/z/u3dTa8
foo()і bar()викликати обидва.
compare_exchange_*для виконання операції RMW на атомному булі, не змінюючи його значення (просто встановіть очікуване і нове на те саме значення).
atomic<bool>має exchangeі compare_exchange_weak. Останній може бути використаний для того, щоб зробити манекен RMW шляхом (спроби) CAS (істинного, істинного) або false, хибного. Він або відмовляється, або атомно замінює значення самим собою. (У x86-64 asm, ця хитрість lock cmpxchg16bполягає в тому, як ви робите гарантовані атомні 16-байтові навантаження; неефективні, але менш погані, ніж знімати окремий замок.)
foo()і bar()не будуть викликані. Я не хотів залучати до багатьох елементів "реального світу" коду, щоб уникнути "ви думаєте, що у вас проблема X, але у вас є проблема Y". Але, якщо дійсно потрібно знати, що таке передісторія: set()це дійсно some_mutex_exit(), check()є try_enter_some_mutex(), yє "є якісь офіціанти", foo()це "вихід, не прокидаючи нікого", bar()це "чекати розбудови" ... Але я відмовляюся обговоримо цей дизайн тут - я не можу його реально змінити.
std::atomic_thread_fence(std::memory_order_seq_cst)складається до повного бар'єру, але оскільки вся концепція є детальною інформацією про реалізацію, ви не знайдете будь-яка згадка про це в стандарті. (Модель пам'яті процесора зазвичай є визначені в термінах того , що reorerings допускаються по відношенню до послідовної послідовності , наприклад х86 сл-сСт + магазин буфер ж / експедирування.)