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

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

РКН: https://vk.cc/cJPGOP
Download Telegram
Неявный 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
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 пример:
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.
🐳21🔥1
process.nextTick: невидимый убийца event loop, который кладет 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.
11🔥1
SharedArrayBuffer без Atomics: гонка, которую не видно
Когда два 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 с динамической структурой:
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% производительности.
👍43🐳3😁1
Implicit ABA в async-генераторах: тихая гонка в очередях сообщений

Работа с очередями в 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 требует явного управления состоянием очереди, иначе гонка становится неявной и трудноуловимой.