javascript

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

  • четверг, 25 мая 2017 г. в 03:15:44
https://habrahabr.ru/post/329310/
  • Node.JS
  • 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
Примеры
Тесты
NPM