Event loop: как браузер успевает всё
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Браузер умудряется крутить анимацию, ждать ответ сервера и слушать клики одновременно — хотя ваш JavaScript выполняется в один поток. Весь фокус в event loop: бесконечном цикле, который раздаёт единственному потоку дела строго по одному.
Движок делает ровно одно дело за раз. Чтобы не потерять, где он сейчас находится, он ведёт стек вызовов — стопку карточек. Вызвали функцию — карточка легла сверху, функция вернулась — карточка снялась. Пока стопка не пуста, поток занят.
Стопка не бесконечная. Если функции складываются быстрее, чем снимаются, место заканчивается — и движок честно об этом сообщает.
Ни setTimeout, ни fetch, ни подписка на клик не описаны в языке. Это функции браузера. Движок просто передаёт поручение: «отсчитай полсекунды и позови вот эту функцию» — и тут же снимает карточку со стека. Считает уже браузер, своими средствами, вне потока JavaScript.
Поэтому ожидание не блокирует поток. Пока таймер тикает, а сервер думает над ответом, стек пуст и поток свободен для другой работы.
Когда таймер дозвонил или ответ пришёл, колбэк не врывается в выполнение. Он встаёт в очередь задач и ждёт. Очередь честная: кто раньше стал готов, тот раньше и выполнится.
Обратите внимание: порядок строк в коде ничего не решает. Решает момент, когда задача попала в очередь.
Очередей на самом деле две. У промисов своя — очередь микрозадач, и она разбирается целиком после каждой задачи, до того как дойдёт черёд до таймеров. Что такое промис и зачем он нужен, объяснит статья «Промисы: основы», а полный порядок двух очередей разбирает «Микрозадачи и макрозадачи».
Практический вывод: .then() выполнится всегда раньше, чем setTimeout(f, 0), даже если стоит в коде позже.
Event loop устроен до обидного просто. Он спрашивает: стек пуст? Если да — берёт первую задачу из очереди и кладёт её на стек. Если нет — ждёт. И так бесконечно, пока открыта вкладка.
Ключевое свойство — задачу никто не прерывает на полпути. Взялся выполнять функцию — доведёт до конца, и только потом заглянет в очередь. Отсюда все радости и все беды однопоточности.
| Где | Что там | Кто хозяин |
|---|---|---|
| Стек вызовов | функции, выполняющиеся прямо сейчас | движок JavaScript |
| Web API | тикающие таймеры, запросы, подписки | браузер |
| Очередь задач | колбэки, готовые к выполнению | event loop |
| Очередь микрозадач | реакции промисов — разбирается целиком после каждой задачи | движок JavaScript |
Правило одного дела: пока функция не вернулась, на странице не происходит ничего, что требует JavaScript и раскладки.
Ноль в setTimeout — это не «выполни немедленно», а «поставь в очередь без дополнительной задержки». Задача всё равно дождётся, пока опустеет стек и разберутся микрозадачи.
Зато у приёма есть законное применение: setTimeout(f, 0) откладывает работу «на потом», отпуская поток и давая браузеру вдохнуть между делами.
Спецификация разрешает браузеру подмешивать минимальную задержку: начиная с седьмого таймера в цепочке вложенных друг в друга она становится 4 мс, а в неактивной вкладке таймеры тормозят ещё сильнее — до раза в секунду и реже.
true, а не число: миллисекунды на разных машинах разные, а порог — тот же самый. Будь «ноль» и правда нулём, двадцать вложенных таймеров прошли бы заметно быстрее 20 мс. Но четырнадцать из них браузер зажимает до 4 мс, и сумма переваливает за порог. След четвёрки виден, хотя само число не напечатано.Перерисовка страницы — работа того же самого потока. Браузер вставляет её между задачами, стараясь успевать около 60 кадров в секунду. Пока на стеке крутится ваша функция, вставить кадр некуда: анимация стоит, кнопка не нажимается, текст не выделяется. Клики при этом не пропадают — они копятся в очереди и обрушатся разом, когда поток освободится.
Лечится это не ускорением кода, а его нарезкой. Считаем небольшую порцию, отдаём поток браузеру через setTimeout — он успевает нарисовать кадр и обработать клики, — и возвращаемся за следующей порцией.
- JavaScript выполняется в один поток: стек вызовов держит одну цепочку функций, и никто не прерывает её на середине.
- Таймеры, запросы и подписки на события — это Web API браузера, а не язык; ожидание идёт вне потока и не блокирует его.
- Готовый колбэк не запускается сразу — он встаёт в очередь задач и ждёт, пока event loop увидит пустой стек.
- setTimeout(f, 0) означает «поскорее», а не «немедленно»: сначала весь синхронный код, затем микрозадачи промисов, потом уже таймер.
- Отрисовка идёт в том же потоке, поэтому долгая синхронная работа замораживает интерфейс — тяжёлые вычисления режут на порции и отпускают поток между ними.
1/3Что выведет код: console.log(1); setTimeout(() => console.log(2), 0); console.log(3);
2/3Пользователь кликнул по кнопке, пока на стеке крутится долгий цикл. Что произойдёт?
3/3Что на самом деле означает setTimeout(f, 0)?
Предскажите порядок вывода
Не запуская код, выпишите порядок строк в консоли. Потом проверьте себя в браузере.
Порядок: A, D, C, B.
Сначала до конца отрабатывает синхронный код — это A и D. Как только стек опустел, разбирается очередь микрозадач: C. И лишь затем event loop берёт задачу таймера — B.
Почему переменная пустая
Код должен вывести 42, но выводит null. Объясните почему и почините — не «ускоряя» таймер и не добавляя второй.
console.log выполняется в тот же миг, а колбэк таймера попадёт на стек минимум через 100 мс — к моменту вывода присваивания ещё не было.
Чинится переносом использования внутрь колбэка: работать с данными нужно там, где они уже появились.
Это базовый рефлекс асинхронного кода: не ждать значение снаружи, а продолжать работу в колбэке.