Основы веб-воркеров

Проблема: параллелизм JavaScript

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

Единственное, что остается помехой для JavaScript, — это сам язык. JavaScript — это однопоточная среда, то есть несколько сценариев не могут выполняться одновременно. В качестве примера представьте себе сайт, которому необходимо обрабатывать события пользовательского интерфейса, запрашивать и обрабатывать большие объемы данных API, а также манипулировать DOM. Довольно распространенное явление, не так ли? К сожалению, все это не может происходить одновременно из-за ограничений среды выполнения JavaScript в браузерах. Выполнение скрипта происходит в одном потоке.

Разработчики имитируют «параллелизм», используя такие методы, как setTimeout() , setInterval() , XMLHttpRequest и обработчики событий. Да, все эти функции работают асинхронно, но отсутствие блокировки не обязательно означает параллелизм. Асинхронные события обрабатываются после завершения текущего исполняемого сценария. Хорошая новость в том, что HTML5 дает нам нечто лучшее, чем эти хаки!

Знакомство с веб-воркерами: добавьте многопоточность в JavaScript

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

Диалоговое окно не отвечающего сценария
Обычное диалоговое окно не отвечающего сценария.

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

Типы веб-работников

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

Начиная

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

var worker = new Worker('task.js');

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

После создания работника запустите его, вызвав метод postMessage() :

worker.postMessage(); // Start the worker.

Общение с работником посредством передачи сообщений

Связь между произведением и его родительской страницей осуществляется с помощью модели событий и метода postMessage() . В зависимости от вашего браузера/версии postMessage() может принимать в качестве единственного аргумента либо строку, либо объект JSON. Последние версии современных браузеров поддерживают передачу объекта JSON.

Ниже приведен пример использования строки для передачи «Hello World» работнику в doWork.js. Рабочий просто возвращает переданное ему сообщение.

Основной сценарий:

var worker = new Worker('doWork.js');

worker.addEventListener('message', function(e) {
console.log('Worker said: ', e.data);
}, false);

worker.postMessage('Hello World'); // Send data to our worker.

doWork.js (работник):

self.addEventListener('message', function(e) {
self.postMessage(e.data);
}, false);

Когда postMessage() вызывается с главной страницы, наш работник обрабатывает это сообщение, определяя обработчик onmessage для события message . Полезная нагрузка сообщения (в данном случае «Hello World») доступна в Event.data . Хотя этот конкретный пример не очень интересен, он демонстрирует, что postMessage() также является средством передачи данных обратно в основной поток. Удобный!

Сообщения, передаваемые между главной страницей и рабочими процессами, копируются, а не передаются. Например, в следующем примере свойство msg сообщения JSON доступно в обоих местах. Похоже, что объект передается непосредственно работнику, хотя он выполняется в отдельном выделенном пространстве. На самом деле происходит следующее: объект сериализуется при передаче работнику, а затем десериализуется на другом конце. Страница и рабочий процесс не используют один и тот же экземпляр, поэтому в конечном результате при каждом проходе создается дубликат. Большинство браузеров реализуют эту функцию путем автоматического кодирования/декодирования значения JSON на обоих концах.

Ниже приведен более сложный пример передачи сообщений с использованием объектов JSON.

Основной сценарий:

<button onclick="sayHI()">Say HI</button>
<button onclick="unknownCmd()">Send unknown command</button>
<button onclick="stop()">Stop worker</button>
<output id="result"></output>

<script>
function sayHI() {
worker.postMessage({'cmd': 'start', 'msg': 'Hi'});
}

function stop() {
// worker.terminate() from this script would also stop the worker.
worker.postMessage({'cmd': 'stop', 'msg': 'Bye'});
}

function unknownCmd() {
worker.postMessage({'cmd': 'foobard', 'msg': '???'});
}

var worker = new Worker('doWork2.js');

worker.addEventListener('message', function(e) {
document.getElementById('result').textContent = e.data;
}, false);
</script>

doWork2.js:

self.addEventListener('message', function(e) {
var data = e.data;
switch (data.cmd) {
case 'start':
    self.postMessage('WORKER STARTED: ' + data.msg);
    break;
case 'stop':
    self.postMessage('WORKER STOPPED: ' + data.msg +
                    '. (buttons will no longer work)');
    self.close(); // Terminates the worker.
    break;
default:
    self.postMessage('Unknown command: ' + data.msg);
};
}, false);

Передаваемые объекты

Большинство браузеров реализуют алгоритм структурированного клонирования , который позволяет передавать в или из рабочих процессов более сложные типы, такие как объекты File , Blob , ArrayBuffer и JSON. Однако при передаче этих типов данных с помощью postMessage() копия все равно создается. Поэтому, если вы передаете большой файл размером 50 МБ (например), при передаче этого файла между рабочим и основным потоком возникают заметные накладные расходы.

Структурированное клонирование — это здорово, но копирование может занять сотни миллисекунд. Для борьбы с ударом по производительности можно использовать Transferable Objects .

С помощью Transferable Objects данные передаются из одного контекста в другой. Это нулевое копирование, что значительно повышает производительность отправки данных работнику. Если вы из мира C/C++, думайте об этом как о передаче по ссылке. Однако, в отличие от передачи по ссылке, «версия» из контекста вызова больше не доступна после передачи в новый контекст. Например, при переносе ArrayBuffer из основного приложения в Worker исходный ArrayBuffer очищается и больше не пригоден для использования. Его содержимое (в буквальном смысле слова) переносится в контекст Worker.

Чтобы использовать передаваемые объекты, используйте немного другую сигнатуру postMessage() :

worker.postMessage(arrayBuffer, [arrayBuffer]);
window.postMessage(arrayBuffer, targetOrigin, [arrayBuffer]);

Рабочий случай: первый аргумент — это данные, а второй — список элементов, которые следует передать. Кстати, первый аргумент не обязательно должен быть ArrayBuffer . Например, это может быть объект JSON:

worker.postMessage({data: int8View, moreData: anotherBuffer},
                [int8View.buffer, anotherBuffer]);

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

Дополнительную информацию о передаваемых объектах можно найти в нашей публикации на сайте Developer.chrome.com .

Рабочая среда

Область действия работника

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

addEventListener('message', function(e) {
var data = e.data;
switch (data.cmd) {
case 'start':
    postMessage('WORKER STARTED: ' + data.msg);
    break;
case 'stop':
...
}, false);

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

onmessage = function(e) {
var data = e.data;
...
};

Функции, доступные работникам

Из-за своего многопоточного поведения Web Workers имеет доступ только к подмножеству функций JavaScript:

  • Объект navigator
  • Объект location (только для чтения)
  • XMLHttpRequest
  • setTimeout()/clearTimeout() и setInterval()/clearInterval()
  • Кэш приложения
  • Импорт внешних скриптов с помощью метода importScripts()
  • Создание других веб-работников

Работники НЕ имеют доступа к:

  • DOM (он не потокобезопасен)
  • Объект window
  • Объект document
  • parent объект

Загрузка внешних скриптов

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

В этом примере скрипты script1.js и script2.js загружаются в рабочий процесс:

рабочий.js:

importScripts('script1.js');
importScripts('script2.js');

Это также можно записать как один оператор импорта:

importScripts('script1.js', 'script2.js');

Подработники

Рабочие имеют возможность порождать детей-работников. Это отлично подходит для дальнейшего разделения больших задач во время выполнения. Однако у субработников есть несколько предостережений:

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

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

Пример создания подчиненного работника см . в спецификации.

Линейные рабочие

Что, если вы хотите создать рабочий скрипт «на лету» или создать автономную страницу без необходимости создавать отдельные рабочие файлы? С помощью Blob() вы можете «встроить» своего работника в тот же HTML-файл, что и основную логику, создав дескриптор URL-адреса кода работника в виде строки:

var blob = new Blob([
"onmessage = function(e) { postMessage('msg from worker'); }"]);

// Obtain a blob URL reference to our worker 'file'.
var blobURL = window.URL.createObjectURL(blob);

var worker = new Worker(blobURL);
worker.onmessage = function(e) {
// e.data == 'msg from worker'
};
worker.postMessage(); // Start the worker.

URL-адреса BLOB-объектов

Волшебство приходит с вызовом window.URL.createObjectURL() . Этот метод создает простую строку URL-адреса, которую можно использовать для ссылки на данные, хранящиеся в File DOM или объекте Blob . Например:

blob:http://localhost/c745ef73-ece9-46da-8f66-ebes574789b1

URL-адреса BLOB-объектов уникальны и действуют в течение всего времени существования вашего приложения (например, до тех пор, пока document не будет выгружен). Если вы создаете много URL-адресов BLOB-объектов, рекомендуется освободить ссылки, которые больше не нужны. Вы можете явно освободить URL-адреса Blob, передав их в window.URL.revokeObjectURL() :

window.URL.revokeObjectURL(blobURL);

В Chrome есть удобная страница для просмотра всех URL-адресов созданных больших двоичных объектов: chrome://blob-internals/ .

Полный пример

Сделав еще один шаг вперед, мы можем разобраться в том, как JS-код воркера встроен в нашу страницу. Этот метод использует тег <script> для определения работника:

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>

<div id="log"></div>

<script id="worker1" type="javascript/worker">
// This script won't be parsed by JS engines
// because its type is javascript/worker.
self.onmessage = function(e) {
    self.postMessage('msg from worker');
};
// Rest of your worker code goes here.
</script>

<script>
function log(msg) {
    // Use a fragment: browser will only render/reflow once.
    var fragment = document.createDocumentFragment();
    fragment.appendChild(document.createTextNode(msg));
    fragment.appendChild(document.createElement('br'));

    document.querySelector("#log").appendChild(fragment);
}

var blob = new Blob([document.querySelector('#worker1').textContent]);

var worker = new Worker(window.URL.createObjectURL(blob));
worker.onmessage = function(e) {
    log("Received: " + e.data);
}
worker.postMessage(); // Start the worker.
</script>
</body>
</html>

На мой взгляд, этот новый подход немного чище и понятнее. Он определяет тег сценария с id="worker1" и type='javascript/worker' (поэтому браузер не анализирует JS). Этот код извлекается в виде строки с помощью document.querySelector('#worker1').textContent и передается в Blob() для создания файла.

Загрузка внешних скриптов

При использовании этих методов для встраивания рабочего кода importScripts() будет работать только в том случае, если вы укажете абсолютный URI. Если вы попытаетесь передать относительный URI, браузер сообщит об ошибке безопасности. Причина в том, что рабочий процесс (теперь созданный из URL-адреса большого двоичного объекта) будет разрешен с помощью префикса blob: в то время как ваше приложение будет работать по другой схеме (предположительно http:// ). Следовательно, сбой будет вызван ограничениями перекрестного происхождения.

Один из способов использования importScripts() во встроенном рабочем процессе — «внедрить» текущий URL-адрес вашего основного сценария, передав его встроенному рабочему процессу и создав абсолютный URL-адрес вручную. Это гарантирует, что внешний скрипт будет импортирован из того же источника. Предполагая, что ваше основное приложение работает с http://example.com/index.html :

...
<script id="worker2" type="javascript/worker">
self.onmessage = function(e) {
var data = e.data;

if (data.url) {
var url = data.url.href;
var index = url.indexOf('index.html');
if (index != -1) {
    url = url.substring(0, index);
}
importScripts(url + 'engine.js');
}
...
};
</script>
<script>
var worker = new Worker(window.URL.createObjectURL(bb.getBlob()));
worker.postMessage(<b>{url: document.location}</b>);
</script>

обработка ошибок

Как и в случае с любой логикой JavaScript, вам потребуется обрабатывать любые ошибки, возникающие в ваших веб-воркерах. Если во время выполнения работника возникает ошибка, запускается событие ErrorEvent . Интерфейс содержит три полезных свойства, позволяющих выяснить, что пошло не так: filename — имя рабочего сценария, вызвавшего ошибку, lineno — номер строки, в которой произошла ошибка, и message — осмысленное описание ошибки. Вот пример настройки обработчика события onerror для печати свойств ошибки:

<output id="error" style="color: red;"></output>
<output id="result"></output>

<script>
function onError(e) {
document.getElementById('error').textContent = [
    'ERROR: Line ', e.lineno, ' in ', e.filename, ': ', e.message
].join('');
}

function onMsg(e) {
document.getElementById('result').textContent = e.data;
}

var worker = new Worker('workerWithError.js');
worker.addEventListener('message', onMsg, false);
worker.addEventListener('error', onError, false);
worker.postMessage(); // Start worker without a message.
</script>

Пример : workerWithError.js пытается выполнить 1/x, где x не определен.

// TODO: DevSite – удален пример кода, поскольку в нем использовались встроенные обработчики событий

рабочийСError.js:

self.addEventListener('message', function(e) {
postMessage(1/x); // Intentional error.
};

Несколько слов о безопасности

Ограничения при локальном доступе

Из-за ограничений безопасности Google Chrome рабочие процессы не будут запускаться локально (например, из file:// ) в последних версиях браузера. Вместо этого они терпят неудачу молча! Чтобы запустить приложение из схемы file:// , запустите Chrome с установленным флагом --allow-file-access-from-files .

Другие браузеры не налагают такого же ограничения.

Соображения об одном и том же происхождении

Рабочие скрипты должны быть внешними файлами с той же схемой, что и их вызывающая страница. Таким образом, вы не можете загрузить сценарий из URL-адреса data: или javascript: URL, а страница https: не может запускать рабочие сценарии, начинающиеся с URL-адресов http:

Случаи использования

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

  • Предварительная выборка и/или кэширование данных для последующего использования.
  • Подсветка синтаксиса кода или другое форматирование текста в реальном времени.
  • Программа проверки орфографии.
  • Анализ видео или аудио данных.
  • Фоновый ввод-вывод или опрос веб-сервисов.
  • Обработка больших массивов или огромных ответов JSON.
  • Фильтрация изображений в <canvas> .
  • Обновление многих строк локальной веб-базы данных.

Дополнительные сведения о вариантах использования API Web Workers см. на странице Обзор Workers .

Демо

Рекомендации