Pull to refresh

Comments 57

Любопытно. Эту библиотеку планируется включать в буст?

Во-первых, это совершенно не от меня зависит, но потребуется множество изменений в стиле и внутренностях.


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

стек на куче имеет целый ряд недостатков

Зато локальные переменные в куче чувствуют себя превосходно.


Необходимо реализовать комплексную логику сохранения, восстановления и удаления состояния сопрограмм

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


сложно говорить за антивирусы, но вероятно не особо им это нравится на примере проблем с JVM в прошлом

Какое дело антивирусам до локальных переменных в куче?


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

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


PS автор, добавь, пожалуйста, в свои сравнения варианты использующие Coroutines TS

Зато локальные переменные в куче чувствуют себя превосходно.

Повреждение данных на куче не столь опасно как повреждение стека, которое издревле используется для атак.


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

Не суть где логика реализована, она необходима. Так же как и в обработчиках системных вызовов.


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

Допускаю, что не совсем корректное определение в "сахаре" async/await, но изначальный Promise в ECMAScript берёт именно функцию-callback в then. О нём и речь в контексте С++.


PS автор, добавь, пожалуйста, в свои сравнения варианты использующие Coroutines TS

Была такая идея, но посмотрев на прения в GCC — идея пока отложена.

Очень крутая штука.
Во введении к статье («взгляд назад») не помешал бы обзор недостатков традиционной реализации асинхронности на коллбэках. Просто не все в курсе, зачем вообще весь этот хайп вокруг промисов, асинков и корутин. Ну типа такой:
1. Более-менее сложный алгоритм обработки превращается в жуткий спагетти-код, когда логика размазана тонким слоем по десяткам неявно вызывающих друг друга функций. Особенно печально с обработкой ошибок (та же отмена обработки из-за отвала клиента становится очень нетривиальной).
2. Без поддержки замыканий в языке получается ужас-ужас по утечкам памяти. Т.е. лямбды можно реализовать самостоятельно на базе функторов (объектов с перегруженным operator() и почти неизбежным delete this), но очень непросто это сделать правильно. Впрочем, даже поддержка замыканий и наличие сборщика мусора совершенно не мешает памяти течь, особенно при неявном захвате переменных замыканием (привет JS).
3. Даже при наличии замыканий код остаётся однопоточным, хотя и асинхронным. Многопоточность можно реализовать только вручную, и только при наличии стальных тестикул. Хоть с асинками, хоть с сопрограммами.

Благодарю за отзыв.
Давайте ваш коммент заменит часть статьи. Иначе она уж совсем некультурно разрастётся.

Читая такие ужасы понимаю почему так любят асинхронщину в rust на tokio, где оно не только асинхронно но и выполняется сразу в многопоточном режиме.
1. Более-менее сложный алгоритм обработки превращается в жуткий спагетти-код, когда логика размазана тонким слоем по десяткам неявно вызывающих друг друга функций. Особенно печально с обработкой ошибок (та же отмена обработки из-за отвала клиента становится очень нетривиальной).

Неужели описанный в статье подход от этих ужасов избавляет? От таких ужасов как раз избавляет простой линейный код, который раньше писали в виде однопоточных процессов (и форкали процессы для распараллеливания), затем в виде нитей внутри многопоточных процессов. Затем, поскольку у процессов и нитей слишком высокие накладные расходы, стали использовать stackful coroutines. Которые значительно дешевле, чем реальный нити ОС, но так же позволяют писать линейный код в синхронном стиле (пряча от пользователя детали переключения контекстов).

Автор же как раз предлагает от stackful coroutines перейти к размазыванию логики по множеству коллбэков.

Да, по каким-то критериям (в том числе и по перечисленным автором в статье), этот подход может быть где-то выгоднее. Но вот то, что этот подход упрощает написание бизнес-логики — это спорный тезис. По крайней мере в статье это явно не показано.
Предложение автора это и есть жуткий спагетти код, в котором логика размазана по куче коллбеков, плюс перереализовано куча ключевых слов языка: условия, циклы вот это всё.
UFO just landed and posted this here

Вы похоже пытаетесь меня убедить, что неправильно делаю то, с чем не согласен априори — у меня другое видение проблемы.


Давайте вы сначала развернёте свою мысль, а не будете наводить тень на плетень. Потом я объясню где вы возможно не правы.

Основная притензия к текущим реализациям, насколько я понял — неконтролируемая отмена и очистка (и неудобная) конкуретных процессов. Про стек — это вопрос не к библиотекам, а скорее к компилятору. Это он должен конечный автомат генерить, соответственно, стека данных там и нет. А в книге, что я порекомендовал, очень неплохая математика на альтернативах и negative knowledge. Если интересно, есть даже рабочая реализация этого подхода (на .NET, не моя github.com/Hopac/Hopac/blob/master/Docs/Programming.md)

Вот к этому я и веду. Вы смотрите на AsyncSteps через призму CML, обособленных потоков-сопрограмм и каналов связи. Именно такая концепция меня не устраивает, т.к. она имитирует ещё историческую модель разделения на процессы в UNIX, где множество запросов/событий обрабатываются в одном и том же процессе в цикле без перерыва. У каждого процесса своя задача в этом конвейере. Им требуется постоянно передавать работу друг-другу.


Потом с процессов переползли на потоки, но принцип остался: поток на чтение с сокетов, поток на запись в сокеты, поток на таймер и т.д. Далее с потоков переползли на сопрограммы, но принцип остался.


Благо [для меня] когда-то часть разработчиков решила сменить вектор. Вместо конвейера под каждый запрос стали создавать отдельный поток, который обслуживал запрос от и до. Это стало стандартом в веб серверах. Ключевое отличие — отсутствует обмен сообщениями между потоками. Они изолированы и независимы.


AsyncSteps идёт именно этим путём: один поток — один запрос, максимальное подражание синхронному программированию с абстрактным линейным потоком управления. Каждый поток изолирован от всех других. Здесь мало уместны наработки каналов связи и синхронизации CML, goroutine и прочих решений из соседней оперетты.


К слову, каноничный async/await, Promise и Boost.Fiber идут тем же путём, а вот goroutine идёт частично по пути CML. Статья о разнице в технической реализации общего пути "один поток-один запрос", о том что сделать его на рельсах а-ля CML (Boost.Fiber) выходит менее эффективно в реальных цифрах, что в чистом Promise не хватает функционала, ну и конечно немного камней в огород иного подхода.


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

Тестирование Boost.Fiber не может считаться полным без segmented stack — он обеспечит и гораздо меньшее потребление памяти, и быстрый старт.
Принципиально, интерфейс FutoIn AsyncSteps проще, понятнее и более функционален чем любые из конкурентов без «сахара» async/await

Как раз таки Boost.Fiber даёт писать самый простой и понятный код. Писать так, как спроектирован язык — в линейной логике. А не передавать функции обратного вызова. Это неудобно в любом языке, тем более в C++, где необходимо следить за временем жизни и списком захвата.

Я бы с вами согласился, если бы не одно "НО" — это необходимость пользоваться всё той же парой boost::fibers::promise<> и boost::fibers::future. Для меня это выглядит обструкцией.
В С++ безусловно появляется описанная в статье проблема с временем жизни локальных объектов, но она отсутствует в языках с GC.

Я бы с вами согласился, если бы не одно «НО» — это необходимость пользоваться всё той же парой boost::fibers::promise<> и boost::fibers::future

Зачем ими пользоваться? Вы ведь сами пишете в другом комментарии
Вместо конвейера под каждый запрос стали создавать отдельный поток, который обслуживал запрос от и до

Вот и создавайте fiber на запрос и обрабатывайте его от и до.
В С++ безусловно появляется описанная в статье проблема с временем жизни локальных объектов, но она отсутствует в языках с GC

Что не делает обратные вызовы радикально более удобными. За это всегда ругали Node.js. Не я сказал, но я полностью согласен: Node.js — это для не осиливших Erlang.
Вот и создавайте fiber на запрос и обрабатывайте его от и до.

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


Во-вторых, fiber не решает проблемы частичной отмены подзапроса по таймеру — это требуется делать во внешней реализации обработчика, куда должен передаваться таймаут и "надеяться", что он будет корректно обработан. Например, может задаваться одно общее ограничение на обработку всего запроса, на каждый подзапрос своё более маленькое ограничение. В свою очередь подзапрос может делать ещё и свои подзапросы со своими ограничениями времени. Как всё это считать? Как отменить первый подзапрос чётко по времени и продолжить выполнение по альтернативному пути?
Эта реальная проблема была решена одной из первых в AsyncSteps, убирая хрупкую логику подсчёта таймеров из реализации внешних событий. Неэффективные таймеры могут дать задержки в производительности в несколько порядков — вместо этого навязывается протокол запрос-отмена.


В-третьих, когда речь заходит про внешнее событие, которое обычно представляет из себя вызов ОС, начинают плодиться сущности в виде promise и future. Интерфейс AsyncSteps попросту заменяет их и сводит всё сложность к работе с одним указателем.

Основная претензия всё же была к манипуляциям со стеком и связанными с этим ограничениями

Озвученные вами ограничения снимаются при segmented stack.
угрозами

Вы не назвали конкретных угроз, связанных именно с необходимость поддержки стека для fibers. Если же речь идёт о стандартных угрозах, то, во-первых, ваша библиотека от них никак не спасает, а во-вторых, мы же не замшелые сишники, чтобы использовать всякие sprintf в ограниченный буфер — у нас есть stream'ы, контейнеры и прочие плюшки. Так что не вижу каким образом Fiber увеличивает риски.
Без вызова псевдо-блокирующего API, не будет переключения между fibers. При пироге из фреймворков может быть не очевидно, где требуется вручную впихнуть yield чтобы чрезмерно не занимать процессор

А как вы меня остановите, если я в callback'е вашей библиотеки начну считать число «пи» до рекордного знака? Только насильственными методами, что не уживается с корректным освобождением ресурсов. Так что претензия не принимается.
Кроме того, если между вызовами асинхронного API проходит так много времени, что необходимо заботиться о yield, то и все эти библиотеки используются совершенно напрасно — вычислительные задачи должны вращаться в отдельном пуле потоков, возможно, даже на другой машине.
Во-вторых, fiber не решает проблемы частичной отмены подзапроса по таймеру — это требуется делать во внешней реализации обработчика, куда должен передаваться таймаут и «надеяться», что он будет корректно обработан. Например, может задаваться одно общее ограничение на обработку всего запроса, на каждый подзапрос своё более маленькое ограничение. В свою очередь подзапрос может делать ещё и свои подзапросы со своими ограничениями времени. Как всё это считать? Как отменить первый подзапрос чётко по времени и продолжить выполнение по альтернативному пути?

Ну, асинхронное API всегда предоставляет *_for и *_until методы. Так что описанную проблему можно решать по аналогии с интеграцией Fiber с ASIO. Передавать извне только общее время (таймаут на всю операцию), и таймаут на операцию. Логика подсчёта интегрируется в прослойку, и бизнес-логика в fiber'ах ею не нагружается.
В-третьих, когда речь заходит про внешнее событие, которое обычно представляет из себя вызов ОС, начинают плодиться сущности в виде promise и future. Интерфейс AsyncSteps попросту заменяет их и сводит всё сложность к работе с одним указателем.

Я не очень понимаю проблемы. Но тем не менее, абсолютно линейный и последовательный вызов future.get() в fiber'е всегда проще и понятнее, чем callback.

Вы пересказываете тезисы из статьи. Проблему таймеров вы не поняли. segmented_stack имеет свои оговорки и нюансы.


Вас никто не заставляет отказываться от более медленного Boost.Fiber с кучей оговорок по ABI и поддержкой со стороны компилятора, как и от "железных" потоков.


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

Вы пересказываете тезисы из статьи

Но где, простите? Я топлю за последовательный и линейный код, а статья про передачу callback'ов друг другу.
Проблему таймеров вы не поняли

Возможно. А возможно, что вы не поняли её решения. Что вам не понравилось в моих словах?
segmented_stack имеет свои оговорки и нюансы

Как и всё в этой жизни. Но вы ничего в статье не написали. Не пишите и в комментариях.
Вас никто не заставляет отказываться от более медленного Boost.Fiber

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

Да, знаете, это запрещено в бортовых авиационных комплексах, которые мы разрабатываем. Лётчик может сильно обидеться…
А вы, простите, что же, по выражению одного erlang'иста, «выгребаете core'ки по утрам»?
На этом вас и оставлю

Да? Так скоро? Ну, ладно, бывайте.
… а статья про передачу callback'ов друг другу.

Вот вы совершенно не разобрались. Нет такого.


А возможно, что вы не поняли её решения.

Кто по вашему будет делать обработку таймеров и все _for API. Вы в каждой библиотеке будете их реализовывать?


Покажу на более понятно примере: операторы сравнения в С++. Как решение, добавили оператор <=>. Если суть проблемы и решения не понятны, то помочь далее вам не могу.


Да, знаете, это запрещено в бортовых авиационных комплексах...

Зато segmented_stack там разрешён? Вот вы меня уморили…

Вот вы совершенно не разобрались. Нет такого.

Я так смотрю, я не один такой. Раскройте же секрет: где мы заблуждаемся?
Кто по вашему будет делать обработку таймеров и все _for API. Вы в каждой библиотеке будете их реализовывать?

Да, я в том числе. И времени уйдёт меньше, чем на написание библиотеки по вызову callback'ов.
Покажу на более понятно примере: операторы сравнения в С++. Как решение, добавили оператор <=>. Если суть проблемы и решения не понятны, то помочь далее вам не могу.

Мне то как раз всё понятно. Я вам предложил такой оператор <=> — это прослойка интеграции API и Fiber. Вопросы?
Зато segmented_stack там разрешён? Вот вы меня уморили…

Возможно, пока не существует сертифицированной реализации (хотя за весь мир говорить не могу), но вы уморились так уверенно, что я убеждён: вам не составит труда процитировать мне пункт(ы) с запретом из КТ-178.
допустим, такой подход действительно оптимален по утилизации ресурсов. Но библиотека точно не даст забыть о том, что вы её используете — посудите сами, 2/3 кода с++-примеров относятся к ней, а не к бизнес-логике. Хотелось бы конечно, чтобы намерение сделать код асинхронным передавалось буквально одним-двумя ключевыми словами языка:
detach alpha();
detach {
    beta();
} then {
    gamma();
} then {
    delta();
};

parallel {
    auto a = sigma();
    auto b = tau();
    {
        auto c = theta();
        // ...
        auto d = omega(c);
    }
};

auto e = async kappa();
auto f = xi();
// ...
auto g = pi(a, b);

Думаю, здесь мои намерения очевидны. Код на AsyncSteps больно тяжело читать

Об этом как раз и писалось в заключении про "сахар", который возможно добавить. Некорректно сравнивать по читаемости. Пример Promise и async/await в JS — совершенно разный уровень читаемости, а суть одна.


В примерах много кода AsyncSteps чтобы показывать суть интерфейса. Так же сравнительно много кода не бизнес-логики во всех примерах Boost.Fiber и Promise в JS.


В реальной жизни основная часть кода остаётся за бизнес-логикой.

Простите, я, видимо, сильно торможу, но для меня остались непонятными следующие вещи:

1. Как у вас происходит отображение ваших AsyncStep-ов на реальные рабочие потоки (нити) ОС? Складывается ощущение, что за AsyncTool-ом прячется всего один рабочий поток, а все привязанные к этому AsyncTool-у объекты с коллбэками будут запускаться по очереди на этом единственном рабочем потоке.

2. Не понятно, зачем вы заложились на единообразие интерфейсов своих AsyncStep-ов для разных языков программирования. Например, меня, как C++ разработчика, мало интересует тот факт, что AsyncStep-ы выглядят точно так же в JS. JS-разработчиков, полагаю, так же мало интересует интерфейс C++ной реализации. Разработчиков, которые пишут и на C++, и на JS, среди читателей статьи, полагаю, сильно меньше, чем тех, кто использует всего один из этих языков. Соответственно, непонятно, какие выгоды может дать такая межъязыковая унификация.

3. Сложилось ощущение, что в своей статье вы термином «сопрограммы» называли и stackful- и stackless coroutines. Между тем это довольно таки разные вещи по своим возможностям, стоимости и удобству применения. При этом вы явно не обозначили (как мне показалось) какому именно типу сопрограмм вы противопоставляете свое решение. Если stackful coroutines, то по сути ваше предложение сводится к тому, чтобы погрузить пользователя в callback hell, объяснив ему это преимуществами в безопасности, переносимости и более высокой эффективности. Правильно?

Вы совсем не тормозите, а хорошо понимаете.


  1. Да + одна программа может иметь множество экземпляров AsyncTool для горизонтальной масштабируемости.


  2. Это скорей философский вопрос. Такая концепция.


  3. Оба типа сопрограмм объединены с абстрактной точки зрения. Краткое описание проблемы callback hell — это когда на в стеке есть более одного фрейма обратного вызова. Вот тогда оно нарастает как снежный ком и неконтролируемо. AsyncSteps так никогда не делает — всё идёт через возврат в AsyncTool и чистый вызов следующего шага. Того же добивались через Promise в JS.


Да + одна программа может иметь множество экземпляров AsyncTool для горизонтальной масштабируемости.
Тогда непонятен смысл примитивов синхронизации, которые вы предоставляете. Вот эти Mutex-ы и Throtlle — они зачем нужны при работе в строго однопоточном режиме, да еще с гарантией того, что активным может быть только один callback?
Это скорей философский вопрос. Такая концепция.
Но ведь за ней должна быть какая-то мотивация? Мне, например, понятно, когда в 90-е пытались делать единообразное отображение какой-нибудь CORBA в разные языки программирования. Но тут-то какая выгода преследуется?
Краткое описание проблемы callback hell — это когда на в стеке есть более одного фрейма обратного вызова.
Может быть это так, если вводить формализм со стороны технической реализации. С точки зрения же понятности кода callback hell начинается тогда, когда для выяснения очередного шага алгоритма приходится разбираться с тем, какой именно callback будет вызван. И когда.

Лет 7 назад я попытался сделать нечто подобное описанному в статье на Perl: Async::Defer. У меня тогда это не пошло именно потому, что как callback-и не маскируй — всё-равно такой код поддерживать намного сложнее, чем на CSP (горутинах и каналах). Описанные в статье недостатки горутин, особенно если брать реализацию из Go с 2KB contiguous стеком (Five things that make Go fast, go 1.4 runtime changes, eliminate STW stack re-scanning), на мой взгляд не совсем корректны и не настолько критичны, как это кажется автору.

Применительно к C++. Реализация на callback-ах может быть хороша, если вам требуется максимальная переносимость для фреймворка. И вы не знаете, где и как вы будете работать завтра, будет ли там поддержка Boost.Fiber/Boost.Coroutine вообще. Т.е. если у вас сильно ненулевая вероятность попасть на экзотическую архитектуру/платформу.

В случае же с прикладным софтом, который зачастую разрабатывается под конкретное железо и ОС (ну или выбор этого железа и ОС сильно ограничивается), вполне можно рассчитывать на наличие актуальной и работающей реализации stackful coroutines (как из Boost-а, так и не из Boost-а). Поэтому ряд аргументов автора статьи против короутин, имхо, для прикладной разработки не столь актуальны.

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

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


Мне, например, понятно, когда в 90-е пытались делать единообразное отображение какой-нибудь CORBA в разные языки программирования.

Всё верной дорогой мыслите. Проект FutoIn и есть переосмысление ошибок в том числе CORBA и взятие всего лучшего из него. Сначала встал вопрос поддержи и синхронного, и асинхронного кода, но потом стало ясно, что первое не перспективно. Далее, возникает вопрос каким образом организовать асинхронную работу когда в рамках одного процесса могут смешиваться разные языки (C++, Python, ECMAScript PHP) без транспортного протокола взаимодействия. Use case: .net/CLR, JVM/AppServers и свои цели у FutoIn.


… какой именно callback будет вызван. И когда.

В AsyncSteps с этим всё чётко: либо продолжение следующего шага со следующей итерации AsyncTool, либо развёртывание стека с ошибкой. Третьего не дано. При развёртывании вызывается зачистка. Всё в строго оговорённом порядке.

Она логическая в первую очередь
А можно пример? Кроме ограничения количества запросов к внешней системе.
Например, ограничить количество активных запросов к внешней системе.
Т.е. если у вас какой-то коллбэк «повиснет» на обращении к экземпляру Mutex-а или Throttle, то вы такой коллбэк заблокируете, правильно? И дадите возможность отработать какому-то другому коллбэку. Или как?
Далее, возникает вопрос каким образом организовать асинхронную работу когда в рамках одного процесса могут смешиваться разные языки (C++, Python, ECMAScript PHP) без транспортного протокола взаимодействия.
Тут непонятно. Вы говорите про случай, когда в рамках одного процесса ОС нужно смешать модули, разработанные на разных языках программирования? Т.е. пишем основной код на C++, но вставляем туда же V8, который будет исполнять JavaScript-код…
В AsyncSteps с этим всё чётко: либо продолжение следующего шага со следующей итерации AsyncTool, либо развёртывание стека с ошибкой.
Чего-то я все-таки не понимаю. Вот при обычном task-based parallelism-е можно записать что-то вроде:
auto result = async(calculate_first_value, args...).get() + async(calculate_second_value, args...).get();

В случае с FutoIn, как я понимаю, нужно будет создать три шага вычислений:
  • первый шаг содержит вызов calculate_first_value и сохранение этого результата куда-то;
  • второй шаг содержит вызов calculate_second_value и сохранение этого результата куда-то;
  • третий шаг складывает результаты двух первых шагов.

И каждый шаг будет представлен своей отдельной лямбдой. Правильно?
А можно пример? Кроме ограничения количества запросов к внешней системе.

Их множество — все те же, что и при классическом варианте. Например, при cache miss не эффективно генерировать значения всем интересующимся потокам — один берёт на себя эту задачу, другие ожидают чтобы проверить наличия кэшированного значения снова при захвате мутекса. Если уж и тогда его нет, тогда генерировать в "единственном лице". Другой вопрос, что мало разработчиков понимают как правильно организовать кэш и поддерживать его горячим.


Т.е. если у вас какой-то коллбэк «повиснет» на обращении к экземпляру Mutex-а или Throttle

Они реализованы иным путём и такого не происходит по определению. Если лимит не превышен — продолжается обработка следующего шага через AsyncTool. Иначе, AsyncSteps находится в ожидании завершения внешнего события (освобождения ресурса) в строгой последовательности. Если предполагается более одного "железного" потока, то эти примитивы могут быть реализованы через lockfree или же классические примитивы синхронизации. К слову, последние часто оказываются более эффективными и предсказуемыми в многопоточных приложениях вопреки расхожему мнению. Здесь нет обмена сообщениями и нет каналов связи.


В случае с FutoIn, как я понимаю, нужно будет создать три шага вычислений:

FutoIn работает на уровне бизнес-логики, для параллелизма вычислений есть совершенно иные инструменты вроде OpenCL. Конкретно parallel() имеет своей целью запускать параллельные внешние запросы с автоматическим барьером, а не ускорять вычисления. Каждый из подзапросов может делать захват переменной для записи результата или же использовать state().


И каждый шаг будет представлен своей отдельной лямбдой. Правильно?

Не обязательно лямбдой, это может быть и обычная функция и обычный функтор с реализацией operator()(IAsyncSteps& [, ...args]) [const]. На практике такое может использоваться для улучшения читаемости кода.

Их множество — все те же, что и при классическом варианте.
Простите, но я не понимаю. В классическом варианте есть два (или более) независимых потока управления и общий ресурс, который нужно получить в эксклюзивный доступ. При этом один поток доступ получает, второй приостанавливается и возобновляет свою работу при освобождении ресурса.

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

Посему вопрос «Зачем здесь Mutex?» для меня до сих пор не раскрыт.

Но если уж у вас есть примитивы синхронизации, то не очень понятно, как должна выглядеть работа с ними. С традиционными mutex-ами все просто: lock(); какие-то действия; unlock(). Если на lock()-е нас блокируют, то у нас текущее состояние сохраняется на стеке.

А что у вас? У вас же нельзя написать: lock(); ...; unlock(); для mutex-а. Как же с вашими mutex-ами происходит работа?
Если лимит не превышен — продолжается обработка следующего шага через AsyncTool. Иначе, AsyncSteps находится в ожидании завершения внешнего события (освобождения ресурса) в строгой последовательности.
Опять непонятно. У вас, по сути очередь тасков внутри AsyncTool. Вы выполнили первые N тасков и они исчерпали лимит в каком-то Throtlle-объекте (или что там у вас под «примитивами синхронизации» подразумевается). Берете (N+1) таску и она пытается взять Throtlle объект. Но не может, т.к. лимит выбран. И что, вы блокируете свой AsyncTool со всеми оставшимися тасками?

Вряд ли у вас такой бред происходит. Расскажите, пожалуйста, как работают ваши «примитивы синхронизации».
FutoIn работает на уровне бизнес-логики...
Ну т.е. разработчику таки придется написать эти самые три шага. Вместо одной конструкции, которую мы имеем при классическом task-based подходе. Это и есть прямая дорога к callback hell.
Посему вопрос «Зачем здесь Mutex?» для меня до сих пор не раскрыт.

Вы не учитываете, что этот самый захвативший поток может прерваться на внешнем вызове. Тогда в дело может вступать другой поток. Для этого и требуется логическая синхронизация. Во-вторых, Mutex может выступать в роли семафора с N>1 одновременных потоков. Это не моя выдумка, если что.


Как же с вашими mutex-ами происходит работа?

Смотрите на asi.sync(object, func, onerror) как на std::lock_guard<> для поддерева шагов, начинающегося с func(). А всё остальное — детали реализации.


И что, вы блокируете свой AsyncTool со всеми оставшимися тасками?

AsyncTool (Event Loop) никогда не блокируется за исключением случая отсутствия работы, т.е. отсутствия активных immediate() и deferred(). При логической блокировке AsyncSteps либо становится в очередь и возвращает управление в AsyncTool, либо сразу отваливается с ошибкой по лимиту. Если же лимит не достигнут изначально, то управление всё равно возвращается в AsyncTool (!), который потом вызовет func() — именно поэтому здесь нет никакого callback hell.


При освобождении ресурса, первый AsyncSteps в очереди получает "завершение внешнего события" через вызов asi.success(), который ставит дальнейшие шаги в обработку через AsyncTool::immediate(), а не вызывает их напрямую.


Все эти принципы Event Loop тоже не новы, и уж не первое десятилетие работают в Python Twisted, Node.js, разных UI thread и прочих реализациях.


Callback hell как раз-таки городят поверх, если не выходят обратно в Event Loop и если завершают запрос более чем одним путём.

Вы не учитываете, что этот самый захвативший поток может прерваться на внешнем вызове.
Вы хотите сказать, что показанный в ваших примерах ri::Mutex — это полноценный аналог std::mutex-а? Т.е. это не механизм синхронизации работы ваших тасков внутри AsyncSteps, а механизм синхронизации ваших тасков с внешним миром?
Смотрите на asi.sync(object, func, onerror) как на std::lock_guard<> для поддерева шагов, начинающегося с func().
Ну т.е. речь идет о том, что если кто-то напишет что-то вроде:
as.add([&](IAsyncSteps & as) {
   foo(); // (1)
   as.sync(mtx, [&](IAsyncSteps & as) {
     boo(); // (2)
   });
   baz(); // (3)
  });

То у него сперва будет вызван foo() в точке (1), затем baz() в точке (3) и лишь затем, когда-нибудь, boo() в точке (2)?
А всё остальное — детали реализации.
ИМХО, вам пора уже начать рассказывать об этих самых деталях, а то обычные смертные, вроде меня, из ваших объяснений в высоком штиле мало что понимают.
Callback hell как раз-таки городят поверх, если не выходят обратно в Event Loop и если завершают запрос более чем одним путём.
Вероятно, у кого-то из нас сильно специфический взгляд на callback hell.

Не очень понял первый вопрос. Объекты с интерфейсом futoin::ISync не могут использоваться для классической синхронизации, т.к. они по факту не блокируют "железный" поток. Они делают что-то вроде coroutine suspend для абстрактного потока AsyncSteps.


То у него сперва будет вызван foo() в точке (1), затем baz() в точке (3) и лишь затем, когда-нибудь, boo() в точке (2)?

Верно, если надо по-другому, то:


as.add([&](IAsyncSteps & as) {
   foo(); // (1)
   as.sync(mtx, [&](IAsyncSteps & as) {
     boo(); // (2)
   });
   asi.add( [](IAsyncSteps& asi){
     baz(); // (3)
   } );
  });

Этот момент описывается в документации и при необходимости может отслеживаться статическими анализаторами.


При добавлении "сахара", он исчезает и последовательность не будет нарушаться. await даже не потребуется использовать — вызов синхронных и асинхронных функций не будет отличаться в коде.


asi bool some_func() {
    return true;
}
asi [](){
    foo();

    sync(mtx) {
        boo();
    }

    bool res = some_func();
    baz();
};

Такой сахар может транслироваться в :


void some_func(IAsyncSteps& asi) {
     asi.success(true);
}
[](IAsyncSteps& asi){
    foo();

    asi.sync(mtx, [](IAsyncSteps& asi){
        boo();
    });

    asi.add(some_func);
    asi.add([](IAsyncSteps& asi, bool res){
         baz();
    });
};
Не очень понял первый вопрос.
Вопрос в том, для чего предназначен ri::Mutex. Если он нужен для синхронизации доступа к некоторому ресурсу из таска AsyncTool-а и из внешнего по отношению AsyncTool-а потоку управления, то его роль понятна. Но если ri::Mutex нужен для того, чтобы доступ к ресурсу синхронизировался между тасками одного AsyncTool-а, то непонятно, зачем он нужен.
Верно, если надо по-другому, то:
Боюсь показаться слишком грубым, но как только пользователю придется писать такой код вместо линейного:
foo();
{
  lock_guard lock{mtx};
  boo();
}
baz();
то ваш фреймворк отложат до лучших времен не смотря на якобы имеющиеся преимущества в скорости перед Boost.Fiber.
Такой сахар может транслироваться в :
И кто в мире C++ будет иметь такой транслятор?
И кто в мире C++ будет иметь такой транслятор?

Не всё сразу, У С++ был Cfront, у Qt есть moc, у ECMAScript — Babel, у FutoIn может появиться свой.


Сразу отвечаю на "когда сделаешь — тогда приходи", статья про универсальную модель, а не про конкретно С++ реализацию.

Универсальная модель, которая медленно но верно шагает из языка в язык уже есть, и это async/await.
Сразу отвечаю на «когда сделаешь — тогда приходи»
Как раз это-то и не интересует. Интересует ответ на вопрос про ri::Mutex, но этого-то ответа и нет.

Если мои объяснения не устраивают, попробуйте спросить у авторов Boost.Fiber зачем им mutex (https://www.boost.org/doc/libs/1_68_0/libs/fiber/doc/html/fiber/synchronization/mutex_types.html) или у автора cppcoro зачем им async_mutex (https://github.com/lewissbaker/cppcoro), а потом доказывайте им, что они тоже идиоты это не требуется.


К слову, по функциональности Mutex, Throttle и Limiter очевидно, что это не какое-то бездумное копирование из альтернативных решений.

попробуйте спросить у авторов Boost.Fiber зачем им mutex
Как раз там это вопросов не вызывает вообще, т.к., если мне не изменяет склероз, Boost.Fiber обеспечивает конкурентное выполнение для сопрограмм. Т.е. там сопрограмма — это как очень легковесный поток (thread), разве что его планировкой занимается не ОС.

А вот зачем нужен Mutex у вас, если у вас одна таска не может быть приостановлена посередине и эта же рабочая нить не может начать выполнять другую таску из этого же AsyncTool-а, вот это непонятно. Если ваш Mutex нужен для координации с внешним миром — тогда все становится на свои места.
К слову, по функциональности Mutex, Throttle и Limiter очевидно, что это не какое-то бездумное копирование из альтернативных решений.
Простите мне мою прямоту, но есть ощущение, что вы и в статье, и в комментариях ведете рассказ с позиции «вы все в дерьме, а я один Д'Артаньян». Из-за этого тяжело вытаскивать крупицы полезной информации из ваших рассказов о том, что и зачем вы сделали.
А вот зачем нужен Mutex у вас, если у вас одна таска не может быть приостановлена посередине и эта же рабочая нить не может начать выполнять другую таску из этого же AsyncTool-а, вот это непонятно.

Совершенно неверное утверждение.


У вас ведь есть понимание что такое Event Loop (он же AsyncTool)? Task — это AsyncSteps, каждый шаг (AsyncSteps::add()) — это отдельное событие, которое планируется через AsyncTool::immediate(). Это и обеспечивает высокую конкуренцию "тасков".


Соответственно,


AsyncSteps1
  step_1_1
    step_1_1_1
    step_1_1_2
  step_1_2
    step_1_2_1

AsyncSteps2
  step_2_1
    step_2_1_1
    step_2_1_2
  step_2_2
    step_2_2_1

будут выполнены в таком порядке:


step_1_1
step_2_1
step_1_1_1
step_2_1_1
step_1_1_2
step_2_1_2
step_1_2
step_2_2
step_1_2_1
step_2_2_1

Эта ясно показано на примере parallel() — смотрите внимательно.


Если AsyncSteps1 начинает что-то делать с неким ресурсом в step_1_1, то ему требуется взять Mutex чтобы продолжать с ним эксклюзивно работать в step_1_1_1 и step_1_1_2 так, чтобы другой "таск" не смог изменить состояние этого ресурса. Речь о логической гонке, а не хардверной.


Если вам всё ещё не понятно, то вы очевидно обыкновенный тролль, уважаемый.

У вас ведь есть понимание что такое Event Loop
Есть, но Event Loop служит для реактивной обработки событий, поступающих извне. У вас же, насколько можно судить по приведенным примерам, какое-то общение с внешним миром вообще не предусмотрено.
Это и обеспечивает высокую конкуренцию «тасков».
И конкурируют они, я так полагаю, разве что за время на той единственной нити, которая скрывается за AsyncTool.
Если AsyncSteps1 начинает что-то делать с неким ресурсом в step_1_1, то ему требуется взять Mutex чтобы продолжать с ним эксклюзивно работать в step_1_1_1 и step_1_1_2 так, чтобы другой «таск» не смог изменить состояние этого ресурса. Речь о логической гонке, а не хардверной.
Так стало понятно. Не понятно, зачем кому-то потребуется плодить кучу подтасков step_1_1_1, step_1_1_2 и т.д. Но если вы уж взялись их плодить, то тогда роль Mutex-ов в вашей модели становится понятной. А аналоги condition_variable или Window-вых event-ов у вас есть?
Если вам всё ещё не понятно, то вы очевидно обыкновенный тролль, уважаемый.
Есть еще тот вариант, что вы недостаточно хорошо объясняете.

В идеальной ситуации каждый такой переход — это связь с внешним миром и соответственно ожидание ответа от него. Внешний мир — это интерфейсы различных проектов: Boost.Asio, базы данных, неблокирующие системные вызовы и т.д.


Смотрите "API интеграции с внешними событиями" в статье.

Mutex-ы как раз нужны, а вот остальное мне действительно непонятно.
Типичный юзкейс: синхронизация доступа к ресурсу, обрабатывающему одновременно только одного клиента. Вместо того, чтобы в пользовательском коде городить очереди, гораздо проще реализовать примитив, аналогичный многопоточному.
Наличие потоков в современных ОС не отменяет использования отдельных процессов.
Это почему это? Насколько я знаю, процессы вместо потоков используют только x86 легаси-монстры, которые не в состоянии адаптировать код под x64, вследствие чего для одного процесса выскакивает ограничение в 2ГБ по адресному пространству (вроде всякими хаками выгрызают 4ГБ, но это не точно, ну и в 2018 году этого частенько не хватает). Еще, насколько я знаю, иногда в целях безопасности. А в общем случае создавать отдельные процессы вместо потоков смысла нет.

Процессы, в отличие от потоков, обеспечивают немало уникальных плюшек: возможность запускать их на разных машинах (хотя Erlang умеет это делать и для потоков, но это скорее исключение, потому что в других языках с чистыми функциями всё сложно), усиленная изоляция памяти (хотя последние дыры в процессорах помогают её обойти :)), увеличенная устойчивость (падение одного процесса не обрушивает другие… обычно), реализация разных процессов на разных языках, независимый менеджмент (перезапуск, обновление) процессов (хотя горячее обновление в Erlang…).

возможность запускать их на разных машинах
Согласен, я мыслил в контексте одной машины…
независимый менеджмент (перезапуск, обновление) процессов
Можно поподробнее? Я ведь вроде все что угодно с потоками делать могу.

Деплой новой версии микросервиса реализуется перезапуском процесса этого микросервиса. Другие микросервисы, работающие на этом же сервере и общающиеся с первым — продолжают работать. Обновить таким образом одну горутину (или группу горутин) из всех работающих в текущем процессе вроде умеет только Erlang (из известных мне языков).

В .NET таком образом можно выгружать и перезапускать AppDomain (что из коробки умеет делать ASP.NET).
Интересно, а есть ли здесь вообще выигрыш от снижения оверхеда? Асинхронные вызовы используются не для CPU-кода, а для легковесной многозадачности при работе с IO-операциями. То есть добавление новой задачи, либо завершение текущей всегда сопряжено с системным вызовом. А накладные расходы на системный вызов на порядок (если не на два) превышают расходы на переключение асинхронной задачи.

А если я вам скажу, что вместо системных вызов возможно общаться с ОС и сторонними процессами через неблокирующие очереди запросов-ответов в виде конвейера. Идея так же не нова.


В таком случае, количество переключений контекста не привязано к количеству обрабатываемых событий.

Идея хорошая! Но расскажите поподробнее, как общаться со сторонними процессами без блокировок и системных вызовов? Ведь даже в случае с non-blocking POSIX Message Queue от системных вызовов Вы вряд ли куда уйдете. Да, на первый взгляд это будет неблокирующий системный вызов, но все равно вызов с соотвествующими накладными расходами на переключение контекста. Опять же, очередь не засунишь в демультиплексирующие вызовы (кажется). Но есть костыли в виде прослушки еще одним процессом очереди сообщений, который перекладывает эти сообщения в неименованный канал, который и запихивают в демультиплексирующие вызовы. Но все это не обходится без системных вызовов все равно.

Самое простое, что можно потрогать — это Boost.Interprocess + Boost.Lockfree.


Конкретно под разного рода Event Loop, сопрограммы и FutoIn AsyncSteps в т.ч. возможно:


  1. Сделать специальный модуль ядра с lock-free очередью системных вызовов и обратной очередью завершений в памяти процесса,
  2. Вместо ожидания на куче дескрипторов, каждый железный поток сможет слушать всего одну очередь и обрабатывать эти завершения.
  3. В любой очереди сможет включаться получение событий (GUI, system events).
  4. Это сможет заменить прямые системные вызовы со сменой контекста и поллинг (select/poll/epoll/kqueue) в высоконагруженных системах.
  5. Используя lock-free структуры с одним записывающим и одним читающим, пропускная способность будет предельной, а вот приемлемые задержки появятся, но практически исчезнет оверхед от переключения контекстов.

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


Опять же, я не придумываю велосипед, а предлагаю рабочее решение. Такими очередями host-guest оперируют драйвера в виртуализованной среде для работы с устройствами. Может что-то пропустил и уже даже кто-то сделать очереди для syscall'ов.

Sign up to leave a comment.

Articles