std::mutex + std::lock_guard — RAII-блокування

C++ SELF
Рівень 69 , Лекція 1
Відкрита

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()
m.lock(); ...; m.unlock();
легко забути unlock() на одному зі шляхів виходу
RAII через lock_guard
std::lock_guard lock(m);
майже нічого не треба тримати в голові: звільнення привʼязане до області видимості

Межа критичної секції = межа області видимості

Коли ви освоїли 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: захищати один і той самий ресурс різними мʼютексами або інколи взагалі без мʼютекса.
Іноді в проєкті зʼявляється «мʼютекс на запис» і «мʼютекс на читання», або одна частина функцій блокує доступ, а інша працює «і так». Це руйнує саму ідею дисципліни. Для одного ресурсу має бути одне чітке правило захисту, інакше ви отримаєте лише ілюзію безпеки.

1
Задача
C++ SELF, 69 рівень, 1 лекція
Недоступна
Загальний лічильник кліків
Загальний лічильник кліків
1
Задача
C++ SELF, 69 рівень, 1 лекція
Недоступна
Журнал дій команди
Журнал дій команди
1
Задача
C++ SELF, 69 рівень, 1 лекція
Недоступна
Статистика з раннім виходом
Статистика з раннім виходом
1
Задача
C++ SELF, 69 рівень, 1 лекція
Недоступна
Сума квадратів частинами
Сума квадратів частинами
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ