Цикл событий (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 миллисекунд он ставит отрисовку в очередь.

Что означает блокировка рендера? Пока выполняется код, невозможно выделить текст на экране, кликать на кнопки и видеть отклик.

Код на картинке выше организован так, что рендер выполняется асинхронно — в коде оставлено место для того, чтобы он выполнял отрисовку между каждым элементом.

Все, о чем мы говорили выше — имитация того, как работает рендер. Но это хороший способ показать, что имеют в виду программисты, которые предостерегают от блокировки цикла событий.

Предостережения звучат так: «не используйте медленный код, иначе браузер не сможет рендерить красивые и органичные элементы интерфейса». Поэтому когда вы решаете задачу обработки изображений или анимирования большого количества изображений, но при этом не уделили должного внимания очереди, код будет работать медленно. В этой статье мы убедились, что это чистая правда.