[Перевод] Веб-воркеры в JavaScript: безопасный параллелизм

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

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

image

Веб-воркеры позволяют выполнять JavaScript-код в отдельном потоке, который совершенно независим от главного потока и от того, что в нём обычно происходит.

В последнее время было много разговоров о том, какие практические задачи можно решать с помощью веб-воркеров. Учитывая ту вычислительную мощь, которой обладают даже обычные современные персональные компьютеры, и то, что мобильные устройства приближаются к ним в плане производительности и объёма памяти, теперь в приложениях для браузеров можно делать много такого, что раньше считалось слишком сложным.

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

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


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

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

Запуск веб-воркера сводится к созданию соответствующего объекта с передачей ему пути к файлу с JavaScript-кодом.

new Worker(‘worker-script.js’)


После создания воркер работает в отдельном потоке, независимом от главного потока, выполняя любой код, который передан ему в виде файла. Браузер, при поиске указанного при создании веб-воркера файла, использует относительный путь, корнем которого является папка, в которой находится текущая HTML-страница.

Данные между воркерами и главным потоком передаются с помощью двух взаимодополняющих механизмов:

  • Функция postMessage() используется передающей стороной.
  • Обработчик события message применяется на принимающей стороне.


Обработчик события message принимает аргумент события, действуя так же, как и другие обработчики. Этот аргумент имеет свойство data, в котором содержатся данные, переданные принимающей стороне.

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

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

var worker = new Worker("demo1-hello-world.js");

// Получение сообщений, переданных при вызовах postMessage() в веб-воркере
worker.onmessage = (evt) => {
    console.log("Message posted from webworker: " + evt.data);
}

// Передача данных веб-воркеру
worker.postMessage({data: "123456789"});


Вот как выглядит код веб-воркера, в котором организована обработка сообщений, поступающих со страницы и механизм отправки ответов:

// demo1-hello-world.js
postMessage('Worker running');
onmessage = (evt) => {
    postMessage("Worker received data: " + JSON.stringify(evt.data));
};


После выполнения этого кода в консоли будет выведено следующее:

Message posted from webworker: Worker running
Message posted from webworker: Worker received data: {"data":"123456789"}


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

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


Поток каждого воркера имеет выделенное, изолированное глобальное окружение, которое отличается от того JavaScript-окружения, в котором работает код, находящийся на HTML-странице. У воркеров нет доступа к механизмам, доступным из окружения страницы. У них нет доступа к DOM, они не могут работать с объектами window и document.

У воркера есть собственные версии некоторых механизмов, вроде объекта console для логирования сообщений в консоль разработчика, и объекта XMLHttpRequest для выполнения AJAX-запросов. Однако, в других вопросах, ожидается, что код, выполняемый воркером, самодостаточен. Так, например, данные, из потока воркера, которые планируется использовать в главном потоке, должны быть переданы в виде объекта data через функцию postMessage().

Более того, данные, передаваемые с помощью функции postMessage(), копируются, то есть, изменения в эти данные, вносимые главным потоком, не повлияют на исходные данные, находящиеся в потоке воркера. Это — внутренний механизм защиты от конфликтующих параллельных изменений данных, которые передаются между главным потоком и потоком воркера.

Варианты использования веб-воркеров


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

Вот некоторые из возможных вариантов использования веб-воркеров:

  • Предварительная загрузка или кэширование данных для последующего использования.
  • Загрузка данных с веб-сервисов и обработка этих данных.
  • Обработка и подготовка к выводу больших объёмов данных (вроде каких-нибудь научных изображений).
  • Вычисления, связанные с обработкой перемещений в играх.
  • Обработка и фильтрация изображений.
  • Обработка текстовых данных (проверка синтаксиса текстов программ, проверка правописания в обычных текстах, подсчёт количества слов).


В простейшем случае, выбирая задачу для решения с помощью веб-воркера, стоит обратить внимание на объём вычислений, необходимый для её решения. Однако очень важным может быть и учёт времени, необходимый, например, для доступа к сетевым ресурсам. Очень часто сеансы обмена данными через интернет могут занимать совсем мало времени, миллисекунды, но иногда сетевые ресурсы могут оказаться недоступными, обмен данными может останавливаться до восстановления соединения или до наступления тайм-аута запроса (что может занять 1–2 минуты).

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

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

Обработка столкновений в игре


В наши дни весьма распространены HTML5-игры, которые выполняются в браузерах. Один из центральных игровых механизмов — это обсчёт передвижений и взаимодействий объектов игрового мира. У некоторых игр количество движущихся элементов сравнительно невелико, анимировать их несложно (например, как в этом варианте Super Mario). Однако давайте предположим, что перед нами игра, которая требует более интенсивных вычислений.

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

Обработка столкновений с границами — задача сравнительно простая, на её решение не требуется серьёзных вычислений, однако обнаружение столкновений объектов друг с другом может потребовать немало вычислительных ресурсов, так как сложность такой задачи, если не вдаваться в детали, пропорциональна квадрату количества объектов. А именно, для n шаров, нужно проверить положение каждого из них в сравнении со всеми остальными для того, чтобы понять, не пересекаются ли они, и не нужно им менять направление движения, реализуя отскок, что приводит к числу операций, равному n в квадрате.

Итак, для 50 шаров нужно провести порядка 2500 сравнений. Для 100 шаров — требуется уже 10000 проверок (на самом деле, это число немного меньше чем половина указанного, так как, если произведена проверка на столкновение шара n с шаром m, то производить проверку столкновения шара m с шаром n уже не нужно, однако, несмотря на это, для решения подобной задачи потребуется большой объём вычислений).

В данном примере выполнение вычислений для обработки столкновений шаров друг с другом и с границами игрового поля выполняется в отдельном потоке веб-воркера. Обращение к этому потоку производится 60 раз в секунду, что соответствует скорости анимации браузера, или каждому вызову requestAnimationFrame(). Здесь мы описываем объект World, который содержит список объектов Ball. Каждый объект Ball хранит сведения о своей текущей позиции и о скорости (также тут имеются сведения о радиусе и о цвете объекта, которые позволяют вывести его на экран).

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

Объект World передаётся между клиентским кодом в браузере и потоком воркера. Это — сравнительно небольшой объект, даже для нескольких сотен шаров (скажем, для 100 шаров, учитывая то, что на один надо примерно 64 байта данных, общий объем будет примерно 6400 байт). В результате главная проблема здесь — не передача данных об игровых объектах, а вычислительная нагрузка на систему.

Полный код этого примера можно найти здесь. Тут имеется, в частности, класс Ball, который используется для представления анимированных объектов, и класс World, реализующий методы move() и draw(), которые и выполняют анимацию.

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

const canvas = $('#democanvas').get(0),
    canvasBounds = {'left': 0, 'right': canvas.width,
        'top': 0, 'bottom': canvas.height},
    ctx = canvas.getContext('2d');

const numberOfBalls = 150,
    ballRadius = 15,
    maxVelocity = 10;

// Создаём объект World
const world = new World(canvasBounds), '#FFFF00', '#FF00FF', '#00FFFF'];

// Добавляем объекты Ball в объект World
for(let i=0; i < numberOfBalls; i++) {
    world.addObject(new Ball(ballRadius, colors[i % colors.length])
            .setRandomLocation(canvasBounds)
            .setRandomVelocity(maxVelocity));
}
...
// Цикл анимации
function animationStep() {
    world.move();
    world.draw(ctx);
    requestAnimationFrame(animationStep);
}
animationStep();


Тут используется requestAnimationFrame() для вызова функции animationStep() 60 раз в секунду, в рамках периода обновления экрана. Шаг анимации состоит из вызова метода move(), обновляющего позицию каждого шара (и, возможно, направление его движения), и из вызова метода draw(), который выводит, средствами объекта canvas, шары в новых позициях.

Для того чтобы использовать поток воркера в этой программе, вычисления, выполняемые при вызове метода move(), то есть, код из World.move(), должны быть вынесены в воркер. Объект World будет передаваться, в виде объекта data, в поток воркера, с использованием вызова postMessage(), что позволит выполнять здесь вызов метода move(). Очевидно, что передавать между главным потоком и веб-воркером нужно объект World, так как в нём содержится список объектов Ball, выводимых на экран, и данные о прямоугольной области, в пределах которой они должны оставаться. При этом объекты Ball содержат всю информацию о позиции, о скорости, и о направлении движения соответствующих шаров.

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

let worker = new Worker('collider-worker.js');

// Ожидание события draw
worker.addEventListener("message", (evt) => {
    if ( evt.data.message === "draw") {
        world = evt.data.world;
        world.draw(ctx);
        requestAnimationFrame(animationStep);
    }
});

// Цикл анимации
function animationStep() {
    worker.postMessage(world);  // world.move() in worker
}
animationStep();


Вот как будет выглядеть код воркера:

// collider-worker.js
importScripts("collider.js");

this.addEventListener("message", function(evt) {
    var world = evt.data;
    world.move();
    // Сообщаем главному потоку о том, что нужно обновить изображение
    this.postMessage({message: "draw", world: world});
});


Код, представленный здесь, основан на том, что поток веб-воркера принимает объект World, переданный ему с помощью postMessage() из главного потока, а затем передаёт такой же объект назад в главный поток, предварительно вычислив новые значения для положения и скорости объектов игрового мира. Помните о том, что браузер делает копию данного объекта при его передаче между потоками. Здесь мы исходим из предположения, что время, необходимое на создание копии объекта World значительно меньше, чем O (n**n), то есть, время, необходимое для обнаружения столкновений, (на самом деле, в объекте World хранится сравнительно небольшой объём данных).

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

Uncaught TypeError: world.move is not a function
at collider-worker.js:10


Оказывается, что в процессе копирования объекта при передаче его с помощью функции postMessage(), осуществляется копирование данных свойств объекта, но не его прототипа. Методы объекта World отделяются от прототипа, когда объект копируется и передаётся воркеру. Это — часть алгоритма структурного клонирования, стандартного способа копирования объектов при передаче их между главным потоком и веб-воркером. Этот процесс известен ещё как сериализация.

Для того чтобы избавиться от вышеописанной ошибки, добавим в класс World метод для создания его нового экземпляра (который будет иметь прототип с методами) и переназначения свойств этого объекта на основе данных, переданных с помощью postMessage():

static restoreFromData(data) {
    // Восстановление объекта на основе данных, переданных в сериализованном виде в поток воркера
    let world = new World(data.bounds);
    world.displayList = data.displayList;
    return world;
}


Попытка выполнить код после этих изменений приводит к ещё одной, похожей ошибке. Дело в том, что список объектов Ball, которые хранит объект World, тоже нужно восстанавливать:

Uncaught TypeError: obj1.getRadius is not a function
at World.checkForCollisions (collider.js:60)
at World.move (collider.js:36)


Реализацию класса World нужно расширить для того, чтобы тут выполнялось восстановление каждого объекта Ball на основе данных, переданных в postMessage(), так же, как выполняется восстановление самого класса World.

Теперь класс World будет выглядеть так:

static restoreFromData(data) {
    // Восстановление объекта на основе данных, переданных в сериализованном виде в поток воркера
    let world = new World(data.bounds);
    world.animationStep = data.animationStep;
    world.displayList = [];
    data.displayList.forEach((obj) => {
        // Восстановление каждого объекта Ball
        let ball = Ball.restoreFromData(obj);
        world.displayList.push(ball);
    });
    return world;
}


Похожий метод restoreFromData() реализован и в классе Ball:

static restoreFromData(data) {
    // Восстановление объекта на основе данных, переданных в сериализованном виде в поток воркера
    const ball = new Ball(data.radius, data.color);
    ball.position = data.position;
    ball.velocity = data.velocity;
    return ball;
}


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

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

Пороговая обработка изображений


В этом примере мы рассмотрим приложение, которое создаёт значительную нагрузку и на процессор, и на память. Оно берёт пиксельные данные из изображения, представленного в виде HTML5-объекта canvas и трансформирует их, создавая на их основе другое изображение.

Здесь мы используем библиотеку для обработки изображений, созданную в 2012 году Илмари Хейкиненом. Программа будет принимать цветное изображение и конвертировать его в бинарное чёрно-белое изображение. В ходе преобразования будет использоваться пороговое значение серого цвета: пиксели, цветовые значения которых в сером цвете меньше, чем этот порог, станут чёрными, пиксели с большими значениями станут белыми.

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

Filters.threshold = function(pixels, threshold) {
    var d = pixels.data;
    for (var i=0; i < d.length; i+=4) {
        var r = d[i];
        var g = d[i+1];
        var b = d[i+2];
        var v = (0.2126*r + 0.7152*g + 0.0722*b >= threshold) ? 255 : 0;
        d[i] = d[i+1] = d[i+2] = v
    }
    return pixels;
};


Вот исходное изображение.

a3322800371f82e36e80f116440bbe5c.jpg


Исходное изображение

Вот то, что получается после обработки.

73ca2bcf048038c210fb9799a3f3c9f8.png


Обработанное изображение

Код примера можно найти здесь.

Даже при работе с маленькими изображениями, объёмы данных, которые нужно обработать, равно как и вычислительные затраты на обработку, могут быть значительными. Например, в изображении размера 640×480 пикселей имеется 307200 пикселей, каждому из которых соответствует 4 байта RGBA-данных (A — это альфа-канал, задающий прозрачность цвета). В результате, размер такого изображения составляет примерно 1,2 Мб. Наш план заключается в том, чтобы использовать веб-воркер для перебора пиксельных данных и преобразования их цветовых значений. Пиксельные данные для изображения будут передаваться из главного потока веб-воркеру, а модифицированное изображение будет возвращаться из воркера в главный поток. Хорошо было бы, если бы не нужно было копировать эти данные каждый раз, когда они пересекают границу между главным потоком и потоком воркера.

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

       
...


Тут можно использовать любой объект, реализующий интерфейс Transferable. Конструкция data.buffer объекта ImageData соответствует этому требованию — она имеет тип Uint8ClampedArray (массивы этого типа предназначены для хранения 8-ми битных данных изображений). ImageData — это то, что возвращает метод getImageData(), вызванный для context объекта canvas HTML5.

Интерфейс Transferable реализуют несколько стандартных типов данных: ArrayBuffer, MessagePort, и ImageBitmap. ArrayBuffer, в свою очередь, представлен некоторым количеством типов массивов: Int8Array, Uint8Array, Uint8ClampedArray, Int16Array, Uint16Array, Int32Array, Uint32Array, Float32Array, Float64Array.

В результате, если теперь данные передаются между потоками по ссылке, а не по значению, могут ли эти данные быть модифицированы из двух потоков одновременно? Стандарт запрещает подобное поведение. Когда данные передаются с помощью postMessage(), доступ к данным у передающей стороны блокируется (в документации используется термин «neutered»). Передача данных в обратном направлении с помощью postMessage() блокирует доступ к ним в веб-воркере, но делает возможной работу с ними из главного потока. Всё это реализовано средствами JS-движка.

Итоги


Веб-воркеры HTML5 предоставляют разработчику механизм для переноса тяжёлых вычислений в отдельных поток, благодаря чему эти вычисления не нагружают главный поток, ответственный за обработку событий в браузере.

Здесь мы рассмотрели несколько примеров, иллюстрирующих особенности веб-воркеров:

  • Базовый пример обмена сообщениями посредством функции postMessage() и обработчиков события message.
  • Пример, демонстрирующий выполнение тяжёлых вычислений для обнаружения столкновений объектов при выполнении HTML5-анимации.
  • Пример, реализующий технику пороговой обработки изображений, который требует и интенсивных вычислений, и большого объёма памяти, демонстрирующий технику передачи большого массива данных по ссылке в функции postMessage().


В ходе рассмотрения этих примеров мы продемонстрировали некоторые проблемы и особенности реализации веб-воркеров:

  • Процесс сериализации, который применяется при передаче JavaScript-объектов с помощью функции postMessage(), не предусматривает копирования методов прототипа объекта. Для того чтобы обойти эту проблему, понадобилось написать дополнительный код.
  • При передаче массива пиксельных данных, полученных после вызова метода getImageData(), свойство buffer объекта с пиксельными данными должно было быть передано с помощью функции postMessage() (выглядит это как imageData.data.buffer, а не imageData.data). Этот буфер реализует интерфейс Transferable и может быть передан по ссылке.


Веб-воркеры в настоящее время поддерживает большинство современных браузеров. В частности, браузеры Chrome, Safari и FireFox поддерживают их примерно с 2009 года. Веб-воркеры поддерживаются и в MS Edge, и поддерживались в Internet Explorer начиная с IE10.

Если вы используете в своём проекте веб-воркеры, то для проверки совместимости этого проекта с конкретным браузером достаточно выполнить простую проверку вида if (typeof Worker !== "undefined"). Если окажется, что веб-воркеры в браузере не поддерживаются, можно, если подобное предусмотрено, перейти на альтернативный вариант кода, в котором воркеры не используются (код при таком подходе можно выполнять по тайм-ауту или по вызову requestAnimationFrame()).

Уважаемые читатели! Пользуетесь ли вы веб-воркерами?

1ba550d25e8846ce8805de564da6aa63.png

© Habrahabr.ru