JavaScript. Как это устроено? Всё про Call Stack, Event Loop, Callback queue, Macro- and Micro-tasks простым языком.
О чём мы будем говорить:
1. Как выполняется код JavaScript. 2 фазы.
2. Call Stack — стек вызовов.
3. Асинхронные задачи и Event Loop.
4. Макротаски и микротаски.
Как выполняется код JavaScript?
Итак, для того чтобы нам с вами лучше понять как выполняется код JavaScript, давайте сначала вспомним где выполняется код JavaScript и что это за язык.
Как мы уже наверное знаем, JavaScript это однопоточный язык программирования (если ты не знал, не переживай, далее ты это увидишь на примерах), но также он является одновременно компилируемым и интерпретируемым языком.
Как так?
Дело в том, что JavaScript компилируется JavaScript движком за несколько микросекунд до исполнения. Это называется JIT (Just in time compilation).
И да, JavaScript код компилируется не браузером, а JS — движком, который в каждом браузере свой. Самым популярным движком является V8, который есть в Google Chrome и Node.js, также есть и другие — например, SpiderMonkey в Firefox, JavaScriptCore в Safari.
Поскольку V8 (Google Chrome) самый популярный, то на его примере мы дальше и рассмотрим выполнение кода JavaScript.
Движок V8 содержит 2 основные части:
- Глобальная память (её ещё называют “куча”) (Memory Heap)
- Стек вызовов (Call Stack)
Итак, что это такое и зачем нам это?
Глобальная память — это большая неструктурированная область памяти, в которой движок хранит наши переменные и объявленные функции. Короче говоря, это просто большая “куча” с разными данными без какой-либо структуры.
Стек вызовов — это место, где JS — код считывается и выполняется строка за строкой.
Давайте теперь перейдем к простому примеру и посмотрим на следующий код:
Как обрабатывается этот код?
Фаза создания: наш код считывается движком, и как только он определяет первую строку, то кладёт ссылки в глобальную память, т.е. он создаёт в глобальной памяти разделы для нашей функции и переменной. И когда он прочитает приведённый выше код, то в глобальной памяти появятся соответствующие части.
P. S. Так как JS исполняется в некой среде — браузере или Node.js. В таких средах есть много заранее существующих функций и переменных, которые называются глобальными. Поэтому глобальная память будет содержать гораздо больше данных, чем просто переменную val и sum. “Куча” она и есть “куча” :)
А теперь вызовем функцию. Что произойдет?
Фаза выполнения:
Движок добавляет функцию в call stack и исполняет код в ней.
В Call stack элементы могут добавляться сверху. В call stack при исполнении добавляются наши функции и они не могут покинуть стек, пока над ними есть другие функции. (First In Last Out — первый пришел, последний ушел)
Давайте посмотри ещё вот на такой пример:
Что будет происходить тут?
- Движок читает первую строку, первая функция попадает в стек и начинается её выполнение.
- Когда функция выполнится, то в консоль будет выведена строка “Print 1”.
- Затем функция покинет стек (т. к. в стеке над ней больше не было других функций и она выполнила всё что должна)
Далее, все предыдущие 3 шага будут повторяться для других функций.
Что мы видим из этого примера? Подтверждение того, что JavaScript является однопоточным, потому что наши функции обрабатывает лишь один стек вызовов.
Давайте посмотрим на пример посложнее:
Как будет обрабатываться этот пример?
На данном примере хорошо видно как функция после попадания в call stack (стек вызовов) не может его покинуть, пока исполнения ожидают другие функции.
Потому что стек организован по принципу First In Last Out — первый пришел, последний ушел (или можно сформулировать наоборот — Last in First out (LIFO) последний пришел, первый ушел).
Отлично, теперь мы знаем как выполняется синхронный код в JavaScript и как работает call stack. И убедились на примерах, что JavaScript — это однопоточный язык, т.к. одновременно может выполняться только одна задача.
Асинхронность и Event Loop.
Теперь представьте, что вы запускаете задачу, которая занимает 30 секунд…
Да. Во время этой задачи мы ждем 30 секунд, прежде чем что-либо еще может произойти (по умолчанию JavaScript запускается в главном потоке браузера, поэтому весь пользовательский интерфейс будет ждать)😬.
Например, наш пользователь может выполнять какие-то действия на странице, т.е. он видит элементы управления, кнопки и т. д. — может на них нажимать. И если у нас программа не выполняется, то соответственно он ничего сделать не может и ничего не происходит.
И в 2022 году никто не хочет сайт, который тупит.
К счастью, браузер предоставляет нам некоторые функции, которые сам механизм (движок) JavaScript не предоставляет — это Web API. Коллекция удобных инструментов, которая включает в себя DOM API, setTimeout, HTTP-запросы и так далее. Это помогает нам создать асинхронное неблокирующее поведение 🚀.
И поэтому наш полный комплект выглядит вот так:
“Кучу” и Call Stack мы уже знаем; тут добавилось к нам Web API браузера и ещё 2 важных элемента — Callback Queue (очередь обратных вызовов) и Event Loop (цикл событий).
Что каждый из них делает и зачем они нам нужны мы сейчас с вами разберем вот на таком примере:
Уверен, что хоть раз ты видел setTimeout, однако можешь не знать, что эта функция не встроена в JavaScript.
Как так? Вот так, когда JavaScript появился, в нём не было функции setTimeout. По сути, она является частью Web API — коллекции удобных инструментов, которые нам предоставляет браузер.
Чудесно! Но что это означает на практике?
Поскольку setTimeout относится к браузерным API (Web API), эта функция исполняется самим браузером (на мгновение она появляется в стеке вызовов, но сразу оттуда удаляется)
Поэтому в примере 4, когда браузер дойдет до функции setTimeout, она сразу покинет стек и будет выполняться самим браузером.
И после 10 секунд (10 000 миллисекунд) браузер возьмёт callback функцию которую мы ему передали и передаст в очередь обратных вызовов (callback queue).
Callback queue — это структура данных в виде очереди,
и, как видно из названия, представляет собой упорядоченную очередь из функций (callbacks).
Каждая асинхронная функция должна пройти через очередь обратных вызовов, прежде чем попасть в стек вызовов.
Но кто отправляет функции дальше? Это делает компонент под названием цикл событий (event loop).
Пока что цикл событий занимается только одним: проверяет, пуст ли стек вызовов. Если в очереди обратных вызовов есть какая-нибудь функция и если стек вызовов свободен, тогда пора отправлять callback в стек вызовов.
Пример 4 можно продемонстрировать вот таким образом:
- На первом этапе наш код считывается движком и все объявленные функции и переменные помещаются в глобальную память.
(Фаза создания) - Далее, начинается выполнение кода (Фаза выполнения) — первый вызов функции sum(val), тут синхронная операция — простое сложение 2-х чисел.
- Затем обрабатывается вызов setTimeout. Callback который мы передали в setTimeout добавляется в Web API, функция setTimeout извлекается из стека вызовов.
- Таймер запускается. Когда отсчёт завершится — пройдет 10 секунд и функция callback добавится в очередь.
- Цикл обработки событий видит, что call stack пуст, после чего колбэк добавляется в стек вызовов.
- Callback вызывает console.log и выполнение кода завершается.
Макротаски.
Неужели всё так просто?
Да, но в реальных условиях обстоит немного иначе.
Сама идея цикла событий очень проста. Есть бесконечный цикл, в котором движок JavaScript ожидает задачи, исполняет их и снова ожидает появления новых.
Но что касается самих задач, то они бывают разные:
Примеры задач:
- Когда загружается внешний скрипт <script src=”…”>, то задача — это выполнение этого скрипта.
- Когда пользователь двигает мышь, задача — сгенерировать событие mousemove и выполнить его обработчики.
- Когда истечёт таймер, установленный с помощью setTimeout(func, …), задача — это выполнение функции func
- И так далее.
Задачи поступают на выполнение — движок выполняет их — затем ожидает новые задачи (во время ожидания практически не нагружая процессор компьютера).
Может так случиться, что задача поступает, когда движок занят чем-то другим, тогда она ставится в очередь.
Очередь, которую формируют такие задачи, называют «очередью макрозадач» (macrotask queue, термин v8).
И как мы знаем, задачи из очереди исполняются по правилу «первым пришёл — первым ушёл». Когда браузер заканчивает выполнение скрипта, он обрабатывает событие mousemove, затем выполняет обработчик, заданный setTimeout, и так далее.
Пока что всё просто, не правда ли?
Отметим две детали:
- Рендеринг (отрисовка страницы) никогда не происходит во время выполнения задачи движком. Не имеет значения, сколь долго выполняется задача. Изменения в DOM отрисовываются только после того, как задача выполнена.
- Если задача выполняется очень долго, то браузер не может выполнять другие задачи, обрабатывать пользовательские события, поэтому спустя некоторое время браузер предлагает «убить» долго выполняющуюся задачу. Такое возможно, когда в скрипте много сложных вычислений или ошибка, ведущая к бесконечному циклу.
Видели такой экран? (Ответ оставьте в комментариях)
Как думаете, что произойдет, если запустить в консоли браузера этот фрагмент кода?
Давайте попробуем выполнить этот скрипт в браузере Google Chrome. Для этого я создал простой HTML-документ и подключил в нем script.js с этим фрагментом кода. После открытия документа заходим в инструменты разработчика, и открываем вкладку Perfomance и жмем там кнопку ‘start profiling and reload page’:
Видим, что наши макротаски выполняются по одной в цикл, примерно раз в 4ms. (в браузере есть минимальная задержка в 4 миллисекунды при множестве вложенных вызовов setTimeout.
Даже если мы указываем задержку 0, на самом деле она будет равна 4 мс, или чуть больше.)
Давайте посмотрим вот на этот пример:
Здесь мы видим функцию main, включающую в себя два console.log, выводящих в консоль A и C. Между ними находится setTimeout, вызов которого выведет в консоль B после ожидания в 0 секунд.
Вот что происходит внутри во время исполнения (знакомая уже нам схема):
После выполнения последнего выражения функции main, элемент main удаляется из стека вызовов (call stack), оставляя его пустым. Стек вызовов должен быть пустым, для того чтобы браузер поместил в него элемент из callback queue. Именно по этой причине даже если в setTimeout указано время ожидания в 0 секунд, функция exec() не выполняется, пока не закончится выполнение всех элементов в стеке вызовов.
Таким образом аргумент delay в setTimeout(function, delayTime) не означает точное время задержки, после которого функция выполнится. Он означает минимальное время ожидания, после которого в какой-нибудь момент времени, функция будет вызвана.
Микротаски.
Помимо макрозадач, существуют микрозадачи.
Микрозадачи приходят только из кода. Обычно они создаются промисами: выполнение обработчика .then/catch/finally становится микрозадачей. Микрозадачи также используются «под капотом» await, т.к. это форма обработки промиса.
Также есть специальная функция queueMicrotask(func), которая помещает func в очередь микрозадач.
Сразу после каждой макрозадачи движок исполняет все задачи из очереди микрозадач перед тем, как выполнить следующую макрозадачу или отобразить изменения на странице, или сделать что-то ещё.
Давайте рассмотрим вот этот пример:
Какой здесь будет порядок?
- цифра “4” появляется первой, т.к. это обычный синхронный вызов console.log;
- цифра “2” появляется второй, потому что .then проходит через очередь микрозадач и выполняется после текущего синхронного кода;
- затем цифра “3”, а потом уже цифра “5”, потому что второй .then появился в очереди позже;
- и цифра “1” появляется последней, потому что это макрозадача.
Более подробное изображение событийного цикла выглядит так:
Все микрозадачи завершаются до обработки каких-либо событий или рендеринга, или перехода к другой макрозадаче.
На этом всё, надеюсь статья была вам полезной и теперь вы понимаете как работает цикл событий ( Event Loop ) в JS.
А чтобы проверить знания, попробуйте решить эту задачку и затем проверить себя, запустив код в браузере.
Всем добра)
Если вам понравилась статья, внизу можете поддержать хлопками👏🏻 Спасибо за прочтение!
Использованные источники: