NNODE LOOP LABruntime observatoryNNEON · Статьи на 90 языках
CONNECTING
v24.18.0linux/x64
Retainer path → доказанная причина
09

Closures, GC и heap snapshots

Воспроизведите утечку через замыкание или глобальный кэш, снимите heap и найдите путь удержания от payload до GC root.

PROCESS IDтекущий сервер
UPTIMEпосле запуска
LOOP DELAY P95perf_hooks
UTILIZATIONevent loop
HTTP ROUNDTRIPbrowser → server
LIVE TRACE

График памяти

ГОТОВ
PUBLIC SAFE
Публичный режим · принудительное завершение

Максимум 256 MB retained и 512 MB RSS. Через 60 секунд дочерний процесс завершается и возвращает память контейнеру.

RETAINED0.0 MB0 блоков
HEAP USED0.0 MBобъекты V8
EXTERNAL0.0 MBBuffer / ArrayBuffer
CHILD RSS0.0 MBPID —
MEMORY OVER TIME
retainedheapexternalrss
0sscale 128 MBnow
Heap snapshot для Chrome DevTools

Снимок синхронно блокирует дочерний процесс и может временно потребовать около 2× V8 heap. Поэтому он разрешён только до 64 MB retained.

Снимок ещё не создан
Эксперимент выключен. Память не выделяется.
ГЛАВА 09
ПОДРОБНЫЙ РАЗБОР · ОТ БАЗЫ К КОДУ

Разбираем: Closures, GC и heap snapshots

Этот раздел можно читать до запуска опыта. После теории вернитесь к live trace и сопоставьте каждый шаг с реальным событием.

СНАЧАЛА ПРОСТЫМИ СЛОВАМИ

Замыкание — это функция с доступом к переменным места создания. Само по себе оно полезно и безопасно. Утечка появляется, когда долгоживущая ссылка на функцию случайно продлевает жизнь крупному payload. Heap snapshot позволяет пройти обратный путь: от объекта через retaining edges к GC root.

ТЕХНИЧЕСКАЯ ОСНОВА

V8 управляет JavaScript heap и начинает обход с GC roots: globals, активных стеков, closures и внутренних handles. Недостижимые объекты можно собрать; достижимые считаются живыми независимо от бизнес-смысла. Generational GC оптимизирует предположение, что большинство молодых объектов быстро умирает, а пережившие сборки продвигаются в старшее поколение.

Зачем это знатьВ production медленная утечка может неделями выглядеть как обычный кэш. Затем растут частота GC, pause time и RSS, контейнер получает OOMKill, а рестарт лишь временно скрывает причину. Нужны воспроизводимый workload, временной ряд и доказанный retainer path.
ГДЕ ВЫПОЛНЯЕТСЯ РАБОТА
01ВАШ JS-КОДfunctions · callbacks
02NODE APIsfs · crypto · timers
03V8 + LIBUVheap · loop · pool
04ОПЕРАЦИОННАЯ СИСТЕМАI/O · threads · memory
01 · СЛОВАРЬ

Термины этого эксперимента

Сначала поймите слова — затем порядок выполнения.

01

Closure

Функция вместе с доступом к lexical environment, в котором она была создана.

02

Shallow size

Память самого объекта без всех объектов, доступных через его ссылки.

03

Retained size

Оценка памяти, которая может стать недостижимой при удалении конкретного объекта и его удерживающих путей.

04

Retainer

Объект или связь, из-за которой исследуемый объект остаётся достижимым от GC root.

05

Dominator

Узел, через который проходят пути от GC roots к группе объектов; полезен для поиска владельца большого retained subtree.

06

Heap snapshot

Сериализованный граф объектов одного V8 isolate и связей между ними в определённый момент.

02 · МЕХАНИКА

Что происходит по шагам

Каждый шаг соответствует наблюдаемому состоянию runtime.

  1. 01
    Зафиксируйте симптом

    Ищите устойчивый рост heap/RSS после одинаковых циклов нагрузки, а не один случайный пик.

  2. 02
    Сделайте baseline

    После прогрева и стабилизации нагрузки создайте первый snapshot безопасной реплики.

  3. 03
    Воспроизведите рост

    Повторите одну операцию контролируемое число раз и дождитесь завершения временных задач.

  4. 04
    Создайте второй snapshot

    Сравните количество объектов, shallow/retained size и constructor/group deltas.

  5. 05
    Пройдите retaining path

    Найдите цепочку от выросших объектов до массива closures, глобального Map, listener или другого root.

  6. 06
    Исправьте владение и перепроверьте

    Добавьте cleanup, TTL/LRU/size bound или удаление listener; затем повторите тот же workload и сравнение.

03 · КОНТЕКСТ

Где результат требует оговорки

Эти детали объясняют, почему похожий код иногда даёт другой trace.

01

Closure не копирует весь scope по простому правилу

Думайте о доступном lexical environment, а не о буквальной полной копии каждой локальной переменной. В snapshot важна фактическая retaining edge текущей версии V8.

02

Snapshot принадлежит одному isolate

Снимок основного Next.js-процесса не покажет heap дочерней memory-lab или Worker Thread. Поэтому кнопка запускает writeHeapSnapshot внутри child.

03

Buffer виден не так, как Array

Heap snapshot показывает JS-обёртки и связи, но основная backing memory Buffer учитывается как external. Для неё сопоставляйте snapshot с external, arrayBuffers и RSS.

04

Снимок сам опасен

Сериализация синхронно блокирует Event Loop снимаемого процесса и может потребовать около двух размеров heap. Делайте это на реплике, с лимитом памяти и планом на возможный restart.

05

Падение RSS не обязательно немедленно

После удаления ссылок GC освобождает объекты для allocator, но тот может сохранить страницы для повторного использования. Проверяйте новый plateau и heap trend.

01
Теория

Сначала разберитесь, какие части Node участвуют в выполнении.

02
Упрощённый код

Затем уберите служебные детали и рассмотрите только главную идею.

03
Runtime-код

После этого сопоставьте модель с кодом, который создаёт live trace.

04 · Упрощённый код

Минимальная модель без служебного кода

src/demos.js · учебный фрагментJavaScript
const retainedClosures = [];
const globalCache = new Map();

function createHandler() {
  const payload = buildLargePayload();
  return () => payload.id; // closure удерживает payload
}

retainedClosures.push(createHandler());
globalCache.set(requestId, buildLargePayload());

// Исправление lifetime:
retainedClosures.length = 0;
globalCache.clear();
05 · Runtime-код

Полный код, который выполняет сценарий

Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.

ФАКТИЧЕСКИЙ SOURCE

Код сформирован из реальной серверной функции. Для сценариев с отдельным процессом или Worker показаны все участвующие файлы.

src/memory-lab.js
supervisor346 строк
import { fork } from 'node:child_process';
import { unlink } from 'node:fs/promises';
import path from 'node:path';
import { labProfile } from './lab-profile.js';

const childPath = path.resolve(
  process.env.NODE_LOOP_SOURCE_DIR ||
    path.join(/* turbopackIgnore: true */ process.cwd(), 'src'),
  'memory-leak-child.js',
);

const MB = 1024 * 1024;
const childEnvironmentKeys = [
  'NODE_ENV',
  'TZ',
  'LANG',
  'LC_ALL',
  'TEMP',
  'TMP',
  'TMPDIR',
  'SystemRoot',
  'WINDIR',
];

function isolatedChildEnvironment() {
  return {
    NODE_LOOP_LAB_MEMORY_CHILD: '1',
    ...Object.fromEntries(
      childEnvironmentKeys
        .filter((key) => process.env[key] !== undefined)
        .map((key) => [key, process.env[key]]),
    ),
  };
}

function safeConfig(input = {}, profile = labProfile) {
  const memory = profile.memory;
  const defaultConfig = memory.defaultConfig;
  return {
    kind: memory.kinds.includes(input.kind) ? input.kind : defaultConfig.kind,
    allocationMb: memory.allocationMb.includes(Number(input.allocationMb))
      ? Number(input.allocationMb)
      : defaultConfig.allocationMb,
    intervalMs: memory.intervalMs.includes(Number(input.intervalMs))
      ? Number(input.intervalMs)
      : defaultConfig.intervalMs,
    limitMb: memory.limitMb.includes(Number(input.limitMb))
      ? Number(input.limitMb)
      : defaultConfig.limitMb,
  };
}

class MemoryLab {
  constructor(profile = labProfile) {
    this.profile = profile;
    this.child = null;
    this.clients = new Set();
    this.stopTimer = null;
    this.snapshotPath = null;
    this.state = {
      status: 'idle',
      pid: null,
      config: null,
      latest: null,
      snapshot: { status: 'idle' },
      lastLog: 'Эксперимент ещё не запускался',
    };
  }

  cleanupSnapshot() {
    const previousPath = this.snapshotPath;
    this.snapshotPath = null;
    if (previousPath) void unlink(previousPath).catch(() => {});
  }

  snapshot() {
    return structuredClone(this.state);
  }

  broadcast(event, data) {
    const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;

    for (const client of this.clients) {
      try {
        client.controller.enqueue(client.encoder.encode(frame));
      } catch {
        client.close();
      }
    }
  }

  createEventStream(signal) {
    if (this.clients.size >= this.profile.api.maxSseClients) {
      const error = new Error(
        'Достигнут лимит подключений к потоку memory-lab',
      );
      error.statusCode = 503;
      throw error;
    }

    const lab = this;
    let client;
    return new ReadableStream({
      start(controller) {
        const encoder = new TextEncoder();
        let closed = false;
        const close = () => {
          if (closed) return;
          closed = true;
          clearInterval(client.heartbeat);
          lab.clients.delete(client);
          try {
            controller.close();
          } catch {
            // Поток уже закрыт браузером.
          }
        };

        client = { controller, encoder, close, heartbeat: null };
        lab.clients.add(client);
        controller.enqueue(
          encoder.encode(
            `event: state\ndata: ${JSON.stringify(lab.snapshot())}\n\n`,
          ),
        );
        client.heartbeat = setInterval(() => {
          try {
            controller.enqueue(encoder.encode(': keep-alive\n\n'));
          } catch {
            close();
          }
        }, 15_000);
        client.heartbeat.unref?.();
        signal?.addEventListener('abort', close, { once: true });
      },
      cancel() {
        client?.close();
      },
    });
  }

  start(input) {
    if (this.child) {
      const error = new Error('Эксперимент уже запущен');
      error.statusCode = 409;
      throw error;
    }

    this.cleanupSnapshot();
    const config = safeConfig(input, this.profile);
    const safety = {
      retainedLimitMb: this.profile.memory.retainedLimitMb,
      hardRssLimitMb: this.profile.memory.hardRssLimitMb,
      maxDurationMs: this.profile.memory.maxDurationMs,
      deadlineAction: this.profile.memory.deadlineAction,
    };
    const child = fork(/* turbopackIgnore: true */ childPath, [], {
      execArgv: [
        '--expose-gc',
        `--max-old-space-size=${this.profile.memory.v8HeapLimitMb}`,
      ],
      stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
      // Heap snapshots can contain strings reachable in the target isolate.
      // Do not let the synthetic lab child inherit application secrets.
      env: isolatedChildEnvironment(),
      windowsHide: true,
    });

    this.child = child;
    this.state = {
      status: 'starting',
      pid: child.pid,
      config,
      latest: null,
      snapshot: { status: 'idle' },
      lastLog: 'Запускаем изолированный процесс…',
    };
    this.broadcast('state', this.snapshot());

    child.on('message', (message) => {
      if (message.type === 'sample') {
        this.state.status = message.status;
        this.state.latest = {
          elapsedMs: message.elapsedMs,
          retainedBytes: message.retainedBytes,
          blocks: message.blocks,
          memory: message.memory,
          reason: message.reason,
        };
        this.broadcast('sample', this.snapshot());

        // Дочерний процесс также проверяет этот предел сам. Дублирование в
        // supervisor защищает лабораторию при ошибке учебного сценария.
        if (
          message.memory.rss >
          this.profile.memory.hardRssLimitMb * MB
        ) {
          this.state.lastLog =
            'Supervisor остановил процесс: превышен аварийный предел RSS';
          this.broadcast('log', {
            level: 'error',
            message: this.state.lastLog,
          });
          child.kill();
        }
      } else if (message.type === 'log') {
        this.state.lastLog = message.message;
        this.broadcast('log', message);
      } else if (message.type === 'snapshot') {
        if (message.status === 'ready') {
          this.cleanupSnapshot();
          this.snapshotPath = message.path;
          this.state.snapshot = {
            status: 'ready',
            fileName: message.fileName,
            size: message.size,
            createdAt: message.createdAt,
          };
        } else if (message.status === 'error') {
          this.state.snapshot = {
            status: 'error',
            error: message.error,
          };
        } else {
          this.state.snapshot = { status: 'creating' };
        }
        this.broadcast('state', this.snapshot());
      }
    });

    child.stderr.on('data', (chunk) => {
      const message = chunk.toString().trim();
      if (!message) return;
      this.state.lastLog = message;
      this.broadcast('log', { level: 'error', message });
    });

    child.on('error', (error) => {
      this.state.lastLog = error.message;
      this.broadcast('log', { level: 'error', message: error.message });
    });

    child.on('exit', (code, signal) => {
      clearTimeout(this.stopTimer);
      this.stopTimer = null;
      this.child = null;
      this.state.status = 'stopped';
      this.state.pid = null;
      this.state.lastLog =
        code === 0
          ? 'Изолированный процесс остановлен'
          : `Процесс завершился: code=${code ?? '—'}, signal=${signal ?? '—'}`;
      this.broadcast('state', this.snapshot());
      this.broadcast('log', {
        level: code === 0 ? 'info' : 'error',
        message: this.state.lastLog,
      });
    });

    child.send({ type: 'start', config: { ...config, safety } });
    return this.snapshot();
  }

  action(action) {
    const allowed = new Set([
      'pause',
      'resume',
      'release',
      'gc',
      'snapshot',
      'stop',
    ]);
    if (!allowed.has(action)) {
      const error = new Error('Неизвестное действие');
      error.statusCode = 400;
      throw error;
    }

    if (!this.child?.connected) {
      const error = new Error('Сначала запустите эксперимент');
      error.statusCode = 409;
      throw error;
    }

    if (action === 'snapshot') {
      if (this.state.snapshot?.status === 'creating') {
        const error = new Error('Heap snapshot уже создаётся');
        error.statusCode = 409;
        throw error;
      }
      const retainedMb = (this.state.latest?.retainedBytes ?? 0) / MB;
      const snapshotLimit = this.profile.memory.snapshotMaxRetainedMb;
      if (retainedMb > snapshotLimit) {
        const error = new Error(
          `Сначала уменьшите retained до ${snapshotLimit} MB или ниже: heap snapshot может временно удвоить потребление V8 heap`,
        );
        error.statusCode = 413;
        throw error;
      }
      this.state.snapshot = { status: 'creating' };
      this.broadcast('state', this.snapshot());
    }

    this.child.send({ type: 'action', action });

    if (action === 'stop') {
      clearTimeout(this.stopTimer);
      this.stopTimer = setTimeout(() => {
        if (this.child) this.child.kill();
      }, 1000);
      this.stopTimer.unref();
    }

    return this.snapshot();
  }

  snapshotDownload() {
    if (
      !this.snapshotPath ||
      this.state.snapshot?.status !== 'ready'
    ) {
      const error = new Error('Сначала создайте heap snapshot');
      error.statusCode = 404;
      throw error;
    }

    return {
      path: this.snapshotPath,
      ...this.state.snapshot,
    };
  }

  stopForShutdown() {
    if (this.child) {
      this.child.kill();
      this.child = null;
    }
    this.cleanupSnapshot();
  }
}

const memoryLabKey = Symbol.for('node-loop-lab.memory');
export const memoryLab =
  globalThis[memoryLabKey] ?? (globalThis[memoryLabKey] = new MemoryLab());
export { MemoryLab, safeConfig };

Именно вызовы emit(...) превращаются в строки live trace. await и Promise удерживают HTTP-поток открытым до завершения сценария.

06 · НЕ ПЕРЕПУТАЙТЕ

Популярные заблуждения

Миф слева, корректная модель справа.

МИФ

Любое замыкание является утечкой.

НА САМОМ ДЕЛЕ

Замыкание становится проблемой только когда ненужный payload остаётся достижимым дольше требуемого lifetime.

МИФ

Большой shallow size всегда указывает виновника.

НА САМОМ ДЕЛЕ

Небольшой Map или closure может доминировать над огромным подграфом и иметь большой retained size.

МИФ

Один snapshot доказывает утечку.

НА САМОМ ДЕЛЕ

Он показывает состояние. Причину обычно находят сравнением снимков под повторяемой нагрузкой и retaining paths.

МИФ

Вызов global.gc() лечит production leak.

НА САМОМ ДЕЛЕ

GC не удаляет достижимые объекты. Ручной вызов полезен лабораторно, но не исправляет ошибку владения.

МИФ

Неограниченный кэш — не утечка, потому что данные полезны.

НА САМОМ ДЕЛЕ

Если lifetime и верхняя граница не определены, кэш способен исчерпать память так же, как случайно удерживаемый массив.

07 · САМОПРОВЕРКА

Ответьте своими словами

Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.

  1. Какая именно ссылка соединяет payload с GC root в режиме Closure?
  2. Почему snapshot главного серверного процесса не диагностирует child?
  3. Чем TTL, LRU и жёсткий max size защищают кэш по-разному?
  4. Почему heapUsed может упасть, а RSS остаться выше исходного?