Библиотека cppcoro от Льюиса Бейкера (Lewis Baker) дает нам то, чего не дает нам C++20 — библиотеку абстракций корутин C++, основанную на Coroutines TS.
Возможно, два моих последних поста "C++20: бесконечный поток данных с корутинами" и "C++20: синхронизация потоков с помощью корутин" были достаточно сложны для понимания. Но следующие посты про корутины должны легче усваиваться. Я щедро снабдил их примерами существующих корутин cppcoro.
Чтобы немного упростить свою аргументацию, я хочу начать с пары слов о корутинах и фреймворке корутин.
cppcoro
Библиотека cppcoro от Льюиса Бейкера берет за основу Coroutine TS. TS расшифровывается как technical specifications (технические спецификации) и является предварительной версией фреймворка корутин, который мы получили в C++20. Льюис портирует библиотеку cppcoro с фреймворка Coroutines TS на фреймворк корутин, который мы получили в C++20.
Я считаю, что есть одна причина, по которой портирование библиотеки очень важно: в C++20 мы получили не сами корутины, а только фреймворк корутин. Этот нюанс означает, что если вы хотите использовать корутины в C++20, то вы сами по себе. Вам необходимо создавать свои собственные корутины на основе фреймворка корутин C++20. Скорее всего, мы получим какие-нибудь конкретные корутины только в C++23. Честно говоря, я считаю это чрезвычайно важным, потому что реализация корутин довольно сложна и, следовательно, подвержена ошибкам. Этот пробел — именно то, что заполняет cppcoro. Она предоставляет абстракции для корутин, awaitable типы, функции, отмену выполнения (cancellation), планировщики, нетворкинг, метафункции и определяет несколько концептов.
Использование cppcoro
В настоящее время cppcoro основана на фреймворке Coroutines TS и может использоваться в Windows (Visual Studio 2017) или Linux (Clang 5.0/6.0 и libc++). Для всех примеров в наших экспериментах я буду использовать конфигурацию, которую вы можете увидеть в командной строке ниже:
-std=c++17
: поддержка C++17-fcoroutines-ts
: поддержка Coroutines TS C++-Iinclude
: заголовки cppcoro-stdlib=libc++
: LLVM реализация стандартной библиотекиlibcppcoro.a
: библиотека cppcoro
Как я уже упоминал ранее: когда в будущем cppcoro будет актуализирована до C++20, вы сможете использовать ее с каждым компилятором, поддерживающим C++20. А сейчас с помощью нее мы можем получить некоторое представление о конкретных реализациях корутин, которые мы можем увидеть в C++23.
cppcoro показалась мне простой в освоении, и я хочу показать вам несколько примеров ее использования. Чтобы продемонстрировать различные фичи cppcoro, я использую фрагменты из существующего кода и тестов. Начнем с типов корутин.
Типы корутин
cppcoro имеет различные виды задач (task) и генераторов (generator).
task<T>
Что такое задача? Вот определение, взятое прямиком из документации:
Задача (task) представляет собой асинхронное вычисление, которое выполняется лениво, поскольку выполнение корутины не начинается до тех пор, пока задача не станет ожидаемой (awaited).
Задача — это корутина. В следующей программе функция main
ожидает функцию first
, first
ожидает second
, а second
ожидает третью.
// cppcoroTask.cpp
#include <chrono>
#include <iostream>
#include <string>
#include <thread>
#include <cppcoro/sync_wait.hpp>
#include <cppcoro/task.hpp>
using std::chrono::high_resolution_clock;
using std::chrono::time_point;
using std::chrono::duration;
using namespace std::chrono_literals; // 1s
auto getTimeSince(const time_point<high_resolution_clock>& start) {
auto end = high_resolution_clock::now();
duration<double> elapsed = end - start;
return elapsed.count();
}
cppcoro::task<> third(const time_point<high_resolution_clock>& start) {
std::this_thread::sleep_for(1s);
std::cout << "Third waited " << getTimeSince(start) << " seconds." << std::endl;
co_return; // (4)
}
cppcoro::task<> second(const time_point<high_resolution_clock>& start) {
auto thi = third(start); // (2)
std::this_thread::sleep_for(1s);
co_await thi; // (3)
std::cout << "Second waited " << getTimeSince(start) << " seconds." << std::endl;
}
cppcoro::task<> first(const time_point<high_resolution_clock>& start) {
auto sec = second(start); // (2)
std::this_thread::sleep_for(1s);
co_await sec; // (3)
std::cout << "First waited " << getTimeSince(start) << " seconds." << std::endl;
}
int main() {
std::cout << std::endl;
auto start = high_resolution_clock::now();
cppcoro::sync_wait(first(start)); // (1)
std::cout << "Main waited " << getTimeSince(start) << " seconds." << std::endl;
std::cout << std::endl;
}
Как вы можете наблюдать, эта программа особо ничего не делает, кроме как служит демонстрацией рабы с корутинами.
Во-первых, функция main
не может быть корутиной. cppcoro::sync_wait
(строка (1)) часто служит стартовой задачей верхнего уровня и ожидает завершения задачи (не только в рамках этого примера). Корутина first
, как и все остальные корутины, получает в качестве аргумента start
(время начала работы) и отображает время своего выполнения. Что происходит в этой корутине? Она запускает корутину second
(строка (2)), которая немедленно приостанавливается, засыпает на секунду и возобновляет корутину с помощью дескриптора sec
в строке (3). Корутина second
делает то же самое с third
, но у third
уже другое поведение. third
— корутина, которая ничего не возвращает и не ожидает другую корутину. Когда third
заканчивает выполнение, по цепочке выполняются все остальные корутины. В конечном итоге, работа каждой из корутин занимает по три секунды.
Давайте немного изменим программу. Что произойдет, если корутины будут будут уходить в сон после вызова co_await
?
// cppcoroTask2.cpp
#include <chrono>
#include <iostream>
#include <string>
#include <thread>
#include <cppcoro/sync_wait.hpp>
#include <cppcoro/task.hpp>
using std::chrono::high_resolution_clock;
using std::chrono::time_point;
using std::chrono::duration;
using namespace std::chrono_literals;
auto getTimeSince(const time_point<::high_resolution_clock>& start) {
auto end = high_resolution_clock::now();
duration<double> elapsed = end - start;
return elapsed.count();
}
cppcoro::task<> third(const time_point<high_resolution_clock>& start) {
std::cout << "Third waited " << getTimeSince(start) << " seconds." << std::endl;
std::this_thread::sleep_for(1s);
co_return;
}
cppcoro::task<> second(const time_point<high_resolution_clock>& start) {
auto thi = third(start);
co_await thi;
std::cout << "Second waited " << getTimeSince(start) << " seconds." << std::endl;
std::this_thread::sleep_for(1s);
}
cppcoro::task<> first(const time_point<high_resolution_clock>& start) {
auto sec = second(start);
co_await sec;
std::cout << "First waited " << getTimeSince(start) << " seconds." << std::endl;
std::this_thread::sleep_for(1s);
}
int main() {
std::cout << std::endl;
auto start = ::high_resolution_clock::now();
cppcoro::sync_wait(first(start));
std::cout << "Main waited " << getTimeSince(start) << " seconds." << std::endl;
std::cout << std::endl;
}
Возможно, вы уже и сами догадались. Функция main
ждет три секунды, но каждый следующий вызов корутины уменьшает ожидание на одну секунду.
В дальнейших постах я собираюсь рассмотреть задачи в комбинации с потоками (threads) и сигналами (signals).
generator
Вот определение от cppcoro:
generator
представляет тип корутины, который создает последовательность значений типаT
, где значения создаются лениво и синхронно.
Без лишних вступлений к программе cppcoroGenerator.cpp
, которая демонстрирует работу двух генераторов:
// cppcoroGenerator.cpp
#include <iostream>
#include <cppcoro/generator.hpp>
cppcoro::generator<char> hello() {
co_yield 'h';
co_yield 'e';
co_yield 'l';
co_yield 'l';
co_yield 'o';
}
cppcoro::generator<const long long> fibonacci() {
long long a = 0;
long long b = 1;
while (true) {
co_yield b; // (2)
auto tmp = a;
a = b;
b += tmp;
}
}
int main() {
std::cout << std::endl;
for (auto c: hello()) std::cout << c;
std::cout << "\n\n";
for (auto i: fibonacci()) { // (1)
if (i > 1'000'000) break;
std::cout << i << " ";
}
std::cout << "\n\n";
}
Первая корутина hello
возвращает по запросу следующий символ; корутина fibonacci
следующее число из ряда Фибоначчи. fibonacci
создает бесконечный поток данных. Что происходит в строке (1)? Цикл for
c диапазоном запускает выполнение корутины. Первая итерация запускает корутины, возвращает значение co_yield b
и делает паузу. Последующие вызовы этого цикла возобновляют корутину fibonacci
и возвращают следующее число из ряда Фибоначчи.
И напоследок я хотел бы дать интуитивное представление о разнице между co_await
(для задачи) и co_yield
(для генератора): co_await
ждет “внутрь”, co_yield
ждет “наружу”. Например, корутина first
ожидает вызываемой корутины second
(cppcoroTask.cpp
), а корутина fibonacci
(cppcoroGenerator.cpp
) запускается внешним циклом for
с диапазоном.
Что дальше?
В моем следующем посте про cppcoro мы углубимся в задачи. Я рассмотрю их применение с потоками, сигналами или пулами потоков.
Перевод статьи подготовлен в преддверии старта курса "C++ Developer. Professional". Пройдите вступительное тестирование, если вам любопытно узнать свой уровень знаний для поступления.
Комментарии (5)
Kelbon
22.06.2022 15:07+4Говоря про корутины в С++ часто забывают, что есть и другие реализации помимо cppcoro, а cppcoro скорее заброшена
https://github.com/kelbon/kelcoro
Вот тут немного другой взгляд на то какие корутины должны быть
oxx
До сих пор не могу привыкнуть, как народ внезапно открывает для себя технологии 30-летней давности и старше. Сначала все отринуть (кооперативная многозадачность), чтобы потом заново изобрести, забыв про предыдущий опыт (причины появления вытесняющей многозадачности).
pehat
До сих пор не могу привыкнуть, когда люди путают <s>палец с жопой</s> способ эффективно распараллелить задачи моей программы, проводящие большую часть жизни в ожидании завершения операций ввода-вывода, с обязанностью операционной системы запускать любые собранные на коленке левые бинари, которые могут быть написаны криворуким кодером в состоянии алкогольного опьянения и потому без внешнего вмешательства вхолостую гоняющие процессорные такты, пока дискетка отформатируется.