Конкурентность и многопоточность в C++: продвинутый уровень

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Конкурентность и многопоточность в C++: от основ к продвинутым техникам

Современные приложения все чаще требуют максимальной производительности, особенно при работе с многоядерными процессорами. C++ предлагает мощные инструменты для работы с конкурентностью и многопоточностью, начиная с C++11 и заканчивая последними стандартами. В этой статье мы разберем продвинутые аспекты многопоточности: от правильного использования мьютексов и атомарных операций до сложных паттернов синхронизации. Материал рассчитан на разработчиков, уже знакомых с основами потоков в C++.

1. Управление потоками: std::thread и std::jthread

Класс std::thread — основа многопоточного программирования в C++. Однако на продвинутом уровне важно не только создать поток, но и правильно управлять его жизненным циклом. С C++20 появился std::jthread, который автоматически присоединяется при разрушении, предотвращая утечки ресурсов.

Пример использования std::jthread:

#include <iostream>
#include <thread>
#include <chrono>

void worker(int id) {
    std::this_thread::sleep_for(std::chrono::milliseconds(100 * id));
    std::cout << "Поток " << id << " завершен\n";
}

int main() {
    // std::jthread автоматически присоединяется при выходе из области видимости
    std::jthread t1(worker, 1);
    std::jthread t2(worker, 2);
    std::jthread t3(worker, 3);
    
    // Не нужно явно вызывать join() или detach()
    // При разрушении t1, t2, t3 main ждет их завершения
    std::cout << "Главный поток продолжает работу\n";
    return 0;
}

Важно помнить: std::jthread также поддерживает механизм остановки через std::stop_token, что позволяет безопасно завершать долгие фоновые задачи.

2. Синхронизация доступа: мьютексы, блокировки и условные переменные

Главная проблема многопоточности — гонки данных. Для их предотвращения используются мьютексы. На продвинутом уровне рекомендуется применять std::scoped_lock (C++17) для захвата нескольких мьютексов без риска взаимоблокировки.

Пример с std::scoped_lock:

#include <iostream>
#include <mutex>
#include <thread>
#include <vector>

std::mutex mtx1, mtx2;
int shared_data = 0;

void safe_increment(int id) {
    // scoped_lock захватывает оба мьютекса в порядке, избегая deadlock
    std::scoped_lock lock(mtx1, mtx2);
    ++shared_data;
    std::cout << "Поток " << id << " увеличил значение до " << shared_data << "\n";
}

int main() {
    std::vector<std::jthread> threads;
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back(safe_increment, i);
    }
    return 0;
}

Для более сложных сценариев синхронизации (например, producer-consumer) используются условные переменные (std::condition_variable). Они позволяют потокам ждать наступления определенного события, не тратя ресурсы на активное ожидание.

3. Атомарные операции и модель памяти C++

Атомарные операции (std::atomic) — это основа lock-free программирования. Они гарантируют, что чтение и запись переменной будут выполнены как единое неделимое действие. Однако на продвинутом уровне важно понимать модель памяти C++ и уровни упорядочивания (memory ordering).

Основные уровни memory ordering:

  • memory_order_relaxed — минимальные гарантии, только атомарность
  • memory_order_acquire — гарантирует, что все последующие чтения и записи увидят результаты предыдущих операций
  • memory_order_release — гарантирует, что все предыдущие операции будут видны после этой записи
  • memory_order_acq_rel — комбинация acquire и release
  • memory_order_seq_cst — строгая последовательная согласованность (по умолчанию)

Пример использования атомарного счетчика:

#include <iostream>
#include <atomic>
#include <thread>
#include <vector>

std::atomic<int> counter{0};

void increment() {
    for (int i = 0; i < 1000; ++i) {
        // fetch_add с memory_order_relaxed — максимальная производительность
        counter.fetch_add(1, std::memory_order_relaxed);
    }
}

int main() {
    std::vector<std::jthread> threads;
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back(increment);
    }
    // После завершения всех потоков counter == 10000
    std::cout << "Итоговое значение: " << counter << "\n";
    return 0;
}

Важно: неправильное использование memory ordering может привести к трудноуловимым багам. Если вы не уверены — используйте memory_order_seq_cst (по умолчанию) или консультируйтесь с документацией.

4. Асинхронные задачи: std::async и std::future

Для упрощения работы с фоновыми вычислениями C++ предоставляет std::async и std::future. Они позволяют запустить задачу в отдельном потоке (или в том же, в зависимости от политики) и получить результат позже.

Пример с std::async:

#include <iostream>
#include <future>
#include <chrono>

int compute(int x) {
    std::this_thread::sleep_for(std::chrono::seconds(1));
    return x * x;
}

int main() {
    // Запускаем задачу асинхронно
    std::future<int> result = std::async(std::launch::async, compute, 5);
    
    std::cout << "Ожидание результата...\n";
    // Получаем результат (блокирует поток до готовности)
    int value = result.get();
    std::cout << "Результат: " << value << "\n";
    
    // Пример с политикой deferred (ленивое выполнение)
    std::future<int>                

Блоги

Рекомендаций по книгам