С++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

З доданих примітивів, було реалізовано:

Процес дослідження можливостей со-програм та їхніх принципів роботи і внутрішньої реалізації є нетривіальним, на перший погляд, але шляхом сумаризації навчальних джерел є цілком можливим. Розглянемо джерела, які є доцільними для розуміння внутрішніх деталей та варіантів використання.

Джерела про внутрішні деталі та варіанти використання

Детальне пояснення внутрішньої реалізації та перетворень функції, що виконує компілятор розглянуто у серії статей від 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.

Дякую за час, приділений статті та сподіваюсь, що тема була цікавою.

👍ПодобаєтьсяСподобалось11
До обраногоВ обраному7
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

найбільша біда в тому всьому зоопарку, то те, що корутини юзають динамічну пам’ять,
і якщо для ламбди можна зробити sizeof() і (якщо постаратися) навіть поюзати її на такій платформі де «забанили» new, то С++20 корутина, вона «сферичний кінь у вакуумі» з повністю прихованим стейтом, і до неї тоді ще треба прикручувати алокатор...

В тому плані навіть данвя «Сшна» ідея від Simon Tatham що аб’юзить Duff’s device www.chiark.greenend.org.uk/...​~sgtatham/coroutines.html то вона виглядає юзабельніше (якщо запхати «стейт» такої «корутини» в клас), ніж нативні корутини імені С++20...

если памяти хватает, то лучше stackfull )

Что по замерам кол-ва сгенерированного кода в бинарнике?

Треба більше деталей, що саме з чим порівнювати. Технічно можна спробувати погратись на godbolt з різними опціями збирання. У Clang було помічено, що іноді може викидати алокацію coroutine-фрейму.

Там очевидні наступні варіанти:
* хореографія (колбеки, кожен з яких викликає наступний крок)
* оркестрація (колбеки викликають методи класу, що завідує станом системи)
* паралельні проміси на потоках
* корутина з послідовними діями
* RPC з послідовними діями

Я отут dou.ua/forums/topic/32636 трохи заліз в тему, але вийшло стільки коду, що ніхто не читає. Якщо цікаво — можна щось схоже далі ковиряти.

никогда не понимал приставки m_ в названиях переменных. Этот бред даже есть в исходниках net фреймворка

Если я правильно помню, все, что начинается или заканчивается подчеркиванием, зарезервировано для компилятора и библиотек. За это меня по рукам били, когда джуном был. Поэтому для мемберов вместо ptr->_foo пишут ptr->m_foo.

Майже так, але не все зарезервовано. Можна тут ознайомитись:
devblogs.microsoft.com/...​ing/20230109-00/?p=107685
Коротко, якщо _ не у глобальному namespace+ наступна після _ не велика літера — то це технічно допустимо. На практиці — швидше тригериться автоматичне доповнення, бо підтягуються першими символи з _, які параметрами функції ідуть. Але це таке, можна і не використовувати.

timsong-cpp.github.io/cppwp/lex.name#3
```
(3.1)
Each identifier that contains a double underscore __ or begins with an underscore followed by an uppercase letter is reserved to the implementation for any use.
(3.2)
Each identifier that begins with an underscore is reserved to the implementation for use as a name in the global namespace.
```
Що до m_ -> просто скорочення від member, швидше тригерити автодоповнення, простіше читати. Дякую за коментар.

В STL здається всюди з _ поля структур називаються...

Саме тому, що всі імена, що починаються з двох _, а також з одного _ і великої букви, зарезервовані для стандартної бібліотеки.
Її внутрішні деталі реалізації використовують такі імена, щоб гарантувати відсутність колізій з клієнтськими макросами. Бо ти маєш право зробити #define foo bar, потім заінклюдити стандартний хедер, і гарантовано в ньому нічого не зламаєш. Але якщо напишеш #define __foo bar — можеш поламати, ССЗБ.

Щодо імен, які починаються з одного нижнього підкреслення, за яким не слідує велика буква, — їх можна юзати в обмеженому скоупі (поля класів, локальні змінні). На попередній роботі був конвеншн юзати такі назви для приватних змінних у класі.
На поточній юзаємо m_. Спочатку було незвично, але виявилось теж доволі зручно. Це ж все-таки не угорська нотація, яка зашиває в назву змінної її тип. Ось вона — то вже перебор, як на мене.

Пощастило мені — ніколи не був джуном.

А били, але не по руках, вже СТО клієнта...

class Foo
{
public:
   Foo(Bar a, Bar b)       // вот тут мы можем использовать нормальные имена
      : m_a(move(a))
      , m_b(move(b))
   {}
private:
   Bar m_a, m_b;
};

Ще можна було б розказати за підхід future+continuation як альтернативу корутинам. Це також імперативне програмування, менш зручне, але працює швидше, легше за ресурсами, і не потребує останніх стандартів С++.
docs.seastar.io/...​futures-and-continuations

Цікаво, за рахунок використання чого буде швидше працювати + за рахунок чого досягається легкість по ресурсах. З останніми стандартами так-є проблема, бо С++20 не всюди підтримується(перевіряв на apple-clang, там тільки у experimental/coroutine доступно те що треба). Що до ресурсів- coroutine буде виділяти coroutine_handle для зберігання стану + додавати state-машину під час «трансформації» функції + promise у якого можна кастомізувати аллокатор(
en.cppreference.com/...​w/cpp/language/coroutines , секція Dynamic allocation
).
Якщо правильно зрозумів, то .then це буде аллокація std::function під капотом, яка тримає той самий continuation + future.

Дякую за Seastar, не чув про неї. Продивився, схоже на те що бачив для Qt:
github.com/...​walter/qt-promise#example
Та на boost::future, де .then з коробки є. Залишу посилання, може комусь знадобиться:
www.boost.org/...​chronization.futures.then

Сорі, поплутав з stackful coroutines.

future+continuation

Как по мне, так это наиболее неудобный метод

А какие еще есть варианты императивного асинхронного подхода?

Нет, колбеки — event-based. У тебя нет всего кода юз кейса в одной функции (как в корутине) — вместо этого он разбросан по нескольким функциям.

Ну вобщем да. Обычно в таких случаях надо делать выделенные классы-джобы, куда выносятся все релевантные коллбеки.
Но даже так, это, имхо, лучше чем future+continuation

Ну в коде ScyllaDb оно не выглядит страшно. Вернее, не страшнее обычной базы данных с синхронным кодом. Правда, у них там однопоточные сценарии, и в новом коде больше переходят на корутины.

Еще деталь: для систем управления, когда все завязано на глобальное состояние, корутины и фьючеры не пашут — нужно это состояние апдейтить в неожиданных колбеках (а у корутин состояние спрятано в «локальных» переменных, у фьючеров в капчах). А вот для полностью независимых сценариев от разных пользователей (когда нет левых событий, и понятен порядок действий) — корутины должны идеально подходить.

Найцікавіше не розповіли.
Лямбда-корутина коли капчить енвайронмент зробить use after free після першого co_await.

int value = get_value();
std::shared_ptr<Foo> sharedFoo = get_foo();
{
  const auto lambda = [value, sharedFoo]() -> std::future<void>
  {
    co_await something();
    // "sharedFoo" and "value" have already been destroyed
    // the "shared" pointer didn't accomplish anything
  };
  lambda();
} // the lambda closure object has now gone out of scope
isocpp.github.io/...​mbdas-that-are-coroutines

Цікаво. Не подумав про додавання цього, але має місце бути.Дякую!

Додам тоді ще і посилання на баг у GCC де є дискусія щодо цього:
gcc.gnu.org/...​lla/show_bug.cgi?id=95111
Та на реддіт:
www.reddit.com/...​n_be_a_coroutine_but_you

Так, це граблі, але вони дуже логічні, якщо згадати, що таке лямбди в C++ по своїй суті.
Лямбда — це спосіб швидко згенерувати клас з перевантаженим оператором `()` і створити його інстанс.

Замініть в цьому прикладі `operator()` на будь-яку іншу нестатичну мембер-функцію (точніше, нестатичну мембер-корутину) — і отримаєте більш загальну форму цих граблей: нестатичні мембер-функції, що є корутинами, штука стрьомна. Адже об’єкт `*this` може померти, коли корутину хтось ще збирається резьюмити.

Усі баги логічні, коли зрозумів причину бага.

Я пояснив, чому на мою думку це не баг, а фіча. Просто її потрібно занести в категорію поганих практик і не користуватись unless є дуже серйозні причини для зворотнього (в цьому конкретному випадку не можу знайти жодної).

C++ заявлено як мову, котра безпечніша за С.
В С інтуїтивно зрозуміло, що відбувається в якій операції — нема магії. Усі постріли в ноги — від необачності.
В С++11+ усі звикли використовувати лямбди де треба й де не треба — і лямбди в ноги не стріляють. Вони інтуїтивно зрозумілі.
В С++20 додають корутини, котрі також якось працюють.
А от використання двох інтуїтивно зрозумілих фіч разом крешить прогу. Це контрінтуїтивно. Людина програмує, як звикла, і ортимує нестабільний креш.

Такі штуки звуться багами другого порядку: коли кожна фіча сама працює нормально, а при використанні двох фіч разом маємо проблему. І такі баги дійсно проблемні, бо кількість можливих сценаріїв квадратична до кількості фіч — їх нереально протестувати.

Просто її потрібно занести в категорію поганих практик і не користуватись

Для цього є compiler warnings. Вони будуть сипатись від усякої фігні на зразок signed/unsigned mismatch, і нічого не скажуть за 100% креш в проді.

C++ заявлено як мову, котра безпечніша за С.

Ще Страуструп писав: «In C++ it’s harder to shoot yourself in the foot, but when you do, you blow off your whole leg». Це той випадок.

Усі постріли в ноги — від необачності.

Ну так це теж необачність. Людина забула, що написання лямбди з кепчуром — це те саме, що створення класу з відповідним полем. І якщо нестатичний метод класу зробити корутиною, така корутина може пережити сам об’єкт і звертання до полів буде робити use after free.

Для цього є compiler warnings.

Ворнінги таке навряд чи ловитимуть, а от від статичних аналізаторів типу cppcheck я б в майбутньому такого очікував.

Ворнінги таке навряд чи ловитимуть

А в чому проблема кидати ворнінг коли в лямбді є co_await?

* і коли ця лямбда має непустий список кепчурів. Інакше лямбда-корутини норм.

Та ніби ні в чому. Просто за потенційно небезпечними поганими практиками зазвичай більше слідкують статичні аналізатори, ніж компілятори. Хоча я не проти, щоб і компілятор на таке кидав ворнінг.

Це як з монадами ~ неасоціативність...

Щось якось слово знайоме, ця монада.
Це якась модна фіча з «true» функціональних мов програмування типу хаскеля ?

Підписатись на коментарі