Event Loop, Callbacks, Promises, и Async/Await, часть 1 из 4

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

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

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

JavaScript-разработчики должны знать, как работать с асинхронными веб-API и обрабатывать ответ или ошибку в ходе выполнения этих операций. Далее речь пойдёт о цикле событий, взаимодействии с асинхронным поведением с помощью обратных вызовов (callbacks), обещаниях (promises) в ECMAScript 2015 и практике использования async/await.

Цикл событий (event loop)

В этой части разберёмся, как JavaScript обрабатывает асинхронный код с помощью цикла событий. Сначала рассмотрим работу цикла событий (event loop), а затем разберём два элемента цикла событий: стек (stack) и очередь (queue).

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

function first() {
  console.log(1)
}

function second() {
  console.log(2)
}

function third() {
  console.log(3)
}

В этом коде определяются три функции, которые с помощью console.log() выводят числа в консоль. Далее напишем вызовы функций:

first()
second()
third()

Вывод будет основан на порядке вызова функций: first(), second(), затем third().

1
2
3

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

Добавим setTimeout в функцию second() для имитации асинхронного запроса:

function first() {
  console.log(1)
}

function second() {
  setTimeout(() => {
    console.log(2)
  }, 0)
}

function third() {
  console.log(3)
}

Метод setTimeout принимает два аргумента: функцию, которая будет выполнена асинхронно, и время ожидания до вызова этой функции. В этом коде console.log обернули анонимной функцией и передали её в setTimeout, а затем указали вызов этой функции через 0 миллисекунд.

Теперь вызываем функции, как и раньше в том же порядке:

first()
second()
third()

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

1
3
2

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

Стек (stack)

Стек или стек вызовов хранит состояние того, какая функция в настоящее время выполняется. Концепцию стека можно представить, как массив со свойствами «Последний вошел — первым ушел» (LIFO), то есть можно добавлять или удалять элементы только из конца стека. JavaScript запустит текущий кадр (или вызов функции в определенной среде) в стеке, затем удалит его и перейдет к следующему.

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

  • Добавить first() в стек, запустить first(), которая выводит 1 в консоль, удалить first() из стека.
  • Добавить second() в стек, запустить second(), которая выводит 2 в консоль, удалить second() из стека.
  • Добавить third() в стек, запустить third(), которая выводит 3 в консоль, удалить third() из стека.

Второй пример с setTimout выглядит так:

  • Добавить first() в стек, запустить first(), которая выводит 1 в консоль, удалить first() из стека.
  • Добавить second() в стек, запустить second().
    • Добавить setTimeout() в стек, запустить веб-API setTimeout(), который запускает таймер и добавляет анонимную функцию в очередь, удалить setTimeout() из стека.
  • Удалить second() из стека.
  • Добавить third() в стек, запустить third(), которая выводит 3 в консоль, удалить third() из стека.
  • Цикл событий проверяет очередь на наличие любых ожидающих сообщений и находит анонимную функцию из setTimeout(), добавляет функцию в стек, которая записывает 2 в консоль, а затем удаляет её из стека.

Использование setTimeout, асинхронного веб-API, знакомит с концепцией очереди, которую рассмотрим далее.

Очередь (queue)

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

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

Примечание. Существует ещё одна очередь, называемая очередью заданий или очередью микрозадач, которая обрабатывает обещания (promises). Микрозадачи, такие как обещания, обрабатываются с более высоким приоритетом, чем макрозадания, такие как setTimeout.

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