С++20 Сoroutines та огляд бібліотек, які реалізують підтримку со-програм
Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті
Привіт! Мене звати Валентин Корнієнко і я Senior Software Developer в Luxoft. Працюю в галузі Embedded Linux/Automotive як C++ developer, маю досвід у використанні C++11/14/17/20 як для вбудованих систем, так і для додатків для Windows платформи. Мав досвід з Qt/QML для вбудованих рішень та sciter для десктопу.
Метою даної статті є сумаризація навчальних матеріалів з тематики С++20 Coroutines та огляд бібліотек, які реалізують підтримку со-програм. Окремо розгляну приклад використання С++ Coroutines для взаємодії з периферією NRF52832.
Додавання стандартних бібліотек
Coroutines були додані до стандарту С++20 з реалізацією від Gor Nishanov (Microsoft) у вигляді stackless со-програм. З пропозицією до стандарту можна ознайомитись за посиланням.
Наявними варіантами було використання stackfull та stackless со-програм, головною різницею є можливість реалізації stackfull корутін як окремої бібліотеки, як це зроблено у Boost. Підтримка stackless корутін, на відміну від stackfull реалізується з боку компілятора через необхідність аналізу коду для генерації state-машини для обраної функції. Детальніше можна ознайомитись у summary бібліотеки cppcoro від Lewiss Baker.
З боку підтримки стандартної бібліотеки на поточний момент є у наявності реалізація базових примітивів, необхідних для підтримки призупинення функції та додавання нових ключових слів до С++. Новими ключовими словами є:
- co_return
- co_await
- co_yield
З доданих примітивів, було реалізовано:
- std::coroutine_handle, std::noop_coroutine_handle
- пару std::suspend_never/std::suspend_always
- coroutine traits
Процес дослідження можливостей со-програм та їхніх принципів роботи і внутрішньої реалізації є нетривіальним, на перший погляд, але шляхом сумаризації навчальних джерел є цілком можливим. Розглянемо джерела, які є доцільними для розуміння внутрішніх деталей та варіантів використання.
Джерела про внутрішні деталі та варіанти використання
Детальне пояснення внутрішньої реалізації та перетворень функції, що виконує компілятор розглянуто у серії статей від Lewis Baker — автора бібліотеки cppcoro:
- Coroutine theory — розгляд теорії роботи со-програм та приклади роботи стека виклику функції;
- Understanding operator co await — розгляд роботи оператора co_await, реалізації базових типів для роботи со-програм, розробка власних типів Event, Awaiter;
- Understanding the promise type — огляд типу Promise, можливостей кастомізації алокацій для Promise-типу та обробки виключень у корутінах;
- Understanding_symmetric_transfer — принципи роботи механізму symmetric transfer для запобігання Stack Overflow під час перемикання між корутінами;
- Understanding the compiler transform — огляд перетворень, що виконує компілятор під час аналізу коду со-програми, реалізація власного типу Task
Практичнішим є цикл статей від Panics Software, який з першої статті надає приклади використання нових типів та ключових слів, також розглядає моменти, пов’язані з роботою стека викликів та реалізацією власних типів, необхідних для роботи со-програм. Цикл статей включає:
- Your first coroutine — опис нових функцій, реалізацію власної со-програми та роботу з std::suspend_never/std::suspend_always
- Co_awaiting-coroutines — реалізацію типу Awaiter, роботу з оператором co_await та Promise
Доцільним для ознайомлення є пост від Stanford, де розглянуто реалізацію генератора на базі со-програм у C++20.
З відео, що можуть бути корисними у процесі засвоєння матеріалу слід відзначити записи доповідей з відомих конференцій СppCon та ACCU.
- How to Use C++20 Coroutines for Networking, Jim Pascoe, ACCU 2022 — доповідь щодо використання со-програм у мережевих застосунках. Розглянуто інтеграцію з boost::asio для реалізації асинхронного echo-серверу, генератору на базі С++20 примітивів та майбутні пропозиції до С++20/23 з інтеграцією підтримки корутін для примітивів стандартної бібліотеки.
- C++ Coroutines — a negative overhead abstraction, Gor Nishanov, CppCon 2015 — доповідь від Gor Nishanov щодо інтеграції со-програм у стандарт, оглядом роботи мережевого застосунку та проблемами використання попередніх підходів до розробки асинхронного коду. Розглянуто моменти профілювання та оптимізації отриманої реалізації.
- Naked coroutines live (with networking), Gor Nishanov, CppCon 2017 — доповідь від Gor Nishanov з live coding роботи з Networking.
Підтримка у бібліотеках
З боку підтримки у бібліотеках, варто відзначити кілька проєктів, які дозволяють повноцінно використовувати нові можливості С++, а саме:
Розглянемо детальніше кожну з них.
cppcoro
Одна з перших бібліотек, де було додано підтримку со-програмних примітивів. З особливостей — реалізує типові прототипи, які можна брати за основу при написанні власних бібліотек та/або використовувати як навчальний посібник після ознайомлення зі згаданими раніше матеріалами.
Основний репозиторій бібліотеки зараз не оновлюється, тому варто використовувати форк, який містить у собі як частковий bugfix, так і реалізацію нових функцій. Варто звернути увагу на реалізацію примітивів Task, sync_wait, wait_all, when_any, schedule_on/resume_on, single_consumer_event.
Бібліотека доступна у пакетному менеджері conan, що дозволяє досить швидко інтегрувати її у проєкт.
libcoro
Альтернатива бібліотеки cppcoro, має схожі можливості та активно оновлюється. Має у наявності можливості роботи з Networking.
QCoro
Бібліотека, що додає підтримку со-програм для примітивів Qt. Дозволяє використовувати компоненти Qt у JavaScript/Python-like стилі. Має достатню документацію компонентів та приклади використання, окремо слід відзначити підтримку QML.
Наприклад, типовий сценарій використання QNetworkAccessManager в асинхронному вигляді потребує деструктурування логіки обробки подій на сигнали/ слоти, де слотами буде або member-функція класу, або лямбда. Найпростіший приклад типового використання виглядає як:
QNetworkAccessManager* manager = new QNetworkAccessManager(); QNetworkRequest req; req.setUrl(QUrl{"https://moderncpp.ir"}); QNetworkReply* rep = manager->post(req, ""); QObject::connect(manager, &QNetworkAccessManager::finished, [](QNetworkReply* reply) { if (reply->error()) { qDebug() << "ERROR!"; qDebug() << reply->errorString(); } else { qDebug() << reply->readAll(); } }); QObject::connect(manager, &QNetworkAccessManager::finished, rep, &QNetworkReply::deleteLater);
Той самий приклад, але переведений на бібліотеку QCoro буде виглядати приблизно наступним чином:
QNetworkAccessManager* manager = new QNetworkAccessManager(); std::unique_ptr<QNetworkReply> reply(co_await manager->post(QNetworkRequest{QUrl{"https://moderncpp.ir"}})); if (reply->error()) { qDebug() << "ERROR!"; qDebug() << reply->errorString(); co_return; } else { qDebug() << reply->readAll(); }
Технічно, при написанні складнішої логіки можна отримати спрощення шляхом лінеаризації асинхронної логіки до стилю «майже як синхронна». QCoro також підтримує такі примітиви, як:
- Qt Signals
- QFuture
- QIODevice
- QProcess
- QThread
- QTimer
- QAbstractSocket/QLocalSocket/QNetworkReply/QTcpServer
- QImageProvider
- QML Tasks
Детальніше можна ознайомитись за посиланням на офіційну документацію бібліотеки.
Приклад використання QCoro з QML+JavaScript .then, який наявний в документації:
#include <QCoroQml> #include <QCoroQmlTask> int main() { ... qmlRegisterType<Example>("io.me.qmlmodule", 1, 0, "Example"); QCoro::Qml::registerTypes(); ... } class Example : public QObject { Q_OBJECT ... public: Q_INVOKABLE QCoro::QmlTask fetchValue(const QString &name) const { return database->fetchValue(name); // Returns QCoro::Task<QString> } } Example { Component.onCompleted: { fetchValue("key").then((result) => console.log("Result", result)) } }
Інтеграція з периферією NRF52832 з використанням SDK від Nordic
Розглянемо фрагмент використання со-програм у bare-metal оточенні для реалізації наступної задачі — асинхронного обміну через інтерфейс SPI з використанням DMA (direct memory access). Периферія EasyDMA підтримує обмін блоками по 255 байтів.
У bare-metal оточенні доцільним є використання бібліотеки ETL- embedded template library. Для формування масивів прийняття-відправки даних використаємо etl::vector, клас, що відтворює std::vector, але з фіксованою максимальною кількістю елементів.
static constexpr std::uint16_t DmaBufferSize = 255; using DmaBufferType = etl::vector<std::uint8_t, DmaBufferSize>;
Через те, що запит на обмін за інтерфейсом може бути більшим, ніж 255 байтів за раз, транзакції необхідно буде розбити на блоки та контекст, що буде зберігати поточний статус транзакції.
Розглянемо реалізацію методу transmitBuffer, що буде виконувати передачу буфера заданого розміру за обраним інтерфейсом.
void transmitBuffer( std::span<const std::uint8_t> _pBuffer, void* _pUserData, bool _restoreInSpiCtx) noexcept { m_coroHandle = std::coroutine_handle<>::from_address(_pUserData); m_backendImpl.setTransactionCompletedHandler([this] { transmitCompleted(); }); const size_t TransferBufferSize = _pBuffer.size(); const size_t FullDmaTransactionsCount = TransferBufferSize / DmaBufferSize; const size_t ChunkedTransactionsBufSize = TransferBufferSize % DmaBufferSize; const bool ComputeChunkOffsetWithDma = FullDmaTransactionsCount >= 1; TransactionContext newContext{ .restoreInSpiCtx = _restoreInSpiCtx, .computeChunkOffsetWithDma = ComputeChunkOffsetWithDma, .pDataToTransmit = _pBuffer.data(), .fullDmaTransactionsCount = FullDmaTransactionsCount, .chunkedTransactionBufSize = ChunkedTransactionsBufSize, .completedTransactionsCount = 0}; m_transmitContext = std::move(newContext); if (FullDmaTransactionsCount) { --m_transmitContext.fullDmaTransactionsCount; m_backendImpl.sendChunk(_pBuffer.data(), DmaBufferSize); } else { m_transmitContext.chunkedTransactionBufSize = 0; m_backendImpl.sendChunk(_pBuffer.data(), _pBuffer.size()); } } void transmitCompleted() noexcept { const bool isAllDmaTransactionsProceeded = m_transmitContext.fullDmaTransactionsCount == 0; const bool isAllChunckedTransactionsCompleted = m_transmitContext.chunkedTransactionBufSize == 0; if (!isAllDmaTransactionsProceeded) { --m_transmitContext.fullDmaTransactionsCount; m_backendImpl.sendChunk( m_transmitContext.pDataToTransmit + DmaBufferSize * getTransitionOffset(), DmaBufferSize); } else if (!isAllChunckedTransactionsCompleted) { const size_t transmissionOffset = m_transmitContext.computeChunkOffsetWithDma ? DmaBufferSize * getTransitionOffset() : 0; const size_t TransmitBufferSize = m_transmitContext.chunkedTransactionBufSize; m_transmitContext.chunkedTransactionBufSize = 0; m_backendImpl.sendChunk( m_transmitContext.pDataToTransmit + transmissionOffset, TransmitBufferSize); } else { if (m_transmitContext.restoreInSpiCtx) { m_coroHandle.resume(); } else { CoroUtils::CoroQueueMainLoop::GetInstance().pushToLater(m_coroHandle); } } } std::size_t getTransitionOffset() noexcept { return ++m_transmitContext.completedTransactionsCount; }
До фактичної реалізації spi-backend надходять фрагменти, розмір яких дорівнює заданому буферу [*]. На початку транзакції встановлюється обробник події завершення відправки [**] та ініціалізується контекст передачі. У методі transmitCompleted виконується обробка події, яка виникає по завершенню транзакції.
[*] — improvement point — backend може повертати максимально можливий розмір транзакції за один раз [**] — improvement point — можливо переписати m_backendImpl.sendChunk на со-програми, тоді можна буде перенести реалізацію методу transmitCompleted.
Отриманий метод можна використовувати для реалізації обміну з SPI-дисплеєм, для цього треба реалізувати клас Awaiter, який буде підтримуватись у await-expression:
struct Awaiter { bool resetDcPin = false; bool restoreInSpiCtx = false; const std::uint8_t* pTransmitBuffer; This_t* pBaseDisplay; std::uint16_t bufferSize; bool await_ready() const noexcept { const bool isAwaitReady = pTransmitBuffer == nullptr || bufferSize == 0; return isAwaitReady; } void await_resume() const noexcept { if (resetDcPin) pBaseDisplay->setDcPin(); } void await_suspend(std::coroutine_handle<> thisCoroutine) const { if (resetDcPin) pBaseDisplay->resetDcPin(); pBaseDisplay->getSpiBus()->transmitBuffer( std::span(pTransmitBuffer, bufferSize), thisCoroutine.address(), restoreInSpiCtx); } };
Використання Awaiter виглядає наступним чином:
auto sendCommandImpl(const std::uint8_t* _command) noexcept { return Awaiter{ .resetDcPin = true, .restoreInSpiCtx = false, .pTransmitBuffer = _command, .pBaseDisplay = this, .bufferSize = 1}; } auto sendChunk(const std::uint8_t* _pBuffer, std::size_t _bufferSize) noexcept { return Awaiter{ .pTransmitBuffer = _pBuffer, .pBaseDisplay = this, .bufferSize = static_cast<std::uint16_t>(_bufferSize)}; }
З backend-частини нам необхідно підтримати методи sendChunk та реалізувати виклик обробника події transactionCompleted. Також, реалізувати методи роботи з Сhip Select GPIO. У випадку Nordic SDK це буде виглядати наступним чином:
void sendChunk(const std::uint8_t* _pBuffer, const size_t _bufferSize) noexcept { nrfx_spim_xfer_desc_t xferDesc = NRFX_SPIM_XFER_TX(_pBuffer, _bufferSize); nrfx_err_t transmissionError = nrfx_spim_xfer( &SpiInstance::HandleStorage[PeripheralInstance::HandleIdx], &xferDesc, 0); APP_ERROR_CHECK(transmissionError); } using TTransactionCompletedHandler = std::function<void()>; void setTransactionCompletedHandler(const TTransactionCompletedHandler& _handler) noexcept { m_transactionCompleted = _handler; } static void spimEventHandlerThisOne(nrfx_spim_evt_t const* _pEvent, void* _pContext) noexcept { if (_pEvent->type == NRFX_SPIM_EVENT_DONE) { auto pThis = reinterpret_cast<This_t*>(_pContext); pThis->m_transactionCompleted(); } } void setCsPinHigh() noexcept { if constexpr (std::is_same_v< ChipSelectDrivingPolicy, Interface::SpiTemplated::SpiInstance::ChipSelectDrivenByDriver>) { ASSERT(false); } else nrf_gpio_pin_set(PeripheralInstance::SlaveSelectPin); } void setCsPinLow() noexcept { if constexpr (std::is_same_v< ChipSelectDrivingPolicy, Interface::SpiTemplated::SpiInstance::ChipSelectDrivenByDriver>) { ASSERT(false); } else nrf_gpio_pin_clear(PeripheralInstance::SlaveSelectPin); }
spimEventHandlerThisOne — callback функція, що викликається по завершенню передачі даних.
Як результат, ми можемо реалізувати метод заповнення обраної області дисплея буфером, який може бути сформований як користувачем, так і зовнішньою бібліотекою. Формат кольору, що підтримується — RGB565. Гарантії з боку бібліотеки — буфер, що приходить, завжди має фіксований розмір, який відповідає розміру області, що заповнюється.
Спрощений фрагмент реалізації методу наведено далі:
void fillRectangle( std::uint16_t _x, std::uint16_t _y, std::uint16_t _width, std::uint16_t _height, TColor* _colorToFill) noexcept { const std::uint16_t DisplayHeight = getHeight(); const std::uint16_t DisplayWidth = getWidth(); const bool isCoordsValid{!((_x >= DisplayWidth) || (_y >= DisplayHeight))}; if (isCoordsValid) { if (_width >= DisplayWidth) _width = DisplayWidth - _x; if (_height >= DisplayHeight) _height = DisplayHeight - _y; const size_t BytesSizeX = (_width - _x + 1); const size_t BytesSizeY = (_height - _y + 1); const size_t BytesSquare = BytesSizeX * BytesSizeY; const size_t TransferBufferSize = (BytesSquare * sizeof(typename TColor)); co_await m_displayInitialized; co_await setAddrWindow(_x, _y, _width, _height); static std::uint8_t RamWriteCmd{0x2C}; co_await sendCommand(&RamWriteCmd); setDcPin(); co_await sendChunk( reinterpret_cast<const std::uint8_t*>(_colorToFill), TransferBufferSize); resetDcPin(); onRectArreaFilled.emitLater(); } }
Детальніше з реалізацією можна ознайомитись за посиланням:
- репозиторій проєкту-довгобудови, де є приклад використання вищерозглянутих методів.
- приклад використання розглянутих примітивів для ініціалізації дисплея у середовищі симулятора (SPI замінено на логер, що виводить у консоль дані, що відправляються на дисплей).
- використання розроблених примітивів у інтеграції з Google Test Framework.
Корисні посилання
- Сумаризація корисних посилань з тематики С++ Coroutines, оформлена як github gist.
- C++20 Coroutines on Microcontrollers — What We Learned — стаття про використання со-програм у bare-metal оточенні.
- Репозиторій проєкту зі статті C++20 Coroutines on Microcontrollers — What We Learned.
Дякую за час, приділений статті та сподіваюсь, що тема була цікавою.
32 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів