Многопоточность в интернете с помощью модульных воркеров

Теперь переносить ресурсоемкие задачи в фоновые потоки стало проще благодаря модулям JavaScript в веб-воркерах.

JavaScript – однопоточный язык, то есть он может выполнять только одну операцию за раз. Это интуитивно понятно и хорошо работает во многих случаях в Интернете, но может стать проблематичным, когда нам нужно выполнять тяжелые задачи, такие как обработка данных, синтаксический анализ, вычисления или анализ. Поскольку в интернете появляется все больше сложных приложений, растет и потребность в многопоточной обработке.

На веб-платформе основным примитивом для многопоточности и параллелизма является Web Workers API. Воркеры – это легковесная абстракция над потоками операционной системы, которая предоставляет API передачи сообщений для межпоточного взаимодействия. Это может быть чрезвычайно полезно при выполнении дорогостоящих вычислений или работе с большими наборами данных, позволяя основному потоку работать бесперебойно, выполняя дорогостоящие операции в одном или нескольких фоновых потоках.

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

page.js:

const worker = new Worker('worker.js');
worker.addEventListener('message', e => {
  console.log(e.data);
});
worker.postMessage('hello');

worker.js:

addEventListener('message', e => {
  if (e.data === 'hello') {
    postMessage('world');
  }
});

Web Worker API доступен в большинстве браузеров уже более десяти лет. Это означает, что у них отличная поддержка браузеров и они хорошо оптимизированы, но также и то, что они появились задолго до модулей JavaScript. Поскольку на момент разработки воркеров модульной системы не существовало, API для загрузки кода в воркер и составления скриптов остался похожим на подходы к синхронной загрузке скриптов, распространенные в 2009 году.

История: классические рабочие

Конструктор Worker принимает URL-адрес классического скрипта, который относится к URL-адресу документа. Он сразу возвращает ссылку на новый рабочий экземпляр, который предоставляет интерфейс обмена сообщениями, а также метод terminate(), который немедленно останавливает и уничтожает рабочий экземпляр.

const worker = new Worker('worker.js');

Для загрузки дополнительного кода в веб-воркерах доступна функция importScripts(), но она приостанавливает выполнение воркера, чтобы получить и оценить каждый скрипт. Кроме того, скрипты выполняются на глобальном уровне, как и в случае с классическим тегом <script>. Это означает, что переменные в одном скрипте могут быть перезаписаны переменными в другом.

worker.js:

importScripts('greet.js');
// ^ could block for seconds
addEventListener('message', e => {
  postMessage(sayHello());
});

greet.js:

// global to the whole worker
function sayHello() {
  return 'world';
}

По этой причине веб-воркеры исторически оказывали огромное влияние на архитектуру приложения. Разработчикам приходилось создавать сложные инструменты и обходные пути, чтобы использовать веб-воркеры, не отказываясь от современных методов разработки. Например, сборщики, такие как webpack, встраивают небольшую реализацию загрузчика модулей в сгенерированный код, который использует importScripts() для загрузки кода, но упаковывает модули в функции, чтобы избежать конфликтов переменных и имитировать импорт и экспорт зависимостей.

Укажите работников модуля

В Chrome 80 появился новый режим для веб-воркеров, который обладает эргономическими и производительными преимуществами модулей JavaScript. Он называется модульными воркерами. Конструктор Worker теперь принимает новый параметр {type:"module"}, который изменяет загрузку и выполнение скрипта в соответствии с <script type="module">.

const worker = new Worker('worker.js', {
  type: 'module'
});

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

Переход на модули JavaScript также позволяет использовать динамический импорт для отложенной загрузки кода без блокировки выполнения воркера. Динамический импорт гораздо более явный, чем использование importScripts() для загрузки зависимостей, поскольку возвращаются экспорты импортированного модуля, а не полагаются на глобальные переменные.

worker.js:

import { sayHello } from './greet.js';
addEventListener('message', e => {
  postMessage(sayHello());
});

greet.js:

import greetings from './data.js';
export function sayHello() {
  return greetings.hello;
}

Чтобы обеспечить высокую производительность, старый метод importScripts() недоступен в модульных воркерах. Переход на модули JavaScript означает, что весь код загружается в строгом режиме. Ещё одно заметное изменение заключается в том, что значение this в области верхнего уровня модуля JavaScript равно undefined, тогда как в классических воркерах значением является глобальная область воркера. К счастью, всегда существовала глобальная переменная self, которая ссылается на глобальную область видимости. Он доступен во всех типах воркеров, включая сервисные воркеры, а также в DOM.

Предварительная загрузка воркеров с помощью modulepreload

Одно из существенных улучшений производительности, которое обеспечивают модульные воркеры, – это возможность предварительной загрузки воркеров и их зависимостей. При использовании модульных воркеров скрипты загружаются и выполняются как стандартные модули JavaScript. Это означает, что их можно предварительно загрузить и даже обработать с помощью modulepreload:

<!-- preloads worker.js and its dependencies: -->
<link rel="modulepreload" href="worker.js">

<script>
  addEventListener('load', () => {
    // our worker code is likely already parsed and ready to execute!
    const worker = new Worker('worker.js', { type: 'module' });
  });
</script>

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

Раньше возможности предварительной загрузки скриптов веб-воркеров были ограничены и не всегда надежны. Для классических воркеров был предусмотрен собственный тип ресурса worker для предварительной загрузки, но ни один браузер не поддерживал <link rel="preload" as="worker">. В результате основным методом предзагрузки веб-воркеров было использование <link rel="prefetch">, которое полностью зависело от HTTP-кеша. В сочетании с правильными заголовками кеширования это позволяло избежать ожидания скачивания скрипта при создании экземпляра воркера. Однако, в отличие от modulepreload, этот метод не поддерживал предварительную загрузку зависимостей или предварительный синтаксический анализ.

Что насчет общих воркеров?

В Chrome 83 в общие рабочие потоки добавлена поддержка модулей JavaScript. Как и в случае с выделенными воркерами, при создании общего воркера с параметром {type:"module"} скрипт воркера теперь загружается как модуль, а не как классический скрипт:

const worker = new SharedWorker('/worker.js', {
  type: 'module'
});

До того как была добавлена поддержка модулей JavaScript, конструктор SharedWorker() ожидал только URL и необязательный аргумент name. Это по-прежнему будет работать для классических общих рабочих процессов, однако для создания общих рабочих процессов модулей потребуется использовать новый аргумент options. Доступные варианты такие же, как и для выделенного исполнителя, включая параметр name, который заменяет предыдущий аргумент name.

Что такое сервис-воркер?

Спецификация сервисного вокера уже обновлена для поддержки приема модуля JavaScript в качестве точки входа с использованием той же опции {type:"module"}, что и у вокеров модулей, однако это изменение ещё не реализовано в браузерах. После этого можно будет создать экземпляр сервисного скрипта, используя модуль JavaScript со следующим кодом:

navigator.serviceWorker.register('/sw.js', {
  type: 'module'
});

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

Дополнительные ресурсы и материалы