Node.js Internals
Memory Management: Управление памятью
Сервер крашится каждую ночь в 3:47. Логи чистые. CPU в норме. Disk в порядке. Только одна строчка перед падением: `FATAL ERROR: JavaScript heap out of memory`. Память утекла. Но куда? И главное - как найти дырку в абстракции, которая должна «сама управлять памятью»?
- **Production OOM crisis:** Деплой новой фичи, всё работает на staging. Через 3 дня в продакшене heap растёт на 50MB/час. Через неделю сервер падает. Rollback невозможен - уже есть данные в новом формате. Heap snapshot показывает 100K объектов UserSession в Map. Оказалось, забыли вызвать `sessions.delete()` при logout. Фикс - одна строчка кода, но downtime стоил 50K
- **WebSocket memory leak:** Real-time чат для 10K пользователей. При каждом connection регистрируется `eventBus.on('message')`, но при disconnect не вызывается `off()`. Через месяц работы - 500K listeners висят в памяти. Event loop тормозит, latency растёт до 5 секунд. Пользователи уходят. Поиск утечки через heap snapshot comparison - 2 часа, фикс - 10 минут
- **Streaming спасает сервер:** API для генерации отчётов. Изначально загружали весь CSV в память (500MB), потом отдавали клиенту. Два параллельных запроса = 1GB heap = OOM. Переписали на streaming через `Transform` stream - heap usage упал до 10MB, можно обрабатывать 100 параллельных запросов. Профит: scaling из одного инстанса вместо 10
Память как ресурс
**Память - это то, о чём не задумываются до тех пор, пока она не закончится.** В Node.js память управляется автоматически через V8 garbage collector, но это не значит, что можно забыть о ней полностью. В продакшене утечки памяти приводят к падениям сервера, а неоптимальное использование - к лагам и дорогим инстансам.
Сценарий: API обрабатывает миллион запросов в день. Каждый запрос создаёт объекты, замыкания, промисы. Если хотя бы 0.1% объектов не освобождаются - через неделю сервер съест все 2GB heap и рухнет. **Garbage collector не волшебник** - он освобождает только недостижимые объекты. Если в глобальном массиве «на всякий случай» хранится ссылка - память течёт.
**V8 heap limit по умолчанию:** ~2GB на 64-bit системах. Это не жёсткое ограничение RAM, а лимит для управляемой памяти (managed heap). Превышение вызывает `FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory`.
**Почему именно 2GB?** V8 изначально разрабатывался для браузера, где вкладка не должна жрать всю RAM. Для серверов это ограничение устарело, но остаётся default для совместимости. В продакшене для тяжёлых сервисов используют `--max-old-space-size=4096` или больше.
Что произойдёт, если Node.js процесс превысит heap limit?
Архитектура V8 Heap
**V8 heap - это не монолитный блок памяти, а иерархическая структура.** Понимание этой архитектуры критично для оптимизации, потому что разные части heap обрабатываются разными алгоритмами GC с разной скоростью.
**New Space:** Здесь создаются все новые объекты. Маленький размер (1-8MB) обеспечивает быструю сборку мусора (Scavenge). 90% объектов умирают молодыми и не доживают до Old Space. **Old Space:** Сюда попадают объекты, пережившие 2+ цикла Scavenge. Очистка медленнее, но выполняется реже. **Large Object Space:** Массивы, буферы >1MB хранятся отдельно и никогда не перемещаются.
**Два полупространства в New Space:** Semi-space архитектура. Всегда есть From-space (активное) и To-space (резервное). При Scavenge живые объекты копируются из From в To, затем они меняются местами. Это дешевле, чем компактация: копирование только живых объектов вместо перемещения всех.
Почему New Space намеренно делается маленьким (1-8MB)?
Алгоритмы сборки мусора
**V8 использует два принципиально разных алгоритма GC:** Scavenge для New Space (быстрый, частый) и Mark-Sweep-Compact для Old Space (медленный, редкий). Это компромисс между латентностью и пропускной способностью.
**Scavenge (Cheney's algorithm):** Копирование живых объектов из From-space в To-space. Сложность O(живые объекты), мёртвые игнорируются. Пауза ~1-5ms. **Mark-Sweep-Compact:** 1) Mark - пометка достижимых объектов (обход графа) 2) Sweep - освобождение непомеченных 3) Compact - дефрагментация памяти. Пауза ~100-500ms, но выполняется инкрементально.
**Incremental Marking:** V8 не останавливает приложение на 500ms для Mark-Sweep. Вместо этого marking выполняется маленькими порциями (5-10ms) между тактами event loop. Это называется **Tri-color marking:** объекты помечаются белым (не посещены), серым (в очереди), чёрным (обработаны).
**Concurrent и Parallel GC:** Современные V8 версии используют многопоточность. **Parallel** - несколько потоков GC работают во время stop-the-world паузы. **Concurrent** - GC работает параллельно с JS execution (без паузы). Incremental marking теперь выполняется concurrent - приложение почти не тормозит.
Почему Scavenge GC работает быстрее Mark-Sweep?
Паттерны утечек памяти
**Утечка памяти в JS - это не забытый free(), а недостижимый объект, на который всё ещё есть ссылка.** GC не телепат - если массив с миллионом объектов лежит в глобальной переменной «на всякий случай», GC не догадается, что он больше не нужен. Три главных паттерна утечек: **замыкания, event listeners, глобальные переменные**.
**Leak Pattern #1: Замыкания.** Функция захватывает scope родителя. Если она долгоживущая (event handler, timer), весь scope не освобождается. **Leak Pattern #2: Event Listeners.** Забытый `addEventListener` без `removeEventListener` держит ссылку на callback и его scope. **Leak Pattern #3: Глобальные переменные.** `global.cache = []` никогда не освобождается.
**Детектирование утечек в продакшене:** Мониторинг `process.memoryUsage().heapUsed` каждые 10 секунд. Если heap растёт линейно без plateau - это утечка. **Heap snapshot comparison:** делаете snapshot до и после нагрузочного теста, сравниваете - объекты, которых стало больше, и есть подозреваемые.
Почему забытый event listener вызывает утечку памяти?
Heap Snapshots и профилирование
**Heap snapshot - это полный дамп памяти V8 heap в момент времени.** Он содержит все объекты, их размеры, ссылки между ними, retention paths (кто держит кого в живых). Chrome DevTools умеет визуализировать snapshots и сравнивать их - это основной инструмент для поиска утечек.
**Как работает snapshot:** V8 приостанавливает выполнение, обходит весь heap, сериализует объекты в .heapsnapshot файл (JSON). Размер файла ~= heap size. Для 2GB heap → snapshot весит ~2GB. **Retention path:** цепочка ссылок от GC root до объекта. Если path существует → объект достижим → GC не удалит его.
**Анализ в Chrome DevTools:** Открываете DevTools → Memory → Load Snapshot → выбираете файл. Переключаетесь в режим **Comparison** → видите diff между snapshots. Столбцы: **# New** (новые объекты), **# Deleted** (удалённые), **# Delta** (разница), **Retained Size** (сколько памяти держит объект с зависимостями).
Что показывает retention path в heap snapshot?
Оптимизация и best practices
**Оптимизация памяти - это не микрооптимизации, а системный подход.** 80% проблем решаются правильной архитектурой: streaming вместо buffering, пулы объектов вместо аллокаций, bounded queues вместо unbounded. Оставшиеся 20% - это понимание V8 internals и тонкая настройка GC.
**Best Practices:** 1) **Streaming over buffering** - не загружайте 100MB файл в память, стримьте его 2) **Object pooling** - переиспользуйте объекты вместо создания новых (критично для hot path) 3) **Bounded structures** - ограничивайте размер queue/cache через LRU или max size 4) **WeakMap/WeakRef** - для кэшей, где объекты могут умереть независимо.
**Когда увеличивать --max-old-space-size:** Если видите частые Major GC (каждые 10-30 секунд) и heap используется >80% лимита. Увеличение лимита → реже GC → меньше паузы, но больше latency при OOM. **Когда уменьшать:** Если приложение использует <1GB, а лимит 4GB - уменьшите до 2GB. Меньше heap → быстрее Mark-Sweep.
GC автоматически решает все проблемы с памятью, поэтому не нужно о ней думать
GC освобождает только недостижимые объекты. Если ссылки удерживаются (в Map, closure, listeners), GC ничего не сделает. Утечки возможны даже с GC
Garbage Collection - это автоматизация free(), но не автоматизация архитектурных решений. Если код создаёт unbounded Map или забывает removeListener - память будет течь независимо от GC. Программист всё ещё отвечает за lifecycle объектов через управление ссылками
В чём преимущество WeakMap перед обычной Map для кэша?
Ключевые идеи
- **V8 heap состоит из New Space (1-8MB, Scavenge GC ~1-5ms) и Old Space (~2GB, Mark-Sweep ~100-500ms).** Молодые объекты умирают быстро в New Space, долгоживущие переходят в Old Space. Это компромисс между латентностью и throughput
- **GC освобождает только недостижимые объекты.** Утечки возникают из-за забытых ссылок: замыкания захватывают весь scope, event listeners висят после disconnect, глобальные Map/Set растут бесконечно без eviction policy. WeakMap/WeakRef решают часть проблем
- **Heap snapshots + comparison в Chrome DevTools - основной инструмент диагностики.** Retention path показывает цепочку ссылок от GC root до объекта. Если видите рост определённого constructor в comparison → ищите, где создаются объекты и почему не удаляются. Streaming, object pooling, bounded structures - 80% оптимизаций
Связанные темы
Memory Management связан со всем, что создаёт объекты: event loop, streams, concurrency. Понимание heap критично для оптимизации:
- Event Loop & Async — Асинхронные операции создают промисы, callbacks, closures - всё это объекты в heap. Unhandled promise rejection может держать весь chain в памяти
- Streams API — Streaming - ключевая техника для снижения heap usage. Transform/PassThrough streams создают backpressure, предотвращая buffering
- Child Processes — Изоляция памяти через worker_threads/child_process. Каждый процесс имеет свой heap → утечка в одном не влияет на другой
Вопросы для размышления
- Если API создаёт 1000 объектов на запрос и обрабатывает 100 req/sec, сколько объектов создаётся между Major GC циклами (раз в минуту)? Как это влияет на heap size?
- Почему WeakMap не может использовать примитивы (string, number) как ключи, только объекты? Подсказка: как GC узнаёт, что объект больше не нужен?
- В heap snapshot 50K объектов типа Promise с retention path через global.pendingRequests. Какие паттерны кода могли привести к этой утечке? Как исправить без изменения архитектуры?