Базовый CS с нуля
Цикл событий
Прошлый урок оставил одну вещь необъяснённой. Неблокирующий вызов передаёт колбэк и возвращается. Спустя миллисекунды устройство заканчивает — и что-то вызывает этот колбэк. Урок назвал это «механизмом доставки результата» и пошёл дальше. Этот урок и есть тот механизм, названный и трассированный.
Он называется цикл событий, и он проще, чем подсказывает его репутация. Это две вещи:
очередь, держащая колбэки, ожидающие запуска, и цикл, берущий их из очереди по
одному за раз. Когда ты его трассируешь, известная головоломка растворяется: почему
программа, которая печатает 1, затем планирует колбэк, печатающий 2, затем печатает
3, выдаёт вывод 1, 3, 2 — а не 1, 2, 3. 2 опаздывает намеренно, и цикл
событий — ровно почему.
После этого урока ты сможешь дать определение цикла событий как очереди колбэков плюс цикла, который её опустошает, сформулировать правило «запускай колбэк, только когда стек вызовов пуст», объяснить, почему синхронный код всегда завершается раньше любого колбэка, и трассировать небольшую программу, чтобы предсказать порядок её вывода.
Две части из прежних блоков уже на месте. Из Блока 08: стек вызовов — стек кадров для текущих выполняющихся вызовов функций; программа «выполняет синхронный код», когда стек непуст. Из урока 02 этого блока: неблокирующий вызов передаёт колбэк для запуска, когда его медленная работа закончится.
Цикл событий добавляет одну новую часть и одно правило.
Новая часть — очередь колбэков. Очередь — это линия ожидания: элементы добавляются в конец и удаляются с начала, в порядке первый-вход-первый-выход (первый добавленный колбэк запускается первым). Очередь колбэков держит колбэки, которые готовы к запуску — их медленное устройство уже закончило — но ещё не запущены. Когда устройство заканчивает, его колбэк не запускается сразу; он помещается в конец этой очереди.
Правило — запускай колбэк, только когда стек пуст. Цикл событий — это цикл, повторяющийся вечно: проверь, пуст ли стек вызовов; если пуст, возьми передний колбэк из очереди и запусти его; если стек непуст, не делай ничего и проверь снова.
Два следствия вытекают напрямую. Первое: синхронный код всегда завершается первым: пока любой синхронный код выполняется, стек непуст, поэтому цикл не запустит колбэк. Только после того как стек полностью опустеет, может начаться колбэк из очереди. Второе: колбэк выполняется до конца: однажды начавшись, он получает свои собственные кадры стека и выполняется полностью, прежде чем цикл возьмёт следующий колбэк — колбэки никогда не прерывают друг друга.
Программа ниже использует setTimeout(fn, 0) — неблокирующий вызов, означающий «помести
fn в очередь колбэков». Даже с задержкой 0, fn не может запуститься, пока синхронный
код не завершится и стек не опустеет. Это и производит порядок 1, 3, 2.
1
console.log(1); // синхронно — выполняется сейчас
2
3
setTimeout(() => {
4
console.log(2); // тело колбэка — выполняется позже
5
}, 0);
6
7
console.log(3); // синхронно — выполняется сейчас
- L1 Синхронный вызов. Стек непуст; это выполняется сразу. Вывод пока: 1
- L3 setTimeout — неблокирующий вызов: он передаёт колбэк и возвращается тут же. Он НЕ запускает колбэк.
- L4 Эта строка внутри колбэка. Она выполнится, только когда цикл событий позже подхватит колбэк.
- L5 0 означает «помести колбэк в очередь как можно скорее» — но «скоро» всё равно значит после опустошения стека.
- L7 Синхронный вызов. Выполняется сразу после возврата setTimeout — раньше колбэка. Вывод пока: 1, 3
Пройди программу шаг за шагом. Ячейки — это стек вызовов (снизу вверх, старейший слева). Смотри, как синхронные строки выполняются при непустом стеке, колбэк попадает в очередь, и цикл событий запускает его, только когда стек пуст.
1
console.log(1);
2
3
setTimeout(() => {
4
console.log(2);
5
}, 0);
6
7
console.log(3);
Почему это работает
Почему setTimeout(fn, 0) не запускает fn тут же — задержка ведь ноль? 0 задаёт
таймер, а не запуск. Он означает «колбэку разрешено быть помещённым в очередь
немедленно». Но быть помещённым в очередь — не значит быть запущенным. Одно правило цикла
событий всё равно применяется: колбэк запускается, только когда стек вызовов пуст. В момент
вызова setTimeout кадр скрипта всё ещё на стеке, и console.log(3) ещё не выполнился.
Так что fn ждёт в очереди, пока скрипт не завершится. 0 — это минимальная задержка
постановки в очередь; она никогда не может обойти правило пустого стека.
Частая ошибка
Частая ошибка — читать setTimeout(fn, 0) как «запусти fn сейчас» или «запусти fn
через 0 миллисекунд». Это не значит ни то, ни другое. Это значит «помести fn в очередь
колбэков; он становится пригодным к запуску только после того, как стек вызовов опустеет».
Если синхронный код после него занимает 50 мс, колбэк с задержкой 0 ждёт 50 мс. Число
задержки — это минимальное ожидание перед постановкой в очередь, никогда не гарантия
того, когда колбэк запустится.
Программа выполняет: console.log(1); setTimeout(() => console.log(2), 0); console.log(3). Какое число печатается первым?
Та же программа. Какое число печатается последним?
Цикл событий запускает колбэк из очереди, только когда на стеке вызовов сколько кадров?
Три колбэка A, B, C помещены в очередь в этом порядке, и стек пуст. Цикл событий запускает их. Какой запускается первым — введи 1 за A, 2 за B, 3 за C?
Программа: console.log(10); setTimeout(() => console.log(20), 0); setTimeout(() => console.log(30), 0). Сколько чисел печатает синхронная часть, прежде чем запустится любой колбэк?
Что такое цикл событий и какое правило управляет тем, когда запускается колбэк?
Цикл событий — это механизм, запускающий колбэки. Он состоит из двух частей: очередь
колбэков — линия ожидания первый-вход-первый-выход, держащая колбэки, чья медленная
работа закончилась — и цикл, повторяющийся вечно и проверяющий одно правило: если стек
вызовов пуст, возьми передний колбэк из очереди и запусти его; иначе жди. Два факта
вытекают из этого правила. Синхронный код всегда завершается первым, потому что пока он
выполняется, стек непуст, и цикл не запустит колбэк. И каждый колбэк выполняется до
конца без прерывания, потому что цикл не берёт следующий, пока все кадры текущего колбэка
не снимутся. Вот почему console.log(1); setTimeout(cb, 0); console.log(3) печатает
1, 3, 2: колбэк с задержкой 0 только быстро помещается в очередь — он всё равно ждёт
опустошения стека, что происходит после того, как 1 и 3 напечатаны.