Многопоточность в 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 Разница между 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++ не прощает ошибок.
Пока нет комментариев
Будьте первым, кто оставит комментарий к этой статье