Как управлять event loop в JavaScript. Часть 1
Event loop (событийные циклы) — важная часть архитектуры JavaScript. Мы попросили эксперта объяснить, как в этом разобраться.
vlada_maestro / shutterstock
Понимание работы event loop — неотъемлемый пункт карьерного роста middle-разработчика.
А сейчас о событийных циклах рассказывает Александр Кузьмин — ведущий программист с десятилетним опытом во frontend, руководитель отдела клиентской разработки компании IT-Park. Передаём слово эксперту.
Асинхронность в JavaScript
У каждого языка свой подход к параллельному вычислению данных. Например, в языках типа C++ оно передаётся в отдельный поток или даже процесс, который выполняется на другой машине.
Если нужно сообщить потоку что-то вроде «посчитай вот это и положи результат в базу данных, а я когда-нибудь приду за ними», мы имеем дело с асинхронными операциями.
Это значит, что код, который их вызвал, не ждёт завершения выполнения, а продолжает исполняться дальше. Если же мы хотим дождаться результата, у многих современных языков есть операторы async и await для синхронизации исполняемого кода.
В JavaScript асинхронность — основной инструмент. Во времена до появления Node.JS он был практически единственным языком исполнения сценариев на клиенте в вебе (Internet Explorer поддерживал VB Script, но его никто не использовал). Сейчас невозможно представить интернет, где все запросы на сервер отправлялись бы с перезагрузкой страницы. Напротив, мы пришли к одностраничному вебу, в котором на стороне клиента происходит разрешение адресов страниц и отображение соответствующего контента.
Любые данные от сервера запрашиваются асинхронно: отправляется запрос (XMLHttpRequest или XHR), и код не ждёт его возвращения, продолжая выполняться. Когда же сервер отвечает, объект XHR получает уведомление об этом и запускает функцию обратного вызова — callback, который передали в него перед отправкой запроса.
Если придётся ждать, пока запрос придёт, JavaScript перестанет принимать любые события, а страница зависнет. Чтобы пользователь спокойно использовал веб-приложение, запрос выводят из текущего контекста выполнения. Операции, результата которых приходится ждать, прежде чем продолжать выполнение кода, называются блокирующими. О них — во второй части статьи.
Суть кроется в устройстве языка:
JavaScript ориентирован на общение с пользователем, чтобы тот мог запускать несколько событий одновременно.
Если правильно использовать инструменты языка, то выполнение кода, которое происходит последовательно и в одном потоке, никак не мешает приёму событий и реакции на них — человек спокойно работает с интерфейсом, не замечая лагов, сбоев и зависаний.
Event loop в JavaScript — менеджер асинхронных вызовов
Чтобы этот хитрый процесс слаженно работал, в JavaScript реализован механизм для управления очерёдностью исполнения кода. Поскольку это однопоточный язык, возникла необходимость «вклиниваться» в текущий контекст исполнения. Этот механизм называется event loop — событийный цикл.
С английского loop переводится как «петля», что отлично отражает смысл: мы имеем дело с закольцованной очередью.
Event loop регулирует последовательность исполнения контекстов — стек. Он формируется, когда сработало событие или была вызвана функция. Реакция на событие помещается в очередь исполнения, в event loop, который последовательно, с каждым циклом выполняет попадающий в него код. При этом привязанная к событию функция вызывается следующей после текущего контекста исполнения.
В JavaScript постоянно работают связанные между собой синхронная и асинхронная очереди выполнения. Синхронная — стек — формирует очередь и пробрасывает в асинхронную — event loop — вызовы функций, которые будут выполнены после текущего запланированного исполняемого контекста.
Чтобы данные находились в консистентном состоянии, каждая функция должна быть выполнена до конца. Это обусловлено однопоточностью JavaScript и некоторыми другими особенностями, например характерными для функциональных языков программирования замыканиями. Поэтому единственный поток представлен в виде очереди контекстов исполнения, в которой и происходит «вклинивание» функций, прошедших через цикл событий.
Справка
В JavaScript существует понятие «контекст функции». Но есть и другой термин — «контекст исполнения». Это тело функции со всеми переменными и другими функциями, которое называют «область видимости», с английского — «scope». Важно не путать понятия, это принципиально разные вещи.
Как формируется контекст исполнения
JavaScript — интерпретируемый язык. Это значит, что любой код проходит через интерпретатор, который исполняет его построчно. Но и здесь есть нюансы.
Как только скрипт попадает в интерпретатор, формируются глобальный контекст и глобальная область видимости, в которой держится Variable Object, или VO — объект переменных.
Он формируется из переменных вида Function Declaration и атрибутов функции по следующему принципу. Интерпретатор считывает код и находит все объявления:
- переменных по ключевому слову var (const или let в ES6 и выше);
- функций, объявленных ключевым словом function, без присваивания.
Это складывается в VO текущего контекста исполнения. Затем берётся Variable Object внешней области видимости и к нему добавляется сформированный выше VO. Сверху он дополняется параметрами функции и их значениями на момент исполнения.
При этом нет разницы, в каком месте функции они определяются. Переменная может быть определена в любой части кода, как и функция.
Рассмотрим скрипт:
VO этого скрипта формируется:
- Из переменной a, значение которой — undefined.
- Переменной c, значение которой — undefined.
- Переменной b, значение которой — undefined.
- Функции func с соответствующим телом.
Затем скрипт начнет исполняться по следующему сценарию:
- В переменную a запишется значение 10.
- В переменную c запишется значение 7.
- В переменную b запишется значение 3.
- Будет вызвана функция func.
- Создается контекст исполнения функции func.
- В VO контекста исполнения функции func будут записаны переменные из внешней области видимости: a, c и b, c присвоенными значениями.
- В VO контекста исполнения функции func будут созданы переменные из списка аргументов; поскольку переменные a и b уже существуют в VO, добавлена будет только переменная d со значением undefined.
- В переменную a VO контекста исполнения функции func будет записано значение 10.
- В переменную b VO контекста исполнения функции func будет записано значение переменной a внешней области видимости — 10.
- В переменную d VO контекста исполнения функции func будет записано значение переменной b внешней области видимости — 3.
- Контекст исполнения функции func будет запущен.
- В консоль выведется 1010 7 3.
- В переменную c, находящуюся во внешней области видимости, будет записано значение 13.
- Контекст выполнения функции func будет завершён; VO функции func будет удалён.
- В консоль выведется 13.
Теперь перепишем скрипт, добавив setTimeout с нулевым тайм-аутом у вызова функции:
На первый взгляд может показаться, что ничего не изменится и функция func будет выполнена без задержки. Но это не так. На самом деле произойдёт следующее:
- В переменную a запишется значение 10.
- В переменную c запишется значение 7.
- В переменную b запишется значение 3.
- Функция func попадает в пул ожидания.
- Создаётся контекст исполнения функции func.
- По истечении 0 миллисекунд контекст исполнения функции func будет помещён в event loop.
- В консоль выведется 7.
- В VO контекста исполнения функции func будут записаны переменные из внешней области видимости: a, c и b, c присвоенными значениями.
- В VO контекста исполнения функции func будут созданы переменные из списка аргументов; поскольку переменные a и b уже существуют в VO, добавлена будет только переменная d со значением undefined.
- В переменную a VO контекста исполнения функции func будет записано значение 10.
- В переменную b VO контекста исполнения функции func будет записано значение переменной a внешней области видимости — 10.
- В переменную d VO контекста исполнения функции func будет записано значение переменной b внешней области видимости — 3.
- Контекст исполнения функции func будет запущен.
- В консоль выведется 1010 7 3.
- В переменную c, находящуюся во внешней области видимости, будет записано значение 13.
- Контекст выполнения функции func будет завершён; VO функции func будет удалён.
Почему так происходит?
Всему виной setTimeout, очевидно. Он выводит контекст исполнения функции из синхронного потока, помещая его в event loop. То же самое происходит и с регистрацией событий. Мы можем подписаться на событие при помощи функции addEventListener. Передавая функцию обратного вызова — callback, добавляем её в список функций, которые должны быть вызваны при срабатывании этого события.
Допустим, мы хотим нажатием на кнопку перекрасить её в красный цвет. Код, который это выполняет, выглядит так:
Переданная функция всегда выполняется через event loop. При возникновении события в цикл последовательно попадут все привязанные к нему функции. Для каждой будет формироваться контекст исполнения, который будет запущен следом за текущим.
Если в процессе будет вызвано ещё одно событие, его коллбэки будут вставать в очередь по тому же принципу, возможно, перемежаясь с контекстами исполнения первого события.
Более сложный пример: есть две кнопки, первая перекрашивает фон страницы в красный цвет, а вторая — в жёлтый, но у второй перекрашивание фона завёрнуто в setTimeout с нулевой задержкой. И мы вручную вызываем событие нажатия сначала на жёлтую кнопку, а потом — на красную.
Живой пример:
Что происходит в такой ситуации, исходя из того, что мы рассмотрели выше?
- Вызывается событие click на жёлтой кнопке.
- Формируется контекст исполнения для коллбэка жёлтой кнопки.
- Вызывается контекст исполнения для жёлтой кнопки.
- Контекст исполнения коллбэка setTimeout помещается в event loop.
- Вызывается событие click на красной кнопке.
- Формируется контекст исполнения для коллбэка красной кнопки.
- Вызывается контекст исполнения для красной кнопки.
- Фону страницы дается значение »#f00».
- Вызывается событие repaint для DOM.
- Фон страницы становится красным.
- Вызывается контекст исполнения коллбэка setTimeout.
- Фону страницы назначается значение «#ff0».
- Вызывается событие repaint для DOM.
- Фон страницы становится жёлтым.
Обратите внимание, что исполнение коллбэков событий click на кнопках при вызове из кода происходит сразу же, не попадая в event loop: setTimeout с нулевой задержкой отложил перекраску фона в жёлтый, но функция сама была исполнена в момент вызова.
Это происходит из-за того, что события из кода не требуется выполнять асинхронно. Действительно, в такой ситуации мы находимся в предсказуемом окружении, тогда как пользовательские события могут случаться в любой момент.
Это приводит к теме следующей части: об управлении событийным циклом и тем, что будет в него попадать. Подробно рассмотрим, как грамотно формировать цепочки последовательных асинхронно вызванных контекстов вызова, уменьшая вычислительную сложность каждого из них, освобождая поток и позволяя пользовательским событиям «вклиниваться» в очередь исполнения.