Nsynjs – JavaScript-движок с синхронными потоками и без коллбеков

от автора

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

TLDR: nsynjs — это JavaScript-движок, который умеет дожидаться исполнения колбеков и исполнять инструкции последовательно.

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

Nsynjs позволяет писать полностью последовательный код, наподобие такого:

var i=0; while(i<5) {     wait(1000); //  <<-- долгоживущая функция с колбеком внутри     console.log(i, new Date());     i++; } 

или такого

function getStats(userId) {     return { // <<-- выражение, содержащее несколько функций с колбеками             friends: dbQuery("select * from firends where user_id = "+userId).data,             comments: dbQuery("select * from comments where user_id = "+userId).data,             likes: dbQuery("select * from likes where user_id = "+userId).data,     }; }

Nsynjs поддерживает большинство конструкций ECMAScript 2015, включая циклы, условные операторы, исключения, блоки try-catch, замыкания (правильнее было бы перевести как «контекстные переменные»), и т.п.

По-сравнению с Babel он:

  • все ещё легче (81кб без минимизации),
  • не имеет зависимостей,
  • не требует компиляции,
  • исполняется значительно быстрее,
  • позволяет запускать и останавливать долгоживущие потоки.

Для иллюстрации разберем небольшой пример веб-приложения, которое:

  1. Получает список файлов через ajax-запрос
  2. Для каждого файла из списка:
  3. Получает файл через ajax-запрос
  4. Пишет содержимое файла на страницу
  5. Ждет 1 сек

Синхронный псевдокод для этого приложения выглядел бы так (забегая вперёд, реальный код почти такой же):

var data = ajaxGetJson("data/index.json"); for(var i in data) {     var el = ajaxGetJson("data/"+data[i]);     progressDiv.append("<div>"+el+"</div>");     wait(1000); };

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

Функция-обёртка обычно должна сделать следующее:

  • принять указатель на состояние вызывающего потока в качестве параметра (например ctx)
  • вызвать обёртываемую функцию с колбеком
  • вернуть объект в качестве параметра оператора return, результат колбека присвоить какому-либо свойству этого объекта
  • в колбеке вызвать ctx.resume() (если колбеков несколько, то выбрать самый последний)
  • установить функцию-деструктор, которая будет вызвана в случае прерывания потока.

Для всех функций-обёрток свойство ‘synjsHasCallback’ должно быть установлено в true.

Создадим простейшую обёртку для setTimeout. Так как мы не получаем никакие данные из этой функции, то оператор return здесь не нужен. В итоге получится такой код:

var wait = function (ctx, ms) {     setTimeout(function () {         console.log('firing timeout');         ctx.resume(); // <<-- продолжить исполнение вызывающего потока     }, ms); }; wait.synjsHasCallback = true; // <<-- указывает движку nsynjs, что эта функция-обёртка с колбеком внутри 

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

Обёртка тогда получится такой:

var wait = function (ctx, ms) {     var timeoutId = setTimeout(function () {         console.log('firing timeout');         ctx.resume();     }, ms);     ctx.setDestructor(function () {          console.log('clear timeout');         clearTimeout(timeoutId);     }); }; wait.synjsHasCallback = true;

Также нам понадобится обёртка над функцией getJSON библиотеки jQuery. В простейшем случае она будет иметь такой вид:

var ajaxGetJson = function (ctx,url) {     var res = {};     $.getJSON(url, function (data) {         res.data = data;         ctx.resume();     });     return res; }; ajaxGetJson.synjsHasCallback = true; 

Этот код будет работать только если getJSON успешно получила данные. При ошибке ctx.resume() вызван не будет, и вызывающий поток никогда не возобновится. Чтобы обработать ошибки, код необходимо модифицировать код так:

var ajaxGetJson = function (ctx,url) {     var res = {};     var ex;     $.getJSON(url, function (data) {         res.data = data; // <<-- в случае успеха, сохранить данные     })     .fail(function(e) {         ex = e;	// <<-- в случае ошибки, сохранить её     })     .always(function() {         ctx.resume(ex); // <<-- продолжить вызывающий поток в любом случае,                         //         вызвать в нём исключение если была ошибка     });     return res; }; ajaxGetJson.synjsHasCallback = true; 

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

var ajaxGetJson = function (ctx,url) {     var res = {};     var ex;     var ajax = $.getJSON(url, function (data) {         res.data = data; // <<-- в случае успеха, сохранить данные     })     .fail(function(e) {         ex = e;	// <<-- в случае ошибки, сохранить её     })     .always(function() {         ctx.resume(ex); // <<-- продолжить вызывающий поток в любом случае,                         //         вызвать в нём исключение если была ошибка     });     ctx.setDestructor(function () {          ajax.abort();     });     return res; };  

Когда обёртки готовы, мы можем написать саму логику приложения:

function process() {     var log = $('#log');     log.append("<div>Started...</div>");  	// внутри синхронного кода нам доступна переменная  synjsCtx, в которой 	// содержится указатель на контекст текущего потока     var data = ajaxGetJson(synjsCtx, "data/index.json").data;     log.append("<div>Length: "+data.length+"</div>");     for(var i in data) {         log.append("<div>"+i+", "+data[i]+"</div>");         var el = ajaxGetJson(synjsCtx, "data/"+data[i]);         log.append("<div>"+el.data.descr+","+"</div>");         wait(synjsCtx,1000);     }     log.append('Done'); } 

Так как функция ajaxGetJson может в некоторых случая выбрасывать исключение, то имеет смысл заключить ее в блок try-catch:

function process() {     var log = $('#log');     log.append("<div>Started...</div>");     var data = ajaxGetJson(synjsCtx, "data/index.json").data;     log.append("<div>Length: "+data.length+"</div>");     for(var i in data) {         log.append("<div>"+i+", "+data[i]+"</div>");         try {             var el = ajaxGetJson(synjsCtx, "data/"+data[i]);             log.append("<div>"+el.data.descr+","+"</div>");         }         catch (ex) {             log.append("<div>Error: "+ex.statusText+"</div>");         }         wait(synjsCtx,1000);     }     log.append('Done'); } 

Последний шаг — это вызов нашей синхронной функции через движок nsynjs:

nsynjs.run(process,{},function () { 	console.log('process() done.'); }); 

nsynjs.run принимает следующие параметры:

var ctx = nsynjs.run(myFunct,obj, param1, param2 [, param3 etc], callback)
  • myFunct: указатель на функцию, которую требуется выполнить в синхронном режиме
  • obj: объект, который будет доступен через this в функции myFunct
  • param1, param2, etc – параметры для myFunct
  • callback: колбек, который будет вызван при завершении myFunct.

Возвращаемое значение:
Контекст состояния потока.

Под капотом

При вызове какой-либо функции через nsynjs, движок проверят наличие и, при необходимости, создает свойство synjsBin у этой функции. В этом свойстве хранится древовидная структура, эквивалентная откомпилированному коду функции. Далее движок создает контекст состояния потока, в котором сохраняются локальные переменные, стеки, программные счетчики и прочая информация, необходимая для остановки/возобновления исполнения. После этого запускается основной цикл, в котором программный счетчик последовательно перебирает элементы synjsBin, и исполняет их, используя контекст состояния в качестве хранилища.

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

  • синхронные
  • обёртки над колбеками
  • нативные.

Тип функции определяется в рантайме путем анализа следующих свойств:

  • если указатель на функцию имеет свойство synjsBin, то функция будет исполнена через nsynjs в синхронном режиме
  • если указатель на функцию имеет свойство synjsHasCallback, то это функция-обёртка, поэтому nsynjs остановит на ней выполнение. Функция-обёртка должна сама в позаботиться о возобновлении вызывающего синхронного потока путем вызова ctx.resume() в колбаке.
  • Все остальные функции считаются нативными, и возвращающими результат немедленно.

Производительность

При парсинге nsynjs-движок пытается анализировать и оптимизировать элементы кода исходной функции. Например, рассмотрим цикл:

for(i=0; i<arr.length; i++) {     res += arr[i]; } 

Этот цикл будет оптимизирован и скомпилирован в одну внутреннюю функцию, которая будет выполняться почти также быстро, как и нативный код:

this.execute = function(state) {     for(state.localVars.i=0; state.localVars.i<arr.length; state.localVars.i++) {         state.localVars.res += state.localVars.arr[state.localVars.i];     } } 

Однако, если в элементе кода встречаются вызовы функций, а также операторы continue, break и return, то оптимизация для них, а также для всех родительских элементов не выполнится.

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

Например оператор

var n = Math.E

будет оптимизирован в одну функцию:

this.execute = function(state,prev, v) {     return state.localVars.n = Math.E }

Если же в операторе имеется вызов функции, то nsynjs не может знать тип вызываемой функции заранее:

var n = Math.random()

Поэтому весь оператор будет выполнен по-шагам:

 this.execute = function(state) {     return Math } .. this.execute = function(state,prev) {     return prev.random } .. this.execute = function(state,prev) {     return prev() } .. this.execute = function(state,prev, v) {     return state.localVars.n = v } 

Ссылки

Репозиторий на GitHub: github.com/amaksr/nsynjs

Примеры: github.com/amaksr/nsynjs/tree/master/examples

Тесты: github.com/amaksr/nsynjs/tree/master/test

NPM: www.npmjs.com/package/nsynjs
ссылка на оригинал статьи https://habrahabr.ru/post/329310/


Комментарии

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

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