ГЛАВА 06
ПОДРОБНЫЙ РАЗБОР · ОТ БАЗЫ К КОДУ

Разбираем: Утечка памяти

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

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

Сборщик мусора похож на уборщика, который выбрасывает только вещи без владельца. Если ненужные коробки всё ещё записаны в глобальном списке, уборщик считает их нужными. Сначала надо удалить ссылки из списка — только потом память можно вернуть.

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

GC начинает обход от корней: global-объектов, текущих стеков, активных замыканий и внутренних handles. Всё достижимое считается живым. Утечка — это не «GC сломан», а ситуация, когда программа по ошибке сохраняет путь от корня к уже ненужным данным. Метрики лаборатории описывают разные, частично пересекающиеся представления памяти.

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

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

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

01

Heap Used

Часть управляемой V8-кучи, занятая JavaScript-объектами, массивами, строками и служебными структурами.

02

External

Память, связанная с JS-объектами, но расположенная вне V8 heap — например backing store Buffer.

03

RSS

Общая резидентная память процесса: heap, native code, stacks, buffers и другие отображённые страницы.

04

Retained

Объём данных, который остаётся живым благодаря ссылкам. В лаборатории это контролируемая оценка блоков в массиве.

05

GC root

Начальная точка обхода сборщика мусора, например global scope или активный стек.

06

Reachable

Объект, до которого можно дойти по цепочке ссылок от GC root; такой объект удалять нельзя.

02 · МЕХАНИКА

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

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

  1. 01
    Ручной запуск

    Express создаёт отдельный дочерний Node-процесс с V8-лимитом и прикладными предохранителями.

  2. 02
    Allocation

    По таймеру создаётся Buffer, Array или смешанный блок.

  3. 03
    Retention

    Ссылка на блок добавляется в глобальный массив retainedBlocks.

  4. 04
    Пауза

    Новые блоки не создаются, но старые остаются достижимыми и занимают память.

  5. 05
    Release + GC

    Массив очищается, путь от GC root исчезает, после чего GC может удалить объекты.

  6. 06
    Stop

    Завершение дочернего процесса гарантированно возвращает ОС всю принадлежащую ему память.

03 · КОНТЕКСТ

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

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

01

Retained — счётчик лаборатории

Он суммирует запрошенные размеры сохранённых блоков. Это не точный retained size из heap snapshot и не фактический RSS: у объектов есть служебные накладные расходы.

02

Метрики частично пересекаются

arrayBuffers входит в external, а RSS охватывает heap, external, code, stacks и другие страницы. Складывать heapUsed + external + arrayBuffers + RSS нельзя — получится двойной счёт.

03

Release, GC и возврат ОС — три шага

Удаление ссылок лишь делает объекты недостижимыми. GC позже освобождает их для allocator, а allocator может оставить страницы процессу для повторного использования, поэтому RSS не обязан сразу падать.

04

Предохранитель не всегда hard quota

Лимиты retained, RSS и времени контролируются кодом, а --max-old-space-size ограничивает V8 heap, но не всю память процесса. Жёсткий общий предел даёт Docker cgroup из compose.yml — 2 GB на сервер и его child.

01
Теория

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

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

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

03
Runtime-код

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

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

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

src/demos.js · учебный фрагментJavaScript
let leakedBlocks = [];

setInterval(() => {
  // Ссылка остаётся достижимой из global scope:
  leakedBlocks.push(Buffer.alloc(4 * 1024 * 1024));
  console.log(process.memoryUsage());
}, 500);

// Сначала удаляем ссылки:
leakedBlocks = [];
// Только теперь GC может освободить объекты.
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 };

Live trace инструментирован самим приложением: строки и timestamps фиксируются при реальных вызовах emit(...), а названия source/lane задаёт сценарий. Это не profiler V8/libuv и не прямой снимок их внутренних очередей. await и Promise удерживают HTTP-поток открытым до завершения сценария.

06 · PRODUCTION-КЕЙСЫ

Как учебная ошибка превращается в инцидент

Реалистичный сервис: исходный код, наблюдаемая проблема, исправление и причина, по которой оно работает.

КЕЙС 01

EventEmitter хранит request closures после завершения запроса

Nest controller подписывается на глобальный emitter, чтобы записать статус импорта. Listener остаётся после ответа и удерживает DTO и большой parsed CSV.

КОНТЕКСТ ИНЦИДЕНТА

Каждый timeout добавляет долгоживущую ссылку. GC видит объекты достижимыми через emitter → listener → closure и не имеет права освобождать их.

ДОПРОБЛЕМНАЯ РЕАЛИЗАЦИЯ
@Controller('imports')
export class ImportsController {
  constructor(private readonly importer: ImportService) {}

  @Post()
  async start(@Body() input: ImportCsvDto) {
    const rows = parseCsv(input.csv);

    this.importer.events.on('finished', (event) => {
      if (event.id === input.id) {
        this.importer.audit(input.id, rows.length);
      }
    });

    await this.importer.start(input);
    return { status: 'accepted' };
  }
}

on создаёт постоянную подписку. После return никто не вызывает off; closure удерживает input и развёрнутый rows.

ПОСЛЕИСПРАВЛЕННАЯ РЕАЛИЗАЦИЯ
@Controller('imports')
export class ImportsController {
  constructor(
    @InjectQueue('csv-import')
    private readonly importQueue: Queue,
  ) {}

  @Post()
  @HttpCode(HttpStatus.ACCEPTED)
  async start(@Body() input: StartImportDto) {
    // CSV уже загружен в object storage.
    const job = await this.importQueue.add(
      'import',
      { objectKey: input.objectKey },
      { jobId: `import-${input.id}` },
    );

    return {
      jobId: job.id,
      statusUrl: `/imports/${job.id}`,
    };
  }
}

HTTP controller больше не создаёт долгоживущий listener. В Redis хранится маленькая job с objectKey, а CSV принадлежит object storage и обрабатывается отдельным worker.

ФУНКЦИИ И КОНСТРУКЦИИ

Что делают непривычные вызовы из обоих фрагментов кода.

@Body() input
Nest передаёт DTO из тела запроса. Если closure захватит input, весь связанный payload останется достижимым.
emitter.on(event, listener)
Добавляет постоянный listener в EventEmitter. Он останется там до явного off/removeListener.
parseCsv(input.csv)
Условная функция превращает большой CSV в массив объектов; захват этого массива особенно заметен в heap snapshot.
queue.add(...)
Сохраняет небольшое описание фоновой задачи в Redis вместо удержания request closure внутри HTTP-процесса.
objectKey
Небольшой идентификатор файла в S3-совместимом object storage; worker позднее скачает CSV по этому ключу.
@HttpCode(HttpStatus.ACCEPTED)
Заставляет Nest вернуть HTTP 202: работа принята, но будет завершена асинхронно.
ПОЧЕМУ ИСПРАВЛЕНИЕ РАБОТАЕТ

У каждой подписки, timer и cache entry должен быть lifetime. Для долгой production-задачи durable queue обычно надёжнее listener, привязанного к короткому HTTP request.

ЧТО БЫЛО ВИДНО В PRODUCTION
  • Количество emitter listeners монотонно растёт.
  • heapUsed не возвращается к baseline после завершения импортов.
  • Heap snapshot показывает retaining path через EventEmitter._events.
07 · НЕ ПЕРЕПУТАЙТЕ

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

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

МИФ

Любой рост RSS доказывает утечку.

НА САМОМ ДЕЛЕ

Allocator может сохранять свободные страницы для повторного использования. Нужен устойчивый тренд под одинаковой нагрузкой.

МИФ

global.gc() может удалить любой ненужный мне объект.

НА САМОМ ДЕЛЕ

GC не понимает бизнес-смысл. Пока ссылка достижима, объект считается живым.

МИФ

Если heapUsed стабилен, утечки точно нет.

НА САМОМ ДЕЛЕ

Могут расти Buffer/external, native allocations, handles или ресурсы вне V8 heap.

МИФ

const автоматически удерживает объект навсегда.

НА САМОМ ДЕЛЕ

Время жизни определяется достижимостью ссылки, а не ключевым словом let/const.

МИФ

Можно сложить все показанные числа и получить память процесса.

НА САМОМ ДЕЛЕ

Метрики имеют разные границы и пересечения. Для общего резидентного объёма смотрят RSS, а остальные показатели помогают объяснить его состав.

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

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

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

  1. Почему GC до очистки массива не уменьшает retained?
  2. Почему RSS способен остаться высоким даже после успешной очистки?
  3. Какая метрика лучше покажет утечку Buffer?