JavaScript

Параллельное программирование на JavaScript с использованием Web Workers

JavaScript - один из самых популярных языков программирования. Это язык веб-браузера. Еще одна причина, по которой JavaScript стал настолько популярным, - это внедрение Node.js на стороне сервера.

Несмотря на то, что JavaScript - один из самых любимых языков, некоторые программисты не любят его. Одна из причин заключается в том, что JavaScript - это однопоточный язык. Это ужасно, когда дело касается неблокирующих операций.

Всякий раз, когда вы что-то делаете в JavaScript (например, вставляете текст внутри элемента DOM), вся веб-страница зависает и перестает отвечать.

Обычно мы этого не замечаем, потому что современные браузеры работают очень быстро. Но на протяжении всего жизненного цикла веб-страницы эта разница может стать весьма заметной.

Однопоточные языки выполняют только одно вычисление за раз.

Это означает, что когда ваш браузер запускает скрипт, все другие операции (например, манипуляции с DOM, анимация, рисование, отложенное выполнение и другие операции, которые происходят в основном потоке) будут остановлены.

Браузер просто не будет реагировать, пока выполнение этого скрипта не будет завершено.

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

Это происходит с помощью цикла событий. Все это однопоточное выполнение блестяще объясняется Филипом Робертсом в видео ниже. Вам обязательно стоит его посмотреть.

Что бы вы ни делали, от однопоточной природы JavaScript никуда не деться.

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

Просто для подтверждения запустим такой цикл for в консоли веб-браузера. В Google Chrome консоль JavaScript использует основной поток вкладки. Итак, если мы запустим такой цикл в консоли, он заморозит эту вкладку.

Ниже приведен пример продолжительного цикла for:

// benchmark test
var start = Date.now(); // milliseconds
var x = 0;
for (var i = 0; i < 200000000; i++){
 x = x + i;
}
console.log('ended in : ', -(start - Date.now())/1000, ' seconds');
// ended in :  9.867  seconds

В приведенном выше примере цикл for выполняет вычисления 200000000 раз (что очень много).

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

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

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

Некоторые люди советуют использовать функцию setTimeout, чтобы обойти это поведение блокировки. Но вы можете попробовать на себе. Ни в коем случае не работает. Это означает, что выполнение однопоточного JavaScript может стать большой проблемой для нас, разработчиков.

Знакомство с веб-воркерами

По крайней мере, так было до тех пор, пока HTML5 не представил новый веб-API под названием Web Workers. Это было совместное усилие Mozilla Foundation и Google, направленное на то, чтобы сделать свои браузеры более мощными.

Позже многие другие браузеры переняли этот API. На сегодняшний день его поддерживают большинство основных браузеров.

Так что же такое веб-воркеры?

Веб-воркер - это программа JavaScript, работающая в другом потоке параллельно с основным потоком.

Браузер создает по одному потоку для каждой вкладки. Основной поток может порождать неограниченное количество веб-воркеров до тех пор, пока системные ресурсы пользователя не будут полностью израсходованы. Как только вкладка браузера закрывается, основной поток «умирает» и «убивает» всех созданных им веб-воркеров.

Программы JavaScript в основном потоке взаимодействуют с веб-воркерами с помощью событий. Эта связь происходит с использованием только события «сообщение» и полезной нагрузки данных.

Основной поток может «убить» созданных им веб-воркеров, а веб-воркер может «убить» себя. Когда веб-воркер умирает, связанные с ним потоки также умирают.

Но сначала, где мы можем использовать веб-воркеров?

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

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

Но, как правило, вы столкнетесь с проблемой «Страница не отвечает» из-за плохого дизайна.

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

  • Кэширование данных и веб-страниц
  • Обработка и кодирование изображений (преобразование base64)
  • Рисование холста и фильтрация изображений
  • Сетевой опрос и веб-сокеты
  • Фоновые операции ввода-вывода
  • Буферизация и анализ видео / аудио
  • Виртуальная модель DOM
  • Операции с локальной базой данных (indexedDB)
  • Вычислительно-интенсивные операции с данными

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

Хватит разговоров, давайте перейдем к коду и реализуем нашего первого веб-воркера.

Из-за политик безопасности Google Chrome вы не сможете тестировать веб-работников по file:// протоколу. Вместо этого для запуска локального сервера лучше использовать модули npm http-server или live-server. Я объясню, как использовать live-сервер позже в этом блоге.

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

Функция Worker должна использоваться в сочетании с ключевым словом new для создания экземпляра или объекта типа Worker. Следовательно, можно создать несколько экземпляров одного и того же скрипта веб-воркера.

<script>
    // inside -> index.html
    // resolved relative to index.html url path
    var worker = new Worker('worker.js');
....

В приведенном выше коде мы создаем веб-воркера из файла worker.js.

Здесь браузер попытается разрешить worker.js относительно текущего пути страницы, на которой он был создан. Если вы находитесь в http://localhost, то будет получено http://localhost/worker.js. Если вы находитесь на http://localhost/app, будет выведено http://localhost/app/worker.js. Чтобы избежать этой проблемы, вы можете реализовать ее во внешнем файле JavaScript.

Если вышеуказанная реализация выполняется во внешнем файле JavaScript, например main.js, то этот рабочий файл будет разрешен относительно него.

// inside -> main.js
// resolved relative to main.js url path
var worker = new Worker('worker.js');

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

Давайте создадим простой проект для тестирования нашего первого веб-воркера. Ниже представлена ​​структура проекта, которую мы собираемся использовать. Давайте создадим web-workers папку, а под ней создадим пустые файлы.

web-workers
|_ scripts
  |_ main.js   // to hold script for index.html
|_ workers
  |_ for.js    // to hold web worker code
|_ index.html  // main page

Из приведенной выше структуры нашего приложения index.html импортирует main.js, который создаст веб-воркера с помощью for.js скрипта.

Итак, index.html будет выглядеть так:

<!-- index.html -->
<!DOCTYPE html>
<html>
    <head>
        <title>Web Workers</title>
        <!-- main.js -->
        <script src="./scripts/main.js"></script>
    </head>
    <body>
        <div id="result">loading...</div>
    </body>
</html>

В index.html мы импортируем только main.js скрипт в разделе заголовка. Существует элемент div с идентификатором result, который мы будем использовать для печати результата веб-воркера. Вы немного увидите, как это работает.

В main.js мы собираемся создать веб-воркера из for.js скрипта. Поскольку мы говорили об относительном пути скрипта для веб-воркеров, путь for.js относительно main.js будет ../workers/for.js, следовательно, main.js будет выглядеть, как показано ниже.

Вы также можете использовать абсолютный путь /workers/for.js, так как он также будет разрешен для файла main.js, но обычно соблюдается соглашение об относительном пути.

// main.js
var workerFor = new Worker('../workers/for.js');
// listen to message event of worker
workerFor.addEventListener('message', function(event) {
    console.log('message received from workerFor => ', event.data);
});
// listen to error event of worker
workerFor.addEventListener('error', function(event) {
    console.error('error received from workerFor => ', event);
});

Из приведенного выше кода мы создали веб-работника workerFor из скрипта for.js.

Веб-воркер генерирует два события, а именно. сообщение и ошибка на протяжении всего срока его службы. Мы можем прослушать это событие, используя метод addEventListener на экземпляре worker.

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

event содержит множество свойств, одним из которых является data для события message. data в событии - это фактические данные или полезная нагрузка, отправленные веб-работником. В случае события error событие фактически является объектом ошибки JavaScript.

Событие message возникает, когда веб-воркер успешно отправляет данные, которые не являются объектом ошибки. Событие error возникает, когда исполнитель не зарегистрирован успешно или когда исполнитель отправляет объект ошибки в качестве полезной нагрузки.

Пока что мы поняли, как обращаться с веб-воркерами. Теперь давайте посмотрим, как работают веб-воркеры на практике. Ниже приведен очень простой пример веб-воркера.

Вскоре мы приступим к реализации цикла for внутри веб-воркеров, но очень полезно пошагово разбить работу с веб-воркерами, что поможет нам прояснить многие концепции, связанные с веб-воркерами.

// for.js
self.postMessage('Hello World!');

В for.js у нас есть странная переменная self. self - переменная контекста, предоставляемая средой выполнения JavaScript, которая относится к глобальному контексту. Я почти уверен, что вы никогда не использовали self при доступе к глобальным переменным (скажем, x) в JavaScript, потому что, когда переменная определяется глобально, вы просто используете имя переменной x, а не window.x, поскольку window является глобальным контекстом, а все глобальные переменные доступно с синтаксисом window.varName. self в JavaScript также относится к глобальному контексту window. Следовательно, любая глобальная переменная также может быть доступна с синтаксисом self.varName.

В случае веб-воркеров self используется для доступа к глобальной области, которая является самим воркером. Следовательно, если вы удалите self из функции self.postMessage, это тоже будет хорошо. postMessage - это метод, предоставляемый исполнителем для генерации события message. Этот метод принимает один параметр, который является данными или полезной нагрузкой для передачи вместе с событием (который main.js будет доступен с event.data ).

Вы также можете использовать this вместо self, но я не рекомендую это, потому что они сильно отличаются друг от друга. Вот объяснение https://stackoverflow.com/a/16876159/2790983.

Давайте добавим тайм-аут 5 секунд к вызову postMessage, чтобы сделать этот пример более интересным. Код ниже вызовет функцию postMessage через 5 секунд. Следовательно, в main.js мы должны получить это событие через 5 секунд.

setTimeout(() => {
    postMessage('Hello World!');
}, 5000);

Давайте также изменим main.js для печати данных, полученных от веб-воркера, в #result div. Это можно сделать с помощью простых манипуляций с DOM.

// modifications in main.js
...
workerFor.addEventListener('message', function(event) {
    var div = document.getElementById('result');
    div.innerHTML = 'message received => ' + event.data;
});
...

Приступим к применению. Но перед этим нам нужна программа, запускающая статический веб-сервер для размещения нашего проекта по протоколу http. Для этого мы собираемся использовать модуль live-server npm, который дает нам команды CLI для запуска статического веб-сервера из любого каталога. Чтобы установить live-server с помощью npm, используйте команду ниже

$ npm install -g live-server

После завершения установки из папки нашего проекта используйте команду live-server, которая должна запустить веб-сервер на 8080 порту. Он должен автоматически открывать вкладку браузера, но если этого не произошло, используйте URL http://localhost:8080 для перехода на index.html страницу.

Каждый раз, когда вы вносите изменения в файлы, обслуживаемые live-server, вам не нужно перезагружать веб-страницу, live-server сделает это автоматически.

Вы увидите, что сначала на экране отображается loading..., но через 5 секунд он меняется на message received => Hello World!. У нас работает сервисный воркер.

Инструменты разработчика Chrome предоставляют большую поддержку веб-работникам. На вкладке «Источники» перейдите в раздел «Темы» на правой панели. Вы можете увидеть основной поток и поток for.js. На левой панели вы можете увидеть for.js веб-воркера и его местоположение. Используя эту панель, вы также можете отлаживать веб-воркеров, как обычный скрипт.

До сих пор мы видели только одностороннее общение, от веб-исполнителя for.js до main.js. Мы также можем общаться из main.js с веб-работником for.js, используя тот же postMessage метод на экземпляре рабочего workerFor в main.js. Поскольку postMessage генерирует событие message, веб-исполнитель должен также прослушивать событие message аналогично тому, как это делается в main.js. Используя это, веб-воркер может понять, когда main.js запрашивает выполнение задачи.

Теперь давайте добавим наш ужасный цикл for внутри веб-воркера и вернем результат. Первоначально этот цикл for использовал блокировку вкладки нашего браузера. На этот раз этого не должно быть. Давайте создадим кнопку в index.html, чтобы генерировать событие message из main.js .

<!-- index.html -->
<!DOCTYPE html>
<html>
    <head>
        <title>Web Workers</title>
         <!-- main.js -->
        <script src="./scripts/main.js"></script>
    </head>
    <body>
        <div id="result">no results</div>
        <br/>
        <button onclick="loadResult()">Load Result</button>
        <!-- breaks for page scroll -->
        <br/><br/><br/><br/><br/><br/><br/><br/><br/><br/><br/>
        <br/><br/><br/><br/><br/><br/><br/><br/><br/><br/><br/>
        <br/><br/><br/><br/><br/><br/><br/><br/><br/><br/><br/>
        <br/><br/><br/><br/><br/><br/><br/><br/><br/><br/><br/>
        <br/><br/><br/><br/><br/><br/><br/><br/><br/><br/><br/>
    </body>
</html>

В main.js мы добавим функцию loadResult, которая будет отправлять сообщение о событии веб-воркеру.

// main.js
var workerFor = new Worker('../workers/for.js');
// listen to message event of worker
workerFor.onmessage = function(event){
    var div = document.getElementById('result');
    div.innerHTML = 'message received => ' + event.data;
};
// listen to error event of worker
workerFor.onerror = function(event) {
    console.error('error received from workerFor => ', event);
    var div = document.getElementById('result');
    div.innerHTML = 'Error!';
};
// load results from web worker
function loadResult() {
    // add loading text until `message` event listener replaces it
    var div = document.getElementById('result');
    div.innerHTML = 'loading...';
    // emit message event to worker
    workerFor.postMessage(null); // we don't need payload here
};

elem.addEventListener(‘message’, callback) эквивалентно elem.onmessage = callback. Следовательно, вы можете решить, какой подход вы собираетесь использовать.

Поскольку при нажатии кнопки main.js отправляет событие сообщения в for.js, используя метод postMessage на workerFor, for.js должен его прослушать и запустить цикл for внутри функции обратного вызова. Сразу после кода цикла for мы должны вернуть результат обратно в main.js, используя тот же метод postMessage. Это можно реализовать, как показано ниже.

// for.js
self.onmessage = function(event) {
    var x = 0;
    for (var i = 0; i < 200000000; i++) {
        x = x + i;
    }
    self.postMessage(x);
}

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

Чего ждать?

В рабочем потоке веб-приложения требуется меньше секунды, чтобы вычислить то же, что и в основном потоке почти 10 секунд. Вот почему веб-воркеры такие классные. Поскольку они работают в отдельном потоке, среда выполнения должна заботиться только об одной задаче, и она выполняет ее эффективно и быстро. Чтобы смоделировать большую вычислительную задержку, я собираюсь увеличить количество итераций for loop до 2000M. Посмотрим, как сейчас ведет себя наша страница.

Мы видим, что пока веб-воркер выполнял цикл for, наша страница или вкладка вообще не зависали. Таким образом, мы доказали, что веб-воркеры не блокируют.

Может быть один случай, когда вы закончили работу с веб-воркером и хотите его убить, скажем, чтобы освободить некоторые системные ресурсы (поток). Это можно сделать как в main.js, так и в for.js. В объекте веб-воркера есть метод terminate, который убивает веб-воркера, или вы можете использовать метод self.close изнутри веб-воркера, чтобы сделать то же самое. Как только веб-работа умирает, исчезает и поток, в котором она была создана. Когда вы пытаетесь отправить сообщение о событии мертвому веб-воркеру, сообщения не будут проходить, и это также не приведет к возникновению ошибок.

// modifications in for.js
self.onmessage = function(event) {
    ...
    self.postMessage(x);
    self.close();
}

////////////////////// OR ///////////////////////

// modifications inmain.js
...
// listen to message event of worker
workerFor.onmessage = function(event){
    var div = document.getElementById('result');
    div.innerHTML = 'message received => ' + event.data;
    workerFor.terminate();
};
...

Итак, похоже, что мир веб-работников принадлежал нам 😀, но есть еще несколько вещей, которые нужно обсудить.

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

Когда мы отправляем полезную нагрузку по событию, используя событие postMessage, мы клонируем полезную нагрузку из одного контекста в другой. Это также известно как передача по значению. Например, предположим, что у нас есть огромный файловый BLOB-объект в ArrayBuffer в основном потоке, который нам нужно обработать внутри веб-воркера. Когда мы отправляем этот большой двоичный объект в качестве полезной нагрузки, среда выполнения копирует эти данные, а затем отправляет их веб-воркеру, чтобы любые изменения, сделанные внутри веб-воркера, не повлияли на исходную копию. Это удобно, но у него есть существенные недостатки. Если эта полезная нагрузка, в данном случае большой двоичный объект изображения, имеет значительно больший размер, то создание ее копии займет много времени, и, поскольку это происходит в основном потоке, она заблокирует всю страницу и вкладку браузера.

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

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

Переносимые объекты в веб-воркерах передаются с использованием немного другого синтаксиса метода postMessage.

worker.postMessage(payload, transferableObjects)

transferableObjects в приведенном выше синтаксисе - это массив передаваемых объектов, который должен быть передан веб-исполнителю без создания его копий. transferableObjects может быть [payload], если payload - объект / массив не JavaScript, или значения / элементы объекта payload, когда payload - объект / массив JavaScript. Помните, что transferableObjects всегда представляет собой массив передаваемых объектов.

Давайте посмотрим на простой пример.

Полезные данные события в случае Переданных объектов принимаются обычным образом. Приведенный выше пример выводит на консоль следующий результат.

Как вы можете видеть в строках [onmessage], буферы переданного массива пусты. Передача может происходить с обеих сторон, поскольку события могут отправляться как из main.js, так и из сервис-воркера.

Доступные объекты и API

Веб-воркер не имеет доступа ко всем API-интерфейсам браузера из-за его выполнения в другом потоке. Он не может получить доступ к DOM, потому что DOM не принадлежит его контексту. Объекты window и document также недоступны.

Но у него есть доступ только для чтения к объекту location и полный доступ к объекту navigator вместе с веб-API, такими как setTimeout, setInterval и application cache.

Детские работники

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

importScript ()

Мы можем импортировать любой внешний JavaScript внутри веб-воркера, используя функцию importScript, используя importScript(file.js, [...files]). Эта функция доступна в глобальном контексте веб-воркера, поэтому вы также можете использовать self для доступа к ней.

importScript('file1.js');
self.importScript('file2.js', 'file3.js');

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

Встроенные веб-воркеры

До сих пор мы видели код веб-воркера во внешнем файле JavaScript, но мы можем встроить код веб-воркера, создав URL-адрес большого двоичного объекта из кода JavaScript с помощью функции конструктора больших двоичных объектов. Затем этот URL-адрес большого двоичного объекта передается в функцию конструктора Worker. Это достигается, как показано ниже.

// create blob from JavaScript code (ES6 template literal)
var blob = new Blob([`
    self.onmessage = function(e) {
        postMessage('msg from worker');
    }
`]);
// create blob url from blob
var blobURL = window.URL.createObjectURL(blob);
// create web worker from blob url
var worker = new Worker(blobURL);
// send event to web worker
worker.postMessage(null);
// listen to message event from web worker
worker.onmessage = function (event) {
    console.log(event.data);
};

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

До сих пор мы использовали функцию конструктора Worker, но для общих веб-воркеров мы использовали функцию конструктора SharedWorker. Выделенные работники и общие работники более или менее работают одинаково, но в случае общих веб-работников они могут связываться и взаимодействовать с любым потоком, работающим в том же источнике (example.com). Я не собираюсь писать о Shared Workers, поскольку они реализованы не во всех браузерах, фактически, на данный момент только Chrome и Firefox поддерживают. Следовательно, нет смысла внедрять это в производство.

Но если вам интересно взглянуть на реализацию Shared Worker, посетите этот короткий блог по адресу https://www.sitepoint.com/javascript-shared-web-workers-html5/

Это был довольно большой блог о Рабочих. Но ждать!

Когда дело доходит до рабочих, нам не хватает еще одной вещи. Сервисные работники. Service worker - это основной компонент для разработки прогрессивных веб-приложений, и он очень мощный веб-воркер.