Библиотека 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)


  1. oxx
    22.06.2022 14:16
    -4

    До сих пор не могу привыкнуть, как народ внезапно открывает для себя технологии 30-летней давности и старше. Сначала все отринуть (кооперативная многозадачность), чтобы потом заново изобрести, забыв про предыдущий опыт (причины появления вытесняющей многозадачности).


    1. pehat
      22.06.2022 19:25
      +6

      До сих пор не могу привыкнуть, когда люди путают <s>палец с жопой</s> способ эффективно распараллелить задачи моей программы, проводящие большую часть жизни в ожидании завершения операций ввода-вывода, с обязанностью операционной системы запускать любые собранные на коленке левые бинари, которые могут быть написаны криворуким кодером в состоянии алкогольного опьянения и потому без внешнего вмешательства вхолостую гоняющие процессорные такты, пока дискетка отформатируется.


  1. Kelbon
    22.06.2022 15:07
    +4

    Говоря про корутины в С++ часто забывают, что есть и другие реализации помимо cppcoro, а cppcoro скорее заброшена

    https://github.com/kelbon/kelcoro

    Вот тут немного другой взгляд на то какие корутины должны быть


  1. NN1
    22.06.2022 21:48

    Ещё одна реализация: https://github.com/David-Haim/concurrencpp/