Утечка памяти
Управляемая утечка в изолированном процессе: наблюдайте heap, external и RSS, затем освободите ссылки и запустите GC.
График памяти
Разбираем: Утечка памяти
Этот раздел можно читать до запуска опыта. После теории вернитесь к 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 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;
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.state = {
status: 'idle',
pid: null,
config: null,
latest: null,
lastLog: 'Эксперимент ещё не запускался',
};
}
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;
}
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'],
windowsHide: true,
});
this.child = child;
this.state = {
status: 'starting',
pid: child.pid,
config,
latest: null,
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);
}
});
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', '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;
}
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();
}
stopForShutdown() {
if (this.child) {
this.child.kill();
this.child = null;
}
}
}
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-поток открытым до завершения сценария.
Популярные заблуждения
Миф слева, корректная модель справа.
Любой рост RSS доказывает утечку.
Allocator может сохранять свободные страницы для повторного использования. Нужен устойчивый тренд под одинаковой нагрузкой.
global.gc() может удалить любой ненужный мне объект.
GC не понимает бизнес-смысл. Пока ссылка достижима, объект считается живым.
Если heapUsed стабилен, утечки точно нет.
Могут расти Buffer/external, native allocations, handles или ресурсы вне V8 heap.
const автоматически удерживает объект навсегда.
Время жизни определяется достижимостью ссылки, а не ключевым словом let/const.
Можно сложить все показанные числа и получить память процесса.
Метрики имеют разные границы и пересечения. Для общего резидентного объёма смотрят RSS, а остальные показатели помогают объяснить его состав.
Ответьте своими словами
Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.
- Почему GC до очистки массива не уменьшает retained?
- Почему RSS способен остаться высоким даже после успешной очистки?
- Какая метрика лучше покажет утечку Buffer?