Цикл событий (event loop) — ключ к асинхронному программированию на JavaScript. Сам по себе язык однопоточный, но использование этого механизма позволяет создать дополнительные потоки, чтобы код работал быстрее. В этой статье разбираемся, как устроен стек вызовов и как они связаны с циклом событий.
Это адаптированный перевод выступления Роберта Филлипса, JavaScript-разработчика из британской компании Andea, на конференции JSConf. Повествование ведется от лица автора.
Программировать на JavaScript я начал около 10 лет назад, и только недавно задумался, как этот язык программирования работает на самом деле. Долгое время однопоточность, движок V8, функции обратного вызова и циклы событий были для меня просто словами: я знал, как ими пользоваться, но не понимал, как они работают на глубоком уровне.
В процессе изучения я узнал, как работают стеки вызовов (call stack), циклы событий (event loop), очереди обратных вызовов (callback queue), интерфейсы API и другие сущности. В этой статье расскажу о некоторых своих открытиях.
Статья будет полезна как новичкам, так и опытным разработчикам. Первым она поможет понять, почему JavaScript настолько сильно отличается от других языков программирования и чем функция обратного вызова очень полезна на практике. Вторым — глубже разобраться в среде исполнения этого языка программирования.
Как устроена среда исполнения
На схеме ниже — упрощенное представление среды исполнения Runtime. В частности, движок V8 представлен как среда выполнения команд внутри браузера Google Chrome:
Распределение памяти происходит в heap, а stack frames хранятся в стеке вызовов (call stack).
Если мы попытаемся найти в исходном коде V8 функцию асинхронного программирования setTimeout
, DOM или HTTP- запросы, то мы их там не найдем: за них, а также за AJAX-запросы, отвечает браузер, а не движок.
Как говорилось выше, JavaScript — это однопоточный (single threaded) язык программирования с одиночным стеком вызовов. Это означает, что в один момент JavaScript может выполнять только одну операцию (обработать только один кусок кода).
Рассмотрим пример с несколькими функциями: одна перемножает числа, другая возводит получившиеся число в квадрат, а третья — выводит результат на экран.
Прежде, чем разбирать этот код, вернемся на шаг назад и выясним, как работает стек вызовов. Это механизм, предназначенный для отслеживания текущего местонахождения интерпретатора в скрипте. Он вызывает несколько функций и определяет, какая из них выполняется на данный момент, какие функции вызываются внутри выполняемой функции и какая будет вызвана следующей.
Стек вызовов создается, когда внутри функции (или метода) существуют другие функции. В реальных приложениях таких уровней могут быть сотни. В нашем примере это возведение в квадрат — функция внутри функции выведения на экран результата. Стек вызовов формируется каждый раз, когда вы запускаете код на стороне браузера.
Рассмотрим пример кода ниже: baz
— функция, которая после запуска в Chrome вызывает bar
, затем foo
и возвращает ошибку.
В качестве результата выполнения функция выводит на экран трассировку стека (stack trace) — состояние стека в тот момент, когда произошла ошибка. Это пример того, что называется «неполадками в стеке» (blowing the stack).
Посмотрим где именно возникла ошибка. В стеке мы видим очередь вложенных функций foo, bar, baz
и анонимную функцию, которая в данном случае главная.
В этом случае есть главная функция, которая вызывает foo
, которая, в свою очередь вызывает foo
, та снова вызывает foo
, снова foo
и так далее. В какой-то момент Chrome говорит нам: «Возможно, рекурсивно вызывать foo 16000 раз не стоит? Я просто отключу эти функции, чтобы вы могли изучить код программы и найти ошибку».
Перейдем к следующему вопросу: почему программа выполняется медленно? На скорость, например, влияет выполнение условного цикла от одного до миллиарда, а также выполнение запросов по сети. К медленным можно отнести и запросы на загрузку картинок. Большое количество медленных запросов тормозит стек.
Рассмотрим пример: у нас есть код с функцией getSync()
— это функция jQuery с AJAX-запросом.
Представим, что эти запросы — синхронные. При выполнении кода в текущем виде сначала вызывается функция getSync
, а затем — сетевой запрос (network request). Скорость выполнения последнего зависит от компьютера и может быть довольно низкой.
После того, как сетевой запрос прошел, выполняются все три функции. Теперь мы можем очистить стек.
В однопоточном языке программирования (в отличие, например, от Ruby с его threads) для выполнения всех функций нужно ждать выполнения сетевого запроса. Здесь возникает проблема: код выполняется в браузере, который блокирует его на время выполнения этого синхронного запроса: он не может ни отрисовывать изображения, ни выполнять другой код.
Если мы хотим сделать хороший пользовательский интерфейс, блокировки стека допускать нельзя. Самое очевидное решение — использовать асинхронные обратные вызовы (asynchronous callbacks). В этом случае браузер не блокирует функции, а при выполнении кода задается коллбэк, который выполняется позднее.
Рассмотрим на простом примере, как работают асинхронные вызовы:
При запуске функция setTimeout
ставит console.log
в очередь на пять секунд. В это время выводится JSConfEU, а в конце на экран выводится there
.
При запуске кода мы видим, что функции выполняются одновременно. Среда исполнения JS Runtime в один момент времени может выполнить только одну функцию: например, не может выполнить AJAX-запрос во время выполнения другого кода. В данном случае несколько функций выполняются одновременно, поскольку браузер — это не только среда исполнения. В нем есть еще API, это позволяет совершать несколько действий одновременно.
Функция setTimeout
в данном случае выполняется через web-API, который предоставляет браузер. В исходном коде движка V8 этой функции нет. Web-API не изменяет код, а только помещает отдельные команды на стек. Кроме того, любой web-API помещает коллбэк в очередь задач после своего выполнения.
Цикл событий
Теперь мы знаем достаточно, чтобы перейти к главной теме этой статьи — рассказу о том, как работают циклы событий (event loop). Главная задача циклов событий — следить за стеком и очередью задач. Если стек пуст, цикл берет первый элемент из очереди, помещает его в стек и выполняет.
На картинке выше видно, что если стек пуст, то цикл берет первый элемент из очереди задач и помещает его в стек. В очереди задач при этом находится коллбэк.
Первая сложность, с которой мы сталкиваемся — установка нулевого времени в функции setTimeout
. Она ведет себя странно: переданный колбек не сразу выполняется, а попадает в очередь задач. Причина в механизме работы цикла событий: он ждет очистки стека и когда это происходит, помещает колбек в стек. Затем выполняются другие функции.
Установка нулевого времени в функции setTimeout
приводит к тому, что она откладывает исполнение колбека и переносит его в конец стека.
Все web-API работают похожим образом. Например, AJAX-запрос на URL-адрес с обратным вызовом будет выполняться точно также.
XHR-запрос будет выполняться параллельно — он может быть не выполнен никогда, но стек при этом будет продолжать работать.
Рассмотрим пример: перед выступлением я написал инструмент, который визуализирует среду выполнения JavaScript Runtime в реальном времени. Судя по коду, программа должна вывести в лог сообщения при срабатывании клика и колбека в setTimeout
.
Если мы добавим DOM API и задержку по времени, код продолжит работать: коллбэк просто встанет в очередь.
Если я нажму на кнопку «Click Me!», запустится web-API, который добавит в очередь коллбэк и запустит его. Если нажать на кнопку десять раз, вызов встает в очередь и обрабатывается.
Рассмотрим еще один пример с асинхронным API: в нем мы вызываем функцию setTimeout
четыре раза, с односекундной задержкой, после чего выполняем команду console.log, которая выводит на экран hi
.
К тому времени, как все обратные вызовы окажутся в очереди, четвертый запрос с секундной задержкой будет находиться в состоянии ожидания. Этот пример демонстрирует минимальное время на исполнение функций в очереди — setTimeout
не выполняет код сразу же, а сделает это в порядке очереди. Точно также как функция setTimeout с нулевой задержкой не вызывает колбек сразу же.
На этом примере хорошо видна разница между двумя типами обратных вызовов: они могут быть либо функцией, которую вызывает другая функция, либо асинхронным вызовом.
Другой пример: метод forEach
берёт переданную функцию-колбек и запускает её внутри стека, не асинхронно.
Метод forEach
здесь выполняется медленно — после очереди из обратных вызовов мы можем выполнить код и вывести результат на экран через console.log
.
Рендеринг
Определенные действия в JS накладывают на рендеринг в браузере ограничения. В идеальной ситуации браузер перерисовывает экран раз в 16,6 миллисекунд и воспроизводит результат со скоростью 60 кадров в секунду.
Однако пока в стеке есть код, браузер не может проводить рендеринг. Если рендер можно назвать вызовом, то в нем почти всегда есть коллбэк, которому для начала работы нужно дождаться очистки стека. Разница в том, что приоритет рендера выше: каждые 16 миллисекунд он ставит отрисовку в очередь.
Что означает блокировка рендера? Пока выполняется код, невозможно выделить текст на экране, кликать на кнопки и видеть отклик.
Код на картинке выше организован так, что рендер выполняется асинхронно — в коде оставлено место для того, чтобы он выполнял отрисовку между каждым элементом.
Все, о чем мы говорили выше — имитация того, как работает рендер. Но это хороший способ показать, что имеют в виду программисты, которые предостерегают от блокировки цикла событий.
Предостережения звучат так: «не используйте медленный код, иначе браузер не сможет рендерить красивые и органичные элементы интерфейса». Поэтому когда вы решаете задачу обработки изображений или анимирования большого количества изображений, но при этом не уделили должного внимания очереди, код будет работать медленно. В этой статье мы убедились, что это чистая правда.
Комментарии (4)
Kohelet
16.03.2022 18:58-2javascript уже не однопоточный: developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API
kahi4
16.03.2022 19:19JavaScript вообще никогда не был однопоточным, как и никакой другой язык, это вообще не свойство языка, а среды исполнения или рантайма. Откуда это пошло?
Даже если проигнорировать банально возможность открыть вторую вкладку, всегда есть зелёные потоки (пусть и кривенькие в рамках js). Да банально redux saga пример такого.
У JavaScript есть правило что только один контекст исполнения может быть активен, но это имеет посредственное отношение к потокам.
kahi4
16.03.2022 19:31Если мы попытаемся найти в исходном коде V8 функцию асинхронного программирования
setTimeout
, DOM или HTTP- запросы, то мы их там не найдем: за них, а также за AJAX-запросы, отвечает браузер, а не движок.Нужно уточнить, что все эти методы являются частью html api, а не ecmascript, поэтому их и нет в движке. В EcmaScript EventLoop вообще не упоминается. Едиенственные упоминания хоть какого-то подобия это Promise.resolve и async / await.
К слову, раз уж начал занудствовать, то в том же HTML API однозначно написано, что это не очередь, а Map, и расписаны микротаски (раз я уже упоминул про промисы), что тут тоже опущено.
qbz
Не читал (по скольку знаю), но отметил, что неплохо было бы указать адрес на http://latentflip.com/loupe/ (с ваших же скриншотов)