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

Освоение основ параллелизма в JavaScript

Первый вопрос: что такое параллелизм? Проще говоря, параллелизм — это способность языка программирования выполнять несколько задач одновременно. Чтобы представить это в перспективе, давайте рассмотрим пример.

Почему параллелизм вообще необходим?

Вы все написали такой код:

fetch(...).then(function handleResponse(res) {
  // Do something with the response.
})

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

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

Одна вещь за раз

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

Цикл событий в JavaScript

Забавно, как такую ​​простую вещь, как цикл обработки событий в JavaScript, можно сделать такой сложной. Это все равно, что пытаться объяснить, как завязывать шнурки, рассказывая о молекулярной структуре шнурков.

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

Две вещи, которые вы, должно быть, поняли. Во-первых, цикл событий очень похож на цикл while(true). Во-вторых, я очень плохо разбираюсь в схемах.

Что такое очереди задач в цикле событий JavaScript?

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

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

  1. Очередь «микрозадач»: туда помещаются все ваши .then обещания.
  2. Очередь «макрос задач»: settimeout() — ее основной пример.
  3. Очередь «анимационный кадр»: предназначена для анимации JavaScript с использованием requestAnimationFrame().
  4. Очередь «рендеринга»: обрабатывает обновления рендеринга в браузере.

Пример

Давайте разберем все это на примере:

setTimeout(function sayHello() {
  console.log("Hello");
}, 0);

function blockFor500MS() {
  // run a loop to block for 500 ms.
}

function getData() {
    // Return a promise with data
}

console.log("Me first");

blockFor500MS();

getData().then(function printData(data) {
    console.log(data);
});
// block once more
blockFor500MS();

Это относительно простой пример. Я пропустил части кода, чтобы упростить его, поэтому вы не можете просто поместить его в vs code и запустить. Но этого достаточно, чтобы проиллюстрировать суть.

В строке 1 кода мы используем setTimeout для задержки функции на ноль миллисекунд. Функция printHello готова к немедленному выполнению. Однако он не запустится, пока не будет выполнен весь основной код. Таким образом, он помещается в очередь обратного вызова (макроса задачи) для последующего запуска.

Затем мы достигаем console.log, текст немедленно регистрируется. Наша консоль выглядит так:

Me first

Допустим, мы находимся на 2 мс. Подходим к blockFor500MS и останавливаем их на 500 миллисекунд. Больше ничего так долго не делается. На 502 мс мы приближаемся к промису:

getData().then(function printData(data) {
    console.log(data);
});

getData отправляет запрос AJAX, поскольку запросы AJAX обрабатываются браузером, а не JavaScript (подробнее об этом позже), он не будет блокировать код. Мы на 503 мс.

Снова подходим к следующему блоку и код блокируется. Хотя 500 мс еще не прошло, наш AJAX-запрос возвращается, скажем, через 250 мс. Это 753 мс выполнения нашего кода. Однако он не запустится, пока не закончится наш основной код.

Наконец, основной код завершает работу на 1003 мс. sayHello стоит первым в очереди (с первых миллисекунд), но обещания имеют приоритет как всегда (бедный setTimeout). Функция printData запускается, допустим, запрос AJAX вернул эту строку: "How are you?". Он выводится на консоль.

Me first
How are you?

Наконец, нашему sayHello разрешено работать за 1004 мс.

Me first
How are you?
Hello

Изучение основных инструментов параллелизма для высокопроизводительного JavaScript

Давайте взглянем на доступные инструменты параллелизма в JavaScript. Я собираюсь дать краткий обзор и рассказать о них более подробно в следующем посте.

Обратные вызовы

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

Обещания

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

Асинхронный / ожидающий

Async/await — еще одна функция, которая усложняется таким большим количеством объяснений. Это просто синтаксический сахар поверх промисов, который позволяет вам писать асинхронный код в удобной для разработчиков манере.

Рабочие потоки

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

Веб-воркеры

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

SharedArrayBuffer и Atomics

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

RxJS

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

Читать Часть II: Улучшите свой JavaScript-код: работа с Promises и Async/Await

Заключение

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

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

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