вторник, 6 октября 2026 г.

[prog.c++] Пара слов о докладе про переход от акторов к C++20 короутинам

На Хабре появился отчет "У нас есть «плюсы» дома: о чем говорили на митапе YADRO × C++ Russia", в котором опубликованы видео докладов "Make legacy great again: переводим зрелый проект на С++20-корутины" и "С++20-корутины в KvadraOS: как устроены и зачем внедрялись". Первый из этих докладов я посмотрел и, как разработчик акторного фреймворка для С++, имею что сказать.

Из рассказанного и показанного складывается ощущение, что отказ от акторов в пользу stackless короутин был полностью оправдан. И тому есть две причины.


Первая и главная, в том, что акторы, реализованные в виде объектов с коллбэками, плохо подходят для "линейного" кода. Т.е. когда ваш алгоритм выражается простой последовательностью шагов, вроде: сделали запрос к подсистеме №1, дождались ответа, сделали запрос к подсистеме №2, дождались ответа, сделали запрос к подсистеме №3 и т.д., то реализация его как актора превратит код в лапшу. Либо в совсем страшную, если все коллбэки представляются лямбдами, либо чуть в менее страшную, если коллбэки -- это обычные методы обычного объекта. Но все равно будет лапша из-за принципиального ограничения такого подхода -- коллбэк не должен "засыпать" и блокировать тред, на котором работает. Обработчик события должен максимально быстро сделать свое дело и вернуть управление фреймворку, отвечающему за вызов коллбэков.

Поэтому-то код на подобных акторах будет представлять из себя цепочку атомарных действий, не связанных в единое целое. Т.е. вместо простого линейного кода вида:

auto r1 = subsystem_1.ask_something();
auto r2 = subsystem_2.ask_something(r1.some_data);
auto r3 = subsystem_3.ask_something(r2.some_data);
make_response(r3.some_data);

Будет что-то вида (псевдокод, не привязанный к конкретному C++ному фреймворку):

my_actor.on([&](start_signal) {
  send<ask_something>(subsystem_1).reply_to(my_actor);
});
my_actor.on(subsystem_1, [&](response & r) {
  send<ask_something>(subsystem_1, r.some_data).reply_to(my_actor);
});
my_actor.on(subsystem_2, [&](response & r) {
  send<ask_something>(subsystem_2, r.some_data).reply_to(my_actor);
});
my_actor.on(subsystem_3, [&](response & r) {
  make_response(r.some_data);
});

Поэтому если в вашем распоряжении C++ный акторный фреймворк по типу CAF или SObjectizer и вам нужно писать, в основном, линейный код, то задействовав акторы вы только усложните себе работу.

Акторы будут себя оправдывать в ситуациях, когда вы ожидаете не одно входящее событие, а несколько разных. Т.е. у вас нет сценария "запросили что-то у подсистемы 1 и ждем ответа", а у вас есть сценарий "инициировали несколько действий и далее ждем одно из трех/четырех/пяти/... событий". Вот тогда вы либо превращаете свой код в лапшу через switch-и:

auto msg = receive_message();
switch(msg.type)
{
case event_1: ...; break;
case event_2: ...; break;
case event_3: ...; break;
}

либо выделяете реакцию на каждое событие в отдельный коллбэк и позволяете фреймворку самому разбираться с тем, что именно пришло и какой именно коллбэк нужно вызвать для обработки.

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


Вторая причина, как мне кажется, связана с самим выбранным акторным фреймворком. Вроде бы в докладе его название явным образом не произносили, но судя по примерам кода это был CAF. А у CAF-а, на мой очень субъективный и весьма пристрастный взгляд, есть большие проблемы и с читаемостью, и еще большие с сопровождаемостью.

Во-первых, потому, что виденный мной код акторов на CAF -- это лямбда в лямбде верхом на лямбде и лямбдой погоняет.

Во-вторых, потому, что в виденных мной примерах кода на CAF сообщения принято передавать в виде ad-hoc туплов. Все эти туплы приходится выписывать и в декларациях обработчиков сообщений, и в операциях отсылки сообщений. Что требует затем ручной правки всех этих мест в коде если тупл потребовалось изменить (например, добавить еще что-то в отсылаемое сообщение).

В SObjectizer-е принят совсем другой подход. Поэтому код на SObjectizer, надеюсь, чуть более прост и удобен в сопровождении.

Тут я не хочу сказать, что SO-5 для подобной задачи подходит лучше, чем CAF. Увы, это не так, а почему не так описано выше при разборе первой причины. Но, тешу себя надеждой, что будь этот код написан на SO-5, то сопровождать его было бы проще, чем код на CAF. В том числе и из-за того, что в SO-5 нет таких жестких связей между акторами, чтобы один держал ссылку на другого и это бы мешало завершению работы приложения.


Отдельный вопрос в том, а акторы в принципе подходят под решение задач, подобных рассмотренной в докладе "Make legacy great again: переводим зрелый проект на С++20-корутины"?

Увы, не могу однозначно ответить на данный вопрос, т.к. сама задача была озвучена только поверхностно. С точки зрения банальной эрудиции: почему бы и нет?

Однако, похоже на то, что было выбрано решение "один актор на запрос". Что при большом количестве одновременных запросов ведет к появлению большого количества одновременно живущих акторов. К сожалению, неоднократно убеждался на собственном опыте, что большое количество одновременно живущих акторов -- это не решение проблемы, это сама по себе проблема.

Поэтому лично я бы при попытке подойти к подобной проблеме с акторами на перевес, попробовал бы сперва продумать схему "один актор на операцию". Т.е. актор для авторизации, один актор для выполнения запросов к БД, один актор для протоколирования и т.д., и т.п.

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

Так же, возможно, акторы были бы вполне уместны даже в схеме "один актор на запрос", но в случае, если у акторов коллбеки могут безопасно и дешево засыпать на синхронных обращениях к другим акторам, не блокируя при этом механизмы диспетчеризации событий в акторном фреймворке. Например, если каждый актор -- это stackfull короутина (fiber, green thread). Тогда актор запросто может заснуть на синхронной операции, а обслуживающий его тред ОС возьмет в работу какого-то другого актора. Но, во-первых, ни CAF, ни SObjectizer подобные акторы не поддерживают. Во-вторых, в докладе отдельно говорится про проблемы, связанные с fiber-ами.


Теперь пара слов о том, а можно ли получить лучшее из двух миров в одном флаконе, т.е. иметь возможность использовать и акторы, и C++20 stackless короутины.

Главным образом вопрос о том, а можно ли научить акторов вызывать co_await внутри своих коллбэков и при этом не требовать от фреймворка создания сотен тысяч тредов для обслуживания всего этого роскошества.

Да, думаю, что можно.

И я пытаюсь научить SO-5 этому. Но не могу сказать, когда же это заработает на практике.

Тут нужно понимать одну простую вещь: SObjectizer напрямую не приносит нам денег, мы не зарабатываем ни на продаже SO-5, ни на его техподдержке, ни на обучении, ни на кастомизации SO-5 под чьи-то специфические нужды. SO-5 и RESTinio создают нам репутацию, которая приводит клиентов. Но клиенты платят за решение своих проблем в своем коде. И эти проблемы, в 99% случаев, вообще никак не связаны с SO-5.

Поэтому работы над SO-5 ведутся лишь тогда, когда для этого удается выкроить ресурсы. То, что могло бы занять 2-3 месяца нормальной работы над SO-5 в режиме "полный рабочий день", растягивается, буквально, на год, если получается выкраивать по 10-15 часов в неделю, да и то далеко не всегда. Посему поддержка короутин в SO-5, надеюсь, появится. Но не через неделю, и даже не через месяц, а может и не через два. Увы, такова се ля ви.

По текущему статусу добавления С++20 короутин в SO-5 могу сказать следующее: часть подготовительных работ выполнена. Дело уперлось в такое понятие, как event_handler_method_t. Эта штука предназначена для хранения обработчиков сообщений и сейчас она заточена только под синхронные обработчики. А нужно поддержать еще и асинхронные. И вопрос в том как это сделать.

Есть один очевидный вариант. Но он потребует ператрахивания (с) значительной части потрохов SO-5. И, вероятно, немного снизит скорость диспетчеризации сообщений (для реальных прикладных программ это вряд ли будет заметно, но вот на тупых бенчмарках, по которым судят о качестве инструмента, просадка может быть заметна). Однако, больше всего меня смущает трудоемкость.

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

Комментариев нет: