Node.JS [ru] | Серверный JavaScript
11.2K subscribers
171 photos
7 videos
25 files
3.58K links
Node.js, backend и AI-разработка: статьи, новости, вайбкодинг, автоматизация и работа с ИИ-агентами.

Личный блог автора - @just_genych
По вопросам рекламы или разработки: @g_abashkin

РКН: https://vk.cc/cJPGOP
Download Telegram
Diagnostics Channel: Загляни внутрь Node.js в production без console.log и перезаливок

Когда приложение падает в 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:

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 неубиваемым?

Кажется, что 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.

Как выглядит изнутри

Интенсивная работа с 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 тихо сидит в методах:
* 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) и ссылка на прототип. Пока жив экземпляр, весь прототип со всеми методами и данными остаётся в памяти. Это особенно критично для утечек через кеши:

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

Общая память через 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: подводный камень, который ловит даже опытных

Используешь 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 задерживается на его трассировке. Пример:

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.

Как это работает

При 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 переносит лаг на первый запрос при наличии внутренних статических импортов.
😁 Пункта про стоимость и требуемые характеристики к железу не хватает

✖️ xCode Journal
Please open Telegram to view this post
VIEW IN TELEGRAM
😁3👍2
WebSocket backpressure в Node.js: когда ws.send() без контроля ведёт к OOM

Игнорирование скорости потребления данных клиентом при 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 внутри генератора или метода _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
🤯 Девушка получила оффер в OpenAI и поделилась своим опытом поиска работы

Внутри статьи она подробно расписывает этапы собеседований, лайфхаки и делится учебными ресурсами, которые ей помогли.

Плюс девушка великодушно оставила ссылки на свой Notion с полезными заметками по математике и LLM.

✖️ xCode Journal
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-функция в цикле обрабатывает пачку данных:
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. Место на диске есть, 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. Самый частый источник - 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 его удерживает.
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.
🤣 Общаться о синтаксисе теперь тоже можно через агентов

✖️ xCode Journal
Please open Telegram to view this post
VIEW IN TELEGRAM
😁2
Как temporal.io SDK ломает V8 heap snapshot в длительных Workflow

Если ваш 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 это усугубляется межсокетными задержками.
// Проблемный код: 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