Хабрахабр

Асинхронная бизнес-логика в наши дни

Вкратце:

  • Пруф уже реализован на C++, JS и PHP, подходит для Java.
  • Быстрее чем coroutine и Promise, больше фич.
  • Не требует выделения отдельного программного стека.
  • Дружит со всеми средствами безопасности и отладки.
  • Работает на любой архитектуре и не требует особых флагов компилятора.

Взгляд назад

Потом к нему добавили прерывания железа. На заре ЭВМ был единый поток управления c блокировкой на ввод-вывод. Появилась возможность эффективного использования медленных и непредсказуемых устройств.

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

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

Для общения между между процессами и даже различными машинами был предложена абстракция Promise/Future ещё 40+ лет назад.

Пользовательские интерфейсы и смешная сейчас проблема 10K клиентов привели к периоду расцвета Event Loop, Reactor и Proactor подходов, которые больше ориентированы на обработку событий чем ясную последовательную бизнес-логику.

Наконец пришли к современным coroutine (сопрограмма), которые по сути являются эмуляцией потоков поверх описанных выше абстракций с соответствующими техническими ограничениями и детерминированной передачей управления.

Кое-какие конторы решили назвать чуть иначе — "Task". Для передачи событий, результата и исключений вернулись всё к той же концепции Promise/Future.

В конечном счёте всё спрятали в красивую упаковку async/await, которая требует поддержки компилятора или транслятора в зависимости от технологии.

Проблемы с текущий ситуаций асинхронной бизнес-логики

наличие проблем в более старых подходах подтверждает сам процесс эволюции. Рассмотрим только coroutines и Promise, украшенные async/await, т.к.

Например, в ECMAScript нет сопрограмм, а есть синтаксические облегчения для использования Promise, которое в свою очередь лишь организует работу с адом обратных вызовов (callback hell). Эти два термина не тождественны. По факту, скриптовые движки вроде V8 идут дальше и делают особые оптимизации для чистых async/await функций и вызовов.

Пока же традиционное признанное решение Boost. Высказывания экспертов о не попавших в C++17 co_async/co_await есть здесь на ресурсе, но давлением софтверного гиганта сопрограммы таки могут появиться в стандарте именно в их виде. Fiber и Boost. Context, Boost. Coroutine2.

Context необходимо подгоняться под каждую версию JVM и байт кода. В Java так же до сих пор нет async/await на уровне языка, но есть такие решения как EA Async, которые как и Boost.

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

Мнение автора: сопрограммы на голом железе опасны

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

Несколько тезисов:

  1. Требуется выделять стек:
    • стек на куче имеет целый ряд недостатков: проблемы своевременного определения переполнения, повреждение соседями и прочие проблемы надежности/безопасности,
    • защищённый стек требует минимум одну страницу физической памяти, одну условную страницу и дополнительные накладные расходы для каждого вызова async функции: 4+KB (минимум) + повышенные системные лимиты,
    • в конечном итоге может быть так, что значительная часть выделенной под стеки памяти не используется во время простоя сопрограммы.
  2. Необходимо реализовать комплексную логику сохранения, восстановления и удаления состояния сопрограмм:
    • под каждый случай архитектуры процессора (даже модели) и бинарного интерфейса (ABI): пример,
    • новые или опциональные фичи архитектуры вносят потенциально латентные проблемы (например, Intel TSX, со-процессоры ARM или MIPS),
    • другие потенциальные проблемы из-за закрытой документации проприетарных систем (документация Boost на это ссылается).
  3. Потенциальные проблемы с инструментами динамического анализа и с безопасностью в целом:
    • например, требуется интеграция с Valgrind всё из-за тех же скачущих стеков,
    • сложно говорить за антивирусы, но вероятно не особо им это нравится на примере проблем с JVM в прошлом,
    • уверен, появятся новые виды атак и будут вскрыты уязвимости, связанные именно с реализацией сопрограмм.

Мнение автора: генераторы и yield принципиальное зло

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

Для чего создавать проблему обрезанного итератора-генератора — не понятно. Если вкратце, то для любой коллекции должен существовать полноценный итератор. Например, кейс с range() в Python скорее эксклюзивный выпендрёж, чем оправдание технического усложнения.

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

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

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

Мнение автора: модель async/await на Promise из ECMAScript более надёжна, но требует адаптации

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

struct SomeObject void funcCallback(std::function<void()> &&cb, const Value& val) { somehow_call_later(cb); } Value value_;
}; Promise example() { SomeObject some_obj; return some_obj.funcPromise() .catch([](const std::exception &e){ // ... }) .then([&](SomeObject::value &&val){ return Promise([&](Resolve&& resolve, Reject&&){ some_obj.funcCallback(resolve, val); }); });
}

Во-первых, some_obj будет разрушен при выходе из example() и до вызова лямбда-функций.

Во-вторых, лямбда-функции с захватом переменных или ссылок являются объектами и скрытно добавляют копирование/перемещение, что может отрицательно сказаться на производительности при большом количестве захватов и необходимости выделять память на куче в ходе type erasure в обычной std::function.

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

Схематичное НЕ оптимальное решение может выглядеть примерно так:

Promise example() { struct LocalContext { SomeObject some_obj; }; auto ctx = std::make_shared<LocalContext>(); return some_obj.funcPromise() .catch([](const std::exception &e){ // ... }) .then([ctx](SomeObject::Value &&val){ struct LocalContext2 { LocalContext2(std::shared_ptr<LocalContext> &&ctx, SomeObject::Value &&val) : ctx(ctx), val(val) {} std::shared_ptr<LocalContext> ctx; SomeObject::Value val; }; auto ctx2 = std::make_shared<LocalContext2>( std::move(ctx), std::forward<SomeObject::Value>(val) ); return Promise([ctx2](Resolve&& resolve, Reject&&){ ctx2->ctx->some_obj.funcCallback([ctx2, resolve](){ resolve(); }, val); }); });
}

Примечание: std::move вместо std::shared_ptr не подходит из-за невозможности передачи в несколько лямбд сразу и роста их размера.

С добавлением async/await асинхронные "ужасы" приходят в удобоваримое состояние:

async void example() { SomeObject some_obj; try { SomeObject::Value val = await some_obj.func(); } catch (const std::exception& e) ( // ... } // Capture "async context" return Promise([async](Resolve&& resolve, Reject&&){ some_obj.funcCallback([async](){ resolve(); }, val); });
}

Мнение автора: планировщик сопрограмм — это перебор

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

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

Второй вариант — синхронизировать сопрограммы относительно ограничивающего по конкуренции и(или) производительности примитива (Mutex, Throttle). Такое возможно путём создания отдельных экземпляров Event Loop с собственными "железными" потоками и планированием на уровне ОС.

Асинхронное программирование не делает ресурсы процессора резиновыми и требует абсолютно обычных ограничений по количеству одновременно обрабатываемых задач и ограничения общего времени выполнения.

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

Есть возможность пытаться продолжить выполнение той же сопрограммы, если Future уже имеет значение. По второй проблеме требуется исследование, но как минимум сами стеки сопрограмм и детали реализации Future/Promise уже нарушают локальность данных. Такое может либо не дать результата, либо дать весьма двоякий результат в зависимости от размера кэша процессора и количества потоков. Требуется некий механизм подсчёта времени выполнения или количества таких продолжений чтобы не дать одной сопрограмме захватить всё время процессора.

В случае же единого потока Event Loop'а такая синхронизация требуется только на логическом уровне, т.к. Есть ещё и третий момент — многие реализации планировщиков сопрограмм позволяют выполнять их на разных ядрах процессора, что наоборот добавляет проблем из-за обязательной синхронизации при доступе к общим ресурсам. каждый синхронный блок обратного вызова гарантированно работает без гонки с другими.

Мнение автора: всё хорошо в меру

Так же, обработка большого количества клиентов в Event Loop не отменяет использование обособленных "железных" потоков для иных нужд. Наличие потоков в современных ОС не отменяет использования отдельных процессов.

В любом случае, сопрограммы и различные варианты Event Loop'ов усложняют процесс отладки без необходимой поддержки в инструментах, а с локальными переменными на стеке сопрограмм всё становится ещё сложнее — к ним практически не добраться.

FutoIn AsyncSteps — альтернатива сопрограммам

За основу возьмём уже хорошо зарекомендовавший себя паттерн Event Loop и организацию схемы обратных вызовов по типу ECMAScript(JavaScript) Promise.

С точки зрения планирования выполнения нас интересуют следующие действия от Event Loop:

  1. Незамедлительный обратный вызов Handle immediate(callack) с требованием чистого стека вызовов.
  2. Отложенный обратный вызов Handle deferred(delay, callback).
  3. Отмена обратного вызова handle.cancel().

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

Дерево шагов:

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

По конвенции название параметра asi или устарелое as. Всё взаимодействие происходит через единый интерфейс AsyncSteps, который по конвенции передаётся первым параметром в каждый шаг. Такой подход позволяет практически полностью разорвать связь между конкретной реализацией и написанием бизнес-логики в плагинах и библиотеках.

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

Абстрактный пример:

asi.add( // Level 0 step 1 func( asi ){ print( "Level 0 func" ) asi.add( // Level 1 step 1 func( asi ){ print( "Level 1 func" ) asi.error( "MyError" ) }, onerror( asi, error ){ // Level 1 step 1 catch print( "Level 1 onerror: " + error ) asi.error( "NewError" ) } ) }, onerror( asi, error ){ // Level 0 step 1 catch print( "Level 0 onerror: " + error ) if ( error strequal "NewError" ) { asi.success( "Prm", 123, [1, 2, 3], true) } } ) asi.add( // Level 0 step 2 func( asi, str_param, int_param, array_param ){ print( "Level 0 func2: " + param ) } )

Результат выполнения:

Level 0 func 1 Level 1 func 1 Level 1 onerror 1: MyError Level 0 onerror 1: NewError Level 0 func 2: Prm

В синхронном виде выглядело бы так:

str_res, int_res, array_res, bool_res // undefined try { // Level 0 step 1 print( "Level 0 func 1" ) try { // Level 1 step 1 print( "Level 1 func 1" ) throw "MyError" } catch( error ){ // Level 1 step 1 catch print( "Level 1 onerror 1: " + error ) throw "NewError" } } catch( error ){ // Level 0 step 1 catch print( "Level 0 onerror 1: " + error ) if ( error strequal "NewError" ) { str_res = "Prm" int_res = 123 array_res = [1, 2, 3] bool_res = true } else { re-throw } } { // Level 0 step 2 print( "Level 0 func 2: " + str_res ) }

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

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

Базовые API времени выполнения:

  1. add(func[, onerror]) — имитация try-catch.
  2. success([args...]) — явное указание успешного завершения:
    • подразумевается по умолчанию,
    • может передавать результаты на вход в следующий шаг.
  3. error(code[, reason) — прерывание выполнения с ошибкой:
    • code — имеет строковой тип чтобы лучше интегрироваться с сетевыми протоколами в микросервисной архитектуре,
    • reason — произвольное пояснение для человека.
  4. state() — аналог Thread Local Storage. Предопределённые ассоциативные ключи:
    • error_info — пояснение последний ошибки для человека,
    • last_exception — указатель на объект последнего исключения,
    • async_stack — стек асинхронных вызовов на сколько позволяет технология,
    • остальное — задаётся пользователем.

Предыдуший пример уже с реальным С++ кодом и некоторыми дополнительными фичами:

#include <futoin/iasyncsteps.hpp> using namespace futoin; void some_api(IAsyncSteps& asi) { asi.add( [](IAsyncSteps& asi) { std::cout << "Level 0 func 1" << std::endl; asi.add( [](IAsyncSteps& asi) { std::cout << "Level 1 func 1" << std::endl; asi.error("MyError"); }, [](IAsyncSteps& asi, ErrorCode code) { std::cout << "Level 1 onerror 1: " << code << std::endl; asi.error("NewError", "Human-readable description"); } ); }, [](IAsyncSteps& asi, ErrorCode code) { std::cout << "Level 0 onerror 1: " << code << std::endl; if (code == "NewError") { // Human-readable error info assert(asi.state().error_info == "Human-readable description"); // Last exception thrown is also available in state std::exception_ptr e = asi.state().last_exception; // NOTE: smart conversion of "const char*" asi.success("Prm", 123, std::vector<int>({1, 2, 3}, true)); } } ); asi.add( [](IAsyncSteps& asi, const futoin::string& str_res, int int_res, std::vector<int>&& arr_res) { std::cout << "Level 0 func 2: " << str_res << std::endl; } );
}

API для создания циклов:

  1. loop( func, [, label] ) — шаг с бесконечно повторяемым телом.
  2. forEach( map|list, func [, label] ) — шаг-итерация по объекту коллекции.
  3. repeat( count, func [, label] ) — шаг-итерация указанное количество раз.
  4. break( [label] ) — аналог традиционного прерывания цикла.
  5. continue( [label] ) — аналог традиционного продолжения цикла с новой итерации.

Спецификация предлагает альтернативные названия breakLoop, continueLoop и прочие в случае конфликта с зарезервированными словами.

Пример C++:

asi.loop([](IAsyncSteps& asi) { // infinite loop asi.breakLoop(); }); asi.repeat(10, [](IAsyncSteps& asi, size_t i) { // range loop from i=0 till i=9 (inclusive) asi.continueLoop(); }); asi.forEach( std::vector<int>{1, 2, 3}, [](IAsyncSteps& asi, size_t i, int v) { // Iteration of vector-like and list-like objects }); asi.forEach( std::list<futoin::string>{"1", "2", "3"}, [](IAsyncSteps& asi, size_t i, const futoin::string& v) { // Iteration of vector-like and list-like objects }); asi.forEach( std::map<futoin::string, futoin::string>(), [](IAsyncSteps& asi, const futoin::string& key, const futoin::string& v) { // Iteration of map-like objects }); std::map<std::string, futoin::string> non_const_map; asi.forEach( non_const_map, [](IAsyncSteps& asi, const std::string& key, futoin::string& v) { // Iteration of map-like objects, note the value reference type });

API интеграции с внешними событиями:

  1. setTimeout( timeout_ms ) — вызывает ошибку Timeout по истечению времени, если шаг и его поддерево не закончили выполнение.
  2. setCancel( handler ) — устанавливает обработчик отмены, который вызывается при общей отмене потока и при разворачивании стека асинхронных шагов во время обработки ошибки.
  3. waitExternal() — простое ожидание внешнего события.
    • Примечание: безопасно использовать только в технологиях со сборщиком мусора.

Вызов любой из этих функций делает необходимым явный вызов success().

Пример C++:

asi.add([](IAsyncSteps& asi) { auto handle = schedule_external_callback([&](bool err) { if (err) { try { asi.error("ExternalError"); } catch (...) { // pass } } else { asi.success(); } }); asi.setCancel([=](IAsyncSteps& asi) { external_cancel(handle); }); }); asi.add( [](IAsyncSteps& asi) { // Raises Timeout error after specified period asi.setTimeout(std::chrono::seconds{10}); asi.loop([](IAsyncSteps& asi) { // infinite loop }); }, [](IAsyncSteps& asi, ErrorCode code) { if (code == futoin::errors::Timeout) { asi(); } });

Пример ECMAScript:

asi.add( (asi) => { asi.waitExternal(); // disable implicit success() some_obj.read( (err, data) => { if (!asi.state) { // ignore as AsyncSteps execution got canceled } else if (err) { try { asi.error( 'IOError', err ); } catch (_) { // ignore error thrown as there are no // AsyncSteps frames on stack. } } else { asi.success( data ); } } );
} );

API интеграции с Future/Promise:

  1. await(promise_future[, on_error]) — ожидание Future/Promise как шаг.
  2. promise() — превращает весь поток выполнения в Future/Promise, используется вместо execute().

Пример C++:

[](IAsyncSteps& asi) { // Proper way to create new AsyncSteps instances // without hard dependency on implementation. auto new_steps = asi.newInstance(); new_steps->add([](IAsyncSteps& asi) {}); // Can be called outside of AsyncSteps event loop // new_steps.promise().wait(); // or // new_steps.promise<int>().get(); // Proper way to wait for standard std::future asi.await(new_steps->promise()); // Ensure instance lifetime asi.state()["some_obj"] = std::move(new_steps); };

API контроля потока выполнения бизнес-логики:

  1. AsyncSteps(AsyncTool&) — конструктор, который привязывает поток выполнения к конкретному Event Loop.
  2. execute() — запускает поток выполнения.
  3. cancel() — отменяет поток выполнения.

Здесь уже требуется конкретная реализация интерфейса.

Пример C++:

#include <futoin/ri/asyncsteps.hpp>
#include <futoin/ri/asynctool.hpp> void example() { futoin::ri::AsyncTool at; futoin::ri::AsyncSteps asi{at}; asi.loop([&](futoin::IAsyncSteps &asi){ // Some infinite loop logic }); asi.execute(); std::this_thread::sleep_for(std::chrono::seconds{10}); asi.cancel(); // called in d-tor by fact
}

прочие API:

  1. newInstance() — позволяет создать новый поток выполнения без прямой зависимости на реализацию.
  2. sync(object, func, onerror) — то же, но с синхронизацией относительно объекта, реализующего соответствующий интерфейс.
  3. parallel([on_error]) — специальный add(), подшаги которого представляют из себя отдельные потоки AsyncSteps:
    • у всех потоков общий state(),
    • родительский поток продолжает выполнения по завершению всех дочерних,
    • не перехваченная ошибка в любом дочернем сразу отменяет все остальные дочерние потоки.

Примеры C++:

#include <futoin/ri/mutex.hpp> using namespace futoin; ri::Mutex mtx_a; void sync_example(IAsyncSteps& asi) { asi.sync(mtx_a, [](IAsyncSteps& asi) { // synchronized section asi.add([](IAsyncSteps& asi) { // inner step in the section // This synchronization is NOOP for already // acquired Mutex. asi.sync(mtx_a, [](IAsyncSteps& asi) { }); }); }); } void parallel_example(IAsyncSteps& asi) { using OrderVector = std::vector<int>; asi.state("order", OrderVector{}); auto& p = asi.parallel([](IAsyncSteps& asi, ErrorCode) { // Overall error handler asi.success(); }); p.add([](IAsyncSteps& asi) { // regular flow asi.state<OrderVector>("order").push_back(1); asi.add([](IAsyncSteps& asi) { asi.state<OrderVector>("order").push_back(4); }); }); p.add([](IAsyncSteps& asi) { asi.state<OrderVector>("order").push_back(2); asi.add([](IAsyncSteps& asi) { asi.state<OrderVector>("order").push_back(5); asi.error("SomeError"); }); }); p.add([](IAsyncSteps& asi) { asi.state<OrderVector>("order").push_back(3); asi.add([](IAsyncSteps& asi) { asi.state<OrderVector>("order").push_back(6); }); }); asi.add([](IAsyncSteps& asi) { asi.state<OrderVector>("order"); // 1, 2, 3, 4, 5 }); };

Стандартные примитивы для синхронизации

  1. Mutex — ограничивает одновременное исполнение в N потоков с очередью в Q, по умолчанию N=1, Q=unlimited.
  2. Throttle — ограничивает количество входов N в период P с очередью в Q, по умолчанию N=1, P=1s, Q=0.
  3. Limiter — комбинация Mutex и Throttle, которая типично используется на входе обработки внешних запроса и при вызове внешних систем с целью устойчивой работы под нагрузкой.

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

Ключевые преимущества

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

Единая спецификация FTN12 для всех технологий — быстрая адаптация для разработчиков при переключение с одной технологии на другую.

Это избегает проблемы асинхронного выполнения тяжёлых задач, результат которых уже не требуется. Интеграция отмены setCancel() — позволяет отменять все внешние запросы и подчищать ресурсы ясным и безопасным способом при внешней отмене или появлении ошибки времени исполнения. Это аналог RAII и atexit() в традиционном программировании.

Это аналог SIGTERM или pthread_cancel(), оба из которых весьма специфичны в реализации. Непосредственно отмена выполнения cancel() — типично используется при отсоединении клиента, истечения максимального срока выполнения запроса или иных причин завершения.

Действуют на всё поддерево, выкидывает перехватываемую ошибку "Timeout". Таймеры отмены шага setTimeout() — типично используется для ограничения общего времени выполнения запроса и для ограничения времени ожидания подзапросов и внешних событий.

Интеграция с другими технологиями асинхронного программирования — использование FutoIn AsyncSteps не требует отказываться от уже используемых технологий и не требует разработки новых библиотек.

Хорошо подходит для Embedded и безопасно на неполноценных MMU. Универсальная реализация на уровне конкретного языка программирования — нет необходимости в специфичных и потенциально опасных с изменениями ABI манипуляциях на уровне машинного кода, которые требуются для сопрограмм.

Источник картинки

К цифрам

Для тестов используется Intel Xeon E3-1245v2/DDR1333 с Debian Stretch и последним обновлением микрокода.

Сравниваются пять вариантов:

  1. Boost.Fiber с protected_fixedsize_stack.
  2. Boost.Fiber с pooled_fixedsize_stack и выделением на общей куче.
  3. FutoIn AsyncSteps в стандартном исполнении.
  4. FutoIn AsyncSteps в динамически отключенным пулом памяти (FUTOIN_USE_MEMPOOL=false).
    • приведено лишь для свидетельства эффективности futoin::IMemPool.
  5. FutoIn NitroSteps<> — альтернативная реализация со статическим выделением всех буферов во время создания объекта.
    • конкретные параметры лимитов задаются в виде продвинутых параметров шаблона.

Fiber сравниваются следующие показатели производительности: Ввиду функциональной ограниченности Boost.

  1. Последовательное создание и выполнение 1 млн. потоков.
  2. Параллельное создание потоков с лимитом в 30 тыс. и исполнение 1 млн. потоков.
    • ограничение в 30 тыс. исходит из потребности вызывать mmap()/mprotect() для boost::fiber::protected_fixedsize_stack.
    • большая цифра так же выбрана для давления на кэш процессора.
  3. Параллельное создание 30 тыс. потоков и 10 млн. переключений в ожидании внешнего события.
    • в обоих случаях "внешнее" событие удовлетворяется в отдельном потоке в рамках той же технологии.

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

3. Сборка сделана с GCC 6. Результаты с Сlang и tcmalloc также проверялись, но различия несущественны для статьи. 0.

Исходный код тестов доступен на GitHub и GitLab.

1. Последовательное создание

Технология

Время

Гц

Boost.Fiber protected

4.8s

208333.333Hz

Boost.Fiber pooled

0.23s

4347826.086Hz

FutoIn AsyncSteps

0.21s

4761904.761Hz

FutoIn AsyncSteps no mempool

0.31s

3225806.451Hz

FutoIn NitroSteps

0.255s

3921568.627Hz

Больше — лучше.

Fiber из-за системных вызовов работы со страницами памяти, но даже менее безопасный pooled_fixedsize_stack оказывается более медленным, чем стандартная реализация AsyncSteps. Здесь основная потеря у Boost.

2. Параллельное создание и исполнение

Технология

Время

Гц

Boost.Fiber protected

6.31s

158478.605Hz

Boost.Fiber pooled

1.558s

641848.523Hz

FutoIn AsyncSteps

1.13s

884955.752Hz

FutoIn AsyncSteps no mempool

1.353s

739098.300Hz

FutoIn NitroSteps

1.43s

699300.699Hz

Больше — лучше.

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

3. Параллельные циклы

Технология

Время

Гц

Boost.Fiber protected

5.096s

1962323.390Hz

Boost.Fiber pooled

5.077s

1969667.126Hz

FutoIn AsyncSteps

5.361s

1865323.633Hz

FutoIn AsyncSteps no mempool

8.288s

1206563.706Hz

FutoIn NitroSteps

3.68s

2717391.304Hz

Больше — лучше.

Fiber начинает выигрывать у стандартной реализации AsyncSteps, но значительно проигрывает NitroSteps. Убирая оверхед создания, мы видим что в голом переключении потоков Boost.

Технология

Память

Boost.Fiber protected

124M

Boost.Fiber pooled

505M

FutoIn AsyncSteps

124M

FutoIn AsyncSteps no mempool

84M

FutoIn NitroSteps

115M

Меньше — лучше.

Fiber нечем гордиться. И снова, Boost.

Бонус: тесты на Node.js

итераций. Всего два теста так же из-за ограниченности Promise: создание+выполнение и циклы по 10 тыс. Берутся средние значения в Гц второго прохода после оптимизирующего JIT при NODE_ENV=production, используя пакет @futoin/optihelp. Каждый тест 10 секунд.

Используются версии Node.js v8. Исходный код так же на GitHub и GitLab. 0 и v10. 12. 0, установленные через FutoIn CID. 11.

Tech

Simple

Loop

Node.js v10

FutoIn AsyncSteps

1342899.520Hz

587.777Hz

async/await

524983.234Hz

630.863Hz

Node.js v8

FutoIn AsyncSteps

682420.735Hz

588.336Hz

async/await

365050.395Hz

400.575Hz

Больше — лучше.

Да, но в V8 для Node.js v10 подтянули оптимизацию циклов и стало чуть лучше. Внезапный разгром async/await?

Бесконечный цикл без ожидания внешнего события попросту повесит процесс (пруф), но с FutoIn AsyncSteps такого не случается. Стоит добавить, что реализация Promise и async/await блокирует Node.js Event Loop.

Периодический выход из AsyncSteps в Node.js Event Loop и есть причина ложной победы async/await в тесте-цикле на Node.js v10.

Для приближения, результаты тестирования циклов Node.js нужно умножить на 10 тыс. Оговорюсь, что сравнивать показатели производительности с С++ будет некорректно — разная реализация методологии тестирования.

Выводы

Fiber показывают схожую длительную производительность и потребление памяти, а вот на запуске Boost. На примере C++, FutoIn AsyncSteps и 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.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Проверьте также

Закрыть
Кнопка «Наверх»
Закрыть