HTML5 Web Workers - фоновые вычисления

113

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

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

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

Изобретательные веб-разработчики нашли несколько частичных решений данной проблемы. Эти решения основаны на разбиении долговременных задач на несколько меньших частей и исполнении этих частей по одной с помощью метода setInternval() или setTimeout().

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

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

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

Поддержка фоновых потоков основными браузерами
Браузер IE Firefox Chrome Safari Opera Safari iOS Android
Минимальная версия 10 3.5 3 5 10.6 - -

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

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

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

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

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

Пример трудоемкой задачи

Для того чтобы оценить достоинства потоков, нам нужно найти фрагмент кода подходящей трудоемкости. Использовать потоки для небольших задач нет смысла. Но для вычислений, напрягающих центральный процессор, которые могут подвесить браузер больше, чем на пару секунд, потоки существенно меняют дело. Возьмем, например, приложение, показанное на рисунке ниже, которое генерирует простые числа в определенном диапазоне. Хотя код для решения этой задачи простой, сама задача требует трудоемких вычислений, выполнение которых может занять значительное время:

Пример трудоемкой задачи

Установив диапазон, в котором нужно выполнять поиск простых чисел, можно запустить поиск, нажав кнопку "Начать поиск". Для сравнительно узкого диапазона (как на этом рисунке — от 1 до 200 000) задача выполняется в течение нескольких секунд, не причиняя особенных неудобств пользователю. Но установите более широкий диапазон (например, от 1 до 5 000 000), и страница перестанет реагировать в течение нескольких минут, если не больше. Пользователь не сможет щелкать мышью элементы страницы, прокручивать ее или выполнять какие-либо другие действия; кроме этого, браузер может выдать сообщение типа "долго исполняющийся сценарий" или залить серым цветом всю страницу.

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

Эта разметка краткая и четкая. Страница использует два элемента управления <input>, для ввода начального и конечного числа диапазона. На ней имеется кнопка для запуска вычислений, а также два элемента <div> — один для вывода результатов, а другой для отображения сообщения о состоянии. Полностью вся разметка выглядит так:

<body>
   <p>Задайте диапазон чисел от <input id="from" value="1"> до <input id="to" value="200000">.</p>
   <button id="searchButton" onclick="doSearch()">Запустить поиск</button>

   <div id="primeContainer">
   </div>

   <div id="status"></div>
</body>

Одним интересным аспектом является оформление элемента <div> для отображения списка простых чисел. Для него устанавливается фиксированная высота и ширина, а применение свойств overflow и overfiow-x добавляет вертикальную полосу прокрутки (но не горизонтальную):

#primeContainer {
   border: solid 1px black;
   padding: 3px;
   height: 300px;
   max-width: 500px;
   overflow: scroll;
   overflow-x: hidden;
   font-size: x-small;
   margin-top: 20px;
   margin-bottom: 10px;
}

Код JavaScript немного длиннее, но не более сложный. Код извлекает числа из текстовых полей ввода, запускает процесс вычисления, а потом добавляет получаемые простые числа в список. Собственно математические вычисления для нахождения простых чисел этот код не выполняет. Эта задача передается отдельной функции findPrimes(), которая находится в отдельном файле PrimeWorkers.js.

Далее приведен полный код функции doSearch():

function doSearch() {
   // Получаем начальное и конечное число диапазона поиска
   var fromNumber = document.getElementById("from").value;
   var toNumber = document.getElementById("to").value;

   // Выполняем поиск простых чисел. (Это трудоемкая часть задачи.) 
   var primes = findPrimes(fromNumber, toNumber);

   // Перебираем в цикле все простые числа в массиве и 
   // конкатенируем их в одну длинную текстовую строку
   var primeList = "";
   for (var i=0; i<primes.length; i++) {
      primeList += primes[i];
      if (i != primes.length-1) primeList += ", ";
   }
   
   // Вставляем текст с простыми числами в страницу
   var primeContainer = document.getElementById("primeContainer");
   primeContainer.innerHTML = primeList;

   // Обновляем текст статусного сообщения, информируя пользователя 
   // о происходящем
   var statusDisplay = document.getElementById("status");
   if (primeList.length == 0) {
      statusDisplay.innerHTML = "Ошибка поиска.";
   }
   else {
      statusDisplay.innerHTML = "Простые числа найдены!";
   }
}

Сама функция поиска простых чисел:

function findPrimes(fromNumber, toNumber) {
   // Создать массив целых чисел в указанном диапазоне
   var list = [];
   for (var i=fromNumber; i<=toNumber; i++) {
      if (i>1) list.push(i);
   }
   
   // Выбираем простые числа
   var maxDiv = Math.round(Math.sqrt(toNumber));
   var primes = [];

   for (var i=0; i<list.length; i++) {
      var failed = false;
      for (var j=2; j<=maxDiv; j++) {
         if ((list[i] != j) && (list[i] % j == 0)) {
            failed = true;
         } else if ((j==maxDiv) && (failed == false)) {
            primes.push(list[i]);
         }
      }
   }

   return primes;
}

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

Выполнение вычислений в фоновом режиме

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

Далее приведен пример создания нового потока, который исполняет код в файле PrimeWorker.js:

var worker = new Worker("PrimeWorker.js");

Исполняемый потоком код всегда находится в отдельном файле JavaScript. Такой подход препятствует неопытным, но амбициозным программистам создавать код потока, который пытается использовать глобальные переменные или обращаться к элементам страницы напрямую.

Браузеры в обязательном порядке строго разделяют код веб-страницы и код потока. Например, код в файле PrimeWorker.js никаким образом не может напрямую записывать простые числа в элемент <div> страницы. Чтобы поместить свои данные на страницу, код потока должен отправить их коду JavaScript страницы, а уже этот код отображает их.

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

worker.postMessage(myData);

Страница получает событие onMessage, предоставляющее ей копию этих данных, и начинает их обрабатывать.

Прежде чем приступить к углубленному рассмотрению предмета потоков, нужно разобраться еще с одной особенностью. Функция postMessage() принимает только один параметр. Это представляет проблему для кода вычисления простых чисел, поскольку ему нужно передать две единицы данных — начальное и конечное значения диапазона вычислений. Проблема решается помещением этих чисел в литерал объекта JavaScript. Далее приведен пример создания такого объекта с двумя свойствами (первое — from, для начального числа диапазона, а второе — to, для конечного), которым также присваиваются значения:

worker.postMessage(
   { from: 1,
     to: 20000 }
};

Кстати, потоку можно отправить практически любой объект. За кулисами браузер посредством технологии JSON преобразует этот объект в обычный текст, дублирует его, а затем снова преобразует его в объект.

С учетом всех этих подробностей мы можем модифицировать рассмотренную ранее функцию doSearch(). Теперь вместо того чтобы выполнять вычисления по поиску простых чисел, функция doSearch() создает объект потока и усаживает его за эту работу:

function doSearch() {
   // Отключаем кнопку запуска вычислений, чтобы пользователь не мог 
   // запускать несколько процессов поиска одновременно
   searchButton.disabled = true;

   // Получаем начальное и конечное число диапазона поиска
   var fromNumber = document.getElementById("from").value;
   var toNumber = document.getElementById("to").value;

   // Создаем поток
   worker = new Worker("PrimeWorker.js");
   
   // Подключаем функцию к событию onMessage, чтобы получать 
   // сообщения от потока
   worker.onmessage = receivedWorkerMessage;
   
   worker.postMessage(
    { from: fromNumber,
       to: toNumber
    }
   );

   // Информируем пользователя, что вычисления выполняются
   statusDisplay.innerHTML = "Фоновый поток ищет простые числа (от "+
      fromNumber + " до " + toNumber + ") ...";   
}

Теперь к работе приступает код в файле PrimeWorker.js. Он получает событие onMessage, выполняет вычисления, а затем отправляет сообщение со списком простых чисел обратно на страницу:

onmessage = function(event) {
   // Выполняем поиск простых чисел в указанном диапазоне чисел.
   // Диапазон извлекается из свойства event.data
   var primes = findPrimes(event.data.from, event.data.to);
   
   // Поиск завершен. Отправляем результаты веб-странице
   postMessage(primes);
};

function findPrimes(fromNumber, toNumber) {
   ...
}

Когда поток вызывает метод postMessage(), он активирует событие onMessage, которое в свою очередь активирует следующую функцию в веб-странице:

function receivedWorkerMessage(event) {
  // Получаем список простых чисел
  var primes = event.data;
  
  // Отображаем список в соответствующей области страницы
  var primeList = "";
  for (var i=0; i<primes.length; i++) {
    primeList += primes[i];
    if (i != primes.length-1) primeList += ", ";
  }
  
  var primeContainer = document.getElementById("primeContainer");
  primeContainer.innerHTML = primeList;

  if (primeList.length == 0) {
    statusDisplay.innerHTML = "Ошибка поиска.";
  }
  else {
    statusDisplay.innerHTML = "Простые числа найдены!";
  }
  
  // Разблокируем кнопку запуска поиска
  searchButton.disabled = false;
}

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

Работа фонового потока

Обработка ошибок потоков

Как мы узнали, основным средством взаимодействия с потоками является метод postMessage(). Но поток может отправлять сообщения веб-странице еще одним способом — посредством события onerror, которое сигнализирует об ошибке:

function doSearch() {
  ...
  
   worker.onmessage = receivedWorkerMessage;
   worker.onerror = workerError;
  
  ...
}

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

function workerError(error) {
   statusDisplay.innerHTML = error.message;
}

Но кроме свойства message, объект error также имеет свойства lineno и filename, указывающие номер строки и имя файла соответственно, в которых произошла ошибка.

Отмена исполнения фоновой задачи

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

Это можно сделать двумя способами. В первом поток может остановить сам себя, вызвав метод close(). Но в большинстве случаев поток останавливается создавшей его страницей, вызывая метод terminate() объекта Worker (и это второй способ). Например, следующий код задействует кнопку прямой отмены исполнения потока:

<button onClick="cancelSearch()">Остановить поток</button>
function cancelSearch() {
   worker.terminate();
   worker = null;
   statusDisplay.innerHTML = "Поток остановлен.";
   searchButton.disabled = false;
}

Нажатие этой кнопки останавливает текущий поиск и возвращает блокировку кнопке запуска поиска. Но не забывайте, что остановив исполнение потока, вы не сможете больше отправлять ему сообщений, и его больше нельзя будет снова использовать для вычислений. Чтобы выполнить новый поиск, нужно будет создать новый объект Worker. (Но в этом примере это уже делается, поэтому он работает, как следует.)

Резервное решение для фоновых потоков

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

В качестве резервного решения для потоков можно обратиться к проекту Google Gears, который имеет подобную возможность — пул рабочих потоков (worker pool). Но даже при использовании функциональности из проекта Google Gears все равно нужно обеспечить еще одно резервное решение для компьютеров, на которых эта система не установлена. Самое легкое и простое решение — это выполнять те же вычисления на переднем плане:

if (window.Worker) {
   // Функциональность потоков поддерживается.
   // Создаем поток
} else {
   // Функциональность потоков не поддерживается. 
   // Можно просто вызвать функцию поиска простых чисел 
   // и ожидать ее завершения.
}

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

Обмен более сложными сообщениями

В последнем усовершенствовании нашего примера с потоком мы оснастим его возможностью предоставлять информацию о ходе выполнения задачи:

Индикация процесса выполнения фонового потока

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

Лучшим решением этой задачи будет добавление дополнительной информации в сообщение. Например, в сообщение о ходе исполнения поток может добавить метку "Progress", а в сообщение со списком простых чисел-— метку "PrimeList".

Чтобы поместить основную информацию и метку типа сообщения в одно сообщение, нам нужно создать литерал объекта. Это точно такой же метод, как и применяемый страницей для отправки потоку данных о границах диапазона вычислений. Текст метки типа сообщения присваивается в виде значения свойству messageType, а собственно сообщение помещается во второе свойство, data.

Далее приведен модифицированный код потока, который добавляет метку типа сообщения в сообщение со списком простых чисел:

onmessage = function(event) {
   // Выполняем поиск простых чисел
   var primes = findPrimes(event.data.from, event.data.to);
  
   // Поиск завершен. Отправляем результаты веб-странице
   postMessage({messageType: "PrimeList", data: primes});
};

Код в функции findPrimes() также вызывает метод postMessage() для отправки сообщений веб-странице. Он использует те же свойства messageType и data. Но теперь свойство messageType указывает, что сообщение является сообщением о ходе исполнения, а данные содержат значение процента завершения задачи:

function findPrimes(fromNumber, toNumber) {
  ...

  var previousProgress;

  for (var i=0; i<list.length; i++) {
    ...

    // Вычисляем процент выполнения задачи
    var progress = Math.round(i/list.length*100);
    if (progress != previousProgress) {
      postMessage(
       {messageType: "Progress", data: progress}
      );
      previousProgress = progress;    
    }
  }

  return primes;
}

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

function receivedWorkerMessage(event) {
  var message = event.data;

  if (message.messageType == "PrimeList") {
    // Отображаем список в соответствующей области страницы
	var primes = message.data;
	
    var primeList = "";
    for (var i=0; i<primes.length; i++) {
      primeList += primes[i];
      if (i != primes.length-1) primeList += ", ";
    }
  
    var primeContainer = document.getElementById("primeContainer");
    primeContainer.innerHTML = primeList;

    if (primeList.length == 0) {
      statusDisplay.innerHTML = "Ошибка поиска.";
    }
    else {
      statusDisplay.innerHTML = "Простые числа найдены!";
    }
    searchButton.disabled = false;
  }
  else if (message.messageType == "Progress") {
    statusDisplay.innerHTML = message.data + "% выполнено ...";
  }
}

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

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

Пройди тесты
Лучший чат для C# программистов