В этой статье вы узнаете, как работает асинхронность в JavaScript
JavaScript — однопоточный язык программирования. Движок JS единовременно может обрабатывать только одно выражение — в одном потоке.
С одной стороны, отсутствие многопоточности упрощает написание кода, потому что вам ненужно беспокоиться о проблемах параллельного выполнения. С другой стороны, вы не можете выполнять длительные операции, например сетевой доступ, не блокируя основной поток.
Представьте себе запрос данных из API. В зависимости от ситуации, серверу потребуется какое-то время на обработку запроса, а пока основной поток занят — страница будет неотзывчива.
В таких случаях нужна асинхронность. Используя асинхронный JavaScript (например callback, promise, и async/await), вы можете выполнять длительные сетевые запросы, не блокируя основной поток.
Необязательно знать, что происходит «под капотом» JavaScript, но полезно понимать, как это работает?
Начнём разбираться.
Как работает синхронный JavaScript?
Перед тем как перейти к асинхронному JavaScript, сначала разберёмся — как выполняется синхронный код внутри движка JS. Например, этот:
const second = () => { console.log('Hello there!'); } const first = () => { console.log('Hi there!'); second(); console.log('The End'); } first();
Чтобы понять, как выполняется этот код внутри JavaScript, нам нужно знать, что такое контекст выполнения и стек вызовов (или, стек выполнения).
Контекст выполнения
Это абстрактная концепция окружения, где код анализируется и выполняется. Всякий раз, когда в JavaScript выполняется код, это происходит внутри контекста выполнения.
Код функции выполняется внутри контекста выполнения функции, а глобальный код выполняется в глобальном контексте выполнения. У каждой функции есть свой контекст выполнения.
Стек вызовов
Здесь название говорит само за себя. Это стек со структурой LIFO (Last in, First out), в котором хранятся все контексты выполнения, созданные в течение выполнения кода.
В JavaScript есть только один стек вызовов, так как это однопоточный язык. Стек вызовов имеет структуру LIFO, поэтому элементы могут быть добавлены или удалены только сверху стека.
Вернёмся к нашему коду, чтобы разобраться как он выполняется внутри JavaScript.
const second = () => { console.log('Hello there!'); } const first = () => { console.log('Hi there!'); second(); console.log('The End'); } first();
Итак, что же здесь происходит?
При исполнении этого кода, был создан глобальный контекст выполнения (main()
) и помещён в стек, сверху. Когда в коде встречается вызов first()
, он также помещается в стек, сверху.
Далее, в стек попадает console.log('Hi there!')
, также сверху, а после завершения вылетает из стека. Далее мы вызываем функцию second()
, с ней происходит то же самое.
Далее, console.log('Hello there!')
попадает в стек и после завершения вылетает из него. Функция second()
завершается и то же вылетает из стека.
console.log(‘The End’)
попадает в стек и вылетает после завершения. После этого завершается функция first()
и вылетает из стека.
На этом выполнение программы завершено и глобальный контекст выполнения (main()
) вылетает из стека.
Как работает асинхронный JavaScript?
Теперь, когда у вас есть представление о стеке вызовов и синхронном JS, можно переходить к асинхронному JavaScript.
Что такое Блокировка?
Допустим, мы обрабатываем изображение и сетевой запрос, синхронным способом. Например:
const processImage = (image) => { /** * doing some operations on image **/ console.log('Image processed'); } const networkRequest = (url) => { /** * requesting network resource **/ return someData; } const greeting = () => { console.log('Hello World'); } processImage(logo.jpg); networkRequest('www.somerandomurl.com'); greeting();
На обработку изображения и сетевого запроса потребуется время. Продолжительность выполнения функции processImage()
зависит от размера изображения.
После своего завершения функция processImage()
вылетает из стека, после чего мы видим вызов функции networkRequest()
, которая отправляется в стек. И всё это требует время на выполнение.
После завершения networkRequest()
, вызывается функция greeting()
, она выполняется незамедлительно, так как содержит только отчёт console.log,
который выполняется довольно быстро.
Как видите, мы должны ожидать завершения функции (в нашем случае processImage()
или networkRequest()
). Эти функции блокируют стек выполнения или основной поток. До их завершения мы не сможем выполнить другие операции.
Какое здесь может быть решение?
Наиболее простое решение — использовать асинхронные callback’и, чтобы избавить наш код от блокировок. Например:
const networkRequest = () => { setTimeout(() => { console.log('Async Code'); }, 2000); }; console.log('Hello World'); networkRequest();
Я использовал метод setTimeout
, чтобы симулировать сетевой запрос. Имейте в виду, что этот метод является частью web API (в браузере) и C/C++ API (в node.js), в движке JavaScript этого метода нет.
Чтобы понять, как выполняется этот код, нужно разобраться с ещё двумя концепциями: event loop (цикл обработки событий) и callback queue (очередь сообщений).
Event loop, web API и callback queue ― не являются частью движка JavaScript. Это всё относится к среде выполнения браузера или среде выполнения Nodejs (если речь идёт о Nodejs). В Nodejs, web API заменяется C/C++ API.
Давайте посмотрим, как выполняется код асинхронным способом.
const networkRequest = () => { setTimeout(() => { console.log('Async Code'); }, 2000); }; console.log('Hello World'); networkRequest(); console.log('The End');
Когда этот код загружается в браузер, в стек попадает console.log(‘Hello World’)
, и удаляется из стека после своего завершения. Далее в стек попадает networkRequest()
.
Далее в стеке оказывается функция setTimeout()
. У этой функции есть два аргумента: 1) callback и 2) время в микросекундах (мкс).
Метод setTimeout()
запускает таймер на 2s
в окружении web API. На этом этапе выполнение setTimeout()
завершено и он удаляется из стека. Далее в стек попадает console.log('The End')
, и после своего завершения также удаляется из стека.
Тем временем таймер истёк, и теперь callback попадает в message queue(очередь сообщений). Но callback выполняется не сразу, и именно здесь начинается цикл обработки событий.
Цикл обработки событий
Задача цикла обработки событий ― следить за состоянием стека вызовов и определять пуст он или нет. Если стек пуст, то проверяется message queue, нет ли там вызовов, ожидающих выполнения.
В нашем случае, message queue содержит один callback, а стек вызовов в этот момент пуст. Таким образом цикл обработки событий отправляет этот callback в стек.
После этого, в стек отправляется console.log(‘Async Code’)
, и после выполнения удаляется из него. На этом этапе, выполнение callback завершено, и он удалён из стека. Программа наконец-то завершена.
Message queue также содержит callback’и от DOM-событий, такие как click events и keyboard events. Например:
document.querySelector('.btn').addEventListener('click',(event) => { console.log('Button Clicked'); });
В случае с DOM-событиями, «прослушиватель событий» находится в веб API окружении, ожидая определённого события (в этом случае click event). Когда событие происходит — callback функция помещается в message queue и ожидает выполнения.
Цикл обработки событий проверяет, пуст ли стек вызовов. Если стек пуст, то в него отправляется event callback для дальнейшего исполнения.
Отсрочка Выполнения Функции
Мы также можем использовать setTimeout
, чтобы отложить выполнение функции, пока стек не будет очищен. Пример:
const bar = () => { console.log('bar'); } const baz = () => { console.log('baz'); } const foo = () => { console.log('foo'); setTimeout(bar, 0); baz(); } foo();
Результат выполнения:
foo baz bar
Разберём этот код: первой вызывается функция foo()
; внутри foo
вызывается console.log('foo')
; далее вызывается setTimeout()
с bar()
в качестве callback и таймером 0 seconds
.
Если мы не используем setTimeout
, то функция bar()
будет выполнена незамедлительно, но используя setTimeout
с таймером 0 секунд ― мы откладываем выполнение bar
, до опустошения стека.
После того как таймер истёк, callback функция bar()
помещается в message queue, где она ожидает исполнения. Но она начнёт выполняться только тогда, когда стек будет полностью пуст, то есть после завершения функций baz
и foo
.
Очередь заданий в ES6
Мы узнали, как выполняются асинхронные callback’и и DOM events, которые используют message queue для хранения всех callback’ов, ожидающих исполнения.
В ES6 представили концепцию job queue (очередь заданий), которая используется промисами. Разница между message queue и job queue в том, что последний имеет более высокий приоритет. Это означает, что промисы внутри job queue исполняются перед callback’ами внутри message queue.
Пример:
const bar = () => { console.log('bar'); }; const baz = () => { console.log('baz'); }; const foo = () => { console.log('foo'); setTimeout(bar, 0); new Promise((resolve, reject) => { resolve('Promise resolved'); }).then(res => console.log(res)) .catch(err => console.log(err)); baz(); }; foo();
Результат выполнения:
foo baz Promised resolved bar
Мы видим, что промисы выполняются до setTimeout
, потому что их ответ хранится в job queue, у которого более высокий приоритет чем у message queue.
Заключение
Итак, мы узнали, как работает асинхронный JavaScript и такие концепции как call stack, event loop, message queue и job queue. Вместе они создают среду выполнения JavaScript. Знать все эти понятия не обязательно, чтобы быть классным JavaScript-разработчиком, но точно будет полезно.
Перевод статьи Sukhjinder Arora: Understanding Asynchronous JavaScript — the Event Loop