Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Як працює збір сміття в Node.js (V8)?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Garbage collection (збір сміття) в Node.js** виконує V8 у двох поколіннях купи. Молоде покоління використовує scavenge (1-5мс): живі об'єкти копіюються в новий напівпростір, мертві зникають. Старе покоління використовує mark-sweep-compact (100мс+): V8 обходить корені, позначає досяжні об'єкти, очищає решту і компактує купу. ```javascript let temp = { data: 'request-scoped' }; // молоде покоління temp = null; // зникне під час наступного scavenge, не mark-sweep ``` **Ключове:** необмежені кеші і замикання над великими об'єктами переміщують дані до старого покоління і викликають довгі паузи.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Garbage collection (збір сміття) в Node.js** - це процес, завдяки якому V8 автоматично звільняє пам'ять, відстежуючи об'єкти, досяжні з активних коренів, і видаляючи все інше в двох поколіннях купи. ## Теорія ### TL;DR - V8 ділить купу на молоде покоління (мале, швидкий scavenge, 1-5мс) і старе покоління (більше, повільний mark-sweep-compact, 100мс+) - Аналогія: прибирання в готелі. Нові гості отримують швидку щоденну перевірку. Довгожителі - генеральне прибирання лише коли закінчуються вільні кімнати. - Об'єкти, що пережили 2 scavenge, переходять до старого покоління. Туди потрапляє лише ~25%. - Паузи більше 50мс у продакшені - ознака тиску GC. Моніторь через `--trace-gc`. - Ручний виклик `global.gc()` блокує потік на 100-500мс. Нехай V8 сам планує запуски. ### Швидкий приклад ```javascript // Витік: 'leaks' - це корінь, V8 ніколи не збере вміст let leaks = []; setInterval(() => { for (let i = 0; i < 1000; i++) { leaks.push(new Array(100000).fill('x')); // ~400KB кожен запис } console.log('Heap:', process.memoryUsage().heapUsed / 1024 / 1024, 'MB'); }, 1000); // Вивід: Heap: 10 MB -> 50 MB -> 200 MB -> OOM crash // V8 починає з коренів. 'leaks' - корінь. Все що він тримає - живе вічно. ``` Масив `leaks` - корінь GC. V8 стартує з коренів і позначає все досяжне. Оскільки `leaks` завжди досяжний, весь його вміст теж. Нічого не звільняється. ### Два покоління з різними стратегіями V8 ділить купу на дві зони з принципово різними підходами: | Зона | Розмір | Алгоритм | Типова пауза | |------|--------|----------|---------------| | Молоде покоління | 1-32 MB | Scavenge (алгоритм Чені) | 1-5мс | | Старе покоління | до ~1.5 GB | Mark-Sweep-Compact | 100мс+ | Моложе покоління маленьке навмисно. Більшість об'єктів живе коротко: тимчасові змінні, request-об'єкти, проміжні значення. Старе покоління тримає тих, хто вижив. ### Як працює Scavenge (молоде покоління) Молоде покоління ділиться на два рівних напівпростори: "From" і "To". Нові об'єкти потрапляють у "From". Коли "From" заповнюється, V8 запускає scavenge: 1. Знайти всі живі об'єкти у "From" через корені 2. Скопіювати живі об'єкти в "To" 3. Повністю очистити "From" 4. Поміняти "From" і "To" місцями Об'єкти, що пережили два scavenge, переміщуються до старого покоління. На практиці туди потрапляє лише ~25%. Решта гине раніше. Саме тому scavenge швидкий - він обходить тільки живі об'єкти, а не весь heap. ```javascript function handleRequest(req) { const parsed = JSON.parse(req.body); // народжується в молодому поколінні const result = transform(parsed); // теж молоде покоління return result; // parsed стає недосяжним і гине під час наступного scavenge } ``` ### Як працює Mark-Sweep-Compact (старе покоління) Мажорний GC проходить три фази. **Mark (позначення):** Починаючи з коренів (глобальний scope, стеки викликів, внутрішні handles V8), V8 обходить весь граф об'єктів і позначає кожен досяжний. Все непозначене вважається мертвим. **Sweep (очищення):** Мертві об'єкти звільняються. У пам'яті залишаються дірки - фрагментована купа. **Compact (компактація):** V8 переміщує живі об'єкти разом, щоб ліквідувати фрагментацію. Починаючи з V8 8.0, компактація використовує алгоритм sliding і частково виконується у фонових потоках. ``` Позначення: [●][○][●][○][○][●] (● = досяжний, ○ = мертвий) Очищення: [●][ ][●][ ][ ][●] Компактація: [●][●][●][ ] ``` Компактація необов'язкова. Якщо фрагментація низька, V8 пропускає її і економить 20-50% CPU. ### Інкрементний та паралельний GC Старий підхід "зупини світ" призупиняв виконання JS на весь цикл mark-sweep. Сучасний V8 розбиває це на менші операції: - **Інкрементне позначення** (з V8 6.x): фаза mark виконується маленькими шматками між тиками JS. Паузи падають нижче 5мс. - **Паралельне очищення**: sweep виконується у фонових потоках поки JS продовжує роботу. - **Паралельна компактація**: часткова компактація у фонових потоках (V8 7.0+). - **Ліниве очищення**: сторінки очищуються лише коли нові виділення потребують простору. Node 12+ вмикає інкрементне позначення за замовчуванням. Тому паузи GC зазвичай непомітні у добре написаних застосунках. Але щойно старе покоління наповнюється, навіть інкрементне позначення не рятує від паузи. ### Моніторинг і налаштування ```bash # Бачити кожну подію GC з таймінгом node --trace-gc server.js # Приклад виводу: # [12345] 52ms: Scavenge 4.2 -> 3.8 MB, 1.2ms <- норма # [12345] 2100ms: Mark-sweep 180 -> 90 MB, 85ms <- проблема ``` Я бачив, як тиск старого покоління накопичується тихо під навантаженням. Перший сигнал - повторювані записи 80-100мс mark-sweep у `--trace-gc`, а не OOM. До падіння процесу застосунок вже деградує хвилинами. ```javascript // Спостерігати за GC зсередини процесу const { PerformanceObserver } = require('perf_hooks'); const obs = new PerformanceObserver((list) => { list.getEntries().forEach((entry) => { if (entry.duration > 50) { console.warn(`Повільний GC: ${entry.kind} - ${entry.duration.toFixed(0)}мс`); } }); }); obs.observe({ entryTypes: ['gc'] }); ``` Ключові флаги: - `--max-old-space-size=4096`: підняти ліміт старого покоління до 4 GB (за замовчуванням ~1.5 GB на 64-bit) - `--max-semi-space-size=16`: встановити напівпростір молодого покоління в 16 MB (за замовчуванням 4 MB в Node 20) - `v8.getHeapStatistics()`: читати поточний стан купи з коду ### Типові помилки **Помилка 1: Глобальні кеші** ```javascript // Неправильно - глобальна змінна є коренем, вміст Map ніколи не збирається global.cache = new Map(); // Правильно - обмежений кеш з витісненням const { LRUCache } = require('lru-cache'); const cache = new LRUCache({ max: 1000 }); ``` Глобальні змінні - корені. Все досяжне через глобальну живе до завершення процесу. **Помилка 2: Слухачі подій без очищення** ```javascript // Неправильно - слухач тримає замикання нескінченно function setup(heavyObject) { emitter.on('data', () => process(heavyObject)); // замикання захоплює heavyObject } // Правильно emitter.once('data', handler); // або явно видаляй emitter.removeListener('data', handler); ``` Кожен виклик `on()` реєструє замикання (closure). Якщо замикання захоплює великий об'єкт, той живе стільки, скільки живе слухач. Це одна з найпоширеніших причин витоків пам'яті в Node.js. **Помилка 3: Ручний `gc()` у продакшені** ```javascript // Неправильно - блокує потік на синхронний повний GC setInterval(() => global.gc(), 1000); ``` `global.gc()` працює лише з `--expose-gc`. Використовуй у тестах для вимірювання стану купи між операціями. У продакшені V8 планує GC краще ніж будь-який фіксований інтервал. **Помилка 4: Ігнорування швидкості просування** Якщо більше ~25% об'єктів молодого покоління переходить до старого, мажорний GC запускається занадто часто і паузи накопичуються. Слідкуй за `v8.getHeapStatistics().used_heap_size_after_gc` в часі. Зростаючий показник - ознака тиску просування, зазвичай від довгоживучих об'єктів у гарячих шляхах. **Помилка 5: Великі буфери у циклах** ```javascript // Неправильно - швидко потрапляє в старе покоління, фрагментує купу for (let i = 0; i < 1000; i++) { const buf = Buffer.alloc(1e6); // 1MB кожну ітерацію process(buf); } // Правильно - перевикористовуй той самий буфер const buf = Buffer.allocUnsafe(1e6); for (let i = 0; i < 1000; i++) { buf.fill(0); process(buf); } ``` ### Де зустрічається в реальних проектах - **Express API**: необмежений `Map`-кеш у middleware викликає OOM під навантаженням. Використовуй `lru-cache` з параметром `max`. - **Puppeteer**: `page.evaluate()` створює відокремлені DOM-дерева в старому поколінні. Явно викликай `page.close()` після завершення роботи зі сторінкою. - **NestJS**: скоупи dependency injection можуть тримати довгоживучі інстанції. `WeakRef` (Node 14.6+) дозволяє синглтонам збиратись, коли на них більше ніхто не посилається. - **Ізоморфний React**: серверний рендеринг виділяє багато короткоживучих virtual DOM об'єктів на запит. Налаштування `--max-semi-space-size=16` допомагає тримати їх у молодому поколінні довше. - **Redis-клієнти**: пули з'єднань використовують `WeakMap` для тимчасового стану callback, щоб callback не переживав своє з'єднання. ### Питання на співбесіді **Q:** Яка різниця між scavenge і mark-sweep? **A:** Scavenge копіює об'єкти-виживших з молодого покоління і займає 1-5мс. Mark-sweep сканує все старе покоління, звільняє мертві об'єкти і може займати 100мс і більше. В mark-sweep копіювання немає - тільки позначення і звільнення. **Q:** Як V8 визначає витоки пам'яті? **A:** Автоматично не визначає. Ти береш heap snapshot через `--inspect` і Chrome DevTools, потім шукаєш відокремлені DOM-дерева що ростуть, записи Map що ніколи не зменшуються, або об'єкти замикань що накопичуються в старому поколінні між знімками. **Q:** Що змінило інкрементне позначення у V8? **A:** До нього фаза mark зупиняла JS на весь час свого виконання. Інкрементне позначення розбиває її на малі шматки, зазвичай менше 5мс кожен, чергуючи з виконанням JS. Node 12+ вмикає це за замовчуванням - тому паузи "зупини світ" рідкість у сучасних Node-застосунках, якщо старе покоління не переповнене. **Q:** Як `WeakRef` і `FinalizationRegistry` взаємодіють з GC? **A:** `WeakRef` тримає посилання, яке не заважає збору. V8 може зібрати цільовий об'єкт навіть якщо на нього вказує `WeakRef`. `FinalizationRegistry` викликає callback після того як об'єкт зібрано. Обидва доступні з Node 14.6 і корисні для кешів, де об'єкти мають вмирати природно без явного видалення. **Q:** Коли V8 пропускає компактацію і чому це важливо? **A:** V8 вирішує автоматично на основі рівня фрагментації. Компактація коштує 20-50% додаткового CPU, але ліквідує дірки в купі. Якщо її пропустити при високій фрагментації, виділення пам'яті може завершитись помилкою навіть якщо є вільне місце. Деталі видно у виводі `--trace-gc-verbose`. **Q:** Що таке швидкість просування і чому вона критична при масштабуванні? **A:** Це відсоток об'єктів молодого покоління, що переходять до старого. Понад ~25% - мажорний GC запускається постійно і паузи накопичуються. Висока швидкість просування зазвичай означає довгоживучі об'єкти в гарячих шляхах: request-об'єкти в модульних змінних, замикання що живуть довше ніж очікувалось. ## Приклади ### Базовий: спостерігаємо за купою в реальному часі ```javascript // node --trace-gc heap-demo.js const snapshots = []; setInterval(() => { // Кожен тік виділяє короткоживучі дані const temp = new Array(100000).fill({ value: Math.random() }); const stats = process.memoryUsage(); snapshots.push(stats.heapUsed); // зберігаємо число, не temp console.log(`Heap: ${(stats.heapUsed / 1024 / 1024).toFixed(1)} MB`); // temp виходить за scope тут - готовий до наступного scavenge }, 500); // Heap залишається відносно стабільним бо temp гине кожен тік // Заміни snapshots.push(stats.heapUsed) на snapshots.push(temp) // і спостерігай як купа росте без зупинки ``` `temp` створюється і відкидається кожні 500мс. Залишається в молодому поколінні і швидко збирається scavenge. Купа стабільна. Але варто запушити `temp` у `snapshots` - і він стає коренем, heap зростає до краша. ### Середній: Express-кеш без витіснення ```javascript const express = require('express'); const app = express(); // Росте вічно - кожен унікальний user ID додає постійний запис у старому поколінні const cache = new Map(); app.get('/user/:id', async (req, res) => { const { id } = req.params; if (!cache.has(id)) { cache.set(id, await db.getUser(id)); // повний user-об'єкт, ніколи не звільняється } res.json(cache.get(id)); }); // Після 10k унікальних user ID: heapUsed > 200MB, мажорний GC кожні ~2с, паузи 100мс // Виправлення: обмежений кеш з TTL const { LRUCache } = require('lru-cache'); const boundedCache = new LRUCache({ max: 500, ttl: 1000 * 60 * 5, // 5 хвилин }); ``` Необмежений `Map` переміщує все до старого покоління. LRU-кеш з `max: 500` тримає тиск на старе покоління стабільним незалежно від кількості унікальних користувачів. ### Просунутий: замикання що захоплює великий scope ```javascript // Тонко: кожне замикання слухача тримає весь зовнішній scope живим function createProcessor(config) { const lookupTable = loadLargeTable(config.tablePath); // 8MB у пам'яті const emitter = new (require('events'))(); // 1000 слухачів - кожен захоплює lookupTable // V8 бачить 1000 окремих замикань, кожне з посиланням на 8MB таблицю for (let i = 0; i < 1000; i++) { emitter.on('process', (item) => { return lookupTable[item.key]; // читає один ключ, але тримає всі 8MB }); } return emitter; } // v8.getHeapSpaceStatistics() покаже старе покоління біля максимуму // Кожен emitter.on() = ще одне посилання що тримає lookupTable живою // Виправлення: одне замикання, одне посилання function createProcessorFixed(config) { const lookupTable = loadLargeTable(config.tablePath); const lookup = (key) => lookupTable[key]; // одна функція-обгортка const emitter = new (require('events'))(); emitter.on('process', (item) => lookup(item.key)); // один слухач return emitter; } ``` Кожне замикання, зареєстроване через `on()`, тримає власне посилання на `lookupTable`. З 1000 слухачів - 1000 посилань на той самий 8MB об'єкт. V8 не може його звільнити поки всі 1000 не видалено. Одна функція-обгортка `lookup` означає одне замикання, одне посилання - незалежно від кількості слухачів.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.