Вкратце: На заре ЭВМ был единый поток управления c блокировкой на ввод-вывод. Потом к нему добавили прерывания железа. Появилась возможность эффективного использования медленных и непредсказуемых устройств. С ростом возможностей железа и его малой доступности, появилась необходимость выполнять несколько задач одновременно, что снабдили аппаратной поддержкой. Так появились изолированные процессы с абстрагированными от железа прерываниями в виде сигналов. Следующим эволюционным этапом стала многопоточность, которая была реализована на фундаменте тех же процессов, но с общим доступом к памяти и другим ресурсам. Такой подход имеет свои ограничения и существенные накладные расходы на переключение в безопасной ОС. Для общения между между процессами и даже различными машинами был предложена абстракция Promise/Future ещё 40+ лет назад. Пользовательские интерфейсы и смешная сейчас проблема 10K клиентов привели к периоду расцвета Event Loop, Reactor и Proactor подходов, которые больше ориентированы на обработку событий чем ясную последовательную бизнес-логику. Наконец пришли к современным coroutine (сопрограмма), которые по сути являются эмуляцией потоков поверх описанных выше абстракций с соответствующими техническими ограничениями и детерминированной передачей управления. Для передачи событий, результата и исключений вернулись всё к той же концепции Promise/Future. Кое-какие конторы решили назвать чуть иначе — "Task". В конечном счёте всё спрятали в красивую упаковку async/await, которая требует поддержки компилятора или транслятора в зависимости от технологии. Рассмотрим только coroutines и Promise, украшенные async/await, т.к. наличие проблем в более старых подходах подтверждает сам процесс эволюции. Эти два термина не тождественны. Например, в ECMAScript нет сопрограмм, а есть синтаксические облегчения для использования Promise, которое в свою очередь лишь организует работу с адом обратных вызовов (callback hell). По факту, скриптовые движки вроде V8 идут дальше и делают особые оптимизации для чистых async/await функций и вызовов. Высказывания экспертов о не попавших в C++17 co_async/co_await есть здесь на ресурсе, но давлением софтверного гиганта сопрограммы таки могут появиться в стандарте именно в их виде. Пока же традиционное признанное решение Boost.Context, Boost.Fiber и Boost.Coroutine2. В Java так же до сих пор нет async/await на уровне языка, но есть такие решения как EA Async, которые как и Boost.Context необходимо подгоняться под каждую версию JVM и байт кода. В Go присутствуют свои сопрограммы, но если внимательно посмотреть статьи и баг репорты открытых проектов, то выясняется что и здесь не всё так гладко. Возможно, потеря интерфейса сопрограммы как управляемой сущности не лучшая идея. Лично автор мало чего имеет против сопрограмм в динамических языках, но крайне насторожено относится к любым заигрываниям со стеком на уровне машинного кода. Несколько тезисов: Эта казалось бы сторонняя тема прямо связана с концепцией сопрограмм и свойства "продолжения". Если вкратце, то для любой коллекции должен существовать полноценный итератор. Для чего создавать проблему обрезанного итератора-генератора — не понятно. Например, кейс с range() в Python скорее эксклюзивный выпендрёж, чем оправдание технического усложнения. Если же кейс бесконечного генератора, то и логика его реализации элементарна. Для чего создавать дополнительные технические сложности чтобы внутри пихать бесконечный продолжаемый цикл. Единственное дельное позже появившееся оправдание, которое приводят сторонники сопрограмм — это всякого рода поточные парсеры с инвертированием управления. По сути, это узкий специализированный кейс решения единичных проблем уровня библиотек, а не бизнес-логики приложений. При этом есть элегантное, простое и более дескриптивное решение через конечные автоматы. Область этих технических проблем сильно меньше области банальной бизнес-логики. По факту, решаемая проблема получается высосана из пальца и требует сравнительно серьёзных усилий для изначальной реализации и длительной поддержки. На столько, что некоторые проекты могут ввести запрет на использование сопрограмм уровня машинного кода по примеру запрета на goto или использования динамического выделения памяти в отдельных индустриях. В отличии от продолжаемых сопрограмм, в этой модели куски кода скрытно делятся на непрерываемые блоки, оформленные в виде анонимных функций. В С++ это не совсем подходит из-за особенностей управления памятью, пример: Во-первых, some_obj будет разрушен при выходе из example() и до вызова лямбда-функций. Во-вторых, лямбда-функции с захватом переменных или ссылок являются объектами и скрытно добавляют копирование/перемещение, что может отрицательно сказаться на производительности при большом количестве захватов и необходимости выделять память на куче в ходе type erasure в обычной std::function. В-третьих, сам интерфейс Promise зачат на концепции "обещания" результата, а не последовательного выполнения бизнес-логики. Схематичное НЕ оптимальное решение может выглядеть примерно так: Примечание: std::move вместо std::shared_ptr не подходит из-за невозможности передачи в несколько лямбд сразу и роста их размера. С добавлением async/await асинхронные "ужасы" приходят в удобоваримое состояние: Некоторые критики называют проблемой отсутствие планировщика и "нечестное" использование ресурсов процессора. Возможно более серьёзная проблема — это локальность данных и эффективное использование кэша процессора. По первой проблеме: приоритизация на уровне отдельных сопрограмм выглядит большим оверхедом. Вместо этого ими возможно оперировать в общности для конкретной унифицированной задачи. Так поступают с транспортными потоками. Такое возможно путём создания отдельных экземпляров Event Loop с собственными "железными" потоками и планированием на уровне ОС. Второй вариант — синхронизировать сопрограммы относительно ограничивающего по конкуренции и(или) производительности примитива (Mutex, Throttle). Асинхронное программирование не делает ресурсы процессора резиновыми и требует абсолютно обычных ограничений по количеству одновременно обрабатываемых задач и ограничения общего времени выполнения. Защита от длительного блокирования на одной сопрограмме требует тех же мер что и с обратными вызовами — избегать блокирующих системных вызовов и длительных циклов обработки данных. По второй проблеме требуется исследование, но как минимум сами стеки сопрограмм и детали реализации Future/Promise уже нарушают локальность данных. Есть возможность пытаться продолжить выполнение той же сопрограммы, если Future уже имеет значение. Требуется некий механизм подсчёта времени выполнения или количества таких продолжений чтобы не дать одной сопрограмме захватить всё время процессора. Такое может либо не дать результата, либо дать весьма двоякий результат в зависимости от размера кэша процессора и количества потоков. Есть ещё и третий момент — многие реализации планировщиков сопрограмм позволяют выполнять их на разных ядрах процессора, что наоборот добавляет проблем из-за обязательной синхронизации при доступе к общим ресурсам. В случае же единого потока Event Loop'а такая синхронизация требуется только на логическом уровне, т.к. каждый синхронный блок обратного вызова гарантированно работает без гонки с другими. Наличие потоков в современных ОС не отменяет использования отдельных процессов. Так же, обработка большого количества клиентов в Event Loop не отменяет использование обособленных "железных" потоков для иных нужд. В любом случае, сопрограммы и различные варианты Event Loop'ов усложняют процесс отладки без необходимой поддержки в инструментах, а с локальными переменными на стеке сопрограмм всё становится ещё сложнее — к ним практически не добраться. За основу возьмём уже хорошо зарекомендовавший себя паттерн Event Loop и организацию схемы обратных вызовов по типу ECMAScript(JavaScript) Promise. С точки зрения планирования выполнения нас интересуют следующие действия от Event Loop: Так получаем интерфейс с названием AsyncTool, который может быть реализован множеством способов, включая поверх уже существующих проверенных разработок. Прямого отношения к написанию бизнес-логики он не имеет, поэтому не будем вдаваться в дальнейшие детали. В концепции AsyncSteps, выстраивается абстрактное дерево синхронных шагов и выполняется проходом в глубину в последовательности создания. Шаги каждого более глубокого уровня динамично задаются по мере выполнения такого прохода. Всё взаимодействие происходит через единый интерфейс AsyncSteps, который по конвенции передаётся первым параметром в каждый шаг. По конвенции название параметра asi или устарелое as. Такой подход позволяет практически полностью разорвать связь между конкретной реализацией и написанием бизнес-логики в плагинах и библиотеках. В каноничных реализациях, каждый шаг получает свой экземпляр объекта, реализующего AsyncSteps, что позволяет своевременно отслеживать логические ошибки в использовании интерфейса. Абстрактный пример: Результат выполнения: В синхронном виде выглядело бы так: Сразу видна максимальная мимикрия традиционного синхронного кода, что должно помогать в читаемости. С точки зрения бизнес-логики со временем нарастает большой ком требований, но мы можем разделить его на легко понимаемые части. Описанное ниже, результат обкатки на практике на протяжении четырёх лет. Предыдуший пример уже с реальным С++ кодом и некоторыми дополнительными фичами: Спецификация предлагает альтернативные названия breakLoop, continueLoop и прочие в случае конфликта с зарезервированными словами. Пример C++: Вызов любой из этих функций делает необходимым явный вызов success(). Пример C++: Пример ECMAScript: Пример C++: Здесь уже требуется конкретная реализация интерфейса. Пример C++: Примеры C++: В случае выхода за лимиты очереди, поднимается ошибка DefenseRejected, смысл которой ясен из описания Limiter. Концепция AsyncSteps не была самоцелью, а родилась вследствие необходимости более контролируемого асинхронного выполнения программ с точки зрения лимита времени, отмены и в целом связности отдельных обратных вызовов. Ни одно из универсальных решений на тот момент и сейчас не предоставляет такой же функциональности. Поэтому: Единая спецификация FTN12 для всех технологий — быстрая адаптация для разработчиков при переключение с одной технологии на другую. Интеграция отмены setCancel() — позволяет отменять все внешние запросы и подчищать ресурсы ясным и безопасным способом при внешней отмене или появлении ошибки времени исполнения. Это избегает проблемы асинхронного выполнения тяжёлых задач, результат которых уже не требуется. Это аналог RAII и atexit() в традиционном программировании. Непосредственно отмена выполнения cancel() — типично используется при отсоединении клиента, истечения максимального срока выполнения запроса или иных причин завершения. Это аналог SIGTERM или pthread_cancel(), оба из которых весьма специфичны в реализации. Таймеры отмены шага setTimeout() — типично используется для ограничения общего времени выполнения запроса и для ограничения времени ожидания подзапросов и внешних событий. Действуют на всё поддерево, выкидывает перехватываемую ошибку "Timeout". Интеграция с другими технологиями асинхронного программирования — использование FutoIn AsyncSteps не требует отказываться от уже используемых технологий и не требует разработки новых библиотек. Универсальная реализация на уровне конкретного языка программирования — нет необходимости в специфичных и потенциально опасных с изменениями ABI манипуляциях на уровне машинного кода, которые требуются для сопрограмм. Хорошо подходит для Embedded и безопасно на неполноценных MMU. Источник картинки Для тестов используется Intel Xeon E3-1245v2/DDR1333 с Debian Stretch и последним обновлением микрокода. Сравниваются пять вариантов: Ввиду функциональной ограниченности Boost.Fiber сравниваются следующие показатели производительности: Используется одно ядро и один "железный" поток, т.к. это позволяет добиться максимальной производительности, исключая гонки на спинлоках и атомарных операциях. Наилучшие значения из серии тестов идут в результат. Проверяется стабильность показателей. Сборка сделана с GCC 6.3.0. Результаты с Сlang и tcmalloc также проверялись, но различия несущественны для статьи. Исходный код тестов доступен на GitHub и GitLab. Больше — лучше. Здесь основная потеря у Boost.Fiber из-за системных вызовов работы со страницами памяти, но даже менее безопасный pooled_fixedsize_stack оказывается более медленным, чем стандартная реализация AsyncSteps. Больше — лучше. Здесь те же проблемы, но мы видим значительное замедление по сравнению с первым тестом. Нужен более детальный анализ, но теоретическая догадка указывает на неэффективность кэширования — выборочные значения количества параллельных потоков дают картину функции с множеством явных изломов. Больше — лучше. Убирая оверхед создания, мы видим что в голом переключении потоков Boost.Fiber начинает выигрывать у стандартной реализации AsyncSteps, но значительно проигрывает NitroSteps. Меньше — лучше. И снова, Boost.Fiber нечем гордиться. Всего два теста так же из-за ограниченности Promise: создание+выполнение и циклы по 10 тыс. итераций. Каждый тест 10 секунд. Берутся средние значения в Гц второго прохода после оптимизирующего JIT при NODE_ENV=production, используя пакет @futoin/optihelp. Исходный код так же на GitHub и GitLab. Используются версии Node.js v8.12.0 и v10.11.0, установленные через FutoIn CID. Больше — лучше. Внезапный разгром async/await? Да, но в V8 для Node.js v10 подтянули оптимизацию циклов и стало чуть лучше. Стоит добавить, что реализация Promise и async/await блокирует Node.js Event Loop. Бесконечный цикл без ожидания внешнего события попросту повесит процесс (пруф), но с FutoIn AsyncSteps такого не случается. Периодический выход из AsyncSteps в Node.js Event Loop и есть причина ложной победы async/await в тесте-цикле на Node.js v10. Оговорюсь, что сравнивать показатели производительности с С++ будет некорректно — разная реализация методологии тестирования. Для приближения, результаты тестирования циклов Node.js нужно умножить на 10 тыс. На примере C++, FutoIn AsyncSteps и Boost.Fiber показывают схожую длительную производительность и потребление памяти, а вот на запуске Boost.Fiber серьёзно проигрывает и ограничен системными лимитами по количеству mmap()/mprotect. Максимальная производительность зависит от эффективного использования кэша, но в реальной жизни данные бизнес-логики могут оказывать большее давление на кэш, чем реализация сопрограмм. Вопрос полноценного планировщика остаётся открытым. FutoIn AsyncSteps для JavaScript превосходит async/await в производительности даже в последней версии Node.js v10. В конечном счёте, основная нагрузка должна быть непосредственно от бизнес-логики, а не от реализации концепции асинхронного программирования. Поэтому незначительные отклонения не должны быть существенным аргументом. Разумно написанная бизнес-логика внедряет асинхронные переходы только когда подразумевается ожидание внешнего события в любом виде или когда объём обрабатываемых данных слишком велик и на долго блокирует "железный" поток выполнения. Исключение из этого правила — написание библиотечных API. Принципиально, интерфейс FutoIn AsyncSteps проще, понятнее и более функционален чем любые из конкурентов без "сахара" async/await. Вдобавок, он реализован стандартными средствами и унифицирован для всех технологий. Как и в случае с Promise из ECMAScript, AsyncSteps может получить свой "сахар" и его поддержку в компиляторах или средах выполнения практически любого языка. Замеры производительности не сильно оптимизированной реализации показывают сравнимые и даже превосходящие результаты относительно альтернативных технологий. Гибкость возможной реализации доказана двумя разными подходами AsyncSteps и NitroSteps для единого интерфейса. Помимо уже указанной выше спецификации, есть ориентированная на разработчика-пользователя документация. По запросам возможна реализация версии для Java/JVM или любого другого языка с адекватными анонимными функциями — иначе читаемость кода падает. Любая помощь проекту приветствуется. Если вам действительно нравится такая альтернатива, то не стесняйтесь поставить звёздочки на GitHub и/или GitLab. Свободный художник на все руки