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


Введение


Начнем с главного — синхронно или асинхронно? Когда имеется синхронный процесс вычисления и асинхронная функциональность без которой не обойтись, это проблема. Более того, когда асинхронность более выигрышна и вообще best practice то эту проблему надо решать.


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


В последних стандартах, появились свои промайсы, а потом и async/await, что вкупе позволяет свести вычислительный процесс к полностью синхронной модели. И казалось бы, что проблема решена, но у меня есть ряд претензий к данному решению:


  • реализация далека от "элегантности"
  • на данный момент медленно работает
  • плодит плохо читаемый код
  • плохо подходит для организации "массивной" параллельности (без кучи плохо читаемого кода)

Критикуешь — предлагай


Давайте забудем про async/await, забудем про промайсы, как это должно выглядеть? Удобно, единообразно, с минимум дополнительного кода? Что то вроде функции, только асинхронной:


  • что бы определение было как у синхронной
  • что бы принцип работы был как у синхронной
  • что бы работала внутри синхронного вычислительного процесса

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


  • Первое что нужно сделать, для чего-то такого, это введение собственного стека вызовов, но не простого, а древовидного! Классический стек вызовов не годится для массивной параллельности.
  • Второе, это сделать так. что бы вершина любой ветви всегда была в работе, что бы поток исполнения гарантированно мог на нее вернутся в случае ухода.
  • Третье, это отказ от синхронного возврата функции, и замена на асинхронную альтернативу, при которой можно дожидаться чего либо, прежде чем вернуть результат. То есть нужен возврат по команде, а не по безальтернативному порядку исполнения, неудержимо стремящимуся к завершению.
  • Четвертое, это отойти от синхронного вычислительного процесса, внутри синхронной функции, что бы синхронные вызовы данной функции, просто "прокачивали" асинхронный процесс.
  • Пятое и последнее, это пара функций оберток для асинхронного вызова/возврата, которые все что надо разложат куда надо. Пусть они называются call и back.

Вперед в дебри!


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


function A ()
{

}

Начнем с последнего


Пусть call:


  • имеет параметры: текущая вершина, имя функции для вызова, аргументы для этой функции
  • создает новую вершину, для данной ветви, оставляя ссылку на текущую
  • вызывает указанную функцию с указанными аргументами

Пусть back:


  • имеет параметры: текущая вершина, результат
  • удаляет текущую вершину, для данной ветви, переходит по ссылке на предыдущую
  • вызывает функцию, указанную для предыдущей вершины, с результатом, возвращенным функцией текущей вершины.

Теперь введем дерево вызовов


Тут выполняется первых 2 пункта:


  • пусть будет некая начальная вершина, корень дерева, и все вызовы начинаются от нее
  • пусть вершина передается в качестве аргумента вызываемой функции.
  • пусть вершина будет единственным параметром вызываемой функции, и все необходимое, в том числе текущие аргументы данной функции хранятся в ней.
  • при вызове, вершина данной ветви захватывается контекстом исполнения функции
  • при последующем вызове вершина становится узлом ветви, новая вершина захватывается контекстом новой вызванной функции
  • при асинхронной функциональности, например ajax, вершина сохраняется в замыкании
  • в общем и целом: текущий вызов -> дальнейший вызов | ожидание в замыкании | возврат

function A ( top )
{

}

Синхронный возврат нам больше не друг


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


Еще не все, не все так просто


Из вышеописанного становится понятно что вызовов конкретной синхронной функции, превращенной в асинхронную, будет не один а несколько:


  • первый вызов — собственно, классический вызов функции, смотрите описание call
  • второй и последующие — для возврата из асинхронного подвызова, смотрите описание back

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


  • пусть в top будет введено поле mark
  • пусть call ставить mark равной #
  • пусть back ставит mark равной имени функции из текущей вершины (которая удаляется)
  • пусть switch осуществляет направление потока исполнения
  • пусть данные, которые должны быть доступны все время, до асинхронного возврата (back), записываются прямо в top.x = 1;

Смотрим
function A ( top )
{
    switch ( top.mark )
    {
        case '#':

            break;

        case 'B':

            break;

        case 'C':

            break;
    }
}

Собираем все в месте:


  • пусть call ставит переданные аргументы в top.arg
  • пусть back ставит переданный результат в top.ret

Смотрим
function A ( top )
{
    switch ( top.mark )
    {
        case '#':
            call(top, 'B', 'some_data');
            break;

        case 'B':
            call(top, 'C', top.ret + 1);
            break;

        case 'C':
            back(top, {x: top.ret});
            break;
    }
}

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


  • пусть в top будет введено поле size
  • пусть call увеличивает size текущей вершины на единицу
  • пусть back уменьшает size предыдущей вершины на единицу (текущая уничтожается)

Смотрим
function A ( top )
{
    switch ( top.mark )
    {
        case '#':
            top.buf = [];
            call(top, 'B', 'some_data1');
            call(top, 'B', 'some_data2');
            call(top, 'B', 'some_data3');
            break;

        case 'B':
            top.buf.push(top.ret);

            if ( !top.size )
            {
                call(top, 'C', top.buf);
            }
            break;

        case 'C':
            back(top, top.ret);
            break;
    }
}

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


  • пусть в top будет введено новое поле group
  • пусть mark будет заменено на group['#name']
  • пусть size будет заменено на group['#size']
  • пусть в call будет введен новый параметр groupMark
  • пусть call увеличивает top.group[groupMark] текущей вершины на единицу
  • пусть back уменьшает top.group[groupMark] предыдущей вершины на единицу (текущая уничтожается)
  • пусть call и back управляют значением специальных имен top.group['#name'] и top.group['#size'] содержащих имя и размер текущей группы

Смотрим
function A ( top )
{
    switch ( top.group['#name'] )
    {
        case '#':
            top.buf = [];
            call(top, '#group1', 'B1', 'some_data1');
            call(top, '#group1', 'B2', 'some_data2');
            call(top, '#group1', 'B'3, 'some_data3');
            break;

        case '#group1':
            top.buf.push(top.ret);

            if ( !top.size )
            {
                call(top, '#group2', 'C', top.buf);
            }
            break;

        case '#group2':
            back(top, top.ret);
            break;
    }
}

Добавим еще возможность дождаться окончания нескольких запущенных групп, и на этом все :)


  • пусть top.group['##size'] будет содержать сумму всех top.group['#size']
  • пусть будет введено top.group['##name'] содержащая имя группы с которой была вызвана данная функция (имя группы возврата)

Смотрим
function A ( top )
{
    switch ( top.group['#name'] )
    {
        case '#':
            top.listB = [];
            top.listC = [];

            call(top, '#group1', 'B1', 'some_data1');
            call(top, '#group1', 'B1', 'some_data1');
            call(top, '#group1', 'B2', 'some_data2');

            call(top, '#group2', 'С1', 'some_data1');
            call(top, '#group2', 'С1', 'some_data1');
            call(top, '#group2', 'С2', 'some_data2');
            break;

        case '#group1':
            if (top.ret) {top.listB.push(top.ret);}

            if ( !top.group['##size'] )
            {
                back(top, {B: top.listB, C: top.listC});
            }
            break;

        case '#group2':
            if (top.ret) {top.listC.push(top.ret);}

            if ( !top.group['##size'] )
            {
                back(top, {B: top.listB, C: top.listC});
            }
            break;
    }
}

Итог


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


  • сохраняется простота и упорядоченность кода
  • гарантируется единообразность
  • скорость работы куда выше чем у async/await, на данный момент (зависит от реализации)
  • широкие возможности для организации параллельных вычислений

Мной разработана довольно удачная реализация данной концепции, с упором на параллельные вычисления. Ключевая особенность — поддержка потоков (WebWorker) и возможность асинхронного вызова функций, в каком бы потоке они не находились.

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


  1. token
    22.01.2018 16:38

    Иными словами вы просто создали свою собственную реализацию стэка вызовов? И для чего?


  1. Staltec
    22.01.2018 16:47
    +7

    Промайс… Кхм… translate.google.ru/?hl=ru#en/ru/promise нажмите на кнопочку с динамиком и послушайте как на самом деле звучит это слово.


    1. pred8or
      22.01.2018 16:56
      +1

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


    1. dom1n1k
      22.01.2018 18:01
      +2

      Это из одной категории с «раби» © знатоки англо-саксонской мовы :)


  1. token
    22.01.2018 16:49
    +1

    И вот еще, вот про это слышали? caolan.github.io/async решает множество проблем как с читаемостью кода, пониманием, лапшой и т. д. позволяя запускать синхронные и асинхронные задачи в любых мыслимых комбинациях.


  1. dmitry_luzanov
    22.01.2018 18:14
    +1

    В последних стандартах, появились свои промайсы, а потом и async/await, что вкупе позволяет свести вычислительный процесс к полностью синхронной модели. И казалось бы, что проблема решена, но у меня есть ряд претензий к данному решению:

    • реализация далека от «элегантности»
    • на данный момент медленно работает
    • плодит плохо читаемый код
    • плохо подходит для организации «массивной» параллельности (без кучи плохо читаемого кода)


    Можете хоть один пункт аргументировать? Или тег «юмор» добавьте.


  1. justboris
    22.01.2018 20:32

    Вы не поверите, но при трансляции генераторов и асинхронных функций Babel генерирует примерно такой же код, как и у вас в статье:


    regeneratorRuntime.wrap(function _callee$(_context) {
      while (1) {
        switch (_context.prev = _context.next) {
          case 0:
            _context.next = 2;
            return fetch();
    
          case 2:
            result = _context.sent;
            _context.next = 5;
            return result.json();
    
          case 5:
            data = _context.sent;
            return _context.abrupt("return", data);
    
          case 7:
          case "end":
            return _context.stop();
        }
      }
    }, _callee, this);

    Очень странно, что набор switch/case, которые исполняются в неопределенном порядке, кажется вам более читаемым, чем исходник с async/await.


  1. norlin
    22.01.2018 22:25
    +1

    Давайте забудем про async/await
    Что то вроде функции, только асинхронной

    А давайте не будем забывать про нативную фичу языка, которую сделали для работы с асинронностью и которая именно что реализует "функции, только асинхронные"?