Компания Reaktor поделилась в своём блоге принципами и примерами оптимизации JavaScript-кода, применёнными в библиотеке промисов Bluebird, созданной их сотрудником Petka Antonov (Петкой Антоновым).


Bluebird — популярная JavaScript-библиотека промисов. Впервые её заметили в 2013 году, когда оказалось, что она может превосходить по скорости другие реализации промисов с похожими наборами свойств до 100 раз. Bluebird столь быстра благодаря последовательному применению некоторых основополагающих принципов оптимизации в JavaScript. В этой статье детально рассмотрены 3 наиболее важных принципа, использующихся для оптимизации Bluebird.


1. Минимизируйте создание функций


Создание объектов и, в частности, создание объектов функций (примечание переводчика: любая функция — объект) может быть очень накладно в плане производительности, поскольку требует задействования большого количества внутренних данных. Практические реализации JavaScript содержат сборщики мусора, а значит, созданные объекты не просто так сидят в памяти — сборщик мусора постоянно ищет неиспользуемые объекты, чтобы освободить занимаемую ими память. Чем больше памяти вы используете в JavaScript, тем больше ЦПУ занимает сборка мусора и меньше остаётся для работы самого кода. В JavaScript функции являются объектами первого класса. Это означает, что они имеют те же особенности и свойства, что и любые другие объекты. Если у вас есть функция, содержащая объявление другой функции (или функций), то при каждом вызове изначальной функции будут создаваться новые уникальные функции, делающие одно и то же. Рассмотрим простой пример:


function trim(string) {
    function trimStart(string) {
        return string.replace(/^\s+/g, "");
    }

    function trimEnd(string) {
        return string.replace(/\s+$/g, "");
    }

    return trimEnd(trimStart(string))
}

Всякий раз, когда вызывается trim, создаются два объекта функций, представляющие trimStart и trimEnd. Но они не нужны, поскольку в них не используется ни уникальное поведение объектов, вроде присвоения свойств, ни замыкание над переменными. Единственное, зачем они используются — для функциональности содержащегося в них кода.


Этот пример легко оптимизировать — нужно лишь вынести функции из trim. Поскольку пример содержится в модуле, а модуль загружается в программе один раз, то для функций существует лишь одно представление:


function trimStart(string) {
    return string.replace(/^\s+/g, "");
}

function trimEnd(string) {
    return string.replace(/\s+$/g, "");
}

function trim(string) {
    return trimEnd(trimStart(string))
}

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


var fs = require('fs');

function readFileAsJson(fileName, callback) {
    fs.readFile(fileName, 'utf8', function(error, result) {
        // Новый объект функции создаётся при каждом вызове readFileAsJson.
        // Поскольку это замыкание, создаётся внутренний объект Context 
        // для состояния замыкания.
        if (error) {
            return callback(error);
        }
        // Блок try-catch необходим для обработки 
        // возможной синтаксической ошибки из-за невалидного JSON
        try {
            var json = JSON.parse(result);
            callback(null, json);
        } catch (e) {
            callback(e);
        }
    })
}

В этом примере колбэк, передаваемый в fs.readFile, нельзя вынести из readFileAsJson, поскольку он создаёт замыкание вокруг уникальной переменной callback. Следует заметить, что попытка вынести анонимный колбэк в именованную функцию ни к чему не приведёт.


Оптимизация, постоянно используемая внутри Bluebird — использование явного простого объекта для удержания контекстных данных. Для того, чтобы передать колбэк через множество уровней, понадобится выделить память лишь для одного такого объекта. Вместо того, чтобы на каждом уровне создавать новое замыкание, когда колбэк передаётся на следующий уровень, мы будем передавать явный простой объект дополнительным аргументом. Например, если в изначальной функции есть 5 уровней — значит, при использовании замыканий будет создано 5 функций и Context-объектов вместе с ними. В случае же с данной оптимизацией для этих целей будет создан всего лишь один объект.


Если бы можно было изменить fs.readFile, чтобы передавать туда объект контекста, оптимизацию можно было бы применить вот так:


var fs = require('fs-modified');

function internalReadFileCallback(error, result) {
    // Модифицированный readFile вызывает callback при помощи объекта контекста, 
    // установленного в `this`, 
    // что на самом деле является изначально переданным колбэком
    if (error) {
        return this(error);
    }
    // Блок try-catch необходим для обработки 
    // возможной синтаксической ошибки из-за невалидного JSON
    try {
        var json = JSON.parse(result);
        this(null, json);
    } catch (e) {
        this(e);
    }
}

function readFileAsJson(fileName, callback) {
    // Модифицированная fs.readFile принимает объект контекста четвертым аргументом.
    // Нет необходимости создавать отдельный объект, чтобы помещать туда callback,
    // поэтому он может быть передан напрямую в качестве контекста

    fs.readFile(fileName, 'utf8', internalReadFileCallback, callback);
}

Разумеется, вы должны контролировать обе части API — без поддержки параметра контекста такая оптимизация неприменима. Однако там, где это используется (например, когда вы контролируете множество внутренних уровней), выигрыш в производительности значителен. Малоизвестный факт: некоторые встроенные JavaScript Array API, например Array.prototype.forEach, принимают вторым параметром объект контекста.


2. Минимизируйте размер объекта


Критически важно минимизировать размеры часто создаваемых объектов и объектов, создаваемых в больших количествах, таких, как промисы. Куча, в которой создаются объекты в большинстве реализаций JavaScript, разделена на занятые и свободные участки. Объекты меньших размеров дольше заполняют свободное пространство, чем крупные, как следствие, оставляя сборщику мусора меньше работы. Также небольшие объекты обычно содержат меньше полей, поэтому сборщику мусора проще обходить их, помечая живые и мёртвые объекты.


Булевы и/или ограниченные числовые поля сжимаются намного сильнее при помощи побитовых операций. Побитовые операции в JavaScript работают с 32-битными числами. В одном поле вы можете разместить 32 булевых поля, или 8 4-битных числа, или 16 булевых и 2 8-битных числа и т. п. Чтобы код оставался читабельным, каждое логическое поле должно иметь геттер и сеттер, производящие нужные побитовые операции на физическом значении. Вот пример, как можно сжать одно булево поле в число (которое в дальнейшем может быть расширено для других логических полей):


// Используйте 1 << 1 для второго бита, 1 << 2 для третьего и т.д.
const READONLY = 1 << 0;

class File {
    constructor() {
        this._bitField = 0;
    }

    isReadOnly() {
        // Скобки обязательны.
        return (this._bitField & READONLY) !== 0;
    }

    setReadOnly() {
        this._bitField = this._bitField | READONLY;
    }

    unsetReadOnly() {
        this._bitField = this._bitField & (~READONLY);
    }
}

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


Примечание переводчика: базовые сведения о работе JavaScript-компилятора, понятия об инлайн-кэшировании и встраивании функций — в статье Прошлое и будущее компиляции JavaScript. О работе оптимизатора — Optimization killers (временами обновляемый оригинал) Петки Антонова и перевод Убийцы оптимизации (опубликован в 2014).


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


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


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


class EventEmitter {
    constructor() {
        this.listeners = [];
    }

    addListener(fn) {
        this.listeners.push(fn);
    }
}

Вы можете избежать массива:


class EventEmitter {
    constructor() {
        this.length = 0;
    }

    addListener(fn) {
        var index = this.length;
        this.length++;
        this[index] = fn;
    }
}

Если поле .length можно ограничить малым числом (например, 10-битным, т.е. event emitter сможет иметь максимум 1024 слушателей), его можно сделать частью побитового поля, содержащего другие ограниченные числа и булевы значения.


3. Используйте no-op функции. Перезаписывайте их лениво для реализации дорогостоящих опциональных функций


Bluebird содержит несколько опциональных функций, вызывающих равномерную потерю производительности всей библиотеки при использовании. Это ворнинги, трассировки стека, возможность отмены, Promise.prototype.bind, мониторинг состояний промисов и т.п. Эти функции требуют вызовов функций-перехватчиков по всей библиотеке. Например, функция мониторинга промисов требует вызова перехватчика каждый раз при создании промиса.


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


class Promise {

    // ...

    constructor(executor) {
        // ...
        this._promiseCreatedHook();
    }

    // Просто пустой no-op метод.
    _promiseCreatedHook() {}

}

Теперь, если пользователь не включил функцию мониторинга, оптимизатор видит, что перехватчик ничего не делает, и упрощает его. Получается, что метод-перехватчик в конструкторе попросту не существует.


Чтобы функция мониторинга работала, её включение должно перезаписывать все связанные no-op функции на их реальную имплементацию:


function enableMonitoringFeature() {
    Promise.prototype._promiseCreatedHook = function() {
        // Реальная имплементация
    };

    // ...
}

Такая перезапись метода аннулирует инлайн-кэши, созданные для объектов класса Promise. Это следует сделать при старте приложения, перед тем, как будут созданы любые промисы. Таким образом инлайн-кэши, сделанные после этого, не будут знать, что существовали no-op функции.




Оригинал: Three JavaScript performance fundamentals that make Bluebird fast, автор: Petka Antonov.


Перевод: aalexeev, редактура: jabher.

Поделиться с друзьями
-->

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


  1. swpo
    13.09.2016 15:19
    +4

    Тот момент, когда опять придется переписывать свой код.


  1. ReklatsMasters
    13.09.2016 19:40

    Одна из "фич" V8, которые использует Bluebird это sindresorhus/to-fast-properties (код просто вынесен в отдельную либу, но автор Petka Antonov). Этот метод позволяет избежать деоптимизаций при использовании delete (конечно, его лучше не юзать, но если очень хочется, то можно). Даже была статья с объяснением как это работает.


  1. Shannon
    15.09.2016 01:30
    -1

    По поводу 1 пункта, очень часто вложенные функции из функции не получится вынести (хотя бы даже чтобы глобал скоп не засорять кучей ненужных функций)
    Можно сделать проще и воспользоваться тем фактом, что функция это объект, у объекта могут быть «приватные методы» и собственно и вернуть экземпляр объекта с уже созданными единожды функциями trimStart и trimEnd. Эффект будет тот же:

    var trim = function() {
    	function trimStart(string) {
    		return string.replace(/^\s+/g, "");
    	}
    	function trimEnd(string) {
    		return string.replace(/\s+$/g, "");
    	}
    	return function (string) {
    		return trimEnd(trimStart(string))
    	}
    }();
    


    Оригинал:
    trim1: 138.231ms

    Вынесли функции:
    trim2: 102.128ms

    Вернули объект:
    trim3: 101.680ms

    В firefox кстати результат для всех 3 случаев примерно ± одинаков