Сатурация libuv thread pool в Node.js: как crypto, fs и dns.lookup ломают latency в production
В Node.js
Где возникает проблема
По умолчанию
Первые 4
Production-сценарий
Логин считает password hash, HTTP-клиент часто резолвит hostname, а hot path читает сертификат или metadata с диска. CPU может быть не 100%, event loop - "здоровым", но p99 растет из-за очереди к worker'ам.
DNS-нюанс
Что делать
* выносить тяжелый crypto в
* не читать файлы на hot path - грузить и кэшировать заранее;
* включать keep-alive, pooling и DNS cache;
* менять
Больше thread'ов - не silver bullet: CPU-bound задачи могут ухудшить p99 из-за context switching.
Вывод:
libuv thread pool - маленький общий ресурс процесса, поэтому latency-sensitive I/O нельзя смешивать с тяжелым crypto и частым
В Node.js
async не значит "без очередей": часть операций конкурирует за общий libuv thread pool. Частая ошибка в production - смотреть только на event loop lag и не видеть очередь внутри пула.Где возникает проблема
По умолчанию
UV_THREADPOOL_SIZE=4 на процесс. Туда попадают crypto.pbkdf2, scrypt, часть randomBytes, многие fs.*, dns.lookup, zlib.for (let i = 0; i < 8; i++)
crypto.pbkdf2(p, s, 500_000, 64, 'sha512', cb);
await fs.readFile('./config.json');
await dns.lookup('api.internal');
Первые 4
pbkdf2 занимают весь пул, остальные операции ждут. Маленький config-файл или DNS lookup внезапно дают сотни миллисекунд.Production-сценарий
Логин считает password hash, HTTP-клиент часто резолвит hostname, а hot path читает сертификат или metadata с диска. CPU может быть не 100%, event loop - "здоровым", но p99 растет из-за очереди к worker'ам.
DNS-нюанс
dns.lookup использует системный resolver через getaddrinfo и libuv pool. dns.resolve4/6/Mx используют c-ares и не конкурируют с fs/crypto так же. Предупреждение: массовый dns.lookup легко принять за "медленную сеть".Что делать
* выносить тяжелый crypto в
worker_threads, отдельный сервис, процессы или queue;* не читать файлы на hot path - грузить и кэшировать заранее;
* включать keep-alive, pooling и DNS cache;
* менять
UV_THREADPOOL_SIZE только после load test:UV_THREADPOOL_SIZE=32 node server.js
Больше thread'ов - не silver bullet: CPU-bound задачи могут ухудшить p99 из-за context switching.
Вывод:
libuv thread pool - маленький общий ресурс процесса, поэтому latency-sensitive I/O нельзя смешивать с тяжелым crypto и частым
dns.lookup без изоляции и измерений.❤1
AbortController в Node.js production: сквозные дедлайны, cleanup ресурсов и защита от «висящих» операций
AbortController важен не как таймаут для
Сквозной дедлайн
У HTTP-запроса, джобы или RPC должен быть один бюджет, который прокидывается вниз: handler - сервис - БД - внешний API - stream - timer.
Типичная ошибка
Не ставьте независимые
Abort кооперативен
Он работает только там, где API принимает
Cleanup обязателен
Вывод:
Сильный production-паттерн - один сквозной
AbortController важен не как таймаут для
fetch, а как контракт жизненного цикла операции. В production частая ошибка - клиент уже ушел, SLA истек, а сокеты, стримы и временные файлы продолжают жить.Сквозной дедлайн
У HTTP-запроса, джобы или RPC должен быть один бюджет, который прокидывается вниз: handler - сервис - БД - внешний API - stream - timer.
const client = new AbortController();
req.on('close', () => {
client.abort(new Error('client disconnected'));
});
const signal = AbortSignal.any([
client.signal,
AbortSignal.timeout(1500)
]);
try {
const r = await fetch(reportUrl, { signal });
await pipeline(r.body, createWriteStream(file), { signal });
} finally {
await rm(tmpDir, {
recursive: true,
force: true
}).catch(() => {});
}
Типичная ошибка
Не ставьте независимые
timeout: 1000 в каждом слое. Так запрос легко живет 3+ секунды, хотя общий SLA был 1 секунда. Практический совет: передавайте сверху signal, а локальный меньший бюджет делайте через AbortSignal.any().Abort кооперативен
Он работает только там, где API принимает
signal: fetch, timers/promises, stream.pipeline, часть fs, http, child_process, клиенты БД. Предупреждение: Promise.race() с таймаутом не отменяет реальную работу, он только перестает ждать результат.Cleanup обязателен
abort() не удалит временные файлы, не вернет connection в pool и не снимет listeners. Освобождайте ресурсы в finally и прокидывайте внутрь signal, а не controller.Вывод:
Сильный production-паттерн - один сквозной
AbortSignal, понятный signal.reason и гарантированный cleanup каждой операции.👍3
WeakRef, FinalizationRegistry и утечки в нативных коллбэках: ловушки памяти в V8
Node.js и GC V8 делают много работы за нас, но есть сценарии, где автоматическое управление памятью отключается. Особенно когда в дело входят нативные аддоны на C++ с N-API и коллбэки, которые живут дольше, чем само JS-окружение. WeakRef и FinalizationRegistry часто подают как спасение, но на практике это западня, если не понимать деталей.
WeakRef: иллюзия безопасности
WeakRef хранит слабую ссылку на объект и не мешает GC. Выглядит удобно для кэширования, но deref() может вернуть undefined в любой момент:
* Практический совет: никогда не рассчитывай на то, что объект жив после deref().
* Типичная ошибка: думать, что WeakRef продлевает жизнь объекту. Нет, GC может уничтожить его сразу.
FinalizationRegistry: не деструктор
FinalizationRegistry вызывает коллбэк после сборки мусора, но это не гарантированный cleanup:
* Подводный камень: время вызова не определено, и если процесс завершился, коллбэк может не случиться.
* Практический совет: используй FinalizationRegistry только для логирования или диагностики, а не как основной механизм освобождения ресурсов.
Утечки в нативных коллбэках: скрытый убийца
Нативные аддоны с N-API могут захватить JS-коллбэк через napi_ref и забыть его освободить. В результате объект зависает навсегда:
* Production-oriented пример: обработчик событий в long-running процессе, где каждый вызов on создает новую ссылку без очистки.
* Решение: в аддонах используй napi_create_reference с 0 — это слабая ссылка, и явно удаляй ссылку при завершении работы.
Вывод:
Утечки в нативных коллбэках проще предотвратить на этапе проектирования, чем отлавливать с помощью слабых ссылок — дисциплина и явные cleanup-паттерны остаются единственной надежной защитой.
Node.js и GC V8 делают много работы за нас, но есть сценарии, где автоматическое управление памятью отключается. Особенно когда в дело входят нативные аддоны на C++ с N-API и коллбэки, которые живут дольше, чем само JS-окружение. WeakRef и FinalizationRegistry часто подают как спасение, но на практике это западня, если не понимать деталей.
WeakRef: иллюзия безопасности
WeakRef хранит слабую ссылку на объект и не мешает GC. Выглядит удобно для кэширования, но deref() может вернуть undefined в любой момент:
const cache = new Map();
function cachedGet(key, factory) {
let ref = cache.get(key);
if (ref && ref.deref()) return ref.deref();
const obj = factory();
cache.set(key, new WeakRef(obj));
return obj;
}
* Практический совет: никогда не рассчитывай на то, что объект жив после deref().
* Типичная ошибка: думать, что WeakRef продлевает жизнь объекту. Нет, GC может уничтожить его сразу.
FinalizationRegistry: не деструктор
FinalizationRegistry вызывает коллбэк после сборки мусора, но это не гарантированный cleanup:
const registry = new FinalizationRegistry((heldValue) => {
console.log(Cleanup for ${heldValue});
});
registry.register(obj, 'some-id');* Подводный камень: время вызова не определено, и если процесс завершился, коллбэк может не случиться.
* Практический совет: используй FinalizationRegistry только для логирования или диагностики, а не как основной механизм освобождения ресурсов.
Утечки в нативных коллбэках: скрытый убийца
Нативные аддоны с N-API могут захватить JS-коллбэк через napi_ref и забыть его освободить. В результате объект зависает навсегда:
nativeModule.on('data', (msg) => console.log(msg));
// В C++: napi_create_reference(env, callback, 1, &ref);
// И никогда не вызывается napi_delete_reference(ref);* Production-oriented пример: обработчик событий в long-running процессе, где каждый вызов on создает новую ссылку без очистки.
* Решение: в аддонах используй napi_create_reference с 0 — это слабая ссылка, и явно удаляй ссылку при завершении работы.
Вывод:
Утечки в нативных коллбэках проще предотвратить на этапе проектирования, чем отлавливать с помощью слабых ссылок — дисциплина и явные cleanup-паттерны остаются единственной надежной защитой.
❤2
Стратегии борьбы с throttle в event loop при работе с Native Addons и FFI через worker_threads
Node.js живёт на одном потоке — это знают все. Но когда в дело вступают Native Addons (napi, C++ модули) и FFI (Foreign Function Interface), event loop может неожиданно "зависнуть". Даже если ты используешь
FFI тут особенно неприятен. Библиотеки типа
Что с этим делать?
1. Вынос в dedicated worker.
Создаёшь отдельный worker, грузишь туда аддон, общаешься через
Внутри worker:
2. Асинхронные API для аддонов.
Если пишешь аддон сам — используй n-api async work (
3. Пул worker'ов с очередью.
Задачи распределяются, и один тяжёлый вызов не может монополизировать все ресурсы. Стандартный паттерн — берёшь задачу из очереди, отдаёшь свободному воркеру.
4. Atomics и SharedArrayBuffer.
Блокировка без захвата event loop. Но требует поддержки
5. Мониторинг.
Замеряй время вызовов через
Коротко:
-
- Для своих аддонов — async n-api.
- Для FFI — пулы и очереди.
Вывод:
Изоляция нативных вызовов через worker_threads с асинхронными API и мониторингом — единственный способ избежать throttling event loop в production.
Node.js живёт на одном потоке — это знают все. Но когда в дело вступают Native Addons (napi, C++ модули) и FFI (Foreign Function Interface), event loop может неожиданно "зависнуть". Даже если ты используешь
worker_threads, throttle всё равно случается. Почему? Потому что нативные вызовы не всегда изолируются полностью. Блокирующий системный вызов в аддоне может зацепить libuv thread pool или V8, и основной поток начинает тормозить.FFI тут особенно неприятен. Библиотеки типа
ffi-napi или koffi вызывают shared library синхронно — event loop просто стоит и ждёт, пока вызов вернётся. Все остальные задачи замораживаются.Что с этим делать?
1. Вынос в dedicated worker.
Создаёшь отдельный worker, грузишь туда аддон, общаешься через
MessageChannel. Главное — не передавай shared memory без синхронизации, если аддон не thread-safe.const { Worker } = require('worker_threads');
const worker = new Worker('./native-worker.js', { workerData: {} });
worker.postMessage({ task: 'heavyCalc', data: [1, 2, 3] });
worker.on('message', result => console.log('Результат:', result));Внутри worker:
const { parentPort } = require('worker_threads');
const nativeAddon = require('native-addon');
parentPort.on('message', msg => {
const result = nativeAddon.heavySync(msg.data);
parentPort.postMessage(result);
});2. Асинхронные API для аддонов.
Если пишешь аддон сам — используй n-api async work (
napi_create_async_work). Для FFI — заворачивай вызовы в setImmediate или setTimeout с нулевой задержкой. Это даёт event loop шанс обработать другие задачи.3. Пул worker'ов с очередью.
Задачи распределяются, и один тяжёлый вызов не может монополизировать все ресурсы. Стандартный паттерн — берёшь задачу из очереди, отдаёшь свободному воркеру.
4. Atomics и SharedArrayBuffer.
Блокировка без захвата event loop. Но требует поддержки
SharedArrayBuffer и аккуратной реализации.5. Мониторинг.
Замеряй время вызовов через
process.hrtime.bigint() или performance.now(). Если вызов длится дольше 50ms — это красный флаг. Логируй и переключай на worker.Коротко:
-
worker_threads — база, но не панацея.- Для своих аддонов — async n-api.
- Для FFI — пулы и очереди.
Вывод:
Изоляция нативных вызовов через worker_threads с асинхронными API и мониторингом — единственный способ избежать throttling event loop в production.
❤2
Diagnostics Channel: Загляни внутрь Node.js в production без console.log и перезаливок
Когда приложение падает в production, а логи пусты - начинается классика: залипаешь в терминал с
Что за зверь?
Модуль
Где выстрелит?
- Утечки HTTP-соединений - канал
- HTTP/2 сессии и их жизненный цикл
- Работа DNS-резолвера
- Создание и завершение Worker Threads
- Зависания событийного цикла - канал
Пример: ловим утечки HTTP-соединений
Кладёшь этот код в
Как тащить в production?
- Условный require:
- Или динамически подключай по
- Только помни: подписка - синхронная операция. Не делай внутри тяжёлых вычислений или блокирующих I/O, иначе забьёшь event loop и ухудшишь производительность
Вывод:
Diagnostics Channel позволяет заглянуть внутрь Node.js в реальном времени под нагрузкой, чтобы поймать утечки и задержки до того, как они уронят production, причём без единой правки кода приложения.
Когда приложение падает в production, а логи пусты - начинается классика: залипаешь в терминал с
console.log, комментируешь половину кода и перезаливаешь. Но у Node.js есть встроенный механизм для подписки на внутренние события движка и модулей без правки исходников. Называется diagnostics_channel.Что за зверь?
Модуль
diagnostics_channel даёт каналы, именуемые по схеме модуль.событие.подсобытие. Через них V8, libuv и сам Node.js эмитят структурированные данные о внутренних операциях. Подписаться можно без правки кода приложения - просто инициализируешь слушателя в отдельном файле и подключаешь через флаг --require.Где выстрелит?
- Утечки HTTP-соединений - канал
http.client.request.create- HTTP/2 сессии и их жизненный цикл
- Работа DNS-резолвера
dns- Создание и завершение Worker Threads
- Зависания событийного цикла - канал
tickПример: ловим утечки HTTP-соединений
const diagnosticsChannel = require('diagnostics_channel');
const clientRequestChannel = diagnosticsChannel.channel('http.client.request.create');
clientRequestChannel.subscribe(({ request, socket }) => {
const startTime = Date.now();
request.on('response', (res) => {
console.log(Request to ${request.hostname} completed (${Date.now() - startTime}ms));
});
socket.on('close', () => {
if (Date.now() - startTime > 5000) {
console.warn(Long-lived socket detected: ${socket.remotePort});
}
});
});Кладёшь этот код в
diagnostics.js, запускаешь с флагом node --require ./diagnostics.js app.js - и никаких изменений в исходниках приложения.Как тащить в production?
- Условный require:
NODE_DEBUG_DIAGNOSTICS=1 node app.js с проверкой в коде- Или динамически подключай по
process.env при старте, чтобы не плодить лишние подписки- Только помни: подписка - синхронная операция. Не делай внутри тяжёлых вычислений или блокирующих I/O, иначе забьёшь event loop и ухудшишь производительность
Вывод:
Diagnostics Channel позволяет заглянуть внутрь Node.js в реальном времени под нагрузкой, чтобы поймать утечки и задержки до того, как они уронят production, причём без единой правки кода приложения.
Event Loop Lag при тысячах setTimeout: как очередь таймеров убивает latency в production
Когда на сервере больше 1000 активных setTimeout или setInterval, latency неожиданно растет, I/O начинает тормозить, а пользователи жалуются на задержки. Многие разработчики считают, что таймеры безвредны, но на практике их перебор в одной фазе event loop блокирует весь tick.
Всё упирается в фазы цикла событий:
- timers — коллбеки от setTimeout и setInterval
- pending callbacks — I/O ошибки
- idle, prepare — внутренняя кухня libuv
- poll — новые I/O события
- check — очередь setImmediate
- close callbacks — закрытие соединений
Проблема: когда у вас 10 000 активных setTimeout, фаза timers отрабатывает десятки миллисекунд. Всё остальное стоит в очереди. I/O, новые запросы, ответы — всё ждёт, пока переберутся таймеры.
Реальный случай из практики
Был сервис с heartbeats для клиентов. Каждый клиент держал рекурсивный setTimeout:
Для 5000 клиентов каждый tick на фазе timers прогонял до 5000 коллбеков. Попутно лагали все остальные запросы — пользователи чувствовали задержки, мониторинг показывал event loop lag > 100ms.
Что с этим делать?
Во-первых, отлаживать. Берём профайлер Node.js:
Смотрим лог — находим, сколько времени уходит на фазу timers. Часто это открытие.
Во-вторых, оптимизировать.
- Ограничьте количество активных таймеров. Сгруппируйте несколько heartbeat-задач в один setInterval.
- Там, где возможна замена, используйте setImmediate. Он живёт на фазе check — не блокирует poll и timers.
- Мониторьте event loop lag через
Можно ещё раскидать тяжёлые таймерные задачи по worker_threads — чтобы они не тормозили главный поток.
Вывод:
Большое количество таймеров — тихий убийца производительности, который часто замечают слишком поздно, но своевременный профилинг и рефакторинг очередей могут снизить latency в разы.
Когда на сервере больше 1000 активных setTimeout или setInterval, latency неожиданно растет, I/O начинает тормозить, а пользователи жалуются на задержки. Многие разработчики считают, что таймеры безвредны, но на практике их перебор в одной фазе event loop блокирует весь tick.
Всё упирается в фазы цикла событий:
- timers — коллбеки от setTimeout и setInterval
- pending callbacks — I/O ошибки
- idle, prepare — внутренняя кухня libuv
- poll — новые I/O события
- check — очередь setImmediate
- close callbacks — закрытие соединений
Проблема: когда у вас 10 000 активных setTimeout, фаза timers отрабатывает десятки миллисекунд. Всё остальное стоит в очереди. I/O, новые запросы, ответы — всё ждёт, пока переберутся таймеры.
Реальный случай из практики
Был сервис с heartbeats для клиентов. Каждый клиент держал рекурсивный setTimeout:
function heartBeat(clientId) {
setTimeout(() => {
// Тяжёлая операция с БД + проверка состояния
heartBeat(clientId);
}, 1000);
}Для 5000 клиентов каждый tick на фазе timers прогонял до 5000 коллбеков. Попутно лагали все остальные запросы — пользователи чувствовали задержки, мониторинг показывал event loop lag > 100ms.
Что с этим делать?
Во-первых, отлаживать. Берём профайлер Node.js:
node --prof app.js
Смотрим лог — находим, сколько времени уходит на фазу timers. Часто это открытие.
Во-вторых, оптимизировать.
- Ограничьте количество активных таймеров. Сгруппируйте несколько heartbeat-задач в один setInterval.
- Там, где возможна замена, используйте setImmediate. Он живёт на фазе check — не блокирует poll и timers.
- Мониторьте event loop lag через
process.hrtime или модуль event-loop-stats. Если lag > 50ms — это звоночек.Можно ещё раскидать тяжёлые таймерные задачи по worker_threads — чтобы они не тормозили главный поток.
Вывод:
Большое количество таймеров — тихий убийца производительности, который часто замечают слишком поздно, но своевременный профилинг и рефакторинг очередей могут снизить latency в разы.
❤2
process.nextTick в дебаунсерах: как сделать Event Loop неубиваемым?
Кажется, что
Почему nextTick опасен в дебаунсере?
*
* Это убивает производительность при частых событиях, например, в WebSocket или resize.
Что происходит под капотом?
Когда вы пишете:
То при каждом вызове кладется микрозадача. Если событие приходит каждую миллисекунду, Event Loop не успевает обработать I/O, таймеры или setImmediate. Это классический starvation.
Как диагностировать проблему?
* Используйте
* Clinic.js покажет, как микрозадачи занимают 100% цикла.
* Старый трюк — замер времени через
Правильный дебаунс — через setTimeout
Это макрозадача, которая не блокирует Event Loop.
Когда nextTick оправдан?
Только для синхронной обработки ошибок или действий, которые должны выполниться строго до следующей фазы. В дебаунсерах — никогда.
Если приложение тормозит на событиях, первым делом проверяйте дебаунсеры на наличие
Вывод:
Кажется, что
process.nextTick быстрее setTimeout(fn, 0), поэтому его часто используют в дебаунсерах. Но в production это может заблокировать Event Loop, превратив приложение в тормознутый монолит. Ошибка в том, что разработчики путают микрозадачи с макрозадачами и не видят последствий.Почему nextTick опасен в дебаунсере?
process.nextTick выполняется до всех фаз Event Loop, включая I/O и таймеры. Если дебаунсер вызывает nextTick рекурсивно или на каждое событие, микрозадачи забивают очередь, и Event Loop не может перейти к другим фазам. Результат: CPU 100%, запросы висят, приложение умирает.*
nextTick никогда не даст выполниться I/O, пока его очередь не опустеет.* Это убивает производительность при частых событиях, например, в WebSocket или resize.
Что происходит под капотом?
Когда вы пишете:
function badDebounce(fn) {
let pending = false;
return function() {
if (!pending) {
pending = true;
process.nextTick(() => {
pending = false;
fn();
});
}
};
}То при каждом вызове кладется микрозадача. Если событие приходит каждую миллисекунду, Event Loop не успевает обработать I/O, таймеры или setImmediate. Это классический starvation.
Как диагностировать проблему?
* Используйте
node --inspect и CPU profiling: ищите много вызовов nextTick в стеке.* Clinic.js покажет, как микрозадачи занимают 100% цикла.
* Старый трюк — замер времени через
process.hrtime.bigint(): если setImmediate выполняется с большой задержкой, значит, nextTick блокирует.Правильный дебаунс — через setTimeout
function debounce(fn, delay) {
let timer = null;
return function() {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, arguments), delay);
};
}Это макрозадача, которая не блокирует Event Loop.
setTimeout корректно обрабатывает события на I/O фазе и не вызывает starvation.Когда nextTick оправдан?
Только для синхронной обработки ошибок или действий, которые должны выполниться строго до следующей фазы. В дебаунсерах — никогда.
Если приложение тормозит на событиях, первым делом проверяйте дебаунсеры на наличие
process.nextTick. Это частая причина падения производительности в Node.js.Вывод:
process.nextTick в дебаунсере блокирует Event Loop и приводит к starvation I/O, используйте setTimeout как единственно правильный вариант, чтобы не убить производительность в production.⚡1
Когда нет утечки, а OOM приходит: дефрагментация кучи V8
Внезапные Out of Memory в долгоживущих Node.js-процессах при работе с буферами и стримами — частая проблема в production, особенно при файловой обработке или проксировании данных. Разработчики ищут утечки, но код часто чист — настоящая причина скрыта в фрагментации Old Space кучи V8.
Как выглядит изнутри
Интенсивная работа с
Production-oriented пример
Рассмотрим типичный стрим для обработки данных:
Каждый вызов
Типичная ошибка
V8 не гарантирует дефрагментацию: GC двигает объекты только в Young Generation, а буферы как внешние ресурсы дополнительно блокируются в Old Space. Усложняет ситуацию objectMode в стримах — он генерирует тысячи мелких объектов, ускоряя фрагментацию.
Практические советы
* Используйте пулы буферов — переиспользуйте через
* Ограничьте
* Отключайте objectMode, если он не критичен — он создает избыточные объекты.
* Применяйте
* Мониторьте
Вывод:
Дефрагментация кучи V8 — редкая, но реальная причина OOM в долгоживущих процессах с интенсивным выделением буферов, которая лечится нормальными паттернами управления памятью и проектированием потоков.
Внезапные Out of Memory в долгоживущих Node.js-процессах при работе с буферами и стримами — частая проблема в production, особенно при файловой обработке или проксировании данных. Разработчики ищут утечки, но код часто чист — настоящая причина скрыта в фрагментации Old Space кучи V8.
Как выглядит изнутри
Интенсивная работа с
Buffer.alloc, Buffer.concat и стримами создает непрерывный цикл выделения и освобождения памяти. Со временем живые объекты оставляют мелкие "дыры" в Old Space. GC отлично собирает мусор, но не может дефрагментировать неподвижные объекты в старом пространстве. Результат — FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed, хотя суммарно свободной памяти ещё много.Production-oriented пример
Рассмотрим типичный стрим для обработки данных:
const { Readable } = require('stream');
let data = Buffer.alloc(0);
const stream = new Readable({
read(size) {
data = Buffer.concat([data, Buffer.alloc(1024)]);
this.push(Buffer.alloc(1024));
}
});
stream.on('data', () => {});Каждый вызов
Buffer.concat создает новый буфер, старый закрепляется в памяти. Через часы работы — OOM при попытке выделить следующий буфер.Типичная ошибка
V8 не гарантирует дефрагментацию: GC двигает объекты только в Young Generation, а буферы как внешние ресурсы дополнительно блокируются в Old Space. Усложняет ситуацию objectMode в стримах — он генерирует тысячи мелких объектов, ускоряя фрагментацию.
Практические советы
* Используйте пулы буферов — переиспользуйте через
Buffer.allocUnsafe с ручным обнулением или свой pool.* Ограничьте
--max-old-space-size — это не панацея, но даёт запас до падения.* Отключайте objectMode, если он не критичен — он создает избыточные объекты.
* Применяйте
stream.pipeline вместо ручного управления для снижения накопления мусора.* Мониторьте
heapStats: стабильный рост malloced_memory с does_zap_garbage — явный признак фрагментации.Вывод:
Дефрагментация кучи V8 — редкая, но реальная причина OOM в долгоживущих процессах с интенсивным выделением буферов, которая лечится нормальными паттернами управления памятью и проектированием потоков.
Скрытые убийцы Event Loop: ICU и Regex в Unicode-строках
Многие знают, что JSON.parse или fs.readFileSync блокируют Event Loop. Но есть враги похитрее - синхронные вызовы ICU и регулярных выражений на unicode-строках. Типичная ошибка: разработчики не подозревают, что методы нормализации или unicode-регулярки могут висеть десятки миллисекунд, оставаясь незамеченными до production-нагрузки.
Где прячется ICU
ICU тихо сидит в методах:
*
*
*
Регулярки с флагами
Пример скрытой блокировки
Как профилировать
* Используй
* Разбрасывай логи с
* Clinic.js:
Как устранять
Для ICU:
* Выноси нормализацию в Worker Threads
* Режь на чанки по 1000 символов, обрабатывай порциями
* Предобрабатывай данные на этапе загрузки, а не в рантайме
Для RegExp:
* Типичная ошибка: паттерны с backtracking, например
* Попробуй
* Разбивай строку через
Практический совет
Используй
Вывод: ICU и unicode-регекспы выглядят безобидно, но регулярно просаживают производительность - профилируйте их отдельно под нагрузкой, а не только в изолированных тестах.
Многие знают, что JSON.parse или fs.readFileSync блокируют Event Loop. Но есть враги похитрее - синхронные вызовы ICU и регулярных выражений на unicode-строках. Типичная ошибка: разработчики не подозревают, что методы нормализации или unicode-регулярки могут висеть десятки миллисекунд, оставаясь незамеченными до production-нагрузки.
Где прячется ICU
ICU тихо сидит в методах:
*
String.prototype.normalize()*
String.prototype.toLocaleLowerCase()*
Intl APIРегулярки с флагами
/u или /s на больших строках (эмодзи, иероглифы) могут висеть синхронно десятки миллисекунд. И ты даже не сразу поймешь, почему твой сервер тупит.Пример скрытой блокировки
const hugeText = '🦄'.repeat(100000);
const start = performance.now();
hugeText.normalize('NFC'); // ICU молча обрабатывает 100к символов
console.log(Блокировка: ${performance.now() - start}ms);
Как профилировать
* Используй
--prof флаг Node.js, потом node --prof-process.* Разбрасывай логи с
performance.now() до и после подозрительных операций.* Clinic.js:
npx clinic doctor -- node app.jsКак устранять
Для ICU:
* Выноси нормализацию в Worker Threads
* Режь на чанки по 1000 символов, обрабатывай порциями
* Предобрабатывай данные на этапе загрузки, а не в рантайме
const { Worker } = require('worker_threads');
const worker = new Worker('./normalize-worker.js');
worker.postMessage(hugeText);Для RegExp:
* Типичная ошибка: паттерны с backtracking, например
(a+)+b. Меняй на a+b.* Попробуй
re2 - без backtracking, работает предсказуемо* Разбивай строку через
string.slice, если можноconst safePattern = /^[a-z]+$/u;
Практический совет
Используй
monitorEventLoopDelay() из perf_hooks - он показывает задержки Event Loop в наносекундах. Запусти его в фоне и сравнивай с пиками нагрузки.Вывод: ICU и unicode-регекспы выглядят безобидно, но регулярно просаживают производительность - профилируйте их отдельно под нагрузкой, а не только в изолированных тестах.
❤2
Неявный pinning в V8: как скрытые ссылки в прототипах и замыканиях мешают GC собирать мусор в production
Замечали, как Node.js внезапно начинает жрать память, хотя все объекты вроде уже не нужны? Чаще всего проблема в неявном pinning — GC просто не видит, что объекты можно собирать, из-за скрытых ссылок, живых прототипов и замыканий, которые V8 ошибочно считает нужными.
Прототипы и скрытые классы
У каждого объекта в V8 есть скрытый класс (HiddenClass) и ссылка на прототип. Пока жив экземпляр, весь прототип со всеми методами и данными остаётся в памяти. Это особенно критично для утечек через кеши:
Замыкания и живые контексты
V8 оптимизирует замыкания, но ошибается: если внутри замыкания объявлена большая переменная, а используется только часть контекста, GC всё равно держит всю аллокацию:
Неявные ссылки через WeakMap и Symbol.species
Слабая карта не гарантирует сборку — если ключ жив, значение тоже живёт. Symbol.species в подклассах может создать неявную ссылку на конструктор, удерживая целый прототип. Типичная ошибка — использовать Map для кеша, когда нужно WeakMap.
Что делать в production:
* Для кеширования используй WeakMap, а не Map — это единственный безопасный способ.
* В замыканиях явно зануляй большие объекты:
* Снимай heap snapshots через
* После создания массива экземпляров можно занулить
Вывод:
Одна невидимая ссылка через прототип или замыкание может закрепить сотни мегабайт в куче — используй WeakMap, явное зануление и профилирование, чтобы production не упал по памяти.
Замечали, как Node.js внезапно начинает жрать память, хотя все объекты вроде уже не нужны? Чаще всего проблема в неявном pinning — GC просто не видит, что объекты можно собирать, из-за скрытых ссылок, живых прототипов и замыканий, которые V8 ошибочно считает нужными.
Прототипы и скрытые классы
У каждого объекта в V8 есть скрытый класс (HiddenClass) и ссылка на прототип. Пока жив экземпляр, весь прототип со всеми методами и данными остаётся в памяти. Это особенно критично для утечек через кеши:
function Data(id) {
this.id = id;
this.buffer = new ArrayBuffer(100);
}
const obj = new Data(1);
// Если obj утёк через замыкание — Data.prototype не соберётся GCЗамыкания и живые контексты
V8 оптимизирует замыкания, но ошибается: если внутри замыкания объявлена большая переменная, а используется только часть контекста, GC всё равно держит всю аллокацию:
function createProcessor() {
const hugeData = new ArrayBuffer(500); // утечка
return {
process: () => console.log('ok'),
release: () => {}
};
}
// Пока жив processor, hugeData не соберётсяНеявные ссылки через WeakMap и Symbol.species
Слабая карта не гарантирует сборку — если ключ жив, значение тоже живёт. Symbol.species в подклассах может создать неявную ссылку на конструктор, удерживая целый прототип. Типичная ошибка — использовать Map для кеша, когда нужно WeakMap.
Что делать в production:
* Для кеширования используй WeakMap, а не Map — это единственный безопасный способ.
* В замыканиях явно зануляй большие объекты:
hugeData = null после создания.* Снимай heap snapshots через
node --inspect или Chrome DevTools — ищи пачки объектов с одинаковым прототипом.* После создания массива экземпляров можно занулить
Constructor.prototype.constructor = null — это ломает ссылку на конструктор, но рискованно.Вывод:
Одна невидимая ссылка через прототип или замыкание может закрепить сотни мегабайт в куче — используй WeakMap, явное зануление и профилирование, чтобы production не упал по памяти.
Atomics.wait заблокировал event loop — как не убить Node.js в production
Общая память через
Как блокировка ломает Event Loop
Когда worker вызывает
Production-сценарий: дедлок коммуникации
Часто main thread посылает worker задачу и ждёт ответа через
Типичная ошибка: забыть про альтернативы
Многие пишут
Практический совет:
Используйте экспериментальный
Вывод:
Общая память через
SharedArrayBuffer даёт мощную межпоточную синхронизацию, но некорректное использование Atomics.wait в worker_threads способно полностью парализовать приложение. Частая ошибка: разработчики забывают, что Atomics.wait — синхронный блокирующий вызов, который останавливает весь поток, включая event loop.Как блокировка ломает Event Loop
Когда worker вызывает
Atomics.wait(buf, 0, 0), он зависает, пока другой поток не изменит значение. В main thread такой вызов выбрасывает TypeError, но внутри worker это законно. Поток перестаёт обрабатывать сообщения и таймеры — worker становится мёртвым.Production-сценарий: дедлок коммуникации
Часто main thread посылает worker задачу и ждёт ответа через
postMessage. Если worker перед этим вызвал Atomics.wait и заблокировался — ответ никогда не придёт. Приложение зависает без краша или явной ошибки.// worker.js
const { parentPort, workerData } = require('worker_threads');
const sab = new SharedArrayBuffer(4);
const view = new Int32Array(sab);
Atomics.wait(view, 0, 0); // Worker hangs forever
parentPort.postMessage('done'); // Never reached
Типичная ошибка: забыть про альтернативы
Многие пишут
Atomics.wait без таймаута или не проверяют, что воркеры уже заняты. Всегда указывайте значение таймаута: Atomics.wait(buf, 0, 0, 1000) — хотя бы не бесконечное ожидание.Практический совет:
Atomics.waitAsync или изоляцияИспользуйте экспериментальный
Atomics.waitAsync() для неблокирующего ожидания. Либо выносите всю логику блокирующих вызовов в отдельные воркеры, которые общаются только через Atomics.store и Atomics.load без синхронных ожиданий.Вывод:
Atomics.wait в worker_threads может полностью убить event loop, если не контролировать время блокировки или связь с main thread.Гонки данных в AsyncLocalStorage при HTTP/2: подводный камень, который ловит даже опытных
Используешь
В чём подвох
Как это выглядит на практике
Обычно думают: раз
Почему это ломается
Что делать
* Не используй общие изменяемые объекты внутри ALS для разных потоков. Каждый поток - свой Map, свой instance.
* Проверяй идемпотентность: если
* После завершения потока зови
* Для воркеров передавай контекст явно через
Безопасный вариант
Вывод: Даже с async_hooks явное создание изолированного состояния на каждый поток - единственный надёжный способ избежать гонок данных при HTTP/2 мультиплексировании.
Используешь
AsyncLocalStorage для проброса контекста запроса через всю цепочку обработки? Нормальная практика. Но когда приходит HTTP/2 с мультиплексированием, привычная схема als.run(new Map(), () => next()) может дать сбой. И доки про это молчат.В чём подвох
AsyncLocalStorage в Node.js работает через async_hooks - он привязан к цепочке асинхронных операций. HTTP/2 потоки - это отдельные асинхронные цепочки, которые выполняются конкурентно. Если в коде есть общие ресурсы (пул соединений с БД, общий кеш, переменные на уровне модуля), начинается гонка. Поток A записывает userId в хранилище, поток B его перезаписывает, и A при чтении получает данные B.Как это выглядит на практике
const als = new AsyncLocalStorage();
const server = http2.createServer();
server.on('stream', (stream) => {
als.run(new Map(), async () => {
const store = als.getStore();
store.set('userId', await getUserId(stream));
});
});
Обычно думают: раз
async/await сохраняет контекст через хуки, всё чисто. Но проблема не в разрыве контекста, а в том, что несколько потоков одновременно пишут в один и тот же Map. Асинхронная изоляция работает только для одной цепочки, а не для нескольких параллельных.Почему это ломается
async_hooks корректно отслеживает цепочки Promise. Но HTTP/2 потоки могут попасть в один тик event loop. Если код использует синхронные побочные эффекты после await (вроде store.set), хуки не спасут - данные уже перемешались. Контекст сохраняется, но параллельные set() делают своё дело.Что делать
* Не используй общие изменяемые объекты внутри ALS для разных потоков. Каждый поток - свой Map, свой instance.
* Проверяй идемпотентность: если
setUserId вызывается дважды для одного потока - ок. Но если два потока пишут в один объект - ошибка.* После завершения потока зови
als.disable() - чтобы не копить утечки памяти.* Для воркеров передавай контекст явно через
postMessage. Надеяться, что ALS дотянется до другого процесса, не стоит.Безопасный вариант
server.on('stream', (stream) => {
const store = new Map(); // локальный для потока
store.set('start', Date.now());
als.run(store, () => processStream(stream));
});Вывод: Даже с async_hooks явное создание изолированного состояния на каждый поток - единственный надёжный способ избежать гонок данных при HTTP/2 мультиплексировании.
Как WeakRef в EventEmitter блокирует GC на секунды и что с этим делать
В high-load Node.js приложениях EventEmitter с подписками через WeakRef часто приводит к pause-блокировкам GC до 2-5 секунд. Разработчики ошибочно полагают, что WeakRef автоматически решает утечки памяти, но на деле он продлевает жизнь long-lived объектам в old-space.
Причина пауз
WeakRef.deref() возвращает объект только при наличии сильной ссылки. Если такой объект попадает в old-space, V8 на этапе Mark-Sweep-Compact задерживается на его трассировке. Пример:
Пока Listener жив через другую ссылку, GC видит его как long-lived, вызывая блокировку.
Диагностика в production
Запусти
Практический совет
Не используй WeakRef в горячих путях EventEmitter. Вместо этого явно управляй подписками:
Это убирает deref и GC не тратит время на weak-ссылки.
Типичная ошибка
Держать сильную ссылку на объект, подписанный через WeakRef. Правило "never hold weak" нарушено — GC не может собрать ни weak, ни strong объект. Решение: если WeakRef обязателен, объединяй обработчики в один WeakSet для контроля.
Вывод: Проблема не в производительности GC, а в архитектуре — либо откажись от WeakRef в EventEmitter, либо делай подписчики короткоживущими, иначе GC-паузы неизбежны.
В high-load Node.js приложениях EventEmitter с подписками через WeakRef часто приводит к pause-блокировкам GC до 2-5 секунд. Разработчики ошибочно полагают, что WeakRef автоматически решает утечки памяти, но на деле он продлевает жизнь long-lived объектам в old-space.
Причина пауз
WeakRef.deref() возвращает объект только при наличии сильной ссылки. Если такой объект попадает в old-space, V8 на этапе Mark-Sweep-Compact задерживается на его трассировке. Пример:
const { EventEmitter } = require('events');
const emitter = new EventEmitter();
class Listener { handleEvent() { /* тяжелая логика */ } }
const ref = new WeakRef(new Listener());
emitter.on('data', () => ref.deref()?.handleEvent());Пока Listener жив через другую ссылку, GC видит его как long-lived, вызывая блокировку.
Диагностика в production
Запусти
node --trace-gc app.js и ищи строки Mark-sweep с временем более 100мс. Сравни heapUsed через process.memoryUsage() — неравномерный рост указывает на застревание объектов. Используй Chrome DevTools: Allocation Timeline покажет, как объекты переходят из young в old-space.Практический совет
Не используй WeakRef в горячих путях EventEmitter. Вместо этого явно управляй подписками:
class SafeListener { handle() {} }
const l = new SafeListener();
emitter.on('data', l.handle.bind(l));
// Освобождение
emitter.off('data', l.handle.bind(l));Это убирает deref и GC не тратит время на weak-ссылки.
Типичная ошибка
Держать сильную ссылку на объект, подписанный через WeakRef. Правило "never hold weak" нарушено — GC не может собрать ни weak, ни strong объект. Решение: если WeakRef обязателен, объединяй обработчики в один WeakSet для контроля.
Вывод: Проблема не в производительности GC, а в архитектуре — либо откажись от WeakRef в EventEmitter, либо делай подписчики короткоживущими, иначе GC-паузы неизбежны.
❤1
Деоптимизация ESM: глубокий импорт против Lazy Loading
Один из частых антипаттернов в Node.js — «островная» организация импортов, когда файл A статически импортирует B, B → C и так до 10+ уровней. Для CommonJS это было не так критично, но с ESM и строгой статической структурой модулей (resolution до выполнения кода) такая цепочка напрямую влияет на cold start.
Как это работает
При
Пример: холодок и стек импортов
На cold start все 4 резолвятся синхронно до выполнения кода. Если что-то не используется немедленно — деоптимизация.
Кейсы с lazy loading (динамический import)
Положительный пример: превращаем статический импорт в динамический.
Но! Если
Решение: lazy экспорт всего модуля целиком или ESM-дерево с вершинным
Ошибка: игнорировать цепочки при lazy loading
Типичная ошибка — думать, что любой
Как применять на практике
В production не злоупотребляйте статическими импортами в long chains, если модули не нужны на старте. Lazy loading спасает cold start, но требует ревизии цепочек: load time может мигрировать. Best practice: в entry point оставлять только абсолютно необходимые модули. Всё остальное — под
Вывод: Статические цепочки ESM синхронно резолвят весь граф на cold start, а lazy loading переносит лаг на первый запрос при наличии внутренних статических импортов.
Один из частых антипаттернов в Node.js — «островная» организация импортов, когда файл A статически импортирует B, B → C и так до 10+ уровней. Для CommonJS это было не так критично, но с ESM и строгой статической структурой модулей (resolution до выполнения кода) такая цепочка напрямую влияет на cold start.
Как это работает
При
import в ESM рантайм строит граф зависимостей до выполнения. Чем глубже цепочка, тем больше модулей нужно зарезолвить, прочитать с диска, спарсить и скомпилировать. Каждый шаг — I/O (особенно без кэша ФС), в больших проектах — сотни файлов. Результат: старт приложения может проседать на 30-50%.Пример: холодок и стек импортов
// entry.js → app.js → services/user.js → utils/validation.js → helpers/time.js
import './app.js' // тащит 4 файла за раз
На cold start все 4 резолвятся синхронно до выполнения кода. Если что-то не используется немедленно — деоптимизация.
Кейсы с lazy loading (динамический import)
Положительный пример: превращаем статический импорт в динамический.
// было:
import { expensiveFunction } from './heavy-module.js'
// стало:
const { expensiveFunction } = await import('./heavy-module.js')
await import() исполняется в момент вызова, не блокируя холодный старт. Полезно для: роутеров, сборщиков, SDK с тяжёлой инициализацией.Но! Если
heavy-module сам статически импортирует другие модули, при динамическом вызове весь его граф резолвится в момент выполнения — лаг переносится на первый запрос (cold start → warm request).// heavy-module.js
import { superHeavy } from 'super-heavy-lib' // статический импорт
// при await import('./heavy-module.js') — superHeavy загружается синхронно
Решение: lazy экспорт всего модуля целиком или ESM-дерево с вершинным
export, где все зависимости — динамические.Ошибка: игнорировать цепочки при lazy loading
Типичная ошибка — думать, что любой
await import() магически решает проблему cold start. На деле, если внутри модуля есть статические импорты, они все равно загрузятся синхронно при первой загрузке. Всегда проверяйте граф зависимостей под massive import.Как применять на практике
В production не злоупотребляйте статическими импортами в long chains, если модули не нужны на старте. Lazy loading спасает cold start, но требует ревизии цепочек: load time может мигрировать. Best practice: в entry point оставлять только абсолютно необходимые модули. Всё остальное — под
await import() с экспортами, где зависимости тоже динамические.Вывод: Статические цепочки ESM синхронно резолвят весь граф на cold start, а lazy loading переносит лаг на первый запрос при наличии внутренних статических импортов.
WebSocket backpressure в Node.js: когда ws.send() без контроля ведёт к OOM
Игнорирование скорости потребления данных клиентом при push-рассылках — одна из частых причин падения Node.js-процесса из-за нехватки памяти. Проблема кроется в том, что библиотека
Через bufferedAmount
Каждый сокет библиотеки
Событие drain
После освобождения буфера сокет генерирует событие
Типичная ошибка — навешивать
Собственная очередь с лимитом
Если число неотправленных сообщений превышает лимит, отключайте клиента через
В production при push-рассылках на 10 тысяч клиентов с
Мониторинг
Логируйте клиентов с
Вывод: Проверяйте
Игнорирование скорости потребления данных клиентом при push-рассылках — одна из частых причин падения Node.js-процесса из-за нехватки памяти. Проблема кроется в том, что библиотека
ws буферизует неотправленные фреймы в очереди, которая растёт линейно при медленных или спящих клиентах.Через bufferedAmount
Каждый сокет библиотеки
ws имеет свойство bufferedAmount, показывающее количество байт в очереди на отправку. Перед вызовом ws.send() проверяйте порог. Если он превышен — пауза отправки.if (ws.bufferedAmount > 1_000_000) {
ws.pause();
}Событие drain
После освобождения буфера сокет генерирует событие
drain. Его можно использовать для возобновления отправки, выстраивая собственную очередь с контролем размера.function sendOrQueue(ws, data) {
if (ws.bufferedAmount > 0) {
ws.once('drain', () => ws.send(data));
} else {
ws.send(data);
}
}Типичная ошибка — навешивать
drain на каждый send() без очистки предыдущих обработчиков. Это приводит к утечкам слушателей.Собственная очередь с лимитом
Если число неотправленных сообщений превышает лимит, отключайте клиента через
ws.terminate(). Иначе память растёт неконтролируемо.const MAX_QUEUE = 2000;
if (ws.bufferedAmount > MAX_QUEUE * 1000) {
ws.terminate();
return;
}
В production при push-рассылках на 10 тысяч клиентов с
bufferedAmount > 1 MB каждый, процесс потребляет 10+ GB RAM и падает с OOM.Мониторинг
Логируйте клиентов с
bufferedAmount > 512 KB. Это ранний индикатор проблем. В библиотеке ws до версии 8.x отсутствовал встроенный контроль потока, а net.Socket использует backpressure через write() + drain, но ws его не учитывает по умолчанию.Вывод: Проверяйте
bufferedAmount, используйте drain и задавайте жёсткие лимиты очереди — иначе незаметный OOM гарантирован при любой расходящейся нагрузке.Утечка памяти, которую вы не заметите: rejections в for await...of и стримы
Обработка ошибок в асинхронных итераторах и стримах кажется интуитивной, но неочевидные rejections внутри генератора или метода
Почему это происходит?
Когда вы используете
Пример с генератором:
Хотя ошибка пробросится, внутренние ресурсы — открытые соединения, таймеры — могут остаться висеть.
В стримах ситуация опаснее:
Если
Практические советы:
* Всегда обрабатывайте rejections через try-catch внутри итератора и явно завершайте итерацию —
* Для стримов используйте
* Добавьте
* Проверяйте утечки через heap snapshots или
Вывод:
Обработка ошибок в асинхронных итераторах и стримах кажется интуитивной, но неочевидные rejections внутри генератора или метода
_read() могут привести к зависанию итерации и неосвобождению памяти. В production это проявляется как постепенный рост heap-usage без видимых причин.Почему это происходит?
Когда вы используете
for await...of, движок ожидает объект { value, done } от итератора. Если внутри асинхронного генератора возникает rejection (например, от необработанного промиса), итерация может зависнуть в бесконечном ожидании, а буферы стрима или данные генератора останутся в памяти.Пример с генератором:
async function* gen() {
yield 1;
await Promise.reject(new Error('fail'));
yield 2; // не достигнется
}
for await (const v of gen()) { } // зависнет?Хотя ошибка пробросится, внутренние ресурсы — открытые соединения, таймеры — могут остаться висеть.
В стримах ситуация опаснее:
const { Readable } = require('stream');
const stream = new Readable({
async read() {
const data = await riskyOp();
this.push(data);
// если rejection без this.destroy() - утечка
}
});
for await (const chunk of stream) { }Если
read() не завершится вызовом this.destroy(err), стрим не сгенерирует событие 'error' и не очистит внутренний буфер.Практические советы:
* Всегда обрабатывайте rejections через try-catch внутри итератора и явно завершайте итерацию —
throw или return.* Для стримов используйте
this.destroy(err) при ошибках в _read или предпочтительно pipeline для автоматической обработки.* Добавьте
finally для гарантированной очистки ресурсов (закрытие соединений, таймеров).* Проверяйте утечки через heap snapshots или
process.on('warning') — не стесняйтесь мониторить.Вывод:
for await...of не обеспечивает автоматической очистки, и необработанные rejections — прямой путь к утечке памяти, поэтому всегда завершайте итерацию явно.❤1👍1
Внутри статьи она подробно расписывает этапы собеседований, лайфхаки и делится учебными ресурсами, которые ей помогли.
Плюс девушка великодушно оставила ссылки на свой Notion с полезными заметками по математике и LLM.
Please open Telegram to view this post
VIEW IN TELEGRAM
❤2
Когда await превращается в троттлинг: микрозадачи, которые убивают latency
Event Loop в Node.js — штука предсказуемая. Цикл фаз: таймеры, I/O, poll, check, close. И между каждой макротаской прогоняются все микротаски, которые накопились. Каждый await создаёт микротаску. В спокойном микросервисе это незаметно, но под нагрузкой очередь микрозадач переполняет Event Loop, вызывая рост латентности без нагрузки на CPU.
Проблема: microtask queue overflow
Представь: async-функция в цикле обрабатывает пачку данных:
Каждая итерация пушит микротаску. Под нагрузкой очередь вырастает до десятков тысяч. Event Loop не успевает делать тики — latency растёт, хотя CPU свободен. Типичная ошибка: думать, что async всегда безопасен, и не замечать накладных расходов.
Диагностика: как найти виновника
Симптомы:
* латентность скачет под нагрузкой, но синхронные операции легковесные
* Event Loop utilization показывает всплески в фазе microtask
* таймеры и сокеты отвечают с задержкой, хотя процессор не на 100%
Для диагностики используй:
*
* логирование времени обработки микротаски через хук
Оптимизация: убираем лишние await
1. Не делай await в цикле, если функция синхронная. Вместо:
Просто:
2. Используй
3. Избегай
4. Тяжёлые операции уноси в фоновую очередь (Bull или BullMQ): не грузи Event Loop тем, что можно сделать отдельно.
Почему это критично в микросервисах?
При 5000 RPS и 20 микрозадачах на каждый запрос очередь вырастает до 100к. Чистка занимает миллисекунды — для highload это вечность. Практический совет: если функция могла быть синхронной — не делай её async. async — это не модно, это накладные расходы.
Вывод:
Диагностируй перегрузку Event Loop через мониторинг задержки, а оптимизируй — удалением лишних await и контролем конкурентности в циклах.
Event Loop в Node.js — штука предсказуемая. Цикл фаз: таймеры, I/O, poll, check, close. И между каждой макротаской прогоняются все микротаски, которые накопились. Каждый await создаёт микротаску. В спокойном микросервисе это незаметно, но под нагрузкой очередь микрозадач переполняет Event Loop, вызывая рост латентности без нагрузки на CPU.
Проблема: microtask queue overflow
Представь: async-функция в цикле обрабатывает пачку данных:
async function processBatch(items) {
for (const item of items) {
await processOne(item);
}
}Каждая итерация пушит микротаску. Под нагрузкой очередь вырастает до десятков тысяч. Event Loop не успевает делать тики — latency растёт, хотя CPU свободен. Типичная ошибка: думать, что async всегда безопасен, и не замечать накладных расходов.
Диагностика: как найти виновника
Симптомы:
* латентность скачет под нагрузкой, но синхронные операции легковесные
* Event Loop utilization показывает всплески в фазе microtask
* таймеры и сокеты отвечают с задержкой, хотя процессор не на 100%
Для диагностики используй:
*
perf_hooks.monitorEventLoopDelay() — если задержка растёт вместе с числом микрозадач* логирование времени обработки микротаски через хук
process.nextTick()Оптимизация: убираем лишние await
1. Не делай await в цикле, если функция синхронная. Вместо:
for (const item of items) await syncFunc(item);
Просто:
items.forEach(item => syncFunc(item));
2. Используй
Promise.all с контролем конкурентности:const CONCURRENCY = 10;
for (let i = 0; i < items.length; i += CONCURRENCY) {
await Promise.all(items.slice(i, i + CONCURRENCY).map(processOne));
}
3. Избегай
await с new Promise(resolve => ...) внутри циклов — отложи resolve, если возможно.4. Тяжёлые операции уноси в фоновую очередь (Bull или BullMQ): не грузи Event Loop тем, что можно сделать отдельно.
Почему это критично в микросервисах?
При 5000 RPS и 20 микрозадачах на каждый запрос очередь вырастает до 100к. Чистка занимает миллисекунды — для highload это вечность. Практический совет: если функция могла быть синхронной — не делай её async. async — это не модно, это накладные расходы.
Вывод:
Диагностируй перегрузку Event Loop через мониторинг задержки, а оптимизируй — удалением лишних await и контролем конкурентности в циклах.
«Тихая» утечка дескрипторов в HTTP/2: как она убивает ваш кластер Node.js
Воркер падает с EMFILE или ENOSPC. Место на диске есть,
Скорее всего, это утечка файловых дескрипторов из-за того, что HTTP/2 сессии завершаются неправильно.
В чём дело
HTTP/2 мультиплексирует несколько потоков в одно TCP-соединение. Если клиент обрывает соединение, Node.js не всегда корректно обрабатывает событие
Как найти
Простой скрипт покажет количество открытых FD:
Если число растёт, а нагрузка не меняется — утечка на месте.
Что делать
Ставить таймауты и принудительно закрывать сессию при ошибке потока:
И мониторить FD с перезапуском воркера, если порог превышен:
Без такого мониторинга вы будете гадать, почему воркеры падают без видимой причины. Таймауты, обработка ошибок потоков и перезапуск по лимиту — этого обычно хватает, чтобы проблема не возвращалась.
Вывод:
Утечка дескрипторов в HTTP/2 — системная проблема, решаемая только комбинацией таймаутов, явного закрытия сессий и проактивного мониторинга лимита FD в каждом воркере.
Воркер падает с EMFILE или ENOSPC. Место на диске есть,
lsof ничего подозрительного не показывает. Знакомая картина?Скорее всего, это утечка файловых дескрипторов из-за того, что HTTP/2 сессии завершаются неправильно.
В чём дело
HTTP/2 мультиплексирует несколько потоков в одно TCP-соединение. Если клиент обрывает соединение, Node.js не всегда корректно обрабатывает событие
close у мультиплексора. Пулы Http2Session не очищаются, дескрипторы сокетов и TLS зависают в CLOSE_WAIT. Воркер постепенно съедает лимит — обычно это 1024 или 4096 дескрипторов — и падает.Как найти
Простой скрипт покажет количество открытых FD:
ls /proc/$(pgrep -f node)/fd | wc -l
Если число растёт, а нагрузка не меняется — утечка на месте.
Что делать
Ставить таймауты и принудительно закрывать сессию при ошибке потока:
const http2 = require('http2');
const server = http2.createServer((req, res) => {});
server.on('session', (session) => {
session.setTimeout(30000);
session.on('timeout', () => session.close());
});И мониторить FD с перезапуском воркера, если порог превышен:
const cluster = require('cluster');
const fs = require('fs');
const fdThreshold = 800;
if (cluster.isWorker) {
setInterval(() => {
let fdCount;
try { fdCount = fs.readdirSync(/proc/${process.pid}/fd).length; }
catch (e) { return; }
if (fdCount > fdThreshold) {
console.error(Worker ${process.pid} has ${fdCount} FDs — restarting);
process.exit(1);
}
}, 60000);
}Без такого мониторинга вы будете гадать, почему воркеры падают без видимой причины. Таймауты, обработка ошибок потоков и перезапуск по лимиту — этого обычно хватает, чтобы проблема не возвращалась.
Вывод:
Утечка дескрипторов в HTTP/2 — системная проблема, решаемая только комбинацией таймаутов, явного закрытия сессий и проактивного мониторинга лимита FD в каждом воркере.
👍2
Теневые блокировки Event Loop от deasync: как найти и устранить
В production падает без видимых причин? Часто виноваты "теневые" блокировки event loop. Самый частый источник -
Механизм блокировки
Выглядит это так: ты вызываешь библиотеку, которая внутри использует
Как найти "тень"
Самый надёжный способ - flamegraphs с
Production-ready решения
Очевидный путь - замена на
* Переписать на асинхронные аналоги
* Вынести синхронные вызовы в Worker Thread
* Вставить
Пример. Вместо
Типичная ошибка
Даже один вызов
Вывод: Избегайте любых синхронных вызовов в production-режиме, заменяя их на асинхронные паттерны или Worker Threads, чтобы p99 latency оставалась стабильной под нагрузкой.
В production падает без видимых причин? Часто виноваты "теневые" блокировки event loop. Самый частый источник -
deasync и её клоны вроде sync-rpc или synchd.Механизм блокировки
Выглядит это так: ты вызываешь библиотеку, которая внутри использует
deasync. Внешне код нормальный, но под капотом - блокировка на уровне C++. Event loop замирает. deasync через node-gyp модуль синхронно ждёт завершения асинхронной операции, игнорируя все остальные события.Как найти "тень"
Самый надёжный способ - flamegraphs с
--perf-basic-prof. Ищи длинные синхронные стектрейсы с deasync::run или uv_run. Второй вариант - process._getActiveHandles(). Если активные хендлы резко падают на пиках, это симптом. Можно повесить custom async_hooks и ловить destroy на таймерах, которые не срабатывают.Production-ready решения
Очевидный путь - замена на
child_process.execSync - не выход. Это та же блокировка. Рабочие варианта:* Переписать на асинхронные аналоги
* Вынести синхронные вызовы в Worker Thread
* Вставить
setImmediate после каждого вызова через timers/promisesПример. Вместо
deasync(promise) пишем await promise. Если сторонняя библиотека - заворачиваем в Worker.const { Worker } = require('worker_threads');
const worker = new Worker('./worker.js');
// p99 latency падает с 1200ms до 45msТипичная ошибка
Даже один вызов
deasync может заблокировать event loop на сотни миллисекунд. Под нагрузкой это смертельно. Проверьте своё production: grep -r "require.*deasync" node_modules - и вы узнаете, кто тихо убивает ваш event loop.Вывод: Избегайте любых синхронных вызовов в production-режиме, заменяя их на асинхронные паттерны или Worker Threads, чтобы p99 latency оставалась стабильной под нагрузкой.
❤2