JavaRush /Курси /C++ SELF /Інкапсуляція синхронізації: «мʼютекс усередині класу»

Інкапсуляція синхронізації: «мʼютекс усередині класу»

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

1. Мʼютекс усередині класу

Якщо ви щойно навчилися користуватися std::lock_guard, виникає природне бажання: «Гаразд, я просто додам std::mutex поруч із даними й блокуватиму його де треба». Проблема в тому, що «де треба» — це не географія, а дисципліна. Коли мʼютекс живе зовні, доступ до даних розповзається по всьому проєкту, і в якийсь момент хтось, часто ви самі, але за тиждень, забуде заблокувати мʼютекс. Сьогодні виробляємо звичку: синхронізація — це частина реалізації класу, а не домовленість між людьми.

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

Антиприклад: дані окремо, мʼютекс окремо

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

#include <mutex>
#include <string>
#include <vector>

std::vector<std::string> g_tasks;
std::mutex g_tasks_mutex;

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

#include <string>
#include <vector>

extern std::vector<std::string> g_tasks;

void debug_print_first() {
    if (!g_tasks.empty()) {                 // НЕБЕЗПЕЧНО: читання без синхронізації
        // ...
    }
}

Тут немає жодного lock_guard. І проблема навіть не в тому, що автор поганий. Проблема в тому, що сам інтерфейс невдалий: він дозволяє помилятися надто легко.

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

2. Інкапсуляція: «дані + мʼютекс = один обʼєкт»

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

Зручно уявити це як невелику схему:

flowchart LR
    A[Зовнішній код] -->|викликає методи| B[Клас]
    B -->|усередині блокує| M[(mutex)]
    B -->|захищає| D[(дані)]

Зовнішній код не має постійно тримати в голові, коли саме треба блокувати мʼютекс. Він має просто користуватися методами класу. Тоді:

  1. клас гарантує свої інваріанти,
  2. усі доступи відбуваються за єдиним протоколом,
  3. ймовірність випадково забути про блокування різко зменшується.

3. Мінікомпонент: потокобезпечний TaskLog

Давайте розвивати єдиний навчальний контекст: у нас є консольний застосунок, і ми хочемо записувати туди події з кількох потоків, наприклад, «потік № 1 додав задачу», «потік № 2 обробив задачу». Виведення у std::cout ми вже захищали мʼютексом на минулих лекціях, але тепер зробимо дещо корисніше: накопичуватимемо повідомлення в памʼяті, а потім виводитимемо їх за один раз.

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

Версія «як не треба»: клас віддає нутрощі назовні

Спочатку спеціально зробимо погано, щоб різниця стала очевидною.

#include <string>
#include <vector>

class BadTaskLog {
public:
    std::vector<std::string>& data() { return data_; } // витік нутрощів
private:
    std::vector<std::string> data_;
};

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

Щойно зовнішній код отримав std::vector<std::string>&, клас більше не контролює:

  • хто і коли змінює data_,
  • під яким мʼютексом це відбувається, якщо взагалі під якимось,
  • чи не тримає хтось посилання на елемент, який потім інвалідується під час push_back.

Версія «як треба»: TaskLog сам синхронізує операції

Тепер зробимо правильно: мʼютекс — private, операції — public, усі зміни й читання відбуваються через методи.

#include <mutex>
#include <string>
#include <vector>

class TaskLog {
public:
    void add(std::string msg) {
        std::lock_guard<std::mutex> lock(m_);
        data_.push_back(std::move(msg));
    }

private:
    std::mutex m_;
    std::vector<std::string> data_;
};

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

Тут одразу постає цілком слушне запитання: «А як читати лог?»

Читання: snapshot() і навіщо тут mutable

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

Тому нам потрібен метод читання. Але ми не хочемо повертати посилання. Що робити? Найпростіший і найбезпечніший шлях для навчального коду — повернути копію, тобто знімок.

#include <mutex>
#include <string>
#include <vector>

class TaskLog {
public:
    void add(std::string msg) {
        std::lock_guard<std::mutex> lock(m_);
        data_.push_back(std::move(msg));
    }

    std::vector<std::string> snapshot() const {
        std::lock_guard<std::mutex> lock(m_);
        return data_; // копія
    }

private:
    mutable std::mutex m_;
    std::vector<std::string> data_;
};

Тут одразу два важливі моменти.

Перший: метод snapshot()const, тому що з погляду логіки він не змінює даних лога, тобто самих повідомлень. Але він бере блокування, а блокування змінює стан m_. Тому ми й написали mutable std::mutex m_;.

Другий: так, копіювання std::vector<std::string> — річ не безкоштовна. Але це чесна плата за простий і безпечний інтерфейс. У реальному проєкті ви зважатимете на частоту викликів і обсяг даних, але архітектурна ідея залишиться тією самою: не віддавайте назовні доступ до внутрішнього стану, який ви зобовʼязані захищати.

Слово mutable звучить так, ніби ви зламали компілятор і тепер правила const не діють. Насправді ідея тонша: const у C++ — це контракт щодо логічного стану обʼєкта, а не щодо кожного біта памʼяті.

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

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

4. Чому мʼютекс зовні ламає інваріанти класу

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

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

Коли мʼютекс зовні, виникає така ситуація:

  • метод add() може бути викликаний без блокування, бо клас сам не синхронізується,
  • метод snapshot() теж може бути викликаний без блокування,
  • а ще хтось може отримати посилання на дані й узагалі обійти всі правила.

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

Складені операції: не змушуйте користувача «блокувати зовні»

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

Якщо ми забороняємо зовнішнє блокування, а саме так і треба робити, то такі операції мають бути методами класу.

#include <mutex>
#include <string>
#include <vector>

class TaskLog {
public:
    std::size_t add_and_size(std::string msg) {
        std::lock_guard<std::mutex> lock(m_);
        data_.push_back(std::move(msg));
        return data_.size();
    }

private:
    std::mutex m_;
    std::vector<std::string> data_;
};

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

5. Кілька мʼютексів усередині класу: обережно

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

Чому? Тому що два мʼютекси одразу підвищують ризик:

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

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

6. Мінідемо: кілька потоків пишуть у TaskLog

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

#include <thread>
#include <iostream>
#include <string>

int main() {
    TaskLog log;

    auto worker = [&](int id) {
        for (int i = 0; i < 3; ++i) {
            log.add("thread " + std::to_string(id) + ": step " + std::to_string(i));
        }
    };

    std::thread t1(worker, 1);
    std::thread t2(worker, 2);
    t1.join();
    t2.join();

    for (const auto& s : log.snapshot()) {
        std::cout << s << '\n';
    }
}

Тут важливе саме відчуття: потокам узагалі не потрібно знати, що всередині TaskLog є std::mutex. Вони просто користуються класом як звичайним бібліотечним компонентом. Це і є мета інкапсуляції.

7. Памʼятка: «як було / як стало» і чому не можна віддавати посилання

Іноді корисно побачити різницю на одному екрані — це як «до/після» в рекламі шампуню, тільки цього разу шампунь — від багів.

Архітектура Як виглядає Що може піти не так
Мʼютекс зовні data глобально/публічно, mutex поруч Легко забути про блокування, важко довести коректність, зовнішній код може обходити правила
Мʼютекс усередині класу private mutex, методи самі блокують Дисципліну доступу вбудовано, інваріанти захищено, користувач класу не може «випадково» порушити протокол

Коли ви повертаєте посилання або вказівник на внутрішні дані, ви віддаєте назовні доступ, який живе довше, ніж живе ваш lock_guard.

У цьому й проблема: блокування — це завжди тимчасова гарантія, обмежена межами свого scope. А посилання — це обіцянка: «можете користуватися обʼєктом де завгодно». Ці обіцянки конфліктують.

Якщо ви хочете дати доступ до даних, у вас зазвичай є три чесні варіанти.

Перший варіант — «знімок», як у snapshot(), тобто копіювання даних.

Другий варіант — окремі методи-операції: add, erase, size, contains тощо, щоб користувач міг зробити саме те, що йому потрібно, не отримуючи прямого доступу до контейнера.

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

8. Типові помилки

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

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

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

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

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

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