Конкурентность в C++: от потоков до корутин и async/await

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

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

Начать курс

Введение: почему конкурентность в C++ — это не страшно, а интересно?

Когда-то давно, в эпоху одноядерных процессоров, программы выполнялись строго последовательно. Сегодня даже ваш смартфон имеет 8 ядер, а серверы — десятки. Заставить все эти ядра работать на вас — вот где скрывается настоящая магия производительности. И C++, один из самых мощных и низкоуровневых языков, даёт нам целый арсенал инструментов для этого. Но как не запутаться? Потоки, корутины (их часто называют «горутинами» по аналогии с Go), async/await — что выбрать?

В этой статье мы разложим по полочкам современную конкурентность в C++. Вы узнаете, когда стоит использовать классические потоки, а когда — лёгкие корутины C++20, и как async/await упрощает асинхронное программирование. Поехали!

Классика: многопоточность с std::thread

Начнём с самого очевидного — потоков. std::thread — это прямой доступ к системным потокам операционной системы. Каждый поток — это отдельный контекст выполнения, который может работать на отдельном ядре CPU.

Простой пример: параллельный расчёт

Представьте, что вам нужно вычислить сумму квадратов чисел от 1 до 10 миллионов. Последовательно — это долго. Давайте распараллелим:

#include <iostream>
#include <thread>
#include <vector>
#include <numeric>

void sum_squares(int start, int end, long long& result) {
    result = 0;
    for (int i = start; i <= end; ++i) {
        result += i * i;
    }
}

int main() {
    const int N = 10'000'000;
    const int NUM_THREADS = 4;
    std::vector<std::thread> threads;
    std::vector<long long> results(NUM_THREADS, 0);
    
    int chunk = N / NUM_THREADS;
    for (int i = 0; i < NUM_THREADS; ++i) {
        int start = i * chunk + 1;
        int end = (i == NUM_THREADS - 1) ? N : (i + 1) * chunk;
        threads.emplace_back(sum_squares, start, end, std::ref(results[i]));
    }
    
    for (auto& t : threads) {
        t.join();
    }
    
    long long total = std::accumulate(results.begin(), results.end(), 0LL);
    std::cout << "Сумма квадратов: " << total << std::endl;
    return 0;
}

Здесь мы создаём 4 потока, каждый считает свою часть диапазона. Результаты собираем в вектор. Всё просто и предсказуемо. Но есть нюансы:

  • Накладные расходы: создание каждого потока стоит дорого (выделение стека, системные вызовы). Для тысяч мелких задач это неэффективно.
  • Синхронизация: если потоки обращаются к общим данным, нужны мьютексы, которые могут вызывать блокировки и снижать производительность.
  • Ограниченное количество: число потоков обычно не должно превышать количество ядер CPU (с учётом гипертрединга).

Для тяжёлых, долгих вычислений потоки — отличный выбор. Но для I/O-операций (чтение файлов, сетевые запросы) они часто избыточны. Тут на сцену выходят корутины.

Революция C++20: корутины (горутины)

Корутины в C++20 — это не просто очередная фишка. Это фундаментальное изменение в том, как мы пишем асинхронный код. В отличие от потоков, корутины — это легковесные сопрограммы, которые могут приостанавливать своё выполнение и возобновлять его позже, не блокируя поток ОС.

Многие разработчики называют их «горутинами» из-за схожести с goroutines в Go, хотя технически это разные концепции. Главное преимущество: вы можете запустить сотни тысяч корутин на одном потоке без потери производительности.

Как это работает?

Корутина — это функция, которая содержит ключевое слово co_await, co_return или co_yield. Компилятор автоматически преобразует её в конечный автомат. Вот базовый пример:

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

struct Task {
    struct promise_type {
        Task get_return_object() { return {}; }
        std::suspend_never initial_suspend() { return {}; }
        std::suspend_never final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };
};

// Простая корутина, которая делает паузу
Task example_coroutine() {
    std::cout << "Корутина стартовала\n";
    co_await std::suspend_always{};  // Приостанавливаемся
    std::cout << "Корутина возобновилась\n";
}

int main() {
    example_coroutine();
    std::this_thread::sleep_for(std::chrono::seconds(1));
    return 0;
}

Этот пример тривиален, но он демонстрирует суть: функция может «заморозить» своё выполнение в точке co_await и продолжить позже. При этом поток, на котором она работала, освобождается для других задач.

Реальный сценарий: асинхронное чтение файлов

Представьте, что вам нужно прочитать 1000 файлов. С потоками вы убьёте систему. С корутинами — элегантно:

#include <iostream>
#include <fstream>
#include <string>
#include <vector>
#include <future>
#include <coroutine>

// Упрощённый генератор для демонстрации
struct Generator {
    struct promise_type {
        std::string current_value;
        
        Generator get_return_object() { 
            return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        std::suspend_always yield_value(std::string value) {
            current_value = std::move(value);
            return {};
        }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };
    
    std::coroutine_handle<promise_type> handle;
    
    explicit Generator(std::coroutine_handle<promise_type> h) : handle(h) {}
    ~Generator() { if (handle) handle.destroy(); }
    
    bool next() {
        if (!handle || handle.done()) return false;
        handle.resume();
        return !handle.done();
    }
    
    std::string value() { return handle.promise().current_value; }
};

// Асинхронное чтение (имитация)
Generator async_read_files(const std::vector<std::string>& filenames) {
    for (const auto& name : filenames) {
        // В реальном коде здесь был бы co_await на асинхронное чтение
        std::ifstream file(name);
        std::string content((std::istreambuf_iterator<char>(file)), 
                            std::istreambuf_iterator<char>());
        co_yield content;  // Возвращаем                

Блоги

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