Загрузка
Все статьи

C++: потоки и потокобезопасность — мьютексы, atomics и deadlock-ы на практике

Разбираем многопоточность в C++ на реальных примерах из продакшена: мьютексы, lock_guard, unique_lock, std::atomic, deadlock-ы и как их избежать. Сравнение с корутинами Kotlin.

Многопоточность в C++ — это тема, на которой я сломал себе не одну голову. За шесть лет бэкенд-разработки на C++ я видел всё: от банальных гонок данных до deadlock-ов, которые проявлялись только под нагрузкой раз в несколько дней. В этой статье я поделюсь реальным опытом и конкретными примерами — не из учебников, а из продакшена.

std::thread: основы

Всё начинается с std::thread. Простой пример — параллельная обработка запросов:

#include 
#include 
#include 

void processRequest(int requestId) {
    std::cout << "Processing request " << requestId
              << " on thread " << std::this_thread::get_id()
              << "\n";
    // ... логика обработки ...
}

int main() {
    std::vector workers;

    for (int i = 0; i < 8; ++i) {
        workers.emplace_back(processRequest, i);
    }

    for (auto& t : workers) {
        t.join();
    }

    return 0;
}

Но как только несколько потоков начинают работать с общими данными — начинаются проблемы. Гонка данных (data race) — это когда два потока одновременно читают и пишут в одну переменную без синхронизации. Поведение в таком случае — undefined behavior, и в проде это может проявляться как случайные коррупции данных, краши и «невозможные» баги.

Мьютексы: mutex, lock_guard и unique_lock

Базовый механизм синхронизации — std::mutex. Но вручную вызывать lock() и unlock() — путь к боли. Если между ними бросится исключение, мьютекс останется заблокированным навсегда. Поэтому всегда используем RAII-обёртки:

#include 
#include 
#include 

class ThreadSafeCache {
public:
    std::string get(const std::string& key) {
        std::lock_guard lock(mutex_);
        auto it = cache_.find(key);
        if (it != cache_.end()) {
            return it->second;
        }
        return "";
    }

    void put(const std::string& key, const std::string& value) {
        std::lock_guard lock(mutex_);
        cache_[key] = value;
    }

    // unique_lock нужен, когда нужно разблокировать раньше
    bool tryUpdate(const std::string& key, const std::string& value) {
        std::unique_lock lock(mutex_);
        auto it = cache_.find(key);
        if (it == cache_.end()) return false;

        it->second = value;
        lock.unlock();  // Разблокируем ДО вызова notify
        cv_.notify_one();
        return true;
    }

private:
    std::mutex mutex_;
    std::map cache_;
    std::condition_variable cv_;
};

Разница между lock_guard и unique_lock простая: lock_guard — минимальная обёртка, только захват и освобождение. unique_lock — более гибкий: можно отпустить раньше, переместить, передать в condition_variable.

std::atomic: когда мьютекс — это оверхед

Для простых счётчиков и флагов мьютекс — это избыточно. std::atomic использует процессорные инструкции (CAS — compare-and-swap) для атомарных операций без блокировок:

#include 
#include 
#include 

class Metrics {
public:
    void incrementRequests() {
        request_count_.fetch_add(1, std::memory_order_relaxed);
    }

    void recordLatency(uint64_t ns) {
        // Используем relaxed для счётчика, acquire-release для max
        auto current = max_latency_.load(std::memory_order_relaxed);
        while (ns > current &&
               !max_latency_.compare_exchange_weak(
                   current, ns,
                   std::memory_order_release,
                   std::memory_order_relaxed)) {
            // CAS loop — текущее значение обновилось, пробуем снова
        }
    }

    uint64_t getRequestCount() const {
        return request_count_.load(std::memory_order_relaxed);
    }

    uint64_t getMaxLatency() const {
        return max_latency_.load(std::memory_order_acquire);
    }

private:
    std::atomic request_count_{0};
    std::atomic max_latency_{0};
};

Обратите внимание на memory ordering. memory_order_relaxed — нет гарантий порядка между потоками, подходит для простых счётчиков. memory_order_acquire/release — гарантирует, что запись в одном потоке «видна» после acquire в другом. memory_order_seq_cst — дефолт, самый строгий, но и самый медленный. Я стараюсь использовать relaxed везде, где это возможно, и upgrade до acquire/release только при необходимости.

Deadlock из продакшене: как мы его поймали

Однажды наш сервис обработки платежей начал зависать раз в 2–3 дня. Причём зависал полностью — все рабочие потоки были заблокированы. Мы добавили таймаут на mutex и дамп стектрейсов по сигналу. Через неделю поймали:

// DEADLOCK: Поток 1 и Поток 2 блокируют mutex-ы в разном порядке

class PaymentService {
    std::mutex accounts_mutex_;
    std::mutex transactions_mutex_;

    void transfer(int from, int to, int amount) {
        // Поток 1: A -> B
        std::lock_guard lock1(accounts_mutex_);
        // ... проверка баланса ...
        std::lock_guard lock2(transactions_mutex_);
        // ... создание транзакции ...
    }

    void refund(int transactionId, int accountId) {
        // Поток 2: B -> A (обратный порядок!)
        std::lock_guard lock1(transactions_mutex_);
        // ... поиск транзакции ...
        std::lock_guard lock2(accounts_mutex_);
        // ... возврат средств ...
    }
};

Классический deadlock: Поток 1 держит accounts_mutex_ и ждёт transactions_mutex_. Поток 2 держит transactions_mutex_ и ждёт accounts_mutex_. Кольцевая зависимость — и оба потока висят вечно.

Предотвращение deadlock: lock ordering и std::scoped_lock

Два основных подхода. Первый — фиксированный порядок блокировки (lock ordering). Всегда блокируйте мьютексы в одном и том же порядке. Второй — std::scoped_lock (C++17), который использует deadlock-avoidance алгоритм:

#include 

class SafePaymentService {
    std::mutex accounts_mutex_;
    std::mutex transactions_mutex_;

    void transfer(int from, int to, int amount) {
        // scoped_lock сам определяет порядок и избегает deadlock
        std::scoped_lock lock(accounts_mutex_, transactions_mutex_);
        // ... логика ...
    }

    void refund(int transactionId, int accountId) {
        // Порядок аргументов не важен — scoped_lock разберётся
        std::scoped_lock lock(transactions_mutex_, accounts_mutex_);
        // ... логика ...
    }
};

std::scoped_lock использует тот же алгоритм, что и std::lock(m1, m2, ...) — try-and-back-off. Он пытается заблокировать все мьютексы; если не получается, отпускает все и пробует снова. Это гарантирует отсутствие deadlock при блокировке нескольких мьютексов. С C++17 это должно быть вашим дефолтным выбором для множественных блокировок.

Condition variables

Ещё один важный примитив — std::condition_variable. Он позволяет потокам ждать определённого условия. Классический пример — очередь задач:

#include 
#include 
#include 
#include 

class TaskQueue {
public:
    void push(std::function task) {
        {
            std::lock_guard lock(mutex_);
            queue_.push(std::move(task));
        }
        cv_.notify_one();
    }

    std::function pop() {
        std::unique_lock lock(mutex_);
        cv_.wait(lock, [this] { return !queue_.empty(); });
        auto task = std::move(queue_.front());
        queue_.pop();
        return task;
    }

private:
    std::mutex mutex_;
    std::condition_variable cv_;
    std::queue> queue_;
};

Ключевой момент — cv_.wait() с лямбдой. Это защищает от spurious wakeups — ложных пробуждений, которые допускаются стандартом. Без лямбды вы должны проверять условие в цикле вручную.

Сравнение с корутинами Kotlin

Работая параллельно с Kotlin, я постоянно сравниваю модели. C++ потоки — это «тяжёлые» OS-потоки, каждый стоит 1–8 МБ стека. Корутины Kotlin — легковесные, переключение происходит на уровне пользовательского кода. В C++ для аналогичной модели есть third-party библиотеки (co_await в C++20, folly::coro), но в стандартной библиотеке полная поддержка корутин ещё созревает. С другой стороны, C++ даёт полный контроль над памятью и потоками — что иногда критично для high-performance систем.

Мой совет: если проблема хорошо решается корутинами — используйте Kotlin (или Go). Если нужен максимальный контроль над каждым байтом и наносекундой — C++ потоки с правильной синхронизацией дадут вам это. Но будьте осторожны: многопоточность в C++ не прощает ошибок.

Поделиться статьёй

Комментарии

0
0 / 2000

Пока нет комментариев

Будьте первым, кто оставит комментарий к этой статье

Читайте также

CMake: модульная архитектура для большого C++ проекта — без ad-hoc include путей

Как мы реорганизовали CMake-структуру проекта на 15+ модулей, избавились от хаотичных include_directories и перешли на современный CMake с target-based зависимостями.

20 июня 2026 10 мин

Kotlin + C++: реальный кейс — видеопроцессинг на сервере через JNI

Как мы построили сервер видеотранскодинга на Kotlin REST API + C++ FFmpeg через JNI. Прокачка JNI_OnLoad, передача фреймов через DirectByteBuffer, обработка ошибок и бенчмарки.

19 июня 2026 13 мин

eBPF: профилирование C++ и Kotlin сервисов в продакшене без оверхеда

Как мы внедрили eBPF для профилирования высоконагруженных C++ и Kotlin сервисов в продакшене без остановки и перекомпиляции. Реальные скрипты bpftrace, поиск утечек памяти и трейсинг JVM-системных вызовов.

17 июня 2026 12 мин