В этой статье, я хочу рассказать про свое решение проблемы с асинхронной функциональностью 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)
Staltec
22.01.2018 16:47+7Промайс… Кхм… translate.google.ru/?hl=ru#en/ru/promise нажмите на кнопочку с динамиком и послушайте как на самом деле звучит это слово.
pred8or
22.01.2018 16:56+1После стартового «по средствам» был вознаграждён «промайсами». Даже если дальше написано что-то гениальное, я об этом не узнаю
token
22.01.2018 16:49+1И вот еще, вот про это слышали? caolan.github.io/async решает множество проблем как с читаемостью кода, пониманием, лапшой и т. д. позволяя запускать синхронные и асинхронные задачи в любых мыслимых комбинациях.
dmitry_luzanov
22.01.2018 18:14+1В последних стандартах, появились свои промайсы, а потом и async/await, что вкупе позволяет свести вычислительный процесс к полностью синхронной модели. И казалось бы, что проблема решена, но у меня есть ряд претензий к данному решению:
- реализация далека от «элегантности»
- на данный момент медленно работает
- плодит плохо читаемый код
- плохо подходит для организации «массивной» параллельности (без кучи плохо читаемого кода)
Можете хоть один пункт аргументировать? Или тег «юмор» добавьте.
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.
norlin
22.01.2018 22:25+1Давайте забудем про async/await
Что то вроде функции, только асинхроннойА давайте не будем забывать про нативную фичу языка, которую сделали для работы с асинронностью и которая именно что реализует "функции, только асинхронные"?
token
Иными словами вы просто создали свою собственную реализацию стэка вызовов? И для чего?