Статьи

Параллельный JavaScript с ParallelJS

Одной из самых крутых новых возможностей, появившихся вместе с HTML5, стал интерфейс Worker API Web Workers . Предварительно нам пришлось ввести некоторые хитрости, чтобы по-прежнему представлять пользователю адаптивный веб-сайт. Интерфейс Worker позволяет нам создавать функции, которые отличаются длительным временем выполнения и требуют больших вычислительных усилий. Кроме того, экземпляры Worker могут использоваться одновременно, давая нам возможность порождать столько рабочих, сколько мы захотим.

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

Почему многопоточность?

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

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

Масштабирование закона Мура

Чтобы использовать SMT, нам нужно написать параллельный код, то есть код, который выполняется параллельно для получения одного результата. Обычно нам нужно учитывать специальные алгоритмы, так как большинство последовательных кодов либо очень трудно распараллелить, либо очень неэффективно. Причина кроется в законе Амдала, который гласит, что ускорение S определяется

Закон Амдала

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

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

Многопоточность в JavaScript

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

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

С введением интерфейса Worker было найдено элегантное решение этой проблемы. С точки зрения нашего основного приложения, код веб-работника должен рассматриваться как одновременно выполняемая задача. Сообщение также выполняется таким образом. Мы используем API сообщений, который также доступен для связи с содержащихся веб-сайтов на страницу хостинга.

Например, следующий код отвечает на входящее сообщение, отправляя сообщение отправителю.

 window.addEventListener('message', function (event) { event.source.postMessage('Howdy Cowboy!', event.origin); }, false); 

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

Давайте рассмотрим простой пример вычисления последовательности простых чисел в фоновом режиме, используя два параметра для обозначения начала и конца последовательности. Сначала мы создаем файл с именем prime.js следующего содержания:

 onmessage = function (event) { var arguments = JSON.parse(event.data); run(arguments.start, arguments.end); }; function run (start, end) { var n = start; while (n < end) { var k = Math.sqrt(n); var found = false; for (var i = 2; !found && i <= k; ++i) { found = n % i === 0; } if (!found) { postMessage(n.toString()); } n++; } } 

Теперь нам нужен только следующий код в нашем основном приложении для запуска фонового работника.

 if (typeof Worker !== 'undefined') { var w = new Worker('prime.js'); w.onmessage = function(event) { console.log(event); }; var args = { start : 100, end : 10000 }; w.postMessage(JSON.stringify(args)); } 

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

 var fs = (function () { /* code for the worker */ }).toString(); var blob = new Blob( [fs.substr(13, fs.length - 14)], { type: 'text/javascript' } ); var url = window.URL.createObjectURL(blob); var worker = new Worker(url); // Now setup communication and rest as before 

Конечно, мы можем захотеть найти лучшее решение, чем такие магические числа (13 и 14), и, в зависимости от браузера, необходимо использовать запасной вариант для использования Blob и createObjectURL . Если вы не являетесь экспертом по JavaScript, то fs.substr(13, fs.length - 14) делает fs.substr(13, fs.length - 14) тела функции. Мы делаем это, превращая объявление функции в строку (используя вызов toString() ) и удаляя сигнатуру самой функции.

Разве библиотека не может помочь нам здесь?

Познакомьтесь с ParallelJS

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

 var p = new Parallel([1, 2, 3, 4, 5]); console.log(p.data); 

Поле данных возвращает предоставленный массив. Ничего «параллельного» еще не было вызвано. Однако экземпляр p содержит набор методов, например, spawn , которые создадут нового веб-работника. Он возвращает Promise , что облегчает работу с результатом.

 p.spawn(function (data) { return data.map(function (number) { return number * number; }); }).then(function (data) { console.log(data); }); 

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

Лучшее решение — использовать функцию map экземпляра Parallel .

 p.map(function (number) { return number * number; }).then(function (data) { console.log(data); }); 

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

 function factorial (n) { return n < 2 ? 1 : n * factorial(n - 1); } p.require(factorial) p.map(function (n) { return Math.pow(10, n) / factorial(n); }).reduce(function (data) { return data[0] + data[1]; }).then(function (data) { console.log(data); }); 

Функция reduce помогает объединять фрагментированные результаты в единый результат. Он предоставляет удобную абстракцию для сбора подрезультатов и выполнения некоторых действий после того, как все подрезультаты известны.

Выводы

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

Наряду со способностью использовать SMT в JavaScript, мы также можем использовать возможности векторизации. Здесь SIMD.js кажется жизнеспособным подходом, если поддерживается. Также использование GPU для вычислений может быть допустимым вариантом в некотором (надеюсь, не слишком отдаленном) будущем. В Node.js существуют обертки для CUDA (архитектура параллельных вычислений), но выполнение необработанного кода JavaScript все еще невозможно.

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

А что насчет тебя? Как вы используете возможности современного оборудования, используя JavaScript?