Pull to refresh

Comments 22

Радует в последнее время количество статей про "внешний полиморфизм"/ стирание типа/ "плоский" динамический полиморфизм(без иерархии как в виртуальных функциях)

Конкретно в случае из статьи можно применить ещё одну оптимизацию - так как корутина уже является стиранием типа можно создать тредпул, оперирующий в качестве "функции" объектами corotuine_handle<void>(это стёртый тип всех кортин хендлов), его можно вызывать, будя корутину, а только это и нужно тредпулу. Так мы избежим дополнительного стирания типа и получим максимальную производительность.

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

p.s. реализация на coroutine handle использует для оптимизации тот факт, что корутина сама управляет своим лайфтаймом и выделением памяти

Спасибо за замечание, всё верно, можно использовать coroutine_handle<>, просто я ещё хотел показать, что можно использовать одни и те же ресурсы (потоки) для корутин одновременно с обычными задачами, что может быть важно, например, для рефакторинга.

В классе PromptFetcher я сделал именно так, как вы говорите, возможно, стоило обратить на это внимание в статье. Впрочем, она и так большая.

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

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

Конечно. Но это уже практические аспекты, которые зависят от конкретики системы, и с описанными в статье инструментами их можно решить как видится правильнее.

По теории, лучше не иметь слишком много потоков (в идеале не больше, чем ядер ЦП), а ввод-вывод мультиплексировать с помощью epoll или подобного.

А если у нас не два, а пять или десять таких вызовов? Разделить логику на десять частей?

Разве не лучше сразу делать конечный автомат. При разрастании логики реализовать асинхронные вызовы с помощью корутин модет по началу и хорошо, но по мере роста получите доширак.

ps: для таких задач есть более удобные языки: erlang, elixir.

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

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

Только они конечным автоматом не являются. По крайней мере, не на том уровне на котором обычно рассматривается код. Иначе можно сказать что любой код является конечным автоматом.


Но с остальным я согласен.

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

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

Это всё касается персистентных процессов, которые могут "жить" дни если не месяцы. А когда весь процесс — это голосовое меню, со временем жизни ограниченным временем длительностью даже не звонка, а началом этого самого звонка...


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

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

Касательно изменения графа в процессе выполнения, это получается, что раньше у нас было состояние X, и по сигналу s переходили в состояние Y, а потом вдруг подменили указатель или map, и стали по сигналу s переходить в Z? Лично я противник подобной неявной логики, искать баги в такой - большая головная боль.

"Редактор графа", мне напомнил что-то вроде графического интерфейса AWS Step Functions, и мне представляется, что это инструмент для особых случаев, когда по-другому не сделать. Мне в этом подходе не нравится то, что часть бизнес-логики переходит из кода приложения в язык конфигурации, и кодовую базу становится сложнее читать.

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

Честно сказать, я далеко не специалист в теории типов. И в TAPL термин "внешний полиморфизм" мне не попадался. Так что в рамках ликбеза дайте, пожалуйста, как можно более близкое к формальному определение этого термина, а так же откуда он взялся.

Ваш код для TaskWrapper быстрее просто потому, что размер вызываемого объекта, для которого нет необходимости выделять память, больше, чем у std::function, но откат на реализацию с динамическим выделением у вас не предусмотрен. В чём смысл подобного велосипеда? А если понадобится хранить объект большего размера?

Вероятно, термин происходит из этой статьи. Мой перевод определения: "паттерн, который позволяет использовать классы, не связанные наследованием и/или не имеющие виртуальных методов, как полиморфные.

Если требуется готовая реализация обёртки для вызываемого объекта (callable), то существуют готовые библиотеки. Цель куда в примере - показать как, зная конкретные требования, несложно сделать оптимизацию вручную. Это, кстати, необязательный элемент для внешнего полиморфизма.

Вероятно, термин происходит из этой статьи.
Мой перевод определения: "паттерн, который позволяет использовать
классы, не связанные наследованием и/или не имеющие виртуальных методов,
как полиморфные.

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

Цель куда в примере - показать как, зная конкретные требования, несложно сделать оптимизацию вручную

Не описана причина, по которой оптимизация у вас работает: small object optimization в std::function рассчитана на иной размер.

Смотрите, если есть тип B, унаследованный от А, и тип С, не входящий в дерево наследования, то обычный полиморфизм не позволяет использовать объекты типов B и C взаимозаменяемо (через указатель на базовый класс A, например). Внешний полиморфизм позволяет это сделать. Таким образом, у этих двух подходов явно разные свойства. Поэтому есть и отдельный термин.

Насчёт причины, по которой есть выигрыш в производительности, да, я этого не написал, хорошо, что вы указали.

Смотрите, если есть тип B, унаследованный от А, и тип С, не входящий в дерево наследования, то обычный полиморфизм не позволяет использовать объекты типов B и C взаимозаменяемо (через указатель на базовый класс A, например). Внешний полиморфизм позволяет это сделать.

Опять же, а что вы называете "обычным полиморфизмом"? В той же TAPL даётся три основных вида полиморфизма: параметрический, ad-hoc, подтипов. С ней в целом согласны и английская вики, и русская. И все они вполне будут работать в предложенном случае безо всяких новых терминов.

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

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

Честно говоря, я не увидел никаких разных свойств.

Вероятно, термин происходит из этой статьи.

Просмотрел эту статью. Моя ошибка была в том, что я сразу не понял (не обратил внимания), что речь о паттерне, а потому относился к данному термину как к чему-то серьёзному.

По написанному сложилось мнение, что это смесь капинства и взаимоисключающих параграфов, а потому не привносит в программирование ничего ценного, а только лишь очередной buzzword.

но она по-прежнему работает в два раза быстрее, чем std::function, потому что она не производит динамических выделений памяти

Стоит отметить, что у std::function есть оптимизация - по аналогии с sso у std::string и он не всегда размещается в куче. Зависит от тяжести и кол-ва объектов, например которые захватываем в лямбде.
Мельком глянул и обновил бенчмарк и сократил кол-во параметров в захвате в лямбде. Результаты изменились.

Справедливое замечание. Так как при меньшем числе параметров буфер в 64 байта в TaskWrapper избыточен, я уменьшил его до 20 байт для более честного сравнения. Получилось практически одинаковая производительность.

В принципе, как я и пишу в комментариях к коду SSO является в данном случае главным способом улучшения быстродействия. Самодельная виртуальная таблица никакого выигрыша в производительности не даёт, это просто способ сохранить информацию о типе обёрнутого объекта.

Можно в очередь класть std::variant<> из C++17 с вашими типами. Если sizeof ваших типов примерно одинаковый, то вообще всё хорошо. Внутри в std::variant<> уже происходит вся магия с полиморфизмом, и не надо писать самому так подробно.

Проблема с std::variant в том, что с ним возможен только статический полиморфизм. Объявляя переменную, нужно сразу указать список типов, с которыми variant может работать. А даже две лямбды с одинаковым телом, определённые в разных местах будут иметь разные типы. То есть, невозможно объявить контейнер типа std::queue<std::variant<>> и класть туда что угодно. Есть std::any но с ним вы сами отвечаете за то, чтобы привести содержимое к нужному типу. Нет, без стирания типа или обычного динамического полиморфизма универсальную обёртку не сделать.

std::queue<std::variant<MenuInput,HangUp>>

Зачем класть что угодно, это же не Python. В задаче было сказано, что бывают сообщения двух типов, вот их и будем класть.

Этого недостаточно, так как нужно ещё как-то соединить данные и обработчик в рабочем потоке. Поэкспериментируйте с кодом, увидите, где узкие места.

Задача же демонстрационная, представьте, если обработчиков больше. Для простых случаев std::variant может сработать, хотя будут ещё накладные расходы на вызов visit() , а где-то и наивная имплементация подойдёт. А для обобщённого решения уже нет.

Рекомендую посмотреть выступление Шона Парента, на него есть ссылка в статье, там имеется более расширенный пример, где лучше видна необходимость обобщённого подхода.

Sign up to leave a comment.

Articles