Я хочу написати переносний код (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
атомних store
s та load
s таким чином:
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_lock
ing; у стандарті 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_fence
s в 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 сл-сСт + магазин буфер ж / експедирування.)