Pull to refresh

Реализации setImmediate: сообщения, мутация или обещания, что быстрее?

Reading time 6 min
Views 11K


Доброго времени суток, %username%! Маленькое исследование на тему «какой же способ поставить функцию/метод на обработку в очередь эффективнее» и, как результат, сравнительный тест, и итоговая реализация схожей с setImmediate функции. Этот метод нужен тем, кто хочет разбивать выполнение скрипта, чтобы тот не «подвешивал» браузер, что бывает полезно при огромном скрипте инициализации, разборе большого массива данных, построения сложной структуры не прибегая к WebWorkers.

Для понимания: setImmediate это метод объекта window, который должен вызвать функцию, переданную в неё, асинхронно, эдакий setTimeout(fn, 0), где 0 реально 0, а не минимум 4. Для nodejs-программистов это process.nextTick. Т.к. сам метод (setImmediate) имеет чёткий стандарт с ошибками и дополнительными параметрами, рассмотрим абстрактную задачу асинхронного выполнения переданной функции/метода как можно быстрее.

Исследования исключительно в рамках сценариев браузера, при чём основных, т.к. в работниках (workers) не совсем понятно зачем такое дробление, хотя если нужно, можно попробовать обещания и сообщения.

Итак, давайте узнаем, что же лучше подходит: postMessage, MutationObserver или Promise?

Исследование


Кто-то, возможно, удивится наличию мутаций (MutationObserver) в списке, ведь их настоятельно рекомендуют избегать в продуктовых версиях ПО. Забегая вперёд: врут. Проведём исследование четырёх методов: setTimeout, postMessage, Promise, MutationObserver.

setTimeout


Для начала обзовём наш исследовательский метод nextTick в честь nodejs версии реализации, чтобы не путать с оригинальным setImmediate, т.к. обработку ошибок и разбор дополнительных параметров выкинем к чёрту в рамках исследования. Итак, каким самым простым и кратким способом можно реализовать nextTick? Да через всё тот же setTimeout(fn, 0). (хехе, как сделано тут, то ли от незнания о process.nextTick то ли от старой версии node)

Сформулируем метод в исследовании:

    var nextTick, nextTickTO;

    nextTickTO = function() {
      var call // метод обхода очереди
        , queue // очередь
        , i // указатель на последний непустой элемент
        , fire // индикатор что запущен асинхронный метод
        , nextTick // метод постановки в очередь и пуска механизма
        ;

      i = 0;
      queue = new Array(16); // массив строго заданной длины
      fire = false;

      call = function() { // пройдёмся по очереди?
        var len, s, track; // выделяем длину, указатель и дубликат
        track = queue; // дублируем очередь
        len = i; // т.к. массив предвыделен queue.length вернёт всегда 16
        s = 0; // стартовая позиция
        queue = new Array(16); // сразу выделяем новую очередь
        i = 0; // смещаем указатель
        fire = false;
        while (s < len) {
          track[s++](); // опасно: сломайся внутри что и порушим всё
        }
      };
      nextTick = function(fn) {
        queue[i++] = fn;
        if (!fire) {
          fire = true;
          setTimeout(call, 0);
        }
      };
      return nextTick;
    };
    nextTick = nextTickTO();


Мы создали маленькое замыкание: в нём выделяется память под готовый массив методов (queue сразу определённого размера, чтобы скорость выделения памяти под массивы на разных движках не сказывалась на исследованиях), указатель (i, одновременно индикатор длины массива), метод call который «пройдётся» по массиву, индикатор (fire), что метод асинхронного пуска вызван и сам постановщик в очередь nextTick, который и возвращается.
Ключевой момент: нет обработки ошибок, проверок, в рамках тестов мы «себе» верим. Далее будем опираться на данный шаблон, создавая реализации на основе других асинхронных методиках.

postMessage way


Далее можно найти способ через postMessage (тут или например тут), и он существенно быстрее и можно было бы успокоиться, но всё меняется, когда тебе реально нужен очень быстрый nextTick и с учётом роста рынка мобильных устройств, потребность в оптимизации колоссальная.
Сформулируем тестовый блок:
    nextTickPM = function() {
      var fire, i, nextTick, queue;
      i = 0;
      queue = new Array(16);
      fire = false;
      window.onmessage = function(message) { // вместо call
        var data, len, s, track;
        data = message.data;
        if (data === 'a') { // может что-то посложнее? А зачем?
          track = queue;
          len = i;
          s = 0;
          queue = new Array(16);
          i = 0;
          fire = false;
          while (s < len) {
            track[s++]();
          }
        }
      };
      nextTick = function(fn) {
        queue[i++] = fn;
        if (!fire) {
          fire = true;
          postMessage('a', '*');
        }
      };
      return nextTick;
    };



Чем плох postMessage? Он затрагивает огромную систему передачи сообщения, проверки на доменные имена, проверки на передаваемые значения, постановки в отдельные очереди сообщений. В остальном шусёр бобёр.

Promise way


Далее мой коллега пошёл по пути использования обещаний (Promises), решив, что можно и быстрее, и он оказался прав.
Код:
    nextTickPR = function() {
      var call, fire, i, nextTick, p, queue, s;
      if (typeof Promise === "undefined" || Promise === null) {
        return nextTickMO();
      }
      i = 0;
      r = 0; // счётчик вызова call
      queue = new Array(16);
      fire = false;
      p = Promise.resolve();
      call = function() {
        var len, s, track;
        track = queue;
        len = i;
        s = 0;
        queue = new Array(16);
        i = 0;
        fire = false;
        while (s < len) {
          track[s++]();
        }
        if ((r++) % 10 === 0) { // вот тут момент внимания
          p = Promise.resolve();
        }
      };
      nextTick = function(fn) {
        queue[i++] = fn;
        if (!fire) {
          fire = true;
          p = p.then(call);
        }
      };
      return nextTick;
    };


И метод в его реализации (опущу) оказался вдвое быстрее, чем сообщения, приведя под единый шаблон он оказался быстрее от трёх раз до 10 раз (хром 43)! И тут случилась запинка: при тестировании на 1000-че вызовов старый огнелис стал ругаться на длину рекурсии и пришлось добавить счётчик вызова call (r) и выделение нового Promise.resolve() каждые 10 вызовов. Возможно подойдёт и иное число, но в дальнейшем, как увидим, можно повыкидывать эти строки к лешему (у нечисти уже стандарт setImmediate и счётчик вызова).
Так же лучшего оставила желать деталь: поддержка браузерами, особенно мобильными (к которым я, по долгу работы, особенно трепетно отношусь).

MutationObserver way


Я же пошёл по пути рассуждения, что есть у нас мутации (MutationObserver), в них колбэки вызываются асинхронно, есть у Node-элементов (HTMLElement is instance of Node) метод setAttribute, который программисты не долго думая свяжут же с постановкой в очередь напрямую, без лишних систем проверок, как систему сообщений. Не рекомендуют использовать для полноценных узлов, которые уже встроены в DOM, а что если мы не будем встраивать в DOM и аккуратненько поживёт узел в замыканиях? Как оказалось всё так.

Код:
    nextTickMO = function() {
      var a, fire, i, nextTick, observer, queue, s;
      i = 0;
      r = 0; // счётчик для надёжности
      queue = new Array(16);
      fire = false;
      a = document.createElement('a'); // сам узел
      observer = new MutationObserver(function() { // вместо call
        var len, s, track;
        track = queue;
        len = i;
        s = 0;
        queue = new Array(16);
        i = 0;
        fire = false;
        while (s < len) {
          track[s++]();
        }
      });
      observer.observe(a, { // слушаем и ничего лишнего
        attributes: true,
        attributeFilter: ['lang']
      });
      nextTick = function(fn) {
        queue[i++] = fn;
        if (!fire) {
          fire = true;
          a.setAttribute('lang', (r++).toString());
        }
      };
      return nextTick;
    };


Создаётся узел (a), сервер, который слушает только один узел и одно свойство (может так дольше, а может быстрее, уж не знаю), а чтобы быть уверенным, что событие вызовется, присваивается значение счётчика к существующему атрибуту (не знаю хороший ли выбран атрибут (lang), вроде скорость с ним и с другим атрибутом одинаковая).
И метод оказался хорош, тоже шустрее сообщений.

Сравнение путей


У мутаций поддержка браузерами лучше, но тут есть момент: при сравнении скоростей обещания и мутация сцепились жёстко: хром последних версий на настольном и планшете, а так же опера из последних на планшете показали, что обещания в два раза шустрее мутаций. Огнелис же, родной браузер нексуса (хром 33), сафари на мобильном и планшете показали, что если обещания у них и есть, то они работают в два раза медленнее. Самое неприятное, что в действующих и выпускающихся моделях обещаний вполне может и не быть. Это как браузер собран… postMessage (не говоря уж о setTimeout) оказался далеко позади и только в 12-ом огнелисе, где нет мутаций, он реально пригодился.

Благо попался в руки мне хром (39) у которого скорости выполнения обещаний и мутаций примерно равны. Для оперы можно условиться, что если есть webkit, то пусть будут обещания при их наличии. Надеюсь хабраэффект прольёт свет на то, какая же версия оперы «переходная». Так же UC браузера нет под рукой, в общем данных мало не бывает, если вскроются «любопытные» подробности, исправлю либу.

Под рукой нет IE 10 и 11, поэтому нативный нестандартный setImmediate попросту опущен в исследовании.

Резюме:


  1. Если хром 39 и младше (большей версии), или если опера 15 и младше, то обещания.
  2. Иначе мутация, если есть.
  3. Если нет мутаций, то сообщения, а если нет сообщений — таймер на ноль.


Тесты (есть мелкие расхождения с опубликованным кодом, на производительность не влияют, но исправляя пришлось бы убить статистику):
jsperf.com/tick

Итоговая реализация nextTick (имя прототипа) в виде tick (прод. Версия):
github.com/NightMigera/tick
Tags:
Hubs:
+11
Comments 9
Comments Comments 9

Articles