Вкратце:


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





Взгляд назад


На заре ЭВМ был единый поток управления 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 присутствуют свои сопрограммы, но если внимательно посмотреть статьи и баг репорты открытых проектов, то выясняется что и здесь не всё так гладко. Возможно, потеря интерфейса сопрограммы как управляемой сущности не лучшая идея.


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


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


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


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

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


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


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


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


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


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


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


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


struct SomeObject {
    using Value = std::vector<int>;

    Promise funcPromise() {
        return Promise.resolved(value_);
    }

    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);
    });
}

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


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


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


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


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


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


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


Есть ещё и третий момент — многие реализации планировщиков сопрограмм позволяют выполнять их на разных ядрах процессора, что наоборот добавляет проблем из-за обязательной синхронизации при доступе к общим ресурсам. В случае же единого потока 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, выстраивается абстрактное дерево синхронных шагов и выполняется проходом в глубину в последовательности создания. Шаги каждого более глубокого уровня динамично задаются по мере выполнения такого прохода.


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


В каноничных реализациях, каждый шаг получает свой экземпляр объекта, реализующего 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() в традиционном программировании.


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


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


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


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





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


К цифрам


Для тестов используется 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<> — альтернативная реализация со статическим выделением всех буферов во время создания объекта.
    • конкретные параметры лимитов задаются в виде продвинутых параметров шаблона.

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


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

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


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


Исходный код тестов доступен на 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


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


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


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


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


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


Использование памяти (по RSS)


Технология Память
Boost.Fiber protected 124M
Boost.Fiber pooled 505M
FutoIn AsyncSteps 124M
FutoIn AsyncSteps no mempool 84M
FutoIn NitroSteps 115M


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


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


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


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


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


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



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


Внезапный разгром 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.

Комментарии (57)


  1. NeoCode
    25.09.2018 19:13

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


    1. andvgal Автор
      25.09.2018 19:19

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


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


  1. mayorovp
    25.09.2018 19:23

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

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


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

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


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

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


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

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


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


    1. andvgal Автор
      25.09.2018 19:46

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

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


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

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


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

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


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

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


  1. vanxant
    25.09.2018 19:40
    +1

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


    1. andvgal Автор
      25.09.2018 19:48

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


    1. Fedcomp
      26.09.2018 00:49

      Читая такие ужасы понимаю почему так любят асинхронщину в rust на tokio, где оно не только асинхронно но и выполняется сразу в многопоточном режиме.


    1. eao197
      26.09.2018 08:54
      +1

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

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

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

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


    1. Goldseeker
      26.09.2018 11:27

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


      1. 0xd34df00d
        26.09.2018 19:49
        +1

        Я вообще ничего не понял, чем оно лучше и за счёт чего именно выигрывает у тех же буст.фиберов.

        В какой-то момент оно, конечно, стало напоминать, скажем, dataflow parallelism, когда там asi и это всё, но как-то очень быстро всё превратилось в месиво.


  1. ijsgaus
    25.09.2018 20:39
    +2

    Честно говоря, опять велосипед. Идеи и мысли верные, но проработка… Предлагаю автору почитать вот это www.cambridge.org/us/academic/subjects/computer-science/distributed-networked-and-mobile-computing/concurrent-programming-ml


    1. andvgal Автор
      25.09.2018 20:52

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


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


      1. ijsgaus
        25.09.2018 21:16
        +1

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


        1. andvgal Автор
          25.09.2018 23:19

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


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


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


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


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


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


  1. vamireh
    25.09.2018 23:30
    +1

    Тестирование Boost.Fiber не может считаться полным без segmented stack — он обеспечит и гораздо меньшее потребление памяти, и быстрый старт.

    Принципиально, интерфейс FutoIn AsyncSteps проще, понятнее и более функционален чем любые из конкурентов без «сахара» async/await

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


    1. andvgal Автор
      25.09.2018 23:36

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


      1. vamireh
        25.09.2018 23:43

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

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

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

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


        1. andvgal Автор
          26.09.2018 01:01

          Вот и создавайте fiber на запрос и обрабатывайте его от и до.

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


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


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


          1. vamireh
            26.09.2018 02:06

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

            Озвученные вами ограничения снимаются при 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.


            1. andvgal Автор
              26.09.2018 08:34

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


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


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


              1. vamireh
                26.09.2018 08:57
                +2

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

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

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

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

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

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

                Да? Так скоро? Ну, ладно, бывайте.


                1. andvgal Автор
                  26.09.2018 09:10

                  … а статья про передачу callback'ов друг другу.

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


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

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


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


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

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


                  1. vamireh
                    26.09.2018 09:38

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

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

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

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

                    Возможно, пока не существует сертифицированной реализации (хотя за весь мир говорить не могу), но вы уморились так уверенно, что я убеждён: вам не составит труда процитировать мне пункт(ы) с запретом из КТ-178.


  1. vamireh
    25.09.2018 23:42

    Промазал, удалено.


  1. Antervis
    26.09.2018 01:23

    допустим, такой подход действительно оптимален по утилизации ресурсов. Но библиотека точно не даст забыть о том, что вы её используете — посудите сами, 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 больно тяжело читать


    1. andvgal Автор
      26.09.2018 08:23

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


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


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


  1. eao197
    26.09.2018 08:46

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

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

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

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


    1. andvgal Автор
      26.09.2018 09:01
      -1

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


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


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


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



      1. eao197
        26.09.2018 10:06
        +1

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


        1. powerman
          26.09.2018 12:27
          +1

          Лет 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), на мой взгляд не совсем корректны и не настолько критичны, как это кажется автору.


          1. eao197
            26.09.2018 12:39
            +1

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

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

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


        1. andvgal Автор
          26.09.2018 13:26

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

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


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

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


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

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


          1. eao197
            26.09.2018 14:00

            Она логическая в первую очередь
            А можно пример? Кроме ограничения количества запросов к внешней системе.
            Например, ограничить количество активных запросов к внешней системе.
            Т.е. если у вас какой-то коллбэк «повиснет» на обращении к экземпляру 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 и сохранение этого результата куда-то;
            • третий шаг складывает результаты двух первых шагов.

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


            1. andvgal Автор
              26.09.2018 15:13

              А можно пример? Кроме ограничения количества запросов к внешней системе.

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


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

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


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

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


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

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


              1. eao197
                26.09.2018 15:34

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

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

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

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

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

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


                1. andvgal Автор
                  26.09.2018 16:05

                  Посему вопрос «Зачем здесь 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 и если завершают запрос более чем одним путём.


                  1. eao197
                    26.09.2018 16:21

                    Вы не учитываете, что этот самый захвативший поток может прерваться на внешнем вызове.
                    Вы хотите сказать, что показанный в ваших примерах 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.


                    1. andvgal Автор
                      26.09.2018 16:56

                      Не очень понял первый вопрос. Объекты с интерфейсом 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();
                          });
                      };


                      1. eao197
                        26.09.2018 17:05
                        +1

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


                        1. andvgal Автор
                          26.09.2018 17:17
                          -1

                          И кто в мире C++ будет иметь такой транслятор?

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


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


                          1. mayorovp
                            26.09.2018 17:19

                            Универсальная модель, которая медленно но верно шагает из языка в язык уже есть, и это async/await.


                          1. eao197
                            26.09.2018 17:58

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


                            1. andvgal Автор
                              26.09.2018 18:20

                              Если мои объяснения не устраивают, попробуйте спросить у авторов 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 очевидно, что это не какое-то бездумное копирование из альтернативных решений.


                              1. eao197
                                26.09.2018 18:29

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

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


                                1. andvgal Автор
                                  26.09.2018 18:49

                                  А вот зачем нужен 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 так, чтобы другой "таск" не смог изменить состояние этого ресурса. Речь о логической гонке, а не хардверной.


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


                                  1. eao197
                                    26.09.2018 20:01

                                    У вас ведь есть понимание что такое 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-ов у вас есть?
                                    Если вам всё ещё не понятно, то вы очевидно обыкновенный тролль, уважаемый.
                                    Есть еще тот вариант, что вы недостаточно хорошо объясняете.


                                    1. andvgal Автор
                                      26.09.2018 20:20

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


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


        1. DistortNeo
          26.09.2018 14:36

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


  1. picul
    26.09.2018 12:13

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


    1. powerman
      26.09.2018 12:35
      +2

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


      1. picul
        26.09.2018 13:08

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


        1. powerman
          26.09.2018 20:46
          +1

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


          1. mayorovp
            27.09.2018 12:39

            В .NET таком образом можно выгружать и перезапускать AppDomain (что из коробки умеет делать ASP.NET).


  1. DistortNeo
    26.09.2018 14:30

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


    1. andvgal Автор
      26.09.2018 14:45

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


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


      1. gorodnev
        26.09.2018 20:55
        +1

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


        1. andvgal Автор
          26.09.2018 21:42

          Самое простое, что можно потрогать — это 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'ов.