Сборщик мусора похож на уборщика, который выбрасывает только вещи без владельца. Если ненужные коробки всё ещё записаны в глобальном списке, уборщик считает их нужными. Сначала надо удалить ссылки из списка — только потом память можно вернуть.
Разбираем: Утечка памяти
Этот раздел можно читать до запуска опыта. После теории вернитесь к live trace и сопоставьте каждый шаг с реальным событием.
GC начинает обход от корней: global-объектов, текущих стеков, активных замыканий и внутренних handles. Всё достижимое считается живым. Утечка — это не «GC сломан», а ситуация, когда программа по ошибке сохраняет путь от корня к уже ненужным данным. Метрики лаборатории описывают разные, частично пересекающиеся представления памяти.
Термины этого эксперимента
Сначала поймите слова — затем порядок выполнения.
Heap Used
Часть управляемой V8-кучи, занятая JavaScript-объектами, массивами, строками и служебными структурами.
External
Память, связанная с JS-объектами, но расположенная вне V8 heap — например backing store Buffer.
RSS
Общая резидентная память процесса: heap, native code, stacks, buffers и другие отображённые страницы.
Retained
Объём данных, который остаётся живым благодаря ссылкам. В лаборатории это контролируемая оценка блоков в массиве.
GC root
Начальная точка обхода сборщика мусора, например global scope или активный стек.
Reachable
Объект, до которого можно дойти по цепочке ссылок от GC root; такой объект удалять нельзя.
Что происходит по шагам
Каждый шаг соответствует наблюдаемому состоянию runtime.
- 01Ручной запуск
Express создаёт отдельный дочерний Node-процесс с V8-лимитом и прикладными предохранителями.
- 02Allocation
По таймеру создаётся Buffer, Array или смешанный блок.
- 03Retention
Ссылка на блок добавляется в глобальный массив retainedBlocks.
- 04Пауза
Новые блоки не создаются, но старые остаются достижимыми и занимают память.
- 05Release + GC
Массив очищается, путь от GC root исчезает, после чего GC может удалить объекты.
- 06Stop
Завершение дочернего процесса гарантированно возвращает ОС всю принадлежащую ему память.
Где результат требует оговорки
Эти детали объясняют, почему похожий код иногда даёт другой trace.
Retained — счётчик лаборатории
Он суммирует запрошенные размеры сохранённых блоков. Это не точный retained size из heap snapshot и не фактический RSS: у объектов есть служебные накладные расходы.
Метрики частично пересекаются
arrayBuffers входит в external, а RSS охватывает heap, external, code, stacks и другие страницы. Складывать heapUsed + external + arrayBuffers + RSS нельзя — получится двойной счёт.
Release, GC и возврат ОС — три шага
Удаление ссылок лишь делает объекты недостижимыми. GC позже освобождает их для allocator, а allocator может оставить страницы процессу для повторного использования, поэтому RSS не обязан сразу падать.
Предохранитель не всегда hard quota
Лимиты retained, RSS и времени контролируются кодом, а --max-old-space-size ограничивает V8 heap, но не всю память процесса. Жёсткий общий предел даёт Docker cgroup из compose.yml — 2 GB на сервер и его child.
Сначала разберитесь, какие части Node участвуют в выполнении.
Затем уберите служебные детали и рассмотрите только главную идею.
После этого сопоставьте модель с кодом, который создаёт live trace.
Минимальная модель без служебного кода
let leakedBlocks = [];
setInterval(() => {
// Ссылка остаётся достижимой из global scope:
leakedBlocks.push(Buffer.alloc(4 * 1024 * 1024));
console.log(process.memoryUsage());
}, 500);
// Сначала удаляем ссылки:
leakedBlocks = [];
// Только теперь GC может освободить объекты.Полный код, который выполняет сценарий
Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.
Код сформирован из реальной серверной функции. Для сценариев с отдельным процессом или 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 };
Live trace инструментирован самим приложением: строки и timestamps фиксируются при реальных вызовах emit(...), а названия source/lane задаёт сценарий. Это не profiler V8/libuv и не прямой снимок их внутренних очередей. await и Promise удерживают HTTP-поток открытым до завершения сценария.
Как учебная ошибка превращается в инцидент
Реалистичный сервис: исходный код, наблюдаемая проблема, исправление и причина, по которой оно работает.
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: работа принята, но будет завершена асинхронно.
Популярные заблуждения
Миф слева, корректная модель справа.
Любой рост RSS доказывает утечку.
Allocator может сохранять свободные страницы для повторного использования. Нужен устойчивый тренд под одинаковой нагрузкой.
global.gc() может удалить любой ненужный мне объект.
GC не понимает бизнес-смысл. Пока ссылка достижима, объект считается живым.
Если heapUsed стабилен, утечки точно нет.
Могут расти Buffer/external, native allocations, handles или ресурсы вне V8 heap.
const автоматически удерживает объект навсегда.
Время жизни определяется достижимостью ссылки, а не ключевым словом let/const.
Можно сложить все показанные числа и получить память процесса.
Метрики имеют разные границы и пересечения. Для общего резидентного объёма смотрят RSS, а остальные показатели помогают объяснить его состав.
Ответьте своими словами
Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.
- Почему GC до очистки массива не уменьшает retained?
- Почему RSS способен остаться высоким даже после успешной очистки?
- Какая метрика лучше покажет утечку Buffer?