javascript

Способы разделения длительных задач в JavaScript

  • среда, 26 февраля 2025 г. в 00:00:06
https://habr.com/ru/companies/timeweb/articles/882418/



Иногда возникает необходимость разделить длительную дорогую (с точки зрения вычислений) задачу на несколько тиков (ticks) цикла событий (event loop). Существует множество способов это сделать. Рассмотрим их.


Легко "уничтожить" пользовательский опыт, позволив длительной дорогой задаче захватить основной поток (main thread). Неважно, насколько сложным является приложение, цикл событий может выполнять только одну задачу за раз. Пока выполняется одна задача, другие ждут своей очереди. Как правило, задача выполняется настолько быстро, что пользователь ничего не замечает. Но так бывает не всегда.


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


<button id="button">count</button>
<div>Click count: <span id="clickCount">0</span></div>
<div>Loop count: <span id="loopCount">0</span></div>

<script>
  function waitSync(milliseconds) {
    const start = Date.now();
    while (Date.now() - start < milliseconds) {}
  }

  button.addEventListener("click", () => {
    clickCount.innerText = Number(clickCount.innerText) + 1;
  });

  const items = new Array(100).fill(null);

  for (const i of items) {
    loopCount.innerText = Number(loopCount.innerText) + 1;
    waitSync(50);
  }
</script>

При запуске этого кода ничего не будет обновляться, даже счетчик цикла. Это связано с тем, что браузер не имеет возможности перерисовать (repaint) экран. Обратную связь мы получим только после завершения цикла.





Диаграмма пламени (flame graph) инструментов разработчика в браузере подтверждает это. Одна задача в цикле событий выполняется 5 секунд. Ужасно!





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





Мы хотим получить это:





Существует удивительно много способов достичь этого. Рассмотрим некоторые из них. Начнем с классики — рекурсии.


❯ 1. setTimeout() + рекурсия


Если вам приходилось писать JS до появления нативных промисов, вы без сомнения встречали такую конструкцию, когда функция рекурсивно вызывает сама себя в коллбэке таймаута:


function processItems(items, index) {
  index = index || 0;
  var currentItem = items[index];

  console.log("processing item:", currentItem);

  if (index + 1 < items.length) {
    setTimeout(function () {
      processItems(items, index + 1);
    }, 0);
  }
}

processItems(["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]);

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





Это возвращает отзывчивость UI. Обработчики клика срабатывают, и браузер может рендерить обновления на экране:





Но с момента ES6 прошло много времени, и браузер предлагает несколько других более эргономичных способов.


❯ 2. async/await и setTimeout()


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


<button id="button">count</button>
<div>Click count: <span id="clickCount">0</span></div>
<div>Loop count: <span id="loopCount">0</span></div>

<script>
  function waitSync(milliseconds) {
    const start = Date.now();
    while (Date.now() - start < milliseconds) {}
  }

  button.addEventListener("click", () => {
    clickCount.innerText = Number(clickCount.innerText) + 1;
  });

  (async () => {
    const items = new Array(100).fill(null);

    for (const i of items) {
      loopCount.innerText = Number(loopCount.innerText) + 1;

      await new Promise((resolve) => setTimeout(resolve, 0));

      waitSync(50);
    }
  })();
</script>

Выглядит намного лучше. Простой цикл for и ожидание разрешения промиса. Ритм цикла событий очень похожий, за одним важным исключением, выделенным красным:





Метод then() промиса всегда помещается в очередь микрозадач (microtask queue) и выполняется после очистки стека вызовов (call stack), но перед макрозадачами (macrotasks).


❯ 3. scheduler.postTask()


Интерфейс Scheduler является относительно новым в браузерах на основе Chromium. Он является эффективным инструментом, предоставляющим большой контроль планирования задач. По сути, это улучшенная версия setTimeout():


const items = new Array(100).fill(null);

for (const i of items) {
  loopCount.innerText = Number(loopCount.innerText) + 1;

  await new Promise((resolve) => scheduler.postTask(resolve));

  waitSync(50);
}

Что интересно в использовании postTask(), так это время между запланированными задачами. Вот еще один 400 мс срез диаграммы пламени. Обратите внимание, как плотно задачи прилегают друг к другу:





Дефолтным приоритетом (priority) postTask() является 'user-visible', что аналогично приоритету setTimeout(() => {}, 0). Вывод, кажется, всегда соответствует порядку вызова:


setTimeout(() => console.log("setTimeout"));
scheduler.postTask(() => console.log("postTask"));
// setTimeout
// postTask

scheduler.postTask(() => console.log("postTask"));
setTimeout(() => console.log("setTimeout"));

// postTask
// setTimeout

Однако, в отличие от setTimeout(), postTask() был разработан для планирования, поэтому он не имеет ограничений таймаутов. Все запланированное им помещается в начало очереди задач, предотвращая задержку выполнения, особенно при частой постановке в очередь многих задач.


Повысить приоритет задачи можно следующим образом:


scheduler.postTask(() => {
  console.log("postTask");
}, { priority: "user-blocking" });

Приоритет 'user-blocking' предназначен для задач, критичных для UI (таких как обработка ввода пользователя). Поэтому он, вероятно, не подходит для разделения больших задач. В конце концов, мы хотим разгрузить основной поток.


Понизить приоритет можно так:


scheduler.postTask(() => {
  console.log("postTask - background");
}, { priority: "background" });

setTimeout(() => console.log("setTimeout"));

scheduler.postTask(() => console.log("postTask - default"));

// setTimeout
// postTask - default
// postTask - background

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


requestIdleCallback()


Когда речь заходит о приоритизации задач, люди часто вспоминают о requestIdleCallback(). Он предназначен для выполнения коллбэка в период "простоя" (idle) браузера. Проблема в том, что невозможно определить, когда он будет запущен, и будет ли запущен вообще. Можно установить timeout при его вызове, но все равно придется мириться с тем фактом, что его не поддерживает Safari.


Кроме того, MDN рекомендует использовать timeout для правильной работы requestIdleCallback(), так что для разделения задач я не стал бы его применять.


❯ 4. scheduler.yield()


Метод yield() интерфейса Scheduler является более специфичным, чем рассмотренные нами подходы. MDN:


Метод yield() интерфейса Scheduler используется для переключения на основной поток во время выполнения задачи и продолжения ее выполнения позднее, при этом продолжение планируется как приоритетная задача… Это позволяет разбить длительную работу, чтобы браузер оставался отзывчивым.

Посмотрим, как этот метод используется. Больше нет нужды возвращать и разрешать наш промис. Мы просто ждем разрешения предоставленного промиса:


const items = new Array(100).fill(null);

for (const i of items) {
  loopCount.innerText = Number(loopCount.innerText) + 1;

  await scheduler.yield();

  waitSync(50);
}

Это делает диаграмму пламени немного чище. В стеке становится на один элемент меньше:





Простой API соблазняет использовать его повсюду. Представьте чекбокс, выполняющий сложную задачу в обработчике события change:


document
  .querySelector('input[type="checkbox"]')
  .addEventListener("change", function (e) {
    waitSync(1000);
});

В обычном случае клик по чекбоксу "висит" в течение секунды:





Теперь вернем управление браузеру, чтобы он мог обновить UI после клика:


document
  .querySelector('input[type="checkbox"]')
  .addEventListener("change", async function (e) {
    // Переключаемся на основной поток
    await scheduler.yield();

    waitSync(1000);
});

Получаем:





Поддержка браузеров оставляет желать лучшего, но полифил довольно прост:


globalThis.scheduler = globalThis.scheduler || {};
globalThis.scheduler.yield =
  globalThis.scheduler.yield ||
  (() => new Promise((r) => setTimeout(r, 0)));

❯ 5. requestAnimationFrame()


API requestAnimationFrame() позволяет вмешиваться в цикл перерисовки браузера. Поэтому он отлично подходит для планирования коллбэков. Задача выполняется перед следующей отрисовкой. Это объясняет, почему задачи в диаграмме пламени так плотно прилегают друг к другу. Коллбэки кадров анимации помещаются в отдельную "очередь" и выполняются в определенный момент стадии рендеринга. Это означает, что другим задачам сложно сдвинуть их в конец очереди.





Однако выполнение тяжелой работы во время перерисовок, похоже, ломает рендеринг. Взгляните на эту диаграмму пламени. Желтые прямоугольники показывают "частично представленный кадр" (partially-presented frame):





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


❯ 6. MessageChannel


MessageChannel является более легковесной альтернативой setTimeout() с нулевой задержкой. Вместо того, чтобы просить браузер поместить таймер в очередь и запланировать выполнение его коллбэка, мы инициализируем канал и сразу отправляем в него сообщение:


for (const i of items) {
  loopCount.innerText = Number(loopCount.innerText) + 1;

  await new Promise((resolve) => {
    const channel = new MessageChannel();
    channel.port1.onmessage = resolve();
    channel.port2.postMessage(null);
  });

  waitSync(50);
}

Производительность улучшается, задержки между запланированными задачами почти нет:





Субъективным недостатком такого подхода является относительная сложность использования, поскольку MessageChannel явно проектировался для других задач.


❯ 7. Worker


Если работа не зависит от основного потока, можно делегировать ее выполнение веб-воркеру (web worker). Технически для кода воркера даже не требуется создавать отдельный файл:


const items = new Array(100).fill(null);

const workerScript = `
  function waitSync(milliseconds) {
    const start = Date.now();
    while (Date.now() - start < milliseconds) {}
  }

  self.onmessage = function(e) {
    waitSync(50);
    self.postMessage('Process complete!');
  }
`;

const blob = new Blob([workerScript], { type: "text/javascipt" });
const worker = new Worker(window.URL.createObjectURL(blob));

for (const i of items) {
  worker.postMessage(items);

  await new Promise((resolve) => {
    worker.onmessage = function (e) {
      loopCount.innerText = Number(loopCount.innerText) + 1;
      resolve();
    };
  });
}

Посмотрите, каким чистым становится основной поток, когда задачи выполняются в другом месте. В диаграмме пламени появляется раздел "Worker":





В нашем случае требуется, чтобы прогресс отображался в UI, поэтому мы передаем воркеру отдельные элементы и ждем от него ответа. Однако весь список элементов можно передать воркеру сразу. Это еще больше сократит накладные расходы.


❯ Заключение


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


  • Если работу можно выполнить вне основного потока, без сомнения, следует использовать веб-воркер. Они хорошо поддерживаются браузерами и их основная цель — разгрузка основного потока. Единственный недостаток — их довольно неуклюжий API, но с этим могут помочь такие инструменты, как Workerize и встроенный импорт воркеров Vite.
  • Если мне нужен максимально простой способ разделить задачу, я бы выбрал scheduler.yield(). Мне не нравится, что придется использовать полифил, но я к этому готов.
  • Если мне требуется тонкий контроль расстановки приоритетов, моим выбором будет scheduler.postTask(). Впечатляет, насколько глубоко можно погрузиться в настройки. Этот API предоставляет такие возможности, как управление приоритетами, установка задержек, отмена задач и многое другое. К сожалению, здесь также требуется полифил.
  • Если поддержка браузеров и надежность имеют первостепенное значение, тогда берем старый-добрый setTimeout(). Это легенда, которая никуда не денется, даже когда на сцену выйдут более яркие альтернативы.



Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале


Перед оплатой в разделе «Бонусы и промокоды» в панели управления активируйте промокод и получите кэшбэк на баланс.