Утечка памяти, которую вы не заметите: 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
Диагностика и устранение deferred reference cycles в WeakRef и FinalizationRegistry: кэши и пулы в долгоживущих процессах
С WeakRef и FinalizationRegistry в Node.js можно получить скрытые утечки памяти, когда объекты в кэшах и пулах соединений не освобождаются из-за циклических ссылок через cleanup-функции. В production это проявляется как постепенный рост heap в долгоживущих процессах.
Ловушка deferred cycles
Основная проблема - передача объекта (например, socket) в FinalizationRegistry как heldValue внутри cleanup-функции, которая замыкается на тот же объект. Пока cleanup висит в очереди, GC не может освободить объект, так как registry его удерживает.
Диагностика
- Используй heap snapshots через --inspect: ищи цепочку WeakRef -> Object -> FinalizationRegistry
- Следи за process.memoryUsage().heapUsed при простое - рост без причины признак deferred cycle
- Логируй вызовы FinalizationRegistry: если cleanup не происходит после удаления всех внешних ссылок, цикл активен
Production-ориентированный совет
Для пулов соединений храни только идентификаторы в WeakRef, а реальные объекты - в отдельном Map с таймаутом.
Или используй явную отписку:
Типичная ошибка
Передача в registry объекта с методами, которые держат ссылки на другие ресурсы. Это создает неявные циклы, которые GC не распознает.
Вывод: Deferred reference cycles - это скрытые утечки, которые требует диагностики через heap snapshots и разрыва замыканий через примитивы в FinalizationRegistry.
С WeakRef и FinalizationRegistry в Node.js можно получить скрытые утечки памяти, когда объекты в кэшах и пулах соединений не освобождаются из-за циклических ссылок через cleanup-функции. В production это проявляется как постепенный рост heap в долгоживущих процессах.
Ловушка deferred cycles
Основная проблема - передача объекта (например, socket) в FinalizationRegistry как heldValue внутри cleanup-функции, которая замыкается на тот же объект. Пока cleanup висит в очереди, GC не может освободить объект, так как registry его удерживает.
const registry = new FinalizationRegistry((heldValue) => {
heldValue.socket.destroy();
});
class CachedConnection {
constructor(socket) {
this.socket = socket;
registry.register(this, this); // цикл: this как heldValue
}
}Диагностика
- Используй heap snapshots через --inspect: ищи цепочку WeakRef -> Object -> FinalizationRegistry
- Следи за process.memoryUsage().heapUsed при простое - рост без причины признак deferred cycle
- Логируй вызовы FinalizationRegistry: если cleanup не происходит после удаления всех внешних ссылок, цикл активен
Production-ориентированный совет
Для пулов соединений храни только идентификаторы в WeakRef, а реальные объекты - в отдельном Map с таймаутом.
registry.register(this, this.socket.id); // примитив, разрывает цикл
Или используй явную отписку:
registry.unregister(this) // перед удалением ссылки
Типичная ошибка
Передача в registry объекта с методами, которые держат ссылки на другие ресурсы. Это создает неявные циклы, которые GC не распознает.
Вывод: Deferred reference cycles - это скрытые утечки, которые требует диагностики через heap snapshots и разрыва замыканий через примитивы в FinalizationRegistry.
Как temporal.io SDK ломает V8 heap snapshot в длительных Workflow
Если ваш Workflow Execution живёт неделями, готовьтесь к сюрпризам: V8 перестаёт адекватно собирать мусор, а heap snapshot превращается в помойку из ретейнеров. Частая ошибка — считать, что GC справится сам, но Temporal SDK активно мешает ему.
Почему GC не работает
SDK держит один V8 isolate на весь Worker. Пока Workflow крутится, Temporal сохраняет историю состояний и коллбэки. V8 честно чистит память, но SDK не даёт объектам уйти через замыкания в таймерах и маппингах.
Production-кейс: Map на миллион элементов
Пишете долгий воркфлоу с
Практические советы
* Обновляйтесь до
* Чистите
* Дёргайте
* Мониторьте heap через
Вывод: В длительных Workflow явное управление памятью важнее надежды на GC — закладывайте очистку структур данных и рестарты Worker в архитектуру с самого начала.
Если ваш Workflow Execution живёт неделями, готовьтесь к сюрпризам: V8 перестаёт адекватно собирать мусор, а heap snapshot превращается в помойку из ретейнеров. Частая ошибка — считать, что GC справится сам, но Temporal SDK активно мешает ему.
Почему GC не работает
SDK держит один V8 isolate на весь Worker. Пока Workflow крутится, Temporal сохраняет историю состояний и коллбэки. V8 честно чистит память, но SDK не даёт объектам уйти через замыкания в таймерах и маппингах.
WeakRef и FinalizationRegistry в детерминированном коде — отдельная боль: GC просто не видит, что объекты уже никому не нужны. В результате в heap зависают ретейнеры вроде WorkflowContext и ActivityContext.Production-кейс: Map на миллион элементов
Пишете долгий воркфлоу с
Map на миллион элементов, потом ставите Workflow.sleep('1 year'). Этот Map останется в памяти до перезапуска воркера. Почему: SDK замораживает каждое состояние через Object.freeze(), и ссылки на объекты в V8 heap остаются. V8 считает их живыми, пока isolate жив — heap аккумулируется, RSS ползёт вверх, GC тормозит.Практические советы
* Обновляйтесь до
@temporalio/workflow >=1.8.0 — там появился явный context.reset().* Чистите
Map и Set руками: largeMap.clear().* Дёргайте
maxWorkflowCachedExecution, чтобы принудительно пересоздавать isolate.* Мониторьте heap через
process.memoryUsage() и рестартуйте Worker, если память перевалила за лимит.Вывод: В длительных Workflow явное управление памятью важнее надежды на GC — закладывайте очистку структур данных и рестарты Worker в архитектуру с самого начала.
🐳2
False Sharing в SharedArrayBuffer на NUMA: скрытый убийца производительности Node.js
Параллельная обработка данных через worker_threads с SharedArrayBuffer часто даёт неожиданное падение производительности вместо ожидаемого ускорения. Причина — false sharing, который на NUMA-системах с несколькими сокетами может снижать пропускную способность в десятки раз, оставаясь незамеченным в обычных профилировщиках.
Суть проблемы
Когда два воркера пишут в разные элементы одного Int32Array, но они попадают в одну кэш-линию (64 байта = 16 int32), процессор инвалидирует кэш у соседнего ядра. Протокол MESI создаёт лавину обменов между L1-кэшами, а на NUMA это усугубляется межсокетными задержками.
Диагностика через аппаратные счётчики
Используйте perf для выявления:
Резкий рост LLC misses при добавлении воркеров — явный признак. На NUMA добавьте проверку uncore_imc событий для обнаружения межсокетных обращений.
Устранение: padding и привязка к NUMA
Разделите данные на границы кэш-линии — отступ в 16 элементов для Int32Array:
Это устраняет конфликт, но не решает проблему доступа к памяти другого сокета. Привяжите воркеров к локальным нодам через numactl или модуль numa-bindings:
Типичная ошибка
Многие разработчики используют Atomics.store/load каждую итерацию, пытаясь синхронизировать данные. Это лишь добавляет барьеры памяти и усугубляет false sharing. Используйте редкую синхронизацию (раз в N итераций) или локальные буферы с периодическим слиянием.
Production-совет
На высоких нагрузках (8+ ядер) комбинация padding + привязка к локальной памяти даёт прирост 5-10 раз. Для 4-сокетных систем используйте распределение SharedArrayBuffer через libnuma: массив делится на сегменты по числу нод, каждый воркер пишет только в свой сегмент.
Вывод: False sharing на NUMA требует обязательного выравнивания по кэш-линии и привязки worker_threads к локальным нодам памяти — только так можно получить реальный прирост от параллельной обработки.
Параллельная обработка данных через worker_threads с SharedArrayBuffer часто даёт неожиданное падение производительности вместо ожидаемого ускорения. Причина — false sharing, который на NUMA-системах с несколькими сокетами может снижать пропускную способность в десятки раз, оставаясь незамеченным в обычных профилировщиках.
Суть проблемы
Когда два воркера пишут в разные элементы одного Int32Array, но они попадают в одну кэш-линию (64 байта = 16 int32), процессор инвалидирует кэш у соседнего ядра. Протокол MESI создаёт лавину обменов между L1-кэшами, а на NUMA это усугубляется межсокетными задержками.
// Проблемный код: data[0] и data[1] в одной кэш-линии
const sharedBuffer = new SharedArrayBuffer(4 * 1024);
const data = new Int32Array(sharedBuffer);
// Worker 1: data[0]++ | Worker 2: data[1]++
Диагностика через аппаратные счётчики
Используйте perf для выявления:
perf stat -e cache-misses,cache-references,LLC-load-misses node app.js
Резкий рост LLC misses при добавлении воркеров — явный признак. На NUMA добавьте проверку uncore_imc событий для обнаружения межсокетных обращений.
Устранение: padding и привязка к NUMA
Разделите данные на границы кэш-линии — отступ в 16 элементов для Int32Array:
const CACHE_LINE = 16;
const realIndex = workerId * CACHE_LINE;
Это устраняет конфликт, но не решает проблему доступа к памяти другого сокета. Привяжите воркеров к локальным нодам через numactl или модуль numa-bindings:
// Привязка к NUMA-ноде 0
worker.setEnvironment({ 'NODE_NUMA_NODE': '0' });
Типичная ошибка
Многие разработчики используют Atomics.store/load каждую итерацию, пытаясь синхронизировать данные. Это лишь добавляет барьеры памяти и усугубляет false sharing. Используйте редкую синхронизацию (раз в N итераций) или локальные буферы с периодическим слиянием.
Production-совет
На высоких нагрузках (8+ ядер) комбинация padding + привязка к локальной памяти даёт прирост 5-10 раз. Для 4-сокетных систем используйте распределение SharedArrayBuffer через libnuma: массив делится на сегменты по числу нод, каждый воркер пишет только в свой сегмент.
Вывод: False sharing на NUMA требует обязательного выравнивания по кэш-линии и привязки worker_threads к локальным нодам памяти — только так можно получить реальный прирост от параллельной обработки.
🐳2
Starvation в worker_threads: почему libuv заставляет ваши воркеры голодать при смешанной нагрузке I/O и CPU
Когда вы смешиваете I/O и CPU-bound задачи в worker_threads, неочевидная деталь может убить производительность. В production часто встречается ситуация: воркеры простаивают, хотя ядра свободны. Корень проблемы - приоритеты очередей libuv.
Как libuv ест процессорное время воркеров
У libuv есть I/O callback queue с высоким приоритетом. Если в главном потоке активны I/O операции (чтение файлов, сетевые запросы), они обрабатываются первыми. Worker_threads получают CPU только на этапе idle. В результате воркер может ждать 100-200 мс при реальной нагрузке, даже если на ядрах нет другой работы.
Диагностика: ловим задержки
Начните с event loop lag. Вот production-ready пример:
Также полезно смотреть process._getActiveHandles() - там видно, какие задачи висят в libuv. Типичная ошибка: думать, что раз воркер в отдельном потоке, он не зависит от I/O в главном.
Четыре способа исправить
1. Ограничьте частоту I/O в главном потоке. Не спамьте readFile каждые 10 мс - используйте debounce или batch.
2. Разбивайте данные на чанки с setImmediate(). Это перераспределяет приоритет очереди libuv и дает воркерам больше шансов:
3. Настройте UV_THREADPOOL_SIZE. Не ставьте максимальное значение - обычно достаточно os.cpus().length * 2, но не больше 4.
4. Выделите одно ядро главному потоку: workerCount = os.cpus().length - 1.
Важный предостережение
Даже с настройками worker_threads остается зависимым от главного event loop. Если не изолировать CPU-bound и I/O задачи в разные процессы, при пиковой нагрузке воркеры все равно будут ждать. Планирование завязано на libuv - не игнорируйте это.
Вывод: Причина starvation не в worker_threads, а в неявных приоритетах libuv - решайте её через контроль I/O в главном потоке и разбивку задач с setImmediate.
Когда вы смешиваете I/O и CPU-bound задачи в worker_threads, неочевидная деталь может убить производительность. В production часто встречается ситуация: воркеры простаивают, хотя ядра свободны. Корень проблемы - приоритеты очередей libuv.
Как libuv ест процессорное время воркеров
У libuv есть I/O callback queue с высоким приоритетом. Если в главном потоке активны I/O операции (чтение файлов, сетевые запросы), они обрабатываются первыми. Worker_threads получают CPU только на этапе idle. В результате воркер может ждать 100-200 мс при реальной нагрузке, даже если на ядрах нет другой работы.
Диагностика: ловим задержки
Начните с event loop lag. Вот production-ready пример:
const { performance } = require('perf_hooks');
const start = performance.now();
setTimeout(() => {
const lag = performance.now() - start - 100;
if (lag > 50) console.warn(Event loop lag: ${lag.toFixed(2)}ms);
// Если lag растет - воркеры голодают
}, 100);Также полезно смотреть process._getActiveHandles() - там видно, какие задачи висят в libuv. Типичная ошибка: думать, что раз воркер в отдельном потоке, он не зависит от I/O в главном.
Четыре способа исправить
1. Ограничьте частоту I/O в главном потоке. Не спамьте readFile каждые 10 мс - используйте debounce или batch.
2. Разбивайте данные на чанки с setImmediate(). Это перераспределяет приоритет очереди libuv и дает воркерам больше шансов:
function processChunks(data) {
const chunk = data.splice(0, 100);
if (chunk.length) {
setImmediate(() => processChunks(data));
}
}3. Настройте UV_THREADPOOL_SIZE. Не ставьте максимальное значение - обычно достаточно os.cpus().length * 2, но не больше 4.
4. Выделите одно ядро главному потоку: workerCount = os.cpus().length - 1.
Важный предостережение
Даже с настройками worker_threads остается зависимым от главного event loop. Если не изолировать CPU-bound и I/O задачи в разные процессы, при пиковой нагрузке воркеры все равно будут ждать. Планирование завязано на libuv - не игнорируйте это.
Вывод: Причина starvation не в worker_threads, а в неявных приоритетах libuv - решайте её через контроль I/O в главном потоке и разбивку задач с setImmediate.
🐳2⚡1🔥1
process.nextTick: невидимый убийца event loop, который кладет production-сервисы
Кажется, что все знают про process.nextTick, но на практике я регулярно вижу, как он валит продакшен. Проблема в том, что он имеет высший приоритет в микротаск-очереди и выполняется до любого I/O и таймеров. Это делает его опасным при рекурсивном использовании.
Как рекурсивный nextTick убивает таймеры
Event loop после каждого макротаска вычищает все микротаски. process.nextTick — микротаск с наивысшим приоритетом. Рекурсия без выхода блокирует очередь:
После первого макротаска event loop заходит в микротаски. Там recursiveTick добавляет новый nextTick. И так бесконечно. setTimeout и setInterval не запускаются — они в макротасках, а микротаски не кончаются.
Production-кейс с кэшем и БД
У нас система с кэшем при ошибке доступа к БД в nextTick крутила переинициализацию соединения. В ошибочном сценарии — рекурсия. Результат: health-check’и молчали (таймеры не работали), входящие запросы висели, сервис падал веером. Типичная ошибка — думать, что nextTick безопасен для retry-логики.
Практические решения
* Никогда не используйте рекурсивный nextTick. Вместо него setImmediate — он ставит задачу в следующую фазу event loop, давая I/O и таймерам шанс:
* Если очередь нужна, добавьте лимит итераций. После, скажем, 1000 вызовов — принудительный yield через setTimeout(fn, 0).
* Для длинных цепочек используйте библиотеки для backpressure, иначе даже без бесконечной рекурсии производительность упадет.
Вывод:
process.nextTick ломает кооперативную природу event loop, и даже короткая рекурсия может полностью парализовать I/O и таймеры в production.
Кажется, что все знают про process.nextTick, но на практике я регулярно вижу, как он валит продакшен. Проблема в том, что он имеет высший приоритет в микротаск-очереди и выполняется до любого I/O и таймеров. Это делает его опасным при рекурсивном использовании.
Как рекурсивный nextTick убивает таймеры
Event loop после каждого макротаска вычищает все микротаски. process.nextTick — микротаск с наивысшим приоритетом. Рекурсия без выхода блокирует очередь:
function recursiveTick() {
process.nextTick(recursiveTick);
}
recursiveTick();После первого макротаска event loop заходит в микротаски. Там recursiveTick добавляет новый nextTick. И так бесконечно. setTimeout и setInterval не запускаются — они в макротасках, а микротаски не кончаются.
Production-кейс с кэшем и БД
У нас система с кэшем при ошибке доступа к БД в nextTick крутила переинициализацию соединения. В ошибочном сценарии — рекурсия. Результат: health-check’и молчали (таймеры не работали), входящие запросы висели, сервис падал веером. Типичная ошибка — думать, что nextTick безопасен для retry-логики.
Практические решения
* Никогда не используйте рекурсивный nextTick. Вместо него setImmediate — он ставит задачу в следующую фазу event loop, давая I/O и таймерам шанс:
function safeRecursive() {
// логика
setImmediate(safeRecursive);
}* Если очередь нужна, добавьте лимит итераций. После, скажем, 1000 вызовов — принудительный yield через setTimeout(fn, 0).
* Для длинных цепочек используйте библиотеки для backpressure, иначе даже без бесконечной рекурсии производительность упадет.
Вывод:
process.nextTick ломает кооперативную природу event loop, и даже короткая рекурсия может полностью парализовать I/O и таймеры в production.
❤1⚡1🔥1
SharedArrayBuffer без Atomics: гонка, которую не видно
Когда два worker_thread пишут в один SharedArrayBuffer, состояние памяти может разрушиться без видимых исключений. Особенно это заметно на ARM: вместо ожидаемых 0xFF00FF00 и 0x00FF00FF получается 0x0000FFFF — байты частично обновляются и перемешиваются. Частая ошибка — думать, что «просто запись» в SharedArrayBuffer безопасна без Atomics.
Почему это ломается
Два воркера одновременно пишут в соседние ячейки Uint32Array. Кэш-линии процессора конфликтуют, и результат — частичное обновление. В production это проявляется как расходящиеся данные или краши без явной причины. Диагностика сложна: console.log с двоичным выводом может показать мусор, но только постфактум.
Как диагностировать
Вставь Atomics.load() для проверки — он вернет актуальное значение и выявит «битые» данные. Но без Atomics.store() запись остается неатомарной.
Лечение: только Atomics
Используй
Типичная ошибка
Игнорировать атомарность на write-heavy сценариях. «Оптимизация» без Atomics кончится багом, который воспроизводится через раз. Не пытайся экономить на синхронизации — это ломает production.
Вывод: При конкурентной записи в SharedArrayBuffer из разных воркеров используй только целочисленные Atomics-методы — любая другая запись гарантирует state corruption на уровне памяти.
Когда два worker_thread пишут в один SharedArrayBuffer, состояние памяти может разрушиться без видимых исключений. Особенно это заметно на ARM: вместо ожидаемых 0xFF00FF00 и 0x00FF00FF получается 0x0000FFFF — байты частично обновляются и перемешиваются. Частая ошибка — думать, что «просто запись» в SharedArrayBuffer безопасна без Atomics.
Почему это ломается
Два воркера одновременно пишут в соседние ячейки Uint32Array. Кэш-линии процессора конфликтуют, и результат — частичное обновление. В production это проявляется как расходящиеся данные или краши без явной причины. Диагностика сложна: console.log с двоичным выводом может показать мусор, но только постфактум.
Как диагностировать
Вставь Atomics.load() для проверки — он вернет актуальное значение и выявит «битые» данные. Но без Atomics.store() запись остается неатомарной.
Atomics.load(buffer, index) — первый шаг к обнаружению гонки. Если видишь неожиданные значения, проблема точно в синхронизации.Лечение: только Atomics
Используй
Atomics.store, Atomics.load, Atomics.exchange — это единственный надежный способ. Подходят только целочисленные массивы: Int32Array, Uint32Array, BigInt64Array. Float не поддерживается. Для сложных структур применяй Atomics.compareExchange и спин-лок:const lock = new Int32Array(sab, 0, 1);
while (!Atomics.compareExchange(lock, 0, 0, 1)) {
// spin
}
// работа с shared данными
Atomics.store(lock, 0, 0);
Atomics.isLockFree() для Int32 возвращает true — это быстрее мьютексов. Ошибка: попытка обойти Atomics через Buffer.writeInt32LE или DataView — это не атомарно, особенно на разных ядрах.Типичная ошибка
Игнорировать атомарность на write-heavy сценариях. «Оптимизация» без Atomics кончится багом, который воспроизводится через раз. Не пытайся экономить на синхронизации — это ломает production.
Вывод: При конкурентной записи в SharedArrayBuffer из разных воркеров используй только целочисленные Atomics-методы — любая другая запись гарантирует state corruption на уровне памяти.
👍4🐳3
Hidden Class Degradation: тихий убийца производительности JSON.parse в Node.js
Каждый раз, когда JSON.parse создаёт объекты с разными ключами, V8 генерирует новые hidden classes. После 10-15 тысяч вызовов функция деоптимизируется из-за hidden class mismatch. В production это может снизить throughput на 30%.
Как это выглядит в коде
Представь high-load парсинг внешнего API с динамической структурой:
V8 создаёт два разных hidden class: один с role, другой без. Через ~20 итераций цикл деоптимизирует parseUser.
Диагностика
Запусти Node c флагами:
Ищи строки вроде "deoptimized because of: hidden class mismatch". Если они есть — ты в зоне риска.
Три стратегии исправления
1. Фиксированная структура — инициализируй все поля заранее:
Hidden class один для всех экземпляров.
2. Map для динамических ключей:
Map не имеет hidden class transitions, но чтение/запись чуть медленнее.
3. Пул объектов (для high-load):
Заранее подготовь массив объектов с фиксированной структурой, переиспользуй их через reset полей. Это убирает GC и даёт V8 стабильный hidden class.
Вывод: hidden class transitions — это overhead, который легко упустить; фиксированная структура объекта или Map — дешёвый способ вернуть 30% производительности.
Каждый раз, когда JSON.parse создаёт объекты с разными ключами, V8 генерирует новые hidden classes. После 10-15 тысяч вызовов функция деоптимизируется из-за hidden class mismatch. В production это может снизить throughput на 30%.
Как это выглядит в коде
Представь high-load парсинг внешнего API с динамической структурой:
function parseUser(data) {
const obj = {};
if (data.role) obj.role = data.role;
return obj;
}
for (let i = 0; i < 100000; i++) {
parseUser({ name: "test", role: i % 2 ? "admin" : undefined });
}V8 создаёт два разных hidden class: один с role, другой без. Через ~20 итераций цикл деоптимизирует parseUser.
Диагностика
Запусти Node c флагами:
node --trace-opt --trace-deopt app.js | grep "hide class"
Ищи строки вроде "deoptimized because of: hidden class mismatch". Если они есть — ты в зоне риска.
Три стратегии исправления
1. Фиксированная структура — инициализируй все поля заранее:
const obj = { role: null };Hidden class один для всех экземпляров.
2. Map для динамических ключей:
const map = new Map(Object.entries(data));Map не имеет hidden class transitions, но чтение/запись чуть медленнее.
3. Пул объектов (для high-load):
Заранее подготовь массив объектов с фиксированной структурой, переиспользуй их через reset полей. Это убирает GC и даёт V8 стабильный hidden class.
Вывод: hidden class transitions — это overhead, который легко упустить; фиксированная структура объекта или Map — дешёвый способ вернуть 30% производительности.
👍4❤3🐳3😁1
Implicit ABA в async-генераторах: тихая гонка в очередях сообщений
Работа с очередями в Node.js — дело привычное. Kafka, RabbitMQ, Redis Streams. Берёшь async-генератор, читаешь сообщения, обрабатываешь. Но implicit ABA problem в async-генераторах выстреливает в production неожиданно: yield — точка прерывания, и пока генератор приостановлен, другой воркер может изменить состояние очереди.
Где прячется проблема
Вот production-ориентированный пример:
Проблема: пока ты висел на yield, другой процесс мог удалить сообщение, сместить offset. Ты возвращаешься — работаешь с устаревшим состоянием. Это классическая implicit ABA, где "последний offset" меняется под капотом.
Как диагностировать
Логируй время до и после yield. Если пауза больше пары секунд при высокой конкурентности — это красный флаг. Используй детерминированные идентификаторы сообщений, не полагайся на offset как на истину. Смотри на время обработки: между итерациями риск ABA растёт.
Что делать
Самый прямой путь — транзакционная обработка с блокировкой:
Совет: используй снэпшоты и проверяй их свежесть на каждой итерации. Или явно проверяй поколение очереди — если оно изменилось, рвать цикл.
Ошибка
Не пытайся кэшировать offset между yield в глобальных переменных. Это усугубляет ABA, особенно в системах с несколькими воркерами и долгими HTTP-запросами. Если есть такой код в проде — скорее всего, ты уже видел странные баги, которые не воспроизводятся локально.
Вывод: Async-генераторы элегантны, но их "заморозка" между yield требует явного управления состоянием очереди, иначе гонка становится неявной и трудноуловимой.
Работа с очередями в Node.js — дело привычное. Kafka, RabbitMQ, Redis Streams. Берёшь async-генератор, читаешь сообщения, обрабатываешь. Но implicit ABA problem в async-генераторах выстреливает в production неожиданно: yield — точка прерывания, и пока генератор приостановлен, другой воркер может изменить состояние очереди.
Где прячется проблема
Вот production-ориентированный пример:
async function* readMessages(queue) {
let lastOffset = await queue.getLatestOffset();
while (true) {
const messages = await queue.poll(lastOffset);
if (messages.length === 0) continue;
yield messages;
for (const msg of messages) {
if (msg.offset >= lastOffset) {
lastOffset = msg.offset + 1;
}
}
}
}Проблема: пока ты висел на yield, другой процесс мог удалить сообщение, сместить offset. Ты возвращаешься — работаешь с устаревшим состоянием. Это классическая implicit ABA, где "последний offset" меняется под капотом.
Как диагностировать
Логируй время до и после yield. Если пауза больше пары секунд при высокой конкурентности — это красный флаг. Используй детерминированные идентификаторы сообщений, не полагайся на offset как на истину. Смотри на время обработки: между итерациями риск ABA растёт.
Что делать
Самый прямой путь — транзакционная обработка с блокировкой:
async function* safeReader(queue) {
while (true) {
const result = await queue.pollWithLock();
yield result;
await queue.commit();
}
}Совет: используй снэпшоты и проверяй их свежесть на каждой итерации. Или явно проверяй поколение очереди — если оно изменилось, рвать цикл.
Ошибка
Не пытайся кэшировать offset между yield в глобальных переменных. Это усугубляет ABA, особенно в системах с несколькими воркерами и долгими HTTP-запросами. Если есть такой код в проде — скорее всего, ты уже видел странные баги, которые не воспроизводятся локально.
Вывод: Async-генераторы элегантны, но их "заморозка" между yield требует явного управления состоянием очереди, иначе гонка становится неявной и трудноуловимой.
Deadlock из-за
Использование
Симптомы проблемы
Запрос висит ровно
Пример проблемного кода
Здесь
Правильное решение: единый сигнал
Используйте
Диагностика в продакшене:
- Используйте
- Добавьте логирование в каждый middleware через
- Проверяйте открытые соединения через
Вывод:
AbortSignal.timeout в middleware: как не убить свой серверИспользование
AbortSignal.timeout с Promise.race в цепочках middleware кажется простым способом ограничить время выполнения запроса. Однако на практике это приводит к молчаливым зависаниям, которые не ловятся даже unhandledRejection, а сервер начинает накапливать незавершённые запросы.Симптомы проблемы
Запрос висит ровно
timeout секунд, после чего падает с TimeoutError. Middleware не перехватывает ошибку — она улетает в catch верхнего уровня или теряется вовсе. Процесс не падает, но следующие запросы блокируются из-за незакрытых ресурсов.Пример проблемного кода
app.use(async (req, res, next) => {
const controller = new AbortController();
const timeoutSignal = AbortSignal.timeout(3000);
const result = await Promise.race([
someAsyncTask(req, { signal: controller.signal }),
timeoutSignal
]);
next(); // deadlock: never called
});Здесь
AbortSignal.timeout(3000) создаёт независимый сигнал. Promise.race завершается, когда один из промисов срабатывает, но не передаёт отмену вашему controller. Дочерние задачи продолжают висеть, а middleware не вызывает next(), блокируя цепочку.Правильное решение: единый сигнал
Используйте
AbortSignal через req.signal и таймаут на уровне роутера:app.use(async (req, res, next) => {
const controller = new AbortController();
res.on('close', () => controller.abort());
req.signal = controller.signal;
next();
});
app.use(async (req, res, next) => {
const timeout = setTimeout(() => {
req.signal.abort();
res.status(408).send('timeout');
}, 3000);
res.on('finish', () => clearTimeout(timeout));
next();
});Диагностика в продакшене:
- Используйте
async_hooks или cls-hooked для трейсинга запросов.- Добавьте логирование в каждый middleware через
console.time.- Проверяйте открытые соединения через
db.$pool.waitingClients.Вывод:
AbortSignal.timeout должен быть частью контракта middleware, а не хаотично переброшен в Promise.race, иначе deadlock обеспечен.🔥3
process.env mutation: неявная утечка метаданных и NODE_OPTIONS в fork/exec
Многие считают process.env чем-то вроде локального словаря для конфигов, но это глобальное состояние всего процесса. Когда вы форкаете воркеры или вызываете exec, дочерние наследуют всё окружение. Одна опечатка — и пароль утекает в дамп памяти, а instrumentation.js запускается в неожиданном контексте.
Почему process.env опасен
Каждое присваивание в process.env мутирует общую память. Если вы пишете
NODE_OPTIONS — скрытая утечка конфигурации
Переменная
Как защититься в production
При fork/spawn явно указывайте только нужные переменные:
Вывод: process.env — глобальное состояние, которое легко утекает в дочерние процессы; всегда управляйте окружением явно через опцию env при fork/spawn.
Многие считают process.env чем-то вроде локального словаря для конфигов, но это глобальное состояние всего процесса. Когда вы форкаете воркеры или вызываете exec, дочерние наследуют всё окружение. Одна опечатка — и пароль утекает в дамп памяти, а instrumentation.js запускается в неожиданном контексте.
Почему process.env опасен
Каждое присваивание в process.env мутирует общую память. Если вы пишете
process.env.DB_PASS = 'secret', это меняет окружение для всех будущих дочерних процессов. child_process.fork() и spawn() по умолчанию наследуют process.env. Даже если воркер не использует эту переменную, она окажется в логах ошибок или core dump. Пример: production-воркер упал из-за OOM, дамп памяти содержит все переменные окружения, включая секреты. Практический совет: заменяйте process.env на локальные объекты, доступные только в родительском процессе.NODE_OPTIONS — скрытая утечка конфигурации
Переменная
NODE_OPTIONS автоматически применяется к дочерним процессам. Если вы задали process.env.NODE_OPTIONS = '--require ./instrumentation.js' в родителе, то каждый дочерний процесс исполнит этот флаг. Результат: instrumentation.js может переопределить глобальные настройки воркера или подключить модули, которые ему не нужны. Типичная ошибка: флаги для диагностики родителя попадают в production-воркеры, вызывая неожиданные побочные эффекты. Предупреждение: никогда не передавайте NODE_OPTIONS дочерним процессам. Вместо этого используйте свою переменную, например process.env.CUSTOM_FLAGS='--require ./instrumentation.js', и парсьте её внутри воркера, проверяя, что она определена.Как защититься в production
При fork/spawn явно указывайте только нужные переменные:
{ env: { PATH: process.env.PATH, NODE_PATH: process.env.NODE_PATH } }. Это исключает утечку. Храните конфиги в локальных объектах с помощью WeakMap или простых переменных в замыкании. Практический совет: создайте слой конфигурации, который возвращает значения только по запросу, без глобального присваивания.Вывод: process.env — глобальное состояние, которое легко утекает в дочерние процессы; всегда управляйте окружением явно через опцию env при fork/spawn.
❤1
Почему «удалёнка» съедает ROI компании: расчёты потерь
В агентстве «Найт Стрит» считают, что затраты на удалёнку обычно считают по очевидной экономии: не нужен большой офис, падают расходы на командировки, нанимать можно по всей стране. Но у распределённой работы есть вторая колонка расходов, которая почти не попадает в расчёты бухгалтера. Сюда идёт лишний созвон там, где хватило бы сообщения, согласование на два дня вместо десяти минут общения в переговорке, новичок, который вникает в проект месяц, а не положенные две недели.
По отдельности это мелочи, но в команде из сотни дорогих специалистов все эти мелочи складываются в миллионы потерянной выручки за год. Цифры в статье модельные: смысл не в том, чтобы поверить процентам, а в том, чтобы подставить в формулы свои данные.
Читать далее
В агентстве «Найт Стрит» считают, что затраты на удалёнку обычно считают по очевидной экономии: не нужен большой офис, падают расходы на командировки, нанимать можно по всей стране. Но у распределённой работы есть вторая колонка расходов, которая почти не попадает в расчёты бухгалтера. Сюда идёт лишний созвон там, где хватило бы сообщения, согласование на два дня вместо десяти минут общения в переговорке, новичок, который вникает в проект месяц, а не положенные две недели.
По отдельности это мелочи, но в команде из сотни дорогих специалистов все эти мелочи складываются в миллионы потерянной выручки за год. Цифры в статье модельные: смысл не в том, чтобы поверить процентам, а в том, чтобы подставить в формулы свои данные.
Читать далее
👎10😁5
WeakRef и циклы: тихая утечка памяти в долгоживущих процессах
WeakRef в Node.js — инструмент для умных кэшей и подписок, но в долгоживущих серверных процессах он может стать причиной неочевидной утечки памяти. Самая распространенная ошибка — циклическая структура, где WeakRef замыкается с сильными ссылками, и GC молча перестает собирать объекты.
Как возникает утечка
В серверных приложениях часто используется дерево компонентов. Если дочерний элемент хранит WeakRef на родителя, а родитель — сильную ссылку на дочерний, цикл замкнут. GC видит, что объект родителя доступен через WeakRef, но сам WeakRef жив, и сборка блокируется. Результат — плавный рост heap, незаметный неделями.
Диагностика в production
- Снимайте heap snapshots через
- Включайте
- Мониторинг
Практические решения
Самый надежный способ — разорвать цикл. Используйте
Ошибка, которую все допускают
WeakRef внутри замыкания на
Вывод: WeakRef — не панацея: в долгоживущих процессах всегда проверяйте цепочки замыканий и снимайте heap snapshots, иначе тихая утечка памяти станет причиной падения сервера.
WeakRef в Node.js — инструмент для умных кэшей и подписок, но в долгоживущих серверных процессах он может стать причиной неочевидной утечки памяти. Самая распространенная ошибка — циклическая структура, где WeakRef замыкается с сильными ссылками, и GC молча перестает собирать объекты.
Как возникает утечка
В серверных приложениях часто используется дерево компонентов. Если дочерний элемент хранит WeakRef на родителя, а родитель — сильную ссылку на дочерний, цикл замкнут. GC видит, что объект родителя доступен через WeakRef, но сам WeakRef жив, и сборка блокируется. Результат — плавный рост heap, незаметный неделями.
Диагностика в production
- Снимайте heap snapshots через
heapdump или Chrome DevTools. Ищите раздел System / WeakRef — там лежат недоступные объекты.- Включайте
--trace-gc и --trace-gc-verbose. Они покажут, какие объекты GC не может собрать из-за слабых ссылок.- Мониторинг
process.memoryUsage() и v8.getHeapStatistics() — кривая роста без видимых причин говорит о проблеме.Практические решения
Самый надежный способ — разорвать цикл. Используйте
FinalizationRegistry для явного освобождения ресурсов:const registry = new FinalizationRegistry((held) => {
console.log('cleaned', held);
});
class Child {
constructor(parent) {
this.parentWeakRef = new WeakRef(parent);
registry.register(this, 'child');
}
getParent() {
return this.parentWeakRef.deref();
}
}Ошибка, которую все допускают
WeakRef внутри замыкания на
this — классика. Например, в WebSocket подписках: () => this.parent.someMethod сохраняет контекст с сильной ссылкой на весь граф. Всегда пишите явную проверку: this.parentWeakRef?.deref()?.someMethod. Иначе цикл восстановится.Вывод: WeakRef — не панацея: в долгоживущих процессах всегда проверяйте цепочки замыканий и снимайте heap snapshots, иначе тихая утечка памяти станет причиной падения сервера.
❤1
Диагностика и устранение indefinite suspension при мониторинге HTTP-запросов через async-hooks
Ситуация знакомая: используешь async-hooks для отслеживания длинных HTTP-запросов, и вдруг приложение начинает подвисать. Чаще всего это indefinite suspension из-за unhandled rejection.
В чём соль. async-hooks создаёт контекст для каждой асинхронной операции. Если внутри этого контекста Promise упал (rejection), а обработчика нет, хук может зависнуть в ожидании. Сборщик мусора не срабатывает, ресурсы текут — и приложение виснет.
Как ловить
Первое — глобальный перехват unhandledRejection:
Второе — запускай с флагом
Что делать
Вариант 1. Просто и надёжно — добавить
Вариант 2. Явное управление контекстами через destroy. Заводишь Map, чистишь её при завершении:
Вариант 3. Принудительный таймаут. Запускаешь интервал, проверяешь застрявшие контексты (скажем, те, что висят дольше 30 секунд) и прибиваешь их.
На заметку. Если Node.js 14+, бери
Вывод: Indefinite suspension лечится связкой глобального обработчика ошибок и чёткого контроля жизненного цикла контекстов.
Ситуация знакомая: используешь async-hooks для отслеживания длинных HTTP-запросов, и вдруг приложение начинает подвисать. Чаще всего это indefinite suspension из-за unhandled rejection.
В чём соль. async-hooks создаёт контекст для каждой асинхронной операции. Если внутри этого контекста Promise упал (rejection), а обработчика нет, хук может зависнуть в ожидании. Сборщик мусора не срабатывает, ресурсы текут — и приложение виснет.
Как ловить
Первое — глобальный перехват unhandledRejection:
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});Второе — запускай с флагом
--trace-warnings, чтобы видеть стек.Что делать
Вариант 1. Просто и надёжно — добавить
.catch() в каждый Promise внутри хука:const hook = createHook({
init(asyncId, type) {
if (type === 'HTTPINCOMINGMESSAGE') {
const requestPromise = someAsyncOperation();
requestPromise.catch(err => console.error(Request ${asyncId} failed:, err));
}
}
});
hook.enable();Вариант 2. Явное управление контекстами через destroy. Заводишь Map, чистишь её при завершении:
const contexts = new Map();
hook.init(asyncId, type) {
if (type === 'HTTPINCOMINGMESSAGE') {
contexts.set(asyncId, Date.now());
}
}
hook.destroy(asyncId) {
contexts.delete(asyncId);
}
Вариант 3. Принудительный таймаут. Запускаешь интервал, проверяешь застрявшие контексты (скажем, те, что висят дольше 30 секунд) и прибиваешь их.
На заметку. Если Node.js 14+, бери
AsyncLocalStorage — он сам управляет контекстом. И никогда не создавай Promise внутри init без .catch(). Тестируй с --async-stack-traces.Вывод: Indefinite suspension лечится связкой глобального обработчика ошибок и чёткого контроля жизненного цикла контекстов.
❤1
Код я написал за две недели. Продавать его боюсь до сих пор
Три недели назад расширение прошло ревью и появилось в Chrome Web Store. С тех пор ни анонса, ни поста, ни сообщения людям, которые его ждали. Тишина. Это уже не первый раз: в мае автор точно так же на месяц пропал в код, лишь бы не заниматься продвижением. В статье разбирается, почему разработчик прячется от собственного продукта, и приводятся замеры, что происходит с цифрами, когда прятаться перестаёшь.
Читать далее →
Три недели назад расширение прошло ревью и появилось в Chrome Web Store. С тех пор ни анонса, ни поста, ни сообщения людям, которые его ждали. Тишина. Это уже не первый раз: в мае автор точно так же на месяц пропал в код, лишь бы не заниматься продвижением. В статье разбирается, почему разработчик прячется от собственного продукта, и приводятся замеры, что происходит с цифрами, когда прятаться перестаёшь.
Читать далее →
❤3
Диагностика и устранение гонок данных при конкурентном доступе к
В production сценариях с worker_threads и in-memory SQLite в WAL режиме разработчики часто забывают синхронизировать доступ. Итог —
Проблема: гонка без синхронизации
Несколько воркеров читают и пишут в одну in-memory базу без внешней координации. WAL помогает с параллельным чтением, но не спасает от гонок записи. Типичные проявления: ошибки
Решение: один писатель с явными блокировками
Выделите один воркер для критических записей (например, инкременты). Координируйте через
Production-oriented пример с
Начиная с Node.js 23 используйте
Предупреждение: in-memory WAL не для продакшена
In-memory база теряет данные при краше. Для реальных проектов используйте файловую базу с WAL — это гарантирует персистентность.
Вывод: гонки данных в SQLite лечатся одним писателем и явными блокировками через
node:sqlite из worker_threads через in-memory WAL-режимВ production сценариях с worker_threads и in-memory SQLite в WAL режиме разработчики часто забывают синхронизировать доступ. Итог —
SQLITE_BUSY, рассинхрон данных и дубли в уникальных полях.Проблема: гонка без синхронизации
Несколько воркеров читают и пишут в одну in-memory базу без внешней координации. WAL помогает с параллельным чтением, но не спасает от гонок записи. Типичные проявления: ошибки
SQLITE_BUSY, частичные обновления и потеря данных.Решение: один писатель с явными блокировками
Выделите один воркер для критических записей (например, инкременты). Координируйте через
SharedArrayBuffer и Atomics — это дешевле мьютексов. Воркеры-читатели используют отдельные соединения в WAL.Production-oriented пример с
BEGIN IMMEDIATEНачиная с Node.js 23 используйте
DatabaseSync. Обязательно PRAGMA journal_mode=WAL; и PRAGMA synchronous=NORMAL;:import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync(':memory:', { readwrite: true, create: true });
db.exec('PRAGMA journal_mode=WAL;');
db.exec('PRAGMA synchronous=NORMAL;');
function safeUpdate(sql) {
db.exec('BEGIN IMMEDIATE;');
try {
db.exec(sql);
db.exec('COMMIT;');
} catch {
db.exec('ROLLBACK;');
throw new Error('Write failed');
}
}BEGIN IMMEDIATE блокирует запись сразу, избегая гонок. Без него два воркера могут начать транзакции одновременно — ловите SQLITE_BUSY.Предупреждение: in-memory WAL не для продакшена
In-memory база теряет данные при краше. Для реальных проектов используйте файловую базу с WAL — это гарантирует персистентность.
Вывод: гонки данных в SQLite лечатся одним писателем и явными блокировками через
BEGIN IMMEDIATE, давая скорость WAL без потери консистентности.👍1