Перейти к содержанию

Node.js и error-first колбэки: ошибки и нативная диспетчеризация

Источник: theNodeBook — Node.js Error-First Callbacks: Errors & Native Dispatch

Error-first колбэки — исходный асинхронный контракт во всём ядре Node. Суть в форме (err, value): API запускает работу и позже вызывает колбэк, передавая ошибку в первый аргумент или данные успеха после него. Нативные операции, запросы libuv и JavaScript API используют эту единую форму завершения, чтобы запрос к файловой системе или DNS одинаково сообщал об успехе и сбое.


Error-first колбэки в Node.js

Колбэк выполняется на главном потоке, когда Node доходит до точки завершения операции. Ложный первый аргумент означает нормальное завершение. Истинный первый аргумент — обычно объект Error. Важен и момент времени: часть колбэков по контракту синхронна, а асинхронные операции ядра планируют завершение после текущего стека.


Любой async-паттерн опирается на колбэки

Промисы, async/await, потоки — всё в итоге сводится к колбэку. Где-то в слое C++‑биндингов сохраняется указатель на функцию, libuv завершает работу, и JavaScript-функция вызывается с результатом. Эта функция и есть колбэк. Вся асинхронная модель Node.js строилась вокруг этого механизма задолго до более удобных абстракций, и «труба» с тех пор не менялась.

Колбэк — функция, которую вы передаёте аргументом в другую функцию, чтобы её вызвали позже. Никакого особого синтаксиса, ключевого слова или класса. Просто ссылка на функцию, отданная чужому коду. Интересно когда и как её вызовут — и что ломается, если эти два аспекта перепутать.

1
2
3
4
5
6
const fs = require('fs');

fs.readFile('/etc/hostname', 'utf8', (err, data) => {
    if (err) throw err;
    console.log(data.trim());
});

Вы передаёте анонимную стрелочную функцию третьим аргументом в fs.readFile. Node сохраняет ссылку, отдаёт чтение в libuv и сразу возвращается. Ваш код продолжает выполняться. Позже — через миллисекунду или пятьдесят — event loop подхватывает завершённый I/O и вызывает вашу функцию с ошибкой или содержимым файла.

Слово «позже» здесь не пустое. Колбэк не выполняется синхронно внутри readFile. Он срабатывает на будущей итерации event loop, после того как стек, который его зарегистрировал, уже развернулся. Различие синхронного и асинхронного выполнения — фундамент всей этой главы.

И вот что часто опускают в объяснениях: API ядра Node синхронно проверяют аргумент колбэка. Если вместо функции передать undefined, readFile сразу бросит TypeError — до постановки I/O в очередь. Но проверяется только тип: Node убеждается, что аргумент — функция, и сохраняет ссылку. Тело функции не анализируется: есть ли обработка ошибок, используется ли результат. Ссылка непрозрачна; что внутри — полностью ваша зона ответственности.


Синхронные и асинхронные колбэки

Термин «колбэк» относится и к синхронным, и к асинхронным функциям, переданным аргументом. Различие между ними — пожалуй, самое важное, что нужно усвоить дальше.

1
2
3
4
5
const numbers = [3, 1, 4, 1, 5];
numbers.forEach((n) => {
    console.log(n);
});
console.log('done');

Функция в forEach — колбэк. Но она синхронна: выполняется сразу, в том же стеке. Каждый вызов завершается до возврата из forEach. К моменту console.log("done") все пять чисел уже напечатаны. Колбэк и вызывающий код делят один контекст выполнения. Если колбэк бросит исключение, оно пройдёт через forEach, через ваш код и может быть поймано внешним try/catch. Всё ведёт себя как при последовательном выполнении.

Сравните с fs.readFile. При передаче колбэка функция возвращается мгновенно — колбэк ещё не вызывался. Он сработает позже, в другом стеке, после сигнала libuv о завершении I/O. Исходный кадр вызова уже исчез. Синхронный поток давно ушёл дальше.

Это разделение определяет async-программирование в Node. Синхронные колбэки — обычные вызовы с лишней косвенностью. Асинхронные — отложенное выполнение: функция запускается позже, на новом тике, в свежем стеке. Именно здесь усложняется обработка ошибок.

В большинстве случаев тип угадывается легко. Колбэк в API ядра с I/O — асинхронный: fs.readFile, http.get, dns.lookup, child_process.exec. Колбэк в методе массива (map, filter, forEach, reduce) — синхронный. Слушатели EventEmitter (отдельная глава) синхронны: при emitter.emit('data', chunk) все обработчики 'data' выполняются по порядку регистрации до возврата из emit. Колбэк setTimeout — асинхронный. Компаратор Array.sort — синхронный.

Сложнее в пользовательских библиотеках: колбэк могут вызвать синхронно при попадании в кэш и асинхронно при промахе. Такая непоследовательность — источник тонких багов; в экосистеме Node у неё имя — «выпуск Zalgo» (releasing Zalgo). Правило, закреплённое в ранней истории Node Исааком Schlueter, строгое: если функция хоть раз вызывает колбэк асинхронно, она должна делать это всегда, даже когда результат уже готов. process.nextTick() (см. главу про event loop) существует в том числе для этого — чтобы отложить синхронный результат на следующий тик и сохранить единообразное async-поведение.

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


Continuation-Passing Style (CPS)

У передачи колбэка, принимающего результат, есть формальное имя — continuation-passing style (CPS), стиль с передачей продолжения. Вместо прямого возврата значения функция «передаёт» результат продолжению — колбэку, который означает «что делать дальше».

Прямой стиль:

1
2
const data = fs.readFileSync('/etc/hostname', 'utf8');
console.log(data.trim());

Вы вызываете функцию, она возвращает значение, вы его используете. Управление и результат возвращаются вызывающему. Счётчик команд переходит на следующую строку. Всё последовательно, в одном стеке.

CPS переворачивает связь. Функция не возвращает результат — она вызывает другую функцию с результатом:

1
2
3
fs.readFile('/etc/hostname', 'utf8', (err, data) => {
    console.log(data.trim());
});

Колбэк и есть продолжение: остаток программы с этой точки, упакованный в аргумент. Всё, что нужно сделать с содержимым файла, живёт внутри колбэка — только там существует data.

Следствие не сразу очевидно: в CPS вызываемая функция по смыслу не «возвращает» результат. Да, fs.readFile возвращает управление (фактически undefined). Но результат не приходит через return — он приходит по другому каналу, через вызов колбэка. Возвращаемое значение не важно; смысл несёт продолжение.

Отсюда каскад: если продолжению нужна своя async-работа, её результат снова уходит в колбэк, тот — в следующий:

1
2
3
4
5
6
7
8
fs.readFile('config.json', 'utf8', (err, raw) => {
    const config = JSON.parse(raw);
    fs.readFile(config.dataPath, 'utf8', (err, data) => {
        fs.writeFile('output.txt', data, (err) => {
            console.log('wrote output');
        });
    });
});

Три async-операции, три уровня вложенности. Каждый колбэк — продолжение предыдущего. Поток читается сверху вниз и изнутри наружу, а не построчно. Так CPS выглядит на практике. Работает — но читаемость быстро падает.

Терминология важна: CPS из теории языков с 1970-х. Компиляторы Scheme используют CPS как промежуточное представление. Монада продолжений в Haskell — та же идея в другом синтаксисе. Принцип один: не возвращать значение вызывающему, а передать его следующему вычислению. Node взял этот паттерн, потому что в JavaScript были функции первого класса и не было лучшего async-примитива. Колбэки — единственный инструмент. Функции первого класса сделали CPS достаточно удобным в масштабе. Соглашение error-first сделало его достаточно безопасным для экосистемы.

Различие: есть синхронный CPS и асинхронный. Колбэк, вызванный сразу (как в Array.forEach), — синхронное продолжение. Колбэк на следующем тике (fs.readFile) — асинхронный CPS. Node почти везде для I/O использует async-вариант, для итераций — sync. Сложность создаёт именно async: другой стек, другой контекст ошибок.


Разрыв try/catch

Вот что ломает привычки синхронного кода: try/catch не работает через async-границу.

1
2
3
4
5
6
7
try {
    fs.readFile('/nonexistent', 'utf8', (err, data) => {
        console.log(data.trim());
    });
} catch (e) {
    console.log('caught:', e.message);
}

Если /nonexistent нет, блок catch не сработает. try/catch оборачивает вызов fs.readFile, который успешен: I/O поставлен в очередь, исключения нет. Блок try завершается нормально. Программа идёт дальше мимо catch.

Ошибка приходит позже. Libuv пытается открыть файл, получает ENOENT от ядра, кладёт код в структуру запроса. На следующей итерации event loop срабатывает колбэк завершения. Слой C++ строит JavaScript Error из ENOENT и вызывает колбэк с (err, undefined). К этому моменту try/catch давно исчез со стека. Колбэк выполняется в другом стеке без вашего обработчика.

В колбэке err — истинный Error, dataundefined, и data.trim() бросает TypeError. Он идёт вверх по стеку колбэка, не встречает обработчика и становится uncaughtException. Процесс падает (и это правильнее, чем проглотить ошибку).

Фундаментальный разрыв: в синхронном коде ошибка и обработчик в одном стеке. Async-колбэк — в другом. Поймать его ошибку из кода, который колбэк зарегистрировал, физически нельзя.

Стек при выполнении колбэка выглядит примерно так: FSReqCallback::AfterOpenMakeCallback → ваш колбэк. В нём нет кадров вашего исходного кода. try/catch жил только в стеке fs.readFile() и исчез, когда readFile вернулся. Два стека, два контекста, связи нет.

Аргумент err существует потому, что иначе сбой через эту границу не передать надёжно. Нельзя бросить исключение «назад» в старый стек. Ошибка должна ехать как данные — аргументом функции — от слоя, который сбой обнаружил (C++‑биндинги), к коду, который может отреагировать (ваш колбэк).

Многие новички пишут паттерн выше и удивляются, куда «пропали» ошибки. Они не пропадают: приходят в err, который никто не проверил. Или бросаются внутри колбэка без try/catch и роняют процесс. Одна причина — async-граница, которую синхронная обработка ошибок не пересекает.

Проверяйте err в начале каждого async-колбэка. Это ваш аналог try/catch на другой стороне границы стека.


Зачем error-first

Соглашение error-first — ответ Node на этот разрыв. У каждого async-колбэка в API ядра ошибка — первый аргумент:

1
2
3
fs.readFile(path, encoding, (err, data) => { ... });
fs.writeFile(path, content, (err) => { ... });
fs.stat(path, (err, stats) => { ... });

При успехе errnull, остальные аргументы — результат. При сбое errError, аргументы результата — undefined (или отсутствуют: у fs.writeFile при успехе только err, возвращать нечего).

Почему первым? Потому что так вы вынуждены разобраться с ошибкой до результата. Ошибка буквально первая в списке параметров. Удобен ранний выход:

1
2
3
4
5
fs.readFile('data.json', 'utf8', (err, raw) => {
    if (err) return handleError(err);
    const parsed = JSON.parse(raw);
    processData(parsed);
});

Проверили ошибку, вышли при наличии, пошли по happy path. return и передаёт ошибку обработчику, и не даёт коду ниже считать, что raw валиден.

Паттерн if (err) return ... в начале колбэка — аналог try/catch в синхронном коде. Пропустите — код идёт как при успехе; когда-нибудь raw будет undefined, JSON.parse(undefined) бросит, и стек укажет на JSON.parse, а не на исходную I/O-ошибку.

Раньше модули экспериментировали: ошибка второй, события, синхронный throw при async, отдельные колбэки успеха и ошибки (как в jQuery AJAX). Сообщество вокруг Ryan Dahl сошлось на error-first. К эпохе Node 0.4–0.8 это стало универсальной нормой для колбэков; документация и все модули ядра её следуют.

Это контракт между API и потребителем. Видите (err, result) — ждите: err либо null, либо Error; result валиден только при err === null. Библиотеки, ломавшие контракт ((result) без ошибки, отдельные колбэки, коды вместо Error), создавали боль. Один колбэк с error-first оказался простейшим работающим вариантом.

Нюанс: что именно в err, когда он истинный. Ядро Node передаёт экземпляр Error (или подкласс: TypeError, системные ошибки с .code вроде ENOENT, EACCES, EPERM) с .message, .stack и часто .code для switch. В сообществе менее строго: строки, произвольные объекты, числовые коды. Если проектируете callback API — передавайте нормальные Error. Стек и .code важны: строка говорит что случилось, но не где в коде.


Как Node вызывает колбэки на уровне C++

При fs.readFile(path, callback) JavaScript-функция возвращается почти сразу — но цепочка уже запущена. Путь от вызова до колбэка пересекает четыре границы и включает семь передач.

fs.readFile в JavaScript сам файлы не читает. Создаётся объект ReadFileContext — путь, кодировка, буфер для накопления чанков, ваш колбэк. Дальше вызов в C++ через internalBinding('fs') — мост из JS в нативный код.

На уровне C++ создаётся FSReqCallback — класс, наследующий ReqWrap<uv_fs_t> (обёртка над запросом libuv). У объекта есть JS-обёртка; колбэк хранится в свойстве oncomplete. Persistent handle V8 держит обёртку живой на время операции — корень для GC: пока I/O в полёте, колбэк не соберут. Без этого при завершении libuv вызывать было бы нечего.

Биндинг вызывает uv_fs_open / uv_fs_read с loop, структурой uv_fs_t и C++‑колбэком завершения. Работа уходит с JS-потока. Libuv ставит запрос в thread pool (по умолчанию четыре потока, UV_THREADPOOL_SIZE). Воркер делает open() / read() в фоне и кладёт результат или код ошибки в uv_fs_t.

После syscall воркер сигналит главному потоку через uv_async_send (loop->wq_async). Event loop просыпается из epoll_wait (Linux) или kevent (macOS). На следующей итерации uv__work_done на главном потоке обходит очередь завершённой работы и вызывает зарегистрированный C++‑колбэк.

В колбэке завершения из uv_fs_t читают дескриптор, байты или код ошибки. Отрицательный результат → JavaScript Error с code (ENOENT, EACCES, …). Успех → Buffer или строка в нужной кодировке. Из oncomplete обёртки FSReqCallback достают ваш JS-колбэк и вызывают MakeCallback.

MakeCallback (node::MakeCallback) — одна из ключевых внутренних функций Node. Последовательность:

  1. Вход в isolate и context V8 — среда для JS.
  2. InternalCallbackScope — учёт async_hooks (async id, события before / after).
  3. Вызов колбэка через Function::Call с ошибкой (или null) и результатом.
  4. После возврата — InternalCallbackScope::Close и checkpoint микрозадач.
  5. В Close сливаются две очереди. Сначала process.nextTick() (очередь Node, не V8). Затем микрозадачи V8 — обработчики .then() у промисов. Если новая работа снова попадает в очереди, цикл повторяется, пока обе не пусты.
  6. Проверка, нужно ли завершать процесс (нет активных handles/requests).

Шаг 5 важен: после каждого входа из C++ в JS через MakeCallback обе очереди опустошаются. nextTick всегда раньше промисов — поэтому колбэки process.nextTick() опережают .then(). Без этого микрозадачи копились бы до отдельных точек цикла и ломали бы ожидаемый порядок.

Вся цепочка — JS → C++ → libuv → pool → syscall → обратно → C++ → MakeCallback → ваш колбэк → drain микрозадач — скрыта. Видите fs.readFile(path, callback) и позже результат. Восемь передач, четыре границы, checkpoint, async_hooks. Невидимо.

Следствие: this в колбэке зависит от API. Для разовых операций (fs.stat, fs.open, fs.access) обычно globalThis. fs.readFile иначе — ReadFileContext с состоянием многошагового чтения (fd, буферы, кодировка); при обычной function в колбэке this — этот объект. На практике на this не полагаются — только на аргументы.

Если колбэк бросает, исключение идёт через Function::Call в MakeCallback, ловится и уходит в uncaughtException. C++ не падает — слой рассчитан на JS-исключения в середине нативной цепочки.

Каждый вызов колбэка из C++ через MakeCallback завершается опустошением очереди process.nextTick() и затем микрозадач промисов. Это связывает колбэки I/O с промисами и nextTick в едином порядке выполнения.


Паттерны колбэков на практике

Простейший случай — последовательность: одна операция, в её колбэке — следующая.

1
2
3
4
5
6
7
8
fs.readFile('input.txt', 'utf8', (err, data) => {
    if (err) return console.error(err);
    const upper = data.toUpperCase();
    fs.writeFile('output.txt', upper, (err) => {
        if (err) return console.error(err);
        console.log('done');
    });
});

Читаем, преобразуем, пишем. Вторая операция стартует только после первой. С двумя-тремя шагами читается нормально; дальше вложенность и отступы уводят логику вправо.

Параллель сложнее. Три файла нужно обработать вместе — вложенная последовательность сериализует I/O: 5 ms × 3 = 15 ms вместо ~5 ms при одновременном старте.

Типичный счётчик:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const files = ['a.txt', 'b.txt', 'c.txt'];
const results = new Array(files.length);
let pending = files.length;

files.forEach((file, i) => {
    fs.readFile(file, 'utf8', (err, data) => {
        if (err) return console.error(err);
        results[i] = data;
        if (--pending === 0) processAll(results);
    });
});

Три readFile стартуют сразу. Каждый колбэк уменьшает pending и пишет в results[i]. При нуле — все готовы, порядок в массиве сохранён индексом i.

Реализация join корректна, но утомительна и хрупка. Если b.txt нет: логируем ошибку и выходим, но для этой операции счётчик не уменьшили — pending может никогда не дойти до нуля, и processAll не вызовется. Или уменьшаем до проверки ошибки — тогда processAll получит undefined в слоте. Оба исхода неверны.

Нужна защита:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
let errored = false;
files.forEach((file, i) => {
    fs.readFile(file, 'utf8', (err, data) => {
        if (errored) return;
        if (err) {
            errored = true;
            return handleError(err);
        }
        results[i] = data;
        if (--pending === 0) processAll(results);
    });
});

Побеждает первая ошибка — как у Promise.all. Семантика «собрать все ошибки» (Promise.allSettled) потребует отдельных массивов и счётчиков — по сути ручные комбинаторы промисов.

Есть waterfall: цепочка, где результат шага питает следующий, плюс накопленное состояние. Без абстракций состояние тащат через замыкания или лишние аргументы в промежуточных колбэках.

Retry с экспоненциальной задержкой — рекурсивная обёртка с setTimeout, счётчик попыток и лимит в замыкании. Работает, но читатель должен мысленно пройти рекурсию через async-границы.

Библиотека async.js (Caolan McMahon) появилась именно из-за этих повторяющихся и ошибкоопасных схем: parallel, series, waterfall, retry, queue и десятки других. Пик npm-зависимостей — потому что сырые колбэки заставляли вручную вести состояние с одними и теми же багами.

Параллельные колбэки без флага errored и единой политики ошибок легко зависают (счётчик не доходит до нуля) или вызывают финальный обработчик с дырами в данных.


Callback hell и инверсия управления

Вложенность имеет имя — callback hell, «ад колбэков», иногда «пирамида doom».

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
getUser(userId, (err, user) => {
    getOrders(user.id, (err, orders) => {
        getOrderDetails(orders[0].id, (err, details) => {
            getShippingStatus(
                details.trackingId,
                (err, status) => {
                    updateUI(user, orders, details, status);
                }
            );
        });
    });
});

Шесть уровней отступа. Каждый async-шаг углубляет код. Нужно помнить область видимости, переменные из внешних замыканий и какой err кого затеняет. Форма — боковой треугольник, «пирамида».

Но отступы — поверхностная проблема. Именованные функции её сглаживают. Глубже — инверсия управления (inversion of control).

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

Отсюда вопросы доверия — источник продакшен-багов:

Вызовут ли колбэк вообще? Были пути, где колбэк не вызывался — вечное ожидание. Драйвер БД молчал по таймауту. Middleware забывал next() на ветке ошибки. Ни таймаута, ни стека — тишина, память растёт, диагностики нет.

Ровно один раз? Двойной вызов опасен при побочных эффектах: запись в БД, HTTP-ответ. «Headers already sent» в Express, дубликаты в БД, двойное списание. Второй вызов часто незаметен без обёртки once.

Асинхронно ли всегда? Смешение sync/async вызовов ломает порядок: слушатели после синхронного колбэка не сработают, состояние «ещё не готово». Снова Zalgo — правило единообразия; process.nextTick() откладывает даже мгновенный результат.

Верные ли аргументы? Error-first помогает, но язык не проверяет: строка вместо Error, ошибка не в первом слоте, лишние аргументы. Контракт только по соглашению.

Именованные функции улучшают читаемость, не безопасность:

1
2
3
4
5
6
7
8
9
function onUser(err, user) {
    if (err) return handleError(err);
    getOrders(user.id, onOrders);
}
function onOrders(err, orders) {
    if (err) return handleError(err);
    getOrderDetails(orders[0].id, onDetails);
}
getUser(userId, onUser);

Пирамиды нет, поток на уровне модуля читается линейно. Но вы всё ещё доверяете getOrders вызов onOrders один раз и вовремя. Обёртка once:

1
2
3
4
5
6
7
8
function once(fn) {
    let called = false;
    return function (...args) {
        if (called) return;
        called = true;
        fn.apply(this, args);
    };
}

Второй вызов игнорируется. Пластырь: баг скрыт, но альтернатива — двойное списание с карты.

Экосистема Express полна двойных next() и проглоченных error-колбэков. Появились линтеры (node/no-callback-literal, handle-callback-err), соглашения, обёртки. Архитектура колбэков делала проблемы доверия встроенными: вы отдали контроль.

Промисы — структурный ответ: объект будущего результата остаётся у вызывающего; .then(), гарантированная async-фиксация, единоразовое settlement (повторный resolve()/reject() игнорируется). Это тема следующей главы про микрозадачи промисов.


Колбэки сегодня

Несмотря на доминирование промисов и async/await, колбэки никуда не делись — это подложка под всем.

Libuv целиком на колбэках: каждая async-операция завершается вызовом C function pointer. Промисов или futures в libuv нет — внизу везде указатели на функции. C++‑биндинги переводят их в JS через MakeCallback. Даже fs.promises: внутри те же libuv-колбэки, но FSReqPromise резолвит/реджектит промис вместо прямого вызова JS-колбэка. await fs.promises.readFile(path) — сахар над колбэком в persistent handle, который вызовет воркер thread pool.

util.callbackify переводит функцию, возвращающую промис, в callback-API — обратность util.promisify. Зачем «назад»? Внутренности Node и библиотеки с legacy callback-потребителями при async/await внутри.

Производительность на горячих путях: сырой колбэк — без лишних аллокаций кроме самой функции (часто уже есть как замыкание). Промис создаёт объект на heap, внутренние resolve/reject, executor и микрозадачи при settlement. На одном вызове разница микроскопическая; на десятках тысяч в секунду — заметнее. Некоторые драйверы (pg, ioredis, бинарные протоколы) держат callback API рядом с promise API: меньше аллокаций, реже GC, нет overhead микрозадач.

Разрыв сужается с каждым релизом V8: инлайн resolution, меньше промежуточных объектов у await. Для большинства приложений эргономика и безопасность промисов важнее; оптимизируйте после профилирования.

Слушатели EventEmitter — колбэки: server.on('request', handler). Несколько слушателей на событие, синхронный вызов по порядку регистрации. Потоки: 'data', 'error' — колбэки.

setTimeout и setInterval — передали функцию, её вызовут позже. Фаза timers event loop отдаёт колбэки по сроку. Планировщики Node на уровне C — колбэки.

И сюрприз: конструктор Promise принимает колбэк (resolve, reject) => { ... } — вызывается синхронно при создании. Абстракция, сменившая колбэки, начинается с колбэка.

Колбэк — низкоуровневый async-примитив Node. Промисы, async/await, async iterators, pipeline readable stream — внизу стека снова колбэк. FSReqCallback, persistent handles, MakeCallback, checkpoint микрозадач работают под всеми слоями. Понимание этой машинерии отделяет умение писать await от понимания, почему runtime ведёт себя именно так.


Связанное чтение

Комментарии