JavaRush /Курси /C++ SELF /Критична секція — що захищаємо і де

Критична секція — що захищаємо і де

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

1. Чому розмова про критичні секції починається не з мʼютекса

Коли ви починаєте писати багатопотоковий код, дуже хочеться одразу схопити перший-ліпший std::mutex і обкласти ним усе, що рухається, а заодно й думки про сенс життя. Це зрозумілий порив: «заблокую — і все буде правильно». Проблема в тому, що без розуміння що саме ми захищаємо, отримаємо або код, який однаково ламається, або код, який нібито «правильний», але працює як равлик у відпустці.

Сьогодні ми зробимо крок назад і навчимося спершу бачити всю картину: де у нас спільний ресурс, який у нього інваріант і де проходить межа критичної секції. Мʼютекси зʼявляться на наступних лекціях — і тоді ви ставитимете блокування не «на удачу», а доцільно.

Як зрозуміти, що обʼєкт потрібно захищати

Майже будь-яка проблема в багатопоточності починається не з «поганого потоку», а з того, що потоки мають спільний обʼєкт, з яким працюють. Причому спільний ресурс — це не лише глобальна змінна (хоча вона й сама проситься в меми). Це може бути поле обʼєкта, контейнер, файл, потік виведення і навіть «черга повідомлень» (але сьогодні ми черги не розглядаємо).

Скажімо зовсім просто: спільний ресурс — це те, до чого мають доступ два або більше потоків, і принаймні один із них може це змінювати. Якщо обʼєкт доступний лише всередині одного потоку (наприклад, локальна змінна всередині лямбди, яка не розділяється), це не спільний ресурс.

Невелика таблиця для інтуїції:

Приклад Це спільний ресурс? Чому
int local = 0;
усередині функції потоку
Ні Кожен потік має власну копію у своєму стеку
counter захоплено за посиланням [&] і змінюється у двох потоках Так Обидва потоки пишуть в одну й ту саму памʼять
std::vector<int> v; спільний, і обидва потоки роблять push_back Так Контейнер одночасно змінюють кілька потоків
std::cout із двох потоків Так Потік виведення — спільний обʼєкт, тому виведення з потоків може перемішатися

Міні‑приклад 1: «та це ж просто int…»

Перед цим прикладом варто проговорити одну важливу річ. Новачки часто думають: «якщо тип маленький (наприклад, int), то все безпечно». У багатопоточності розмір типу майже ніколи не рятує, бо проблема не в розмірі, а в тому, що операція насправді складається з кількох кроків.

#include <iostream>
#include <thread>

int main() {
    int counter = 0; // спільний ресурс (його використовують два потоки)

    auto work = [&] {
        for (int i = 0; i < 100000; ++i) {
            ++counter; // читання → зміна → запис, конкурентний доступ
        }
    };

    std::thread t1(work);
    std::thread t2(work);
    t1.join();
    t2.join();

    std::cout << counter << '\n'; // часто буде НЕ 200000
}

У цьому коді «небезпечне місце» — не весь work, а конкретно операція ++counter, тому що за змістом це «прочитав старе значення → додав → записав нове», а ці три кроки можуть перемішатися між потоками.

2. Інваріант: що ми зберігаємо «правильним»

Перш ніж говорити «критична секція», корисно ввести слово, яке звучить страшніше, ніж воно є насправді: інваріант. Інваріант — це просто умова, яка має бути істинною завжди (або, якщо точніше, після завершення кожної публічної операції над обʼєктом).

Чому це важливо? Тому що критична секція — це не просто «шматок коду, де страшно». Критична секція — це мінімальна ділянка, у межах якої ми зберігаємо інваріант спільного ресурсу.

Уявіть банківський рахунок:

  • інваріант: баланс має бути коректним значенням, що відображає суму всіх операцій;
  • операція «внести гроші» за змістом має бути атомарною: не можна, щоб один потік побачив баланс «між» кроками зміни.

Міні‑приклад 2: спільний ресурс — поле структури

Зараз приклад виглядає однопотоковим, але він важливий як модель: ми вже бачимо, що захищати доведеться не «функції», а стан (balance).

struct Account {
    int balance = 0; // спільний ресурс, якщо Account спільний для потоків
};

void deposit(Account& a, int amount) {
    a.balance += amount; // читання → зміна → запис за змістом
}

void withdraw(Account& a, int amount) {
    a.balance -= amount; // читання → зміна → запис за змістом
}

Якщо Account використовується кількома потоками, то інваріант (коректність балансу) потребує дисципліни: доступ до balance має бути узгодженим.

4. Критична секція: атомарність за змістом

Дуже поширена помилка — сприймати критичну секцію як «рядки, де стоїть небезпечний оператор». Насправді критична секція визначається не синтаксисом, а змістом: яка послідовність дій має бути неподільною для інших потоків.

Класична схема: «прочитати → обчислити → записати». Іноді обчислення можна винести назовні, а іноді — ні. Критична секція — це те місце, де ви не можете дозволити іншим потокам втрутитися так, щоб інваріант став тимчасово неправильним ззовні.

Міні‑приклад 3: обчислення — окремо, оновлення спільного підсумку — окремо

Зараз буде важлива думка: критична секція має бути короткою за часом, але повною за змістом. Тобто ми намагаємося винести все «довге» назовні, але залишаємо всередині все, що потрібно для узгодженого оновлення спільного стану.

int total = 0; // спільний ресурс, якщо функцію викликають із різних потоків

void add_pair(int a, int b) {
    int local = a + b;  // локальна змінна, це НЕ спільний ресурс
    total += local;     // критична секція за змістом: оновлення total
}

Тут local безпечний: він живе всередині виклику функції (у стеку потоку). А от total — потенційно спільний, і його оновлення має бути атомарним за змістом.

5. Межі критичної секції: мінімально, але достатньо

Коли новачки визначають межі критичної секції, зазвичай трапляються дві крайнощі — і обидві неприємні.

Перша крайність — «давайте захистимо лише запис». Наприклад: «у total += local ми пишемо, отже, захистимо тільки цей рядок». Іноді це справді правильна межа, але дуже часто поруч є читання, яке теж створює гонку даних. Важливо памʼятати: читання спільного стану без синхронізації теж може бути проблемою, тому що гонка даних виникає не лише під час запису, а й у поєднанні «хтось пише, хтось читає/пише».

Друга крайність — «давайте захистимо всю функцію». Це зазвичай «працює», але потім ви дивуєтеся, чому програма стала повільнішою за однопотокову. Причина проста: ви фактично заборонили потокам працювати паралельно навіть там, де вони могли б.

Хороша межа критичної секції зазвичай знаходиться так:

  1. Спершу ви формулюєте інваріант: що саме має бути узгодженим.
  2. Потім виділяєте мінімальну послідовність дій, яка цей інваріант змінює.
  3. Усе інше, що не стосується спільного ресурсу, виносите назовні.

Можна уявити це як «сендвіч»:

[довга робота без спільного ресурсу]
        ↓
[критична секція: швидко й атомарно за змістом]
        ↓
[далі знову без спільного ресурсу]

Синхронізація захищає дані, а не «місця в коді»

Це правило звучить абстрактно, тому поясню простіше. Ви можете поставити блокування «в одному місці», але якщо в іншому місці хтось читає або пише той самий обʼєкт без блокування, ви не захистили дані. Ви лише додали в програму новий ритуал, який заспокоює вас, але не усуває багів.

Тобто дисципліна така: якщо у нас є спільний ресурс X, то усі шляхи доступу до X мають бути узгоджені одним правилом. Зазвичай це означає: «усі доступи до X відбуваються всередині критичної секції, яку захищає один і той самий механізм». У наступних лекціях ми зробимо це через std::mutex, але сьогодні для нас важливий сам принцип: захищаємо стан, а не рядок коду.

6. Практичний приклад: статистика завдань та її інваріанти

Щоб не обмежуватися самими лише прикладами на кшталт «counter++», давайте продовжимо наш умовний консольний застосунок, який ми розвивали раніше: TaskTracker. Уявімо, що ми вже вміємо зберігати завдання й друкувати їх, а тепер вирішили додати «живу статистику» — наприклад, сумарний час виконання завдань.

Нехай у нас є компонент TaskStats, який зберігає тривалості виконаних завдань. Ми хочемо підтримувати швидкий розрахунок середнього значення, тому зберігаємо не лише список, а й суму.

Інваріант тут дуже конкретний і корисний:

total_seconds_ завжди дорівнює сумі всіх елементів durations_seconds_.

Якщо інваріант порушиться, середнє буде неправильним, і ви шукатимете баг «чому математика зламалася», хоча зламається не математика, а багатопоточність.

Міні‑приклад 4: «небезпечна» операція — це два записи, які мають бути одним

Зараз код навмисне без синхронізації. Наше завдання — побачити межі критичної секції.

#include <vector>

class TaskStats {
public:
    void add_duration(int seconds) {
        durations_seconds_.push_back(seconds); // (1) змінюємо контейнер
        total_seconds_ += seconds;             // (2) змінюємо суму
        // За змістом ці два рядки мають утворювати одну критичну секцію.
    }

private:
    std::vector<int> durations_seconds_;
    long long total_seconds_ = 0;
};

Чому це критична секція? Тому що інший потік може втрутитися «між» (1) і (2), і тоді:

  • вектор уже збільшився,
  • а сума ще не збільшилася,

і інваріант тимчасово порушиться. А якщо в цей момент хтось читає статистику, він побачить неузгоджений стан.

І читання теж потребує меж

Перевірмо метод для обчислення середнього значення. Навіть якщо він «нічого не змінює», він читає два повʼязані поля, які мають бути узгодженими.

class TaskStats {
public:
    double average() const {
        if (durations_seconds_.empty()) return 0.0;

        double sum = static_cast<double>(total_seconds_);
        double n = static_cast<double>(durations_seconds_.size());
        return sum / n;
        // Читання total_seconds_ і size() теж має бути узгодженим.
    }

private:
    std::vector<int> durations_seconds_;
    long long total_seconds_ = 0;
};

Тут небезпека в тому, що один потік може змінювати durations_seconds_ і total_seconds_, а інший — одночасно читати. Навіть якщо ви «захистите лише add_duration», але залишите average() без захисту, ви все одно лишите двері відчиненими.

Як малювати критичні секції: схема на серветці

Коли код зростає, критичні секції перестають бути очевидними. І тут допомагає простий прийом: намалювати схему даних та операцій.

Ось спрощена схема для TaskStats:

flowchart TD
    T1["Потік А: add_duration()"] -->|записує| V["durations_seconds_ (вектор)"]
    T1 -->|записує| S["total_seconds_ (сума)"]

    T2["Потік Б: average()"] -->|читає| V
    T2 -->|читає| S

    note1["Інваріант: total_seconds_ дорівнює сумі durations_seconds_"]
    V --- note1
    S --- note1

Межа критичної секції тут проходить навколо «пакета узгоджених операцій»:

  • у add_duration() це push_back + +=,
  • у average() це читання total_seconds_ + читання size() (і перевірка empty() теж належить сюди, тому що це читання контейнера).

Важливо: ми поки що не обираємо інструмент синхронізації. Ми просто навчилися бачити, які дії мають виконуватися взаємовиключно.

8. Типові помилки під час виділення критичних секцій

Помилка № 1: «Захищаю тільки запис, читання не рахується».
Таке мислення зазвичай спирається на логіку «ламає ж запис». Але гонка даних виникає, коли є конкурентний доступ і принаймні один потік пише. Якщо один потік пише, а інший у цей час читає без узгодженого правила, це все одно помилка. Ба більше, читання часто перетворюється на «читання кількох повʼязаних полів», і тоді ви отримуєте неузгоджені знімки стану.

Помилка № 2: «Критична секція — це вся функція, про всяк випадок».
Це працює як пластир на все: і на подряпину, і на ноутбук. Програма справді може перестати ламатися, але ви втрачаєте паралелізм там, де він був можливий. Найчастіше правильніше виділити інваріант і захищати мінімальну послідовність дій, яка цей інваріант змінює.

Помилка № 3: «Я поставлю захист в одному місці, а в іншому “швиденько” прочитаю без нього».
Так часто роблять із методами на кшталт size()/empty()/average(): здається, що вони нешкідливі. Але якщо вони читають дані, які інший потік змінює, то це вже частина конкурентного доступу. У результаті у вас зʼявляється код, який «майже завжди працює», а потім раз на тиждень ламає тести на CI і робить вигляд, що він ні до чого.

Помилка № 4: «Критична секція — це конкретний рядок, а не змістова операція».
Якщо інваріант вимагає узгоджено змінити два поля (як push_back і total +=), то захищати лише один рядок безглуздо. Критична секція має покривати всю операцію оновлення інваріанта, інакше інший потік може побачити стан «між кроками» — а це вже помилка логіки.

Помилка № 5: «Спільний ресурс — це лише глобальні змінні».
Глобальні змінні справді небезпечні, але спільний ресурс може бути і в динамічній памʼяті, і в обʼєкті, і в контейнері, і навіть бути «зовнішнім»: файл, консоль, лог. Тому корисно виробити звичку: щойно у вас зʼявляється потік, одразу ставте собі питання: «Які дані цей потік розділяє з іншими потоками — явно або через посилання, вказівники чи захоплення?»

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ