Введение: почему конкурентность в 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; // Возвращаем