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

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

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

Начать курс

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

Современные приложения требуют максимальной производительности, и многопоточность в C++ — один из ключевых инструментов для её достижения. Однако базовые механизмы (std::thread, std::mutex) часто приводят к блокировкам, гонкам данных и снижению эффективности. В этой статье мы рассмотрим продвинутые техники работы с конкурентностью на C++: от атомарных операций до lock-free структур данных и асинхронных задач.

1. Атомарные операции и memory ordering

Классические мьютексы — тяжёлый механизм. Для простых счётчиков или флагов лучше использовать std::atomic. Он гарантирует атомарность чтения/записи без блокировок. Однако на продвинутом уровне важно понимать memory ordering — модель памяти C++, которая определяет видимость изменений между потоками.

Основные порядки памяти:

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

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

#include <atomic>
#include <thread>
#include <cassert>

std::atomic<bool> ready{false};
int data = 0;

void producer() {
    data = 42;                                   // запись данных
    ready.store(true, std::memory_order_release); // публикуем флаг
}

void consumer() {
    while (!ready.load(std::memory_order_acquire)) // ждём флаг
        ;
    assert(data == 42); // гарантировано увидим data == 42
}

int main() {
    std::thread t1(producer);
    std::thread t2(consumer);
    t1.join();
    t2.join();
    return 0;
}

Здесь release и acquire гарантируют, что запись data произойдёт до установки флага, а чтение — после. Это быстрее мьютекса.

2. Lock-free структуры данных

Lock-free алгоритмы позволяют избежать блокировок, используя атомарные операции и CAS (Compare-And-Swap). Классический пример — lock-free стек на основе односвязного списка.

Реализация lock-free стека:

#include <atomic>
#include <memory>

template<typename T>
class LockFreeStack {
private:
    struct Node {
        T data;
        Node* next;
        Node(const T& val) : data(val), next(nullptr) {}
    };
    std::atomic<Node*> head{nullptr};

public:
    void push(const T& val) {
        Node* new_node = new Node(val);
        new_node->next = head.load(std::memory_order_relaxed);
        while (!head.compare_exchange_weak(
            new_node->next, new_node,
            std::memory_order_release,
            std::memory_order_relaxed)) {
            // цикл, пока CAS не удастся
        }
    }

    bool pop(T& result) {
        Node* old_head = head.load(std::memory_order_relaxed);
        while (old_head != nullptr &&
               !head.compare_exchange_weak(
                   old_head, old_head->next,
                   std::memory_order_acquire,
                   std::memory_order_relaxed)) {
            // ждём
        }
        if (old_head == nullptr) return false;
        result = old_head->data;
        // Внимание: в реальном коде нужен сборщик мусора или hazard pointers
        delete old_head;
        return true;
    }
};

Важные замечания:

  • CAS-цикл — стандартный паттерн для lock-free.
  • Проблема ABA — требуется версионирование указателей (например, tagged pointers).
  • Удаление узлов опасно: другой поток может читать удалённую память. Используйте hazard pointers или epoch-based reclamation (например, библиотека folly от Facebook).

Lock-free структуры оправданы при высокой конкуренции: они не вызывают переключения контекста и инверсии приоритетов.

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

Для выполнения задач в фоне без прямого управления потоками используйте std::async. Это высокоуровневая абстракция над пулом потоков.

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

#include <iostream>
#include <future>
#include <vector>
#include <numeric>

int parallel_sum(const std::vector<int>& vec) {
    if (vec.size() < 1000) {
        return std::accumulate(vec.begin(), vec.end(), 0);
    }
    auto mid = vec.begin() + vec.size() / 2;
    auto future1 = std::async(std::launch::async,
                              parallel_sum, std::vector<int>(vec.begin(), mid));
    auto future2 = std::async(std::launch::async,
                              parallel_sum, std::vector<int>(mid, vec.end()));
    return future1.get() + future2.get();
}

int main() {
    std::vector<int> data(10000, 1);
    std::cout << "Sum: " << parallel_sum(data) << std::endl;
    return 0;
}

std::future позволяет получить результат из другого потока. std::promise — это "обещание" передать значение:

#include <future>
#include <thread>

void set_value(std::promise<int>& prom) {
    std::this_thread::sleep_for(std::chrono::seconds(1));
    prom.set_value(42);
}

int main() {
    std::promise<int> prom;
    std::future<int> fut = prom.get_future();
    std::thread t(set_value, std::ref(prom));
    int result = fut.get(); // блокируется до получения значения
    t.join();
    return 0;
}

Советы по производительности:

  • Используйте std::launch::async явно, иначе реализация может отложить выполнение.
  • Избегайте копирования больших данных в

Блоги

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