5.06. Асинхронность и потоки
Асинхронность и потоки
В современном программировании на C++ задачи всё чаще требуют одновременного выполнения множества операций: от загрузки данных из сети до сложных вычислений, от обработки пользовательского ввода до взаимодействия с внешними системами. Чтобы удовлетворить эти требования, язык предоставляет два мощных подхода: многопоточность и асинхронность. Оба механизма позволяют избежать остановки программы во время ожидания завершения длительных действий, но делают это разными способами и в разных контекстах.
Эта глава посвящена глубокому пониманию этих концепций, их различий, взаимосвязей и практическому применению в C++, особенно с учётом возможностей, появившихся в стандартах C++11, C++17 и C++20. Особое внимание уделено сопрограммам — современному инструменту, который позволяет писать асинхронный код в привычной последовательной манере, не теряя при этом производительности и читаемости.
Конкурентность, параллелизм, многопоточность и асинхронность
Прежде чем перейти к реализации, важно чётко определить ключевые термины, которые часто используются как синонимы, хотя имеют разные значения.
Конкурентное исполнение (concurrency) — это способ организации выполнения нескольких задач так, чтобы они могли прогрессировать независимо друг от друга. Эти задачи могут выполняться поочерёдно на одном ядре процессора или одновременно на нескольких. Главная цель конкурентности — эффективное использование ресурсов и возможность реагировать на несколько событий.
Параллельное исполнение (parallel execution) — частный случай конкурентности, при котором задачи действительно выполняются одновременно, используя несколько вычислительных единиц (ядер процессора). Параллелизм особенно полезен для CPU-интенсивных операций, таких как обработка больших массивов данных, физическое моделирование или машинное обучение.
Многопоточное исполнение (multithreading) — техническая реализация конкурентности и параллелизма через создание нескольких потоков выполнения внутри одного процесса. Каждый поток имеет собственный стек, но разделяет с другими потоками адресное пространство, включая глобальные переменные и кучу. Это даёт высокую степень контроля, но требует аккуратного управления доступом к общим данным.
Асинхронное исполнение (asynchrony) — модель программирования, при которой вызов функции не блокирует текущий поток выполнения. Вместо немедленного возврата результата функция возвращает объект, представляющий будущее значение (например, std::future), или передаёт результат через колбэк. Асинхронность особенно эффективна при работе с операциями ввода-вывода (I/O): чтением файлов, сетевыми запросами, взаимодействием с базами данных. Такие операции часто зависят от внешних факторов и могут занимать значительное время, в течение которого процессор простаивает. Асинхронный подход позволяет использовать это время для выполнения других задач.
Важно понимать: асинхронность не обязательно предполагает параллелизм. Однопоточная программа может быть полностью асинхронной, если она использует цикл событий (event loop) для координации неблокирующих операций. В то же время многопоточность может использоваться для реализации асинхронных операций — например, делегируя долгую задачу в отдельный поток и возвращая управление немедленно.
Потоки в C++
C++ начиная с стандарта C++11 предоставляет стандартную библиотеку для работы с потоками: <thread>, <mutex>, <atomic>, <future> и другие компоненты. Эти инструменты образуют низкоуровневые примитивы, на которых строятся более сложные абстракции.
std::thread — основной класс для создания нового потока выполнения. При его создании указывается функция или лямбда-выражение, которое будет выполняться в новом потоке. После запуска поток работает независимо от основного, пока не завершится или не будет явно присоединён (join) или отсоединён (detach).
#include <thread>
#include <iostream>
void background_task() {
std::cout << "Выполняется в фоновом потоке\n";
}
int main() {
std::thread t(background_task);
t.join(); // Ожидаем завершения потока
return 0;
}
Однако совместное использование данных между потоками требует синхронизации. Без неё возможны гонки данных (data races) — ситуации, когда два или более потока одновременно изменяют одну и ту же переменную, что приводит к неопределённому поведению.
Для защиты общих ресурсов используются:
std::mutex— мьютекс (mutual exclusion), который гарантирует, что только один поток может находиться в критической секции кода в любой момент времени.std::lock_guardиstd::unique_lock— RAII-обёртки, автоматически захватывающие и освобождающие мьютекс при входе и выходе из области видимости.std::atomic— типы, обеспечивающие атомарные операции над переменными без необходимости блокировок. Подходят для простых сценариев, таких как счётчики или флаги.
Кроме того, C++ предлагает высокоуровневые абстракции для передачи результатов между потоками:
std::futureиstd::promise— позволяют одному потоку «обещать» результат (promise), а другому — дождаться его (future). Это удобно для получения значений из фоновых задач.std::async— функция, которая автоматически запускает задачу в фоновом потоке (или откладывает её выполнение) и возвращаетstd::future. Это упрощает написание асинхронного кода без ручного управления потоками.
auto result = std::async(std::launch::async, []() {
return 42;
});
std::cout << "Результат: " << result.get() << "\n";
Хотя эти инструменты мощны, они требуют внимательного проектирования. Ручное управление потоками легко приводит к ошибкам: взаимоблокировкам (deadlocks), гонкам данных, утечкам ресурсов. Поэтому в сложных системах предпочтение отдаётся более высокоуровневым моделям — таким как сопрограммы.
Асинхронность
Исторически асинхронное программирование в C++ и других языках начиналось с колбэков — функций, которые вызываются по завершении операции. Этот подход быстро становится нечитаемым при цепочке зависимых операций («ад колбэков»).
Затем появились обещания и futures, которые позволили выразить асинхронные вычисления как значения, доступные в будущем. Это улучшило композицию, но всё ещё требовало явного управления состоянием и обработки ошибок.
Сопрограммы, введённые в C++20, представляют собой радикальный сдвиг парадигмы. Они позволяют писать асинхронный код в виде обычной последовательной функции, используя ключевые слова co_await, co_yield и co_return. Компилятор автоматически преобразует такую функцию в конечный автомат, который сохраняет состояние между приостановками и возобновлениями.
Сопрограммы в C++20: архитектура и трансформация кода
Сопрограммы — это не просто синтаксический сахар. Это полноценный механизм, поддерживаемый компилятором на уровне генерации кода. Когда компилятор встречает функцию, содержащую хотя бы одно из ключевых слов co_await, co_yield или co_return, он распознаёт её как сопрограмму и применяет к ней специальную обработку.
В отличие от обычной функции, которая выполняется от начала до конца и затем возвращает управление вызывающему коду, сопрограмма может приостанавливать своё выполнение в определённых точках и возобновлять его позже, сохраняя локальное состояние между вызовами.
Компилятор преобразует тело сопрограммы в конечный автомат. Все локальные переменные, параметры и временные объекты перемещаются в динамически выделяемую структуру — фрейм сопрограммы (coroutine frame). Этот фрейм хранит всё необходимое состояние, чтобы функция могла продолжить работу с того места, где она была приостановлена.
Точка приостановки возникает при встрече выражения co_await expr. Здесь expr должен быть объектом, для которого определены три обязательных компонента:
await_ready()— метод, который проверяет, завершена ли операция немедленно. Если он возвращаетtrue, выполнение продолжается без приостановки.await_suspend()— метод, вызываемый, если операция ещё не завершена. Он получает хэндлер текущей сопрограммы (std::coroutine_handle<>) и может запланировать её возобновление позже — например, поместить в очередь задач или зарегистрировать в цикле событий.await_resume()— метод, вызываемый после возобновления сопрограммы. Он возвращает окончательный результат операции.
Эта тройка методов образует awaitable-объект — интерфейс, через который происходит взаимодействие между сопрограммой и внешним миром.
Важно: сам компилятор не предоставляет реализацию планировщика (scheduler) или цикла событий. Он лишь генерирует каркас, а логика управления временем выполнения, очередями и возобновлением остаётся на усмотрении разработчика или библиотеки.
Реализация co_await: создание собственных awaitable-объектов
Чтобы использовать co_await, необходимо определить тип, соответствующий описанному выше интерфейсу. Рассмотрим простой пример — асинхронная задержка (sleep), которая не блокирует поток, а регистрирует возобновление через некоторое время.
struct TimerAwaiter {
std::chrono::milliseconds delay;
bool await_ready() { return false; } // Всегда приостанавливаем
void await_suspend(std::coroutine_handle<> h) {
// Здесь должна быть интеграция с таймером и планировщиком
// Например: global_scheduler.schedule_after(delay, h);
}
void await_resume() {}
};
Функция-сопрограмма может использовать этот awaiter:
Task<void> example() {
co_await TimerAwaiter{100ms}; // Приостановка на 100 мс
std::cout << "Прошло 100 мс\n";
}
Здесь Task<T> — пользовательский тип, который должен соответствовать требованиям promise type. Для каждой сопрограммы компилятор ищет соответствующий promise_type, обычно определяемый через специализацию std::coroutine_traits или через вложенный тип в возвращаемом значении.
Тип Task<T> обычно содержит:
std::coroutine_handle<promise_type>— указатель на фрейм сопрограммы,- методы для получения результата (
get()), - возможность ожидания через
co_await.
Реализация такого типа требует внимательного управления жизненным циклом: когда сопрограмма завершается, её фрейм должен быть корректно освобождён.
Асинхронные примитивы: mutex, baton, семафоры
Одна из главных задач при построении асинхронных систем — обеспечение безопасного доступа к разделяемым ресурсам без блокировки потока. Традиционный std::mutex здесь неприменим: его захват приводит к блокировке, что нарушает принцип неблокирующего выполнения.
Вместо этого создаются асинхронные аналоги. Например, асинхронный мьютекс (async_mutex) позволяет «запросить» захват, возвращая awaitable-объект. Если мьютекс свободен, захват происходит немедленно. Если занят — сопрограмма приостанавливается и ставится в очередь ожидания. По освобождению мьютекса одна из ожидающих сопрограмм возобновляется.
Пример интерфейса:
struct async_mutex {
auto lock(); // возвращает awaitable
void unlock();
};
Использование:
async_mutex mtx;
Task<void> worker() {
auto guard = co_await mtx.lock();
// Критическая секция
shared_resource++;
// guard автоматически вызывает unlock при выходе
}
Аналогично реализуются:
async_baton— примитив синхронизации, который позволяет одному участнику «пробудить» другого. Полезен для сигнализации о завершении задачи.- Асинхронные семафоры — для ограничения числа одновременно выполняющихся операций (например, лимит на количество сетевых соединений).
- Асинхронные каналы — для передачи данных между сопрограммами без блокировок.
Эти примитивы реализуются один раз и затем многократно используются в разных частях системы. Их корректность критична: ошибка в реализации может привести к взаимоблокировкам, утечкам памяти или повреждению данных.
Практическое применение: высокопроизводительные масштабируемые системы
Сопрограммы особенно эффективны в системах, где требуется обрабатывать тысячи или миллионы одновременных операций при ограниченном числе потоков. Типичные сценарии:
- Серверы приложений и микросервисы, обрабатывающие множество HTTP-запросов.
- Игровые серверы, управляющие состоянием тысяч игроков.
- Системы обработки потоковых данных (stream processing).
- Клиентские приложения с богатым интерфейсом, где нельзя допускать «зависаний» основного потока.
В таких системах сопрограммы позволяют писать логику в виде последовательного кода, но при этом достигать производительности, сравнимой с event-driven архитектурами на основе колбэков.
Однако стандартная библиотека C++20 не включает готовый планировщик, цикл событий или асинхронные I/O-операции. Это означает, что для реального применения сопрограмм требуется либо глубокая интеграция с операционной системой (например, через io_uring в Linux), либо использование сторонних библиотек.
Сторонние библиотеки: Folly, cppcoro, Boost.Asio
На практике большинство проектов, использующих сопрограммы, полагаются на зрелые библиотеки:
- Folly (Facebook Open Source Library) — содержит продвинутую реализацию сопрограмм, включая
Task,AsyncGenerator, асинхронные примитивы и интеграцию сfolly::Executor. Широко используется в высоконагруженных сервисах Meta. - cppcoro — библиотека от автора одного из предложений по сопрограммам в C++. Предоставляет
task,generator,async_mutex,when_all,when_anyи другие полезные абстракции. - Boost.Asio — начиная с новых версий, поддерживает сопрограммы через
asio::awaitableиco_await. Позволяет писать сетевой код в синхронном стиле с асинхронной производительностью.
Эти библиотеки решают ключевые проблемы:
- Управление жизненным циклом сопрограмм,
- Интеграция с системными вызовами ввода-вывода,
- Обработка ошибок через исключения в асинхронном контексте,
- Композиция параллельных операций (
when_all,when_any).
Например, запуск нескольких независимых запросов и ожидание всех результатов:
auto [result1, result2] = co_await when_all(
fetch_data("url1"),
fetch_data("url2")
);
Такой код читаем, компактен и эффективен.
Проектирование с сопрограммами: на что обратить внимание
При переходе на сопрограммы разработчик сталкивается с новыми вопросами:
- Жизненный цикл объектов: ссылки и указатели на локальные переменные могут стать недействительными после приостановки. Лучше передавать данные по значению или использовать умные указатели.
- Исключения: сопрограммы поддерживают исключения, но их распространение через границы
co_awaitтребует аккуратной обработки. - Лямбды: в C++20 лямбды не могут быть сопрограммами напрямую, но можно обернуть их в функции или использовать
std::functionс осторожностью. - Отложенность: не все сопрограммы запускаются немедленно. Некоторые библиотеки используют lazy-выполнение — сопрограмма начинает работать только при первом
co_await. - Привязанный планировщик: важно понимать, в каком контексте (на каком потоке или исполнителе) будет возобновляться сопрограмма. Некоторые системы позволяют явно указать планировщик через
co_await switch_to(scheduler).