Closures, GC и heap snapshots
Воспроизведите утечку через замыкание или глобальный кэш, снимите heap и найдите путь удержания от payload до GC root.
График памяти
Снимок синхронно блокирует дочерний процесс и может временно потребовать около 2× V8 heap. Поэтому он разрешён только до 64 MB retained.
Разбираем: Closures, GC и heap snapshots
Этот раздел можно читать до запуска опыта. После теории вернитесь к live trace и сопоставьте каждый шаг с реальным событием.
Замыкание — это функция с доступом к переменным места создания. Само по себе оно полезно и безопасно. Утечка появляется, когда долгоживущая ссылка на функцию случайно продлевает жизнь крупному payload. Heap snapshot позволяет пройти обратный путь: от объекта через retaining edges к GC root.
V8 управляет JavaScript heap и начинает обход с GC roots: globals, активных стеков, closures и внутренних handles. Недостижимые объекты можно собрать; достижимые считаются живыми независимо от бизнес-смысла. Generational GC оптимизирует предположение, что большинство молодых объектов быстро умирает, а пережившие сборки продвигаются в старшее поколение.
Термины этого эксперимента
Сначала поймите слова — затем порядок выполнения.
Closure
Функция вместе с доступом к lexical environment, в котором она была создана.
Shallow size
Память самого объекта без всех объектов, доступных через его ссылки.
Retained size
Оценка памяти, которая может стать недостижимой при удалении конкретного объекта и его удерживающих путей.
Retainer
Объект или связь, из-за которой исследуемый объект остаётся достижимым от GC root.
Dominator
Узел, через который проходят пути от GC roots к группе объектов; полезен для поиска владельца большого retained subtree.
Heap snapshot
Сериализованный граф объектов одного V8 isolate и связей между ними в определённый момент.
Что происходит по шагам
Каждый шаг соответствует наблюдаемому состоянию runtime.
- 01Зафиксируйте симптом
Ищите устойчивый рост heap/RSS после одинаковых циклов нагрузки, а не один случайный пик.
- 02Сделайте baseline
После прогрева и стабилизации нагрузки создайте первый snapshot безопасной реплики.
- 03Воспроизведите рост
Повторите одну операцию контролируемое число раз и дождитесь завершения временных задач.
- 04Создайте второй snapshot
Сравните количество объектов, shallow/retained size и constructor/group deltas.
- 05Пройдите retaining path
Найдите цепочку от выросших объектов до массива closures, глобального Map, listener или другого root.
- 06Исправьте владение и перепроверьте
Добавьте cleanup, TTL/LRU/size bound или удаление listener; затем повторите тот же workload и сравнение.
Где результат требует оговорки
Эти детали объясняют, почему похожий код иногда даёт другой trace.
Closure не копирует весь scope по простому правилу
Думайте о доступном lexical environment, а не о буквальной полной копии каждой локальной переменной. В snapshot важна фактическая retaining edge текущей версии V8.
Snapshot принадлежит одному isolate
Снимок основного Next.js-процесса не покажет heap дочерней memory-lab или Worker Thread. Поэтому кнопка запускает writeHeapSnapshot внутри child.
Buffer виден не так, как Array
Heap snapshot показывает JS-обёртки и связи, но основная backing memory Buffer учитывается как external. Для неё сопоставляйте snapshot с external, arrayBuffers и RSS.
Снимок сам опасен
Сериализация синхронно блокирует Event Loop снимаемого процесса и может потребовать около двух размеров heap. Делайте это на реплике, с лимитом памяти и планом на возможный restart.
Падение RSS не обязательно немедленно
После удаления ссылок GC освобождает объекты для allocator, но тот может сохранить страницы для повторного использования. Проверяйте новый plateau и heap trend.
Сначала разберитесь, какие части Node участвуют в выполнении.
Затем уберите служебные детали и рассмотрите только главную идею.
После этого сопоставьте модель с кодом, который создаёт live trace.
Минимальная модель без служебного кода
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();Полный код, который выполняет сценарий
Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.
Код сформирован из реальной серверной функции. Для сценариев с отдельным процессом или Worker показаны все участвующие файлы.
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-поток открытым до завершения сценария.
Популярные заблуждения
Миф слева, корректная модель справа.
Любое замыкание является утечкой.
Замыкание становится проблемой только когда ненужный payload остаётся достижимым дольше требуемого lifetime.
Большой shallow size всегда указывает виновника.
Небольшой Map или closure может доминировать над огромным подграфом и иметь большой retained size.
Один snapshot доказывает утечку.
Он показывает состояние. Причину обычно находят сравнением снимков под повторяемой нагрузкой и retaining paths.
Вызов global.gc() лечит production leak.
GC не удаляет достижимые объекты. Ручной вызов полезен лабораторно, но не исправляет ошибку владения.
Неограниченный кэш — не утечка, потому что данные полезны.
Если lifetime и верхняя граница не определены, кэш способен исчерпать память так же, как случайно удерживаемый массив.
Ответьте своими словами
Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.
- Какая именно ссылка соединяет payload с GC root в режиме Closure?
- Почему snapshot главного серверного процесса не диагностирует child?
- Чем TTL, LRU и жёсткий max size защищают кэш по-разному?
- Почему heapUsed может упасть, а RSS остаться выше исходного?