Доброго времени суток, %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

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


  1. Louter
    13.01.2016 15:54

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


    1. domix32
      14.01.2016 12:47

      Android 4.4.
      Mozilla 43.0:
      timeout — 3.79 worst
      post message — 104
      mutation observer — 292 best
      promises — 181

      Chrome 47.0.2526.83:
      timeout — 4.34 worst
      post message — 165
      mutation observer — 573
      promises — 783 best
      Версии последние в сторе


  1. rock
    13.01.2016 16:24
    +5

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

    Какбэ setImmediate и postMessage — task, MutationObserver и Promise — microtask, что принципиально.

    Реализации task и microtask в core-js.


    1. nazarpc
      13.01.2016 20:12
      -1

      А в связи с тем, как дико работают Promise в браузерах только MutationObserver и остается для microtask если нужен стабильный результат.
      Вот большая статья по теме: jakearchibald.com/2015/tasks-microtasks-queues-and-schedules


      1. Louter
        13.01.2016 23:27

        На тестах с 1000-ью итерациями я понял, что обещания работают в хромах и операх хорошо, грех жаловаться, а вот там, где они медленнее мутаций — грех их использовать.
        Всё-таки меня в целом удивило вот что: казалось бы Promise довольно простая структура, в отличии от мутаций, но в ранних тестах в фф они давали строго схожий результат (в статье момент опущен), хотя «любили» падать если один объект слишком долго используется, от «обнулятеля» скорость просела вдвое.
        Так что то, что мутации дольше обещаний ожидаемый результат, а что далеко не везде — неожиданность =)

        Зато(!) основываясь на мутациях можно уже делать полифил для Promise =)


        1. nazarpc
          14.01.2016 06:25

          Вы смотрите с точки зрения скорости, я смотрю ещё и с точки правильности. Около недели назад достаточно глубоко вникал в тему, и оказалось что надежный порядок выполнения имеет только MutationObserver. На Promise надеяться нельзя, так как он не консистентный в кроссбраузерном смысле. В статье много подробностей и примеры. Возможно, вам это и подходит, у меня же была цель оптимизировать микрозадачи в Polymer, и ничего лучше MutationObserver пока не нашел (даже в Chrome аналогичная реализация с Promise работала почему-то немного медленнее).


  1. b1rdex
    14.01.2016 09:57

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

    Ничего подобного. process.nextTick и setImmediate похожи лишь тем, что вызывают переданную функцию асинхронно.

    nodejs.org/api/timers.html
    nodejs.org/api/process.html#process_process_nexttick_callback_arg

    Упорядочено: process.nextTick, I/O активность, setImmediate, setTimeout ± setInterval


    1. b1rdex
      14.01.2016 10:03
      +1

      Ну и по поводу

      setTimeout(fn, 0), где 0 реально 0, а не минимум 4
      — это тоже не так.
      setImmediate()
      Calls a function immediately after the browser has completed other operations, such as events and display updates.
      . То есть когда браузер обработает все события и всё отрисует. А это может быть и 0, и 4 и 4000.

      developer.mozilla.org/en-US/Add-ons/Code_snippets/Timers


      1. Louter
        14.01.2016 12:38

        Аналогичнось process.nextTick и setImmediate достаточна же, чтобы человек понял о чём речь в принципе. «Эдакий» это аналогия, допущения.
        Что касается таймера, так если выставить setTimeout(0,20) это так же не значит, что код выполнится через 20мс, он может выполниться и чрез секунду, если основная ветка всю секунду будет фурыкать; второй аргумент (функции setTimeout) правильнее читать, как «не раньше, чем через», а значит, если там реальный 0, то правильно будет прочитать «сразу, как это возможно».

        И именно в виду всех ограничений стандарта, которые сложно так соблюсти (дождаться окончания отрисовки, обработки событий, всей шушеры) итоговая реализация не полифил на тему setImmediate, кой уже есть и работает, а совершенно иначе названный метод «tick», чья цель, как было поставлено в задаче «асинхронного выполнения переданной функции/метода как можно быстрее», кою он, вроде, выполняет)