1. Чому в багатопоточності взагалі потрібен mutex
Якщо ви щойно почали писати багатопоточний код, уявлення зазвичай просте: «пара потоків чесно робить свою роботу». Реальність трохи драматичніша: потоки — як два стажери, яким ви видали один ноутбук і сказали: «Редагуйте цей документ по черзі, але я не буду стежити». Навіть якщо стажери дуже старанні, документ усе одно час від часу перетворюватиметься на абстрактне мистецтво.
У C++ «документ» — це спільний ресурс: змінна, структура, контейнер і навіть std::cout. Якщо два потоки одночасно змінюють той самий стан, виникає гонка. Тому потрібен механізм, який запроваджує просте правило: у кожен момент часу лише один потік виконує критичну частину роботи.
std::mutex — це примітив взаємного виключення (mutual exclusion). Ідея проста: мʼютекс має два стани — «вільний» і «зайнятий». Потік може захопити мʼютекс, виконати критичну секцію, а потім звільнити його.
Схематично це можна уявити так:
flowchart TD
A[Потік 1 хоче оновити спільний ресурс] --> B["lock(mutex)"]
B --> C[Критична секція]
C --> D["unlock(mutex)"]
D --> E[Інші потоки можуть увійти]
Важливо памʼятати: mutex::lock() — це блокувальна операція. Тобто потік може чекати скільки завгодно, доки мʼютекс не звільниться. Саме так усе й працює: виклик lock() зупиняє подальше виконання, поки ресурс недоступний.
2. std::mutex: мінімум і пастки ручного lock()/unlock()
Коли ви вперше бачите std::mutex, легко подумати, що це «магічний амулет від багів». Це нормальний етап — через нього проходять майже всі. Але мʼютекс не робить код потокобезпечним сам собою. Він лише дає змогу запровадити дисципліну: ось ця ділянка виконується лише одним потоком одночасно.
Підключити його дуже просто:
#include <mutex>
Мінімальний приклад
Базовий сценарій виглядає так:
std::mutex m;
m.lock();
// ... критична секція ...
m.unlock();
Тут варто зафіксувати два моменти. По-перше, std::mutex не можна копіювати: копія мʼютекса — концептуально дивна річ, бо копія «ключа від туалету» не може взятися з повітря. По-друге, lock() за задумом не має раптово завершуватися винятком.
Але на практиці важливіше інше: навіть якщо винятків не було, можна просто забути unlock() — і саме це стає реальною проблемою для прикладного програміста.
Чому ручні lock()/unlock() — це пастка
Із ручними lock() / unlock() є одна неприємна особливість: вони вимагають від вас бездоганної дисципліни. А ми пишемо код не в стерильній лабораторії, а в реальному проєкті, де трапляються ранні return, помилки введення, кілька гілок if, а іноді й банальне: «Ой, я додав return і забув про unlock()».
Типовий поганий, але життєвий сценарій:
#include <mutex>
std::mutex m;
int x = 0;
void set_if_positive(int v) {
m.lock();
if (v <= 0) return; // <-- забули unlock()
x = v;
m.unlock();
}
Виглядає невинно, але якщо v <= 0, мʼютекс назавжди залишиться зайнятим. Наступний потік, який викличе set_if_positive, може «зависнути» на lock() і чекати без кінця.
Майже щоразу, коли про застосунок кажуть «він завис», десь поруч сумує мʼютекс, який не дочекався unlock(). Це цілком реальний клас багів: забули unlock() в одній із гілок.
Саме тут і зʼявляється RAII: ми хочемо, щоб дії «взяли блокування» й «відпустили блокування» були привʼязані не до людської памʼяті, а до області видимості.
3. RAII-блокування через std::lock_guard
std::lock_guard — це невеликий обʼєкт, який робить одну просту річ, зате стабільно: у конструкторі захоплює мʼютекс, а в деструкторі — звільняє. Тобто він перетворює блокування на ресурс у стилі RAII.
Базовий приклад lock_guard
#include <mutex>
std::mutex m;
int x = 0;
void safe_set(int v) {
std::lock_guard<std::mutex> lock(m);
x = v;
} // <-- lock_guard знищився, мʼютекс відпустився автоматично
Ранній return більше не ламає дисципліну
#include <mutex>
std::mutex m;
int x = 0;
void set_if_positive(int v) {
std::lock_guard<std::mutex> lock(m);
if (v <= 0) return; // unlock відбудеться автоматично
x = v;
}
Тут ви можете спокійно ставити return де завгодно: коли функція завершується, локальні обʼєкти знищуються, lock_guard теж знищується — і мʼютекс звільняється.
Щоб один раз чітко побачити різницю, зручно звести все в таблицю:
| Підхід | Як виглядає | Де небезпека |
|---|---|---|
| Ручний lock()/unlock() | |
легко забути unlock() на одному зі шляхів виходу |
| RAII через lock_guard | |
майже нічого не треба тримати в голові: звільнення привʼязане до області видимості |
Межа критичної секції = межа області видимості
Коли ви освоїли lock_guard, наступний крок — навчитися чітко окреслювати межі критичної секції. Новачки часто роблять так: «взяв мʼютекс на початку функції й тримаю до кінця для певності». Це безпечно, але іноді перетворюється на затор: інші потоки стоять і чекають, хоча ви під мʼютексом виконуєте щось довге й не повʼязане зі спільними даними.
Правильний підхід такий: критична секція має бути якомога меншою. Часто це означає: «оновити спільний лічильник» або «додати елемент до спільного контейнера», а всі обчислення можна винести назовні.
На рівні синтаксису це зручно оформити окремим блоком { ... }:
#include <mutex>
std::mutex m;
int total = 0;
void add_sum(int a, int b) {
int local = a + b; // довго/безпечно: поза lock
{
std::lock_guard<std::mutex> lock(m);
total += local; // коротко й по суті: під lock
} // lock знято тут
}
Цей маленький блок — як невелика кімната для переговорів: зайшли, швидко домовилися, вийшли. А не оселилися там назавжди.
4. Мініприклади на практиці
Мініприклад: «зламаний» лічильник і як його полагодити
Лічильник — класика, бо він здається максимально безпечним («це ж просто int!»), але ламається дуже наочно. Зробімо два потоки, які збільшують спільний counter. Спочатку — як робити не варто:
#include <thread>
#include <iostream>
int main() {
int counter = 0; // спільний ресурс
auto work = [&] {
for (int i = 0; i < 100000; ++i) ++counter; // гонка
};
std::thread t1(work), t2(work);
t1.join(); t2.join();
std::cout << counter << '\n'; // очікували 200000, отримали "щось"
}
Тепер додамо std::mutex і std::lock_guard. Зверніть увагу: структуру програми ми не змінюємо. Ми лише запроваджуємо правило: інкремент у кожен момент часу виконує тільки один потік.
#include <thread>
#include <mutex>
#include <iostream>
int main() {
int counter = 0;
std::mutex m;
auto work = [&] {
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> lock(m);
++counter;
}
};
std::thread t1(work), t2(work);
t1.join(); t2.join();
std::cout << counter << '\n'; // 200000
}
Так, це може працювати повільніше: ми надто часто захоплюємо мʼютекс. Але тут наша мета — коректність і надійна техніка.
Мініприклад: std::cout теж «спільний ресурс»
Є один сюрприз, із яким на практиці стикаються майже всі: два потоки, що друкують у консоль, раптом починають виводити «кашу». Це не містика, а звичайний конкурентний доступ до спільного обʼєкта виведення.
Уявімо, що в нас є функція логування:
#include <iostream>
#include <string>
void log_line(const std::string& s) {
std::cout << s << '\n';
}
Якщо два потоки викликають log_line одночасно, один рядок може «перемішатися» з іншим. Тому виведення теж варто захищати:
#include <mutex>
#include <iostream>
#include <string>
std::mutex out_m;
void log_line(const std::string& s) {
std::lock_guard<std::mutex> lock(out_m);
std::cout << s << '\n'; // друкуємо атомарно "за змістом"
}
Тут важлива одна думка: ми захищаємо не «cout як такий», а логічну операцію «вивести рядок повністю». Якщо друкувати шматками з різних потоків, користувач бачить шум, а ви — отримуєте головний біль.
Практичний мінікрок: «телеметрія» для потоків
Щоб приклади не виглядали набором розрізнених фрагментів, продовжимо мініпроєкт: у нас є кілька потоків-працівників, які імітують роботу й оновлюють спільну статистику.
Створімо модель «статистики обробки»:
struct Stats {
int ok = 0;
int failed = 0;
};
Тепер — спільний обʼєкт і мʼютекс:
#include <mutex>
Stats stats;
std::mutex stats_m;
Ось дві функції оновлення статистики. Блокування охоплює лише одну логічну операцію:
#include <mutex>
extern Stats stats;
extern std::mutex stats_m;
void add_ok() {
std::lock_guard<std::mutex> lock(stats_m);
++stats.ok;
}
void add_failed() {
std::lock_guard<std::mutex> lock(stats_m);
++stats.failed;
}
Тепер функція потоку. Ми спеціально виконуємо «роботу» поза мʼютексом, а оновлення статистики — під мʼютексом:
void worker(int id) {
// ... тут якась робота (у нашому прикладі — просто логіка) ...
if (id % 2 == 0) add_ok();
else add_failed();
}
І невеликий main, який запускає пару потоків і виводить підсумок:
#include <thread>
#include <iostream>
int main() {
std::thread t1(worker, 1);
std::thread t2(worker, 2);
t1.join();
t2.join();
std::cout << "ok=" << stats.ok << " failed=" << stats.failed << '\n';
// ok=1 failed=1
}
Цей крок важливий саме як звичка: щойно зʼявляється спільний стан, одразу зʼявляються мʼютекс і правило доступу. І добре, коли це правило виражене в коді так, що його важко випадково порушити.
5. Типові помилки під час роботи з std::mutex і std::lock_guard
Помилка № 1: «Це ж просто int, отже безпечно».
Розмір типу не робить конкурентний доступ коректним. Проблема не в тому, що int «маленький», а в тому, що операція ++counter фактично складається з послідовності «прочитати → збільшити → записати», і два потоки можуть втрутитися один в одного.
Помилка № 2: ручний lock()/unlock() «бо так зрозуміліше».
Спочатку здається, що ручний код прозоріший: «ось lock(), ось unlock()». Але водночас це і найкрихкіший варіант: ви додали ранній return, нову гілку if, ще один вихід із функції — і вже легко забути unlock() та повісити програму. lock_guard майже завжди має бути варіантом за замовчуванням.
Помилка № 3: тримати мʼютекс занадто довго, бо «а раптом».
Коли lock_guard створюють на початку функції і він живе до кінця, під блокуванням опиняються зайві обчислення, форматування рядків, іноді навіть виведення в консоль. Це знижує паралелізм і може створювати несподівані затримки. Правильна звичка — обмежувати критичну секцію невеликим блоком {...}.
Помилка № 4: захищати запис, але не захищати читання.
Типова логіка новачка: «я ж тільки читаю stats.ok, отже безпечно». У багатопоточному коді читання спільного стану без синхронізації теж може стати частиною гонки, якщо десь поруч інший потік пише. Якщо для стану діє правило «доступ під мʼютексом», його потрібно дотримуватися і під час читання.
Помилка № 5: захищати один і той самий ресурс різними мʼютексами або інколи взагалі без мʼютекса.
Іноді в проєкті зʼявляється «мʼютекс на запис» і «мʼютекс на читання», або одна частина функцій блокує доступ, а інша працює «і так». Це руйнує саму ідею дисципліни. Для одного ресурсу має бути одне чітке правило захисту, інакше ви отримаєте лише ілюзію безпеки.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ