Колбеки и event loop
Чтобы понимать асинхронность в JavaScript, нужно понимать “потом”. Этот
урок про то, откуда “потом” в JS вообще берётся: что такое event loop,
какие у него очереди, какие правила приоритета, и почему на этом фундаменте
до сих пор удобно жить даже после появления промисов и async/await.
Полезно держать рядом доклад Джейка Арчибальда In The Loop
и официальный гайд Node Don’t Block the Event Loop.
Все примеры в уроке проверяемы локально через node example.js или в
DevTools.
1. Колбек, как контракт
Колбек, это функция, переданная в другую функцию, чтобы её вызвали “когда будет готово”. Самый старый асинхронный контракт в JavaScript, и до сих пор база, на которой стоит весь stdlib Node.
import { readFile } from 'node:fs';
readFile('./users.json', 'utf8', (err, data) => {
if (err !== null) {
console.error('read failed:', err);
return;
}
console.log('got data:', data);
});
Сигнатура (err, value) => void называется
callback-last, error-first.
Конвенция Node, появилась в 2009 году и пережила всё. Главные правила:
- Колбек, всегда последний аргумент.
- Первый аргумент колбека, ошибка (или
null/undefined, если её нет). - Колбек вызывается ровно один раз. Вызвать дважды, баг.
- Колбек вызывается асинхронно, даже если ошибка известна сразу.
Последний пункт коварный. Соблазн сократить так:
const cachedRead = (path, cb) => {
if (cache.has(path)) {
cb(null, cache.get(path));
return;
}
readFile(path, 'utf8', (err, data) => {
if (err === null) cache.set(path, data);
cb(err, data);
});
};
Здесь колбек вызывается синхронно при cache hit и асинхронно при miss.
Это так называемая “Zalgo-функция” (термин Изаака Шлютера). Вызывающий
не может полагаться на порядок: переменные, которые он ставит после
cachedRead(...), иногда инициализированы к моменту вызова колбека, а
иногда нет. Лечится так: при синхронном пути откладываем через
queueMicrotask, setImmediate или process.nextTick.
const cachedRead = (path, cb) => {
if (cache.has(path)) {
queueMicrotask(() => cb(null, cache.get(path)));
return;
}
// ...
};
Контракт колбека “всегда асинхронный”, это база, без которой остальное поломается.
2. Что такое event loop
В одном предложении: event loop, это бесконечный цикл, который достаёт задачи из очередей и выполняет их по одной до тех пор, пока очереди не опустеют (или пока процесс не убьют).
Точная модель отличается в браузере и в Node, но общая схема одна:
- Запустить синхронный код модуля до конца.
- Заглянуть в очереди микротасков, выполнить все.
- Взять одну задачу из очереди макротасков, выполнить.
- Снова заглянуть в очереди микротасков.
- Если есть незакрытые ресурсы (открытый сокет, активный таймер, подписка), вернуться к шагу 3. Иначе, выйти.
Слово “потом” в JS означает: “положить колбек в одну из очередей, чтобы его достал event loop”. Никакой магии, всё детерминировано, если знать правила приоритета.
3. Шесть фаз libuv
В Node за event loop отвечает libuv. Один проход loop-а проходит шесть фаз в строгом порядке.
| Фаза | Что выполняется |
|---|---|
| timers | колбеки setTimeout/setInterval, у которых истекло время |
| pending callbacks | системные ошибки I/O, отложенные с прошлой итерации |
| idle, prepare | внутреннее, для нас не видно |
| poll | новые I/O события (сеть, FS, dns) и их колбеки |
| check | колбеки setImmediate |
| close callbacks | socket.on('close', ...) и подобное |
Между каждой задачей этих фаз Node вычерпывает две очереди приоритета:
process.nextTick-очередь (выше всех).- microtask-очередь (
Promise.then,queueMicrotask).
Это и есть причина, почему Promise.resolve().then(...) всегда
срабатывает раньше setTimeout(0). Промис лежит в microtask-очереди,
таймер в фазе timers.
В браузере фаз нет в явном виде, но логика та же: разделение на макротаски (rendering, user input, network) и микротаски, плюс дополнительный шаг “перерисовать кадр” между макротасками.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.