1. Навіщо взагалі існує memory_order: атомарність ≠ порядок
Якби світ був справедливим, std::atomic автоматично робив би все правильно — завжди й усюди. Але тоді половина книжок про багатопотоковість втратила б сенс, а їхні автори почали б писати про город. У реальному C++ атоміки розмежовують дві ідеї: атомарність і упорядковування, а memory_order здебільшого стосується другої.
Атомарність — це про «операція неподільна»: ніхто не побачить «половину» запису, і два потоки не «розірвуть» значення на шматки. Упорядковування — про інше: які операції компілятор або процесор має право переставляти місцями і які зміни один потік зобовʼязаний побачити разом з іншими.
Уявіть, що у вас є «дані» і «прапор готовності». Ви хочете, щоб усе працювало так: спочатку записуються дані, потім піднімається прапор, а інший потік бачить прапор і починає читати дані. Звучить логічно. Проблема в тому, що без потрібних гарантій упорядковування інший потік може побачити: «прапор уже піднято», але дані ще «ніби старі». І це може бути не збоєм заліза, а цілком легальною оптимізацією.
Схематично ідея виглядає так (дуже спрощено):
flowchart LR A[Потік 1: пише дані] --> B[Потік 1: ставить прапор] C[Потік 2: бачить прапор] --> D[Потік 2: читає дані] note1[[Якщо вибрати неправильний memory_order, потік 2 може побачити прапор, але не «повʼязані» дані.]] B -.-> note1
Важливо: сьогодні ми не будуємо складні протоколи «дані + прапор». Наша мета простіша й практичніша: зрозуміти, чому за замовчуванням розумно залишатися в seq_cst і чому relaxed доречний лише там, де ви точно не використовуєте атомік як «сигнал» для інших даних.
2. Де memory_order в API і що означає «за замовчуванням»
Коли ви вперше бачите memory_order, може здаватися, що це «якийсь зайвий параметр для зануд». На практиці це такий самий важливий вибір, як «mutex чи без mutex», тільки тонший. Хороша новина в тому, що стандартна бібліотека зробила вибір за замовчуванням максимально безпечним — і цим варто користуватися, доки ви не будете цілком упевнені у своєму протоколі.
std::memory_order — це перелічення (у сучасному C++ його оформлено як scoped enum, тобто значення пишуться як std::memory_order::seq_cst, std::memory_order::relaxed і так далі).
Майже всі атомарні операції мають перевантаження з порядком памʼяті. Наприклад (спрощено за змістом):
- x.store(value) і x.store(value, order)
- x.load() і x.load(order)
- x.fetch_add(1) і x.fetch_add(1, order)
І ось ключовий момент цієї лекції: якщо ви не вказали order, використовується найсуворіший і найзрозуміліший режим — seq_cst.
Наочно:
#include <atomic>
int main() {
std::atomic<int> x{0};
x.store(10); // еквівалентно store(10, seq_cst)
int v = x.load(); // еквівалентно load(seq_cst)
(void)v;
}
Це налаштування за замовчуванням — не випадковість. Це буквально вбудований рятувальний жилет для новачка і для звичайного прикладного коду, де коректність і читабельність важливіші, ніж вичавлювання останнього відсотка продуктивності з протоколу синхронізації.
3. memory_order::seq_cst: найзрозуміліший режим
Якщо ви колись бачили код із memory_order::relaxed усюди підряд, знайте: десь плаче один санітайзер. seq_cst (sequentially consistent) — це режим, який дає найпростішу модель: атомарні операції ніби вишиковуються в єдиний спільний порядок, зрозумілий усім потокам.
Слово «ніби» тут важливе: у реальності залізо може робити хитрі речі, але стандарт гарантує вам саме таку модель, у межах якої міркувати про код найлегше. Для навчання і для більшості прикладного коду це величезний плюс: менше магії — менше «а чому воно зламалося лише щопʼятниці».
Подивімося на простий приклад: два потоки збільшують один лічильник. З std::atomic<int> це коректно, а зі звичайним int — це вже data race і UB.
#include <atomic>
#include <iostream>
#include <thread>
int main() {
std::atomic<int> x{0};
std::jthread t1([&] { x.fetch_add(1); }); // seq_cst за замовчуванням
std::jthread t2([&] { x.fetch_add(1); }); // seq_cst за замовчуванням
std::cout << x.load() << '\n'; // найчастіше: 2 (і формально коректно)
}
Чому тут «формально коректно»? Тому що fetch_add — атомарна RMW-операція. І оскільки ми не вказали порядок явно, використовується seq_cst — тобто най«сильніший» і найпростіший для міркувань режим.
Тепер важлива практична думка: ви майже ніколи не програєте, якщо почнете з seq_cst. Натомість можна програти — часом і нервами, — якщо почати з relaxed, а потім виявиться, що ви використали цей атомік як частину протоколу, наприклад як сигнал «дані готові».
4. memory_order::relaxed: коли достатньо «просто атомарно»
memory_order::relaxed звучить дуже спокусливо, особливо якщо ви втомилися й хочете «розслабитися». Але в C++ це не про психологію, а про гарантії: relaxed дає атомарність операції над конкретною змінною, але майже не дає гарантій щодо того, як ця операція впорядковується відносно інших читань і записів, зокрема звичайних змінних.
Щоб не запамʼятати це хибно, можна тримати в голові таку фразу: relaxed — це «чесно атомарно, але без обіцянок, що решта подій світу вишикується як треба».
Найтиповіший сценарій для relaxed — лічильники статистики. Наприклад, «скільки запитів обробили», «скільки завдань виконали», «скільки разів користувач натиснув кнопку». У таких місцях зазвичай не потрібно, щоб читання лічильника «гарантувало видимість» якихось інших даних. Потрібно лише, щоб інкременти не губилися і щоб не було UB.
Мініприклад:
#include <atomic>
int main() {
std::atomic<int> hits{0};
hits.fetch_add(1, std::memory_order::relaxed);
hits.fetch_add(1, std::memory_order::relaxed);
// Важливо: hits коректно збільшується, але це не сигнал для інших даних.
}
Зауважте: тут ми свідомо кажемо собі: «мені потрібен лише коректний конкурентний інкремент». Якщо у вашій голові є хоча б маленьке «а ще за цим прапором я зрозумію, що можна читати дані», — це вже тривожний сигнал: relaxed вам майже напевно не підходить.
5. Практичний приклад: міні‑TaskRunner
Щоб тема не залишилася абстрактною, продовжимо нашу навчальну лінію «застосунок зростає разом із курсом». Раніше у нас уже зʼявлялися потоки, jthread, очікування, і ми поступово вчилися робити невеликий «виконавець завдань». Сьогодні ми не будуємо безблокувальну чергу і не показуємо фокуси з протоколами публікації даних — ми просто додамо статистику, яку можна безпечно й недорого оновлювати з кількох потоків.
Почнемо з ідеї: у нас є робочий потік, який «обробляє завдання» (для прикладу — просто крутить цикл). Нам потрібно рахувати, скільки завдань оброблено. Цей лічильник — типова метрика, і для нього relaxed часто буває достатньо.
Спочатку оголосимо атомарний лічильник:
#include <atomic>
struct Stats {
std::atomic<int> processed{0}; // скільки завдань виконано
};
Тепер робоча функція: вона виконує роботу й збільшує лічильник. Зверніть увагу: ми вибираємо relaxed саме тому, що лічильник — не сигнал готовності даних, а просто число.
#include <atomic>
#include <thread>
void do_work(Stats& stats) {
for (int i = 0; i < 1'000; ++i) {
// ... імітація роботи ...
stats.processed.fetch_add(1, std::memory_order::relaxed);
}
}
А тепер головний потік час від часу хоче «підглядати» статистику і виводити її. Оскільки це просто спостереження за метрикою, читання теж можна зробити relaxed:
#include <chrono>
#include <iostream>
#include <thread>
void print_stats(const Stats& stats) {
using namespace std::chrono_literals;
int v = stats.processed.load(std::memory_order::relaxed);
std::cout << "processed=" << v << '\n'; // наприклад: processed=1000
std::this_thread::sleep_for(50ms);
}
Нарешті, зберемо все це в невеликий main. Тут важливо, що ми не будуємо складний протокол зупинки через атоміки. Ми просто запускаємо потік, чекаємо на завершення (RAII у std::jthread нам допоможе), а наприкінці друкуємо підсумок.
#include <iostream>
#include <thread>
int main() {
Stats stats;
std::jthread worker([&] { do_work(stats); });
// Поки worker працює, можна кілька разів прочитати метрику.
print_stats(stats);
// Коли main закінчиться, jthread дочекається завершення worker.
std::cout << "final=" << stats.processed.load() << '\n'; // final=1000
}
Що тут важливо зрозуміти? Ми не використовуємо processed як «прапор»: не кажемо «якщо processed > 0, то дані готові». Ми не використовуємо цю подію, щоб читати якісь структури, які інший потік змінює без синхронізації. Ми просто рахуємо число. Тому relaxed тут — осмислений вибір.
А от якби ви захотіли зробити так: «коли лічильник став 1000, можна безпечно читати масив результатів, який робочий потік заповнював», — це вже інший клас завдань. У таких завданнях одного relaxed зазвичай недостатньо, і на рівні курсу це вже привід вибрати відповідніший спосіб синхронізації (наприклад, mutex/condition_variable) або інший протокол. Тут ми лише акуратно позначаємо межу, але не йдемо далі.
Мінітаблиця вибору: seq_cst vs relaxed
У реальному проєкті memory_order часто перетворюється на «релігію»: хтось ставить relaxed заради швидкості, хтось забороняє все, крім seq_cst, щоб спокійніше спати. На рівні навчання корисніша не релігія, а невелика мапа місцевості, щоб не заблукати.
| Порядок памʼяті | Що гарантує (простими словами) | Типовий сценарій | Головний ризик |
|---|---|---|---|
|
«Найсуворіша й найзрозуміліша модель»: атоміки поводяться максимально передбачувано між потоками; використовується за замовчуванням, якщо порядок не вказано. | Прапори стану, лічильники, проста синхронізація «аби все було коректно» | Зазвичай це не ризик, а можлива ціна у вигляді нижчої продуктивності та/або надмірної суворості |
|
«Лише атомарність цієї змінної», без обіцянок щодо порядку відносно інших даних | Метрики, статистика, лічильники «скільки разів сталося» | Використати як сигнал готовності даних і отримати рідкісні, злі, важковловимі баги |
Якщо у вас немає чіткої причини вибирати relaxed, то причина майже завжди одна: «я побачив це в інтернеті». Це, мʼяко кажучи, не найкраще джерело для проєктних рішень.
6. Типові помилки
Помилка № 1: ставити relaxed «бо так швидше», не розуміючи протоколу.
Це найпоширеніша історія. Код починає виглядати «професійно», але стає професійно непередбачуваним. Якщо ви не можете пояснити, чому вам достатньо лише атомарності без упорядковування, безпечніше залишити порядок за замовчуванням (seq_cst) і отримати код, який можна читати без шаманського бубна.
Помилка № 2: використовувати relaxed для прапора «готово», а потім читати звичайні дані.
Новачки часто думають так: «прапор атомарний, отже все, що записали перед ним, теж побачать». Це хибна логіка: relaxed не обіцяє «публікації» інших даних. У підсумку ви отримуєте баг, який може проявлятися лише на частині архітектур, лише під оптимізаціями або лише тоді, коли ви вже випустили реліз і поїхали відпочивати.
Помилка № 3: вручну «лагодити» проблему за допомогою sleep_for().
Коли щось не синхронізовано, зʼявляється спокуса «дати потоку час» і вставити затримку. Це створює ілюзію порядку, але не дає жодних гарантій. Затримка не перетворює некоректний протокол на коректний — вона лише робить баг соромʼязливішим.
Помилка № 4: змішувати різні memory_order для одного атоміка без суворої дисципліни.
Навіть якщо ви знаєте, що робите, код із різними порядками на load()/store() швидко стає нечитабельним. Для рівня курсу хороша дисципліна така: або ви всюди живете в режимі за замовчуванням, або використовуєте relaxed лише в «острівцях статистики», де немає протоколів готовності та повʼязаних даних.
Помилка № 5: думати, що «за замовчуванням» — це щось слабке.
У C++ з атоміками все навпаки: режим за замовчуванням — найсуворіший. Тобто якщо ви написали x.load() і не вказали порядок, це не «ну якось», а цілком конкретний і сильний контракт. Так зроблено спеціально, щоб ви могли писати коректний багатопотоковий код, навіть не перетворюючись на фахівця з моделі памʼяті вже в перший день.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ