IlmHamroh
JavaScript Full-stack/24-qism. Node.js48/55-dars21 daqiqa
Mundarija (33)

Node.js memory leak: heap snapshot bilan xotira sizishini topish

Qisqacha: Node jarayonining xotirasi soatlab o'sib borsa — sizish (memory leak) bor. Uni topish uchun ikki marta heap snapshot olasiz: v8.writeHeapSnapshot() yoki --heapsnapshot-near-heap-limit bayrog'i bilan. Keyin ikkala suratni solishtirasiz: qaysi obyekt turi ko'paygan bo'lsa — shubhali. Oxirgi qadam — retainer zanjiri: GC ildizidan o'sha obyektgacha qaysi o'zgaruvchi uni ushlab turibdi. Ko'pincha javob bitta nom bo'ladi: kesh, tinglovchilar, navbat.

Bu darsda

  • Node jarayonidan heap snapshot olishning uch yo'lini bilasiz va Windows'dagi cheklovini tushunasiz.
  • Ikki snapshot'ni o'zingiz yozgan kichik skript bilan solishtirib, "nima ko'paydi" savoliga javob olasiz.
  • Retainer zanjirini o'qiysiz: kesh → Map → table → KursYozuvi.
  • Node'ga xos sizishlarni (EventEmitter tinglovchisi, modul darajasidagi kesh, taymer) taniysiz va tuzatasiz.
  • WeakRef va FinalizationRegistry qachon yordam berishi va qachon xavfli ekanini bilasiz.

Oldin bilishingiz kerak: Xotira sizishlari va ularni topish, Node xotira limitlari va heap statistikasi, CPU profiling va flamegraph, EventEmitter chuqur.

1. Nega bu kerak?

Xotira sizishlari darsida besh manbani ko'rgansiz: unutilgan tinglovchi, to'xtatilmagan taymer, closure, detached DOM va chegarasiz kesh. U yerda sizishni Chrome DevTools'ning Memory panelida topgan edingiz.

Endi vaziyat boshqa. Kod brauzerda emas, serverda yoki terminalda ishlaydi. Oyna yo'q, "Memory" tugmasi yo'q. Server esa haftalab to'xtamaydi. Kuniga 2 MB sizish — bir oyda 60 MB, keyin heap limitiga yetib, JavaScript heap out of memory bilan qulash.

Brauzerda foydalanuvchi sahifani yangilab qo'yadi va sizish "o'z-o'zidan" tozalanadi. Serverda bunday yordamchi yo'q. Shuning uchun backend dasturchi sizishni Node vositalari bilan topa olishi kerak.

O'xshatish: omborxona. Har kuni yuk kiradi va chiqadi. Bir kuni omborchi ko'radi: javonlar to'lib boryapti. Qaysi yuk chiqmayapti? Bilish uchun ikki marta ro'yxat qiladi — dushanba va juma. Juma kuni qaysi qutidan ko'p bo'lsa — o'sha. Keyin "bu qutini kim saqlashni buyurgan?" deb hujjatni qidiradi. Heap snapshot — ro'yxat, retainer — hujjat.

2. Heap snapshot nima

2.1 Xotiraning surati

Heap snapshot (xotira surati) — bir lahzadagi butun JS heap'ning to'liq tavsifi: har obyekt, uning turi, hajmi va kimga havola qilishi. Fayl kengaytmasi .heapsnapshot, ichi JSON.

Ichida ikkita katta massiv bor: tugunlar (nodes) — obyektlar, va qirralar (edges) — "A obyekt B ga havola qiladi" degan bog'lanishlar. Qirraning nomi bo'ladi: xususiyat nomi (kesh), massiv indeksi ([3]) yoki ichki nom (table).

flowchart LR
  R["GC ildizi"] -->|"kesh"| M["Map"]
  M -->|"table"| T["ichki massiv"]
  T -->|"[0]"| A["KursYozuvi"]
  T -->|"[1]"| B["KursYozuvi"]
  A -->|"qiymatlar"| X["Array"]

Nimaga qarang: GC ildizidan strelka bo'ylab yetib boriladigan har obyekt — tirik. Garbage collection darsidagi qoida shu: yetib bo'lmasa — o'chiriladi. Sizish — yetib boriladigan, lekin keraksiz obyekt. Snapshot aynan shu strelkalarni ko'rsatadi.

2.2 Narxi

Surat olish bepul emas:

  • Jarayon to'xtaydi. Hujjatga ko'ra surat olish — sinxron amal, u event loop'ni bloklaydi. Bu vaqtda JS kodi ishlamaydi — server so'rovlarga javob bermaydi.
  • Xotira kerak. Node hujjati ogohlantiradi: surat uchun heap hajmidan taxminan ikki baravar xotira kerak bo'ladi.
  • Fayl katta. Biz o'lchaganda 25 MB heap — 27 MB fayl.

Shuning uchun prodakshnda surat ataylab, bir-ikki marta olinadi, har daqiqada emas.

Tekshirib ko'ring: Snapshot'da A → B qirrasi bor. Bu "A B ni o'chirishga xalaqit beradi" degani bo'ladimi?

Javob

Faqat A ning o'zi tirik bo'lsa. Agar A ga GC ildizidan yo'l bo'lmasa, A ham, B ham o'chiriladi. Havola faqat ildizdan boshlangan zanjirning bir bo'lagi bo'lsa ushlab turadi. Shuning uchun retainer zanjiri ildizdan boshlab o'qiladi.

3. Surat olishning uch yo'li

3.1 Koddan: v8.writeHeapSnapshot()

Eng oddiy yo'l — node:v8 modulidagi funksiya. U faylni yozadi va nomini qaytaradi. Sizishni qayta yaratamiz: «server» har so'rovda kurs yozuvini keshga qo'shadi va hech qachon o'chirmaydi.

js
// sizish.mjs — har "so'rov" kurs yozuvini keshga qo'shadi
import v8 from "node:v8";

class KursYozuvi {
  constructor(i) {
    this.kod = "USD";
    this.sana = `2026-10-${i}`;
    this.qiymatlar = new Array(20).fill(i);
  }
}

const kesh = new Map(); // hech qachon tozalanmaydi

function sorovniBajar(i) {
  kesh.set(`USD:${i}`, new KursYozuvi(i));
}

const mb = () =>
  (process.memoryUsage().heapUsed / 2 ** 20).toFixed(1);

for (let i = 0; i < 10_000; i++) sorovniBajar(i);
gc();
console.log("1-surat oldidan:", mb(), "MB");
console.log(v8.writeHeapSnapshot("1.heapsnapshot"));

for (let i = 10_000; i < 60_000; i++) sorovniBajar(i);
gc();
console.log("2-surat oldidan:", mb(), "MB");
console.log(v8.writeHeapSnapshot("2.heapsnapshot"));

gc() faqat --expose-gc bayrog'i bilan mavjud (Garbage collection darsida ko'rgansiz). Biz node --expose-gc sizish.mjs ni ishga tushirdik (Node 24.21.0, Windows 11, i5-12500H; uch marta — raqamlar 0.1 MB aniqlikda bir xil):

text
1-surat oldidan: 7.6 MB
1.heapsnapshot
2-surat oldidan: 25.3 MB
2.heapsnapshot

Fayllar: 8.1 MB va 27.1 MB. Blokni nocheck qildik — xotira raqamlari Node versiyasi va mashinaga qarab biroz farq qiladi.

Nega surat oldidan gc()? Aks holda suratda "axlat" ham bo'ladi — o'chirilishi kerak, lekin hali o'chirilmagan obyektlar. Aslida writeHeapSnapshot o'zi ham surat oldidan to'liq GC qiladi, lekin o'lchov (mb()) uchun gc() kerak.

Grafikda kanondagi xuddi shunday tajriba (Map kesh, 10 000 dan 60 000 gacha yozuv):

Chegarasiz kesh: heapUsed o'sishi
heapUsed, MB
21,64,4060 000keshdagi yozuvlar, tacheksiz Map: 0 ta → 4,4 MBcheksiz Map: 10 000 ta → 7,3 MBcheksiz Map: 60 000 ta → 21,6 MB
Chegarasiz kesh: heapUsed o'sishi
keshdagi yozuvlarcheksiz Map
04,4
10 0007,3
60 00021,6

Manba: IlmHamroh kanon tajribasi t48, 2026-10-23: Node 24.21.0, Windows 11, i5-12500H, --expose-gc

Nimaga qarang: chiziq to'g'ri. Har 10 000 yozuv ~2.8 MB. To'g'ri chiziq — sizishning tipik "imzosi".

3.2 Signal bilan: --heapsnapshot-signal (Windows'da yo'q)

Ishlab turgan serverni qayta ishga tushirmasdan surat olish kerak bo'lsa, Node hujjati bayroq taklif qiladi:

bash
node --heapsnapshot-signal=SIGUSR2 server.mjs
# boshqa terminalda: kill -USR2 <pid>

Linux va macOS'da SIGUSR2 signali kelganda Node surat yozadi (hujjat bo'yicha; biz Linux'da sinamadik). process va signallar darsidan eslang: Windows'da signallar boshqacha. Biz Windows'da sinab ko'rdik — dastur umuman ishga tushmadi:

text
TypeError [ERR_UNKNOWN_SIGNAL]: Unknown signal: SIGUSR2

Tarjimasi: "noma'lum signal: SIGUSR2". Windows'da SIGUSR2 yo'q, Node esa noto'g'ri bayroqni jim o'tkazib yubormaydi — ishga tushishdan oldin xato beradi. Bu yaxshi: server bayroqsiz ishlab, kerakli paytda surat ololmay qolmaydi.

3.3 Qulashdan oldin: --heapsnapshot-near-heap-limit

Eng foydali yo'l — sizish o'zini ko'rsatgan paytda avtomatik surat. Bayroq: --heapsnapshot-near-heap-limit=N — heap limitga yaqinlashganda N tagacha surat yoziladi. Biz cheksiz massivli dasturni kichik limit bilan ishga tushirdik:

bash
node --max-old-space-size=64 \
  --heapsnapshot-near-heap-limit=1 limit.mjs

Haqiqiy chiqish (qisqartirilgan; fayl yo'li … bilan):

text
Wrote snapshot to …\Heap.20261023.140944.23348.0.001.heapsnapshot
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory

Surat yozildi, keyin baribir qulash. Lekin endi qulash sababi qo'lingizda — 152 MB fayl. Kutilmagan ikki narsa ko'rdik:

  • Vaqt. Qulash ~31 soniyada bo'ldi. Bayroqsiz shunday dastur bir soniyaga yetmay yiqiladi. Surat olish uchun V8 limitni vaqtincha ko'taradi (oxirgi GC qatorida heap 248 MB — 64 emas). Hujjat ham shuni aytadi: Node surat narxini ko'tarish uchun heap'ni moslaydi.
  • Joy. Fayl 152 MB. Serverda disk to'lib qolmasin — --diagnostic-dir=papka bilan alohida joyga yo'naltiring.

Diqqat: Heap snapshot ichida xotiradagi hamma satrlar bor: parollar, tokenlar, foydalanuvchilarning ismi va telefoni, .env dan o'qilgan kalitlar. Suratni chatga yoki ochiq issue'ga yuklamang. Uni sir kabi saqlang va ish tugagach o'chiring.

3.4 DevTools bilan

To'rtinchi yo'l — node --inspect bilan ishga tushirib, Chrome'da chrome://inspect sahifasi orqali ulanish. Keyin Memory panelida xuddi brauzerdagidek surat olinadi. Node'ni debug qilish darsida ulanish qismini ko'rgansiz. Faylga yozilgan .heapsnapshot ni ham Memory panelidagi Load tugmasi bilan ochsa bo'ladi.

Bu yo'lni biz bu safar sinamadik — zavod muhitida brauzer ochilmaydi. Panelning ko'rinishi Xotira sizishlari darsidagi rasmdagidek.

Tekshirib ko'ring: Server prodakshnda haftasiga bir marta qulaydi. Qaysi yo'l bilan surat olasiz va nega?

Javob

--heapsnapshot-near-heap-limit=1 (va --diagnostic-dir). Qulash qachon bo'lishini bilmaysiz — bayroq aynan kerakli paytni o'zi tutadi. writeHeapSnapshot ni qachon chaqirishni bilish kerak, signal esa Windows'da yo'q va kimdir hushyor turishi kerak. Faqat diskda joy borligini va surat olish vaqtida server javob bermasligini hisobga oling.

4. Ikki suratni solishtirish

4.1 Nima ko'paydi

Bitta surat ko'p narsa demaydi — har dasturda minglab obyekt bor. Usul — ikki surat: biri amaldan oldin, biri ko'p takrordan keyin. Ikkinchisida ko'paygan obyekt turi — gumondor.

DevTools'da bu "Comparison" ko'rinishi. Lekin surat oddiy JSON — uni Node'ning o'zi bilan ham o'qisa bo'ladi. Kichik skript yozamiz: har suratda turi object bo'lgan tugunlarni konstruktor nomi bo'yicha sanaydi va farqni chiqaradi.

js
// farq.mjs — ikki heap snapshot: qaysi obyekt turi ko'paydi
// node farq.mjs 1.heapsnapshot 2.heapsnapshot
import { readFileSync } from "node:fs";

function turlarniSana(fayl) {
  const s = JSON.parse(readFileSync(fayl, "utf8"));
  const maydon = s.snapshot.meta.node_fields;
  const qadam = maydon.length;
  const turI = maydon.indexOf("type");
  const nomI = maydon.indexOf("name");
  const turlar = s.snapshot.meta.node_types[0];
  const soni = new Map();
  for (let i = 0; i < s.nodes.length; i += qadam) {
    if (turlar[s.nodes[i + turI]] !== "object") continue;
    const nom = s.strings[s.nodes[i + nomI]];
    soni.set(nom, (soni.get(nom) ?? 0) + 1);
  }
  return soni;
}

const [a, b] = process.argv.slice(2).map(turlarniSana);
const farq = [...b]
  .map(([nom, n]) => [nom, n - (a.get(nom) ?? 0)])
  .filter(([, n]) => n > 0)
  .sort((x, y) => y[1] - x[1])
  .slice(0, 5);
for (const [nom, n] of farq) {
  console.log(`${nom.padEnd(12)} +${n}`);
}

Tuzilmani tushunish uchun bitta narsa kifoya: nodes — bitta uzun sonlar massivi. Har tugun ketma-ket bir nechta sondan iborat (type, name, id, self_size …). Ularning tartibi snapshot.meta.node_fields da yozilgan. Nomlar esa alohida strings massivida, tugunda faqat nom raqami turadi. Bu — faylni ixcham qilish uchun.

sizish.mjs dan keyin biz ishga tushirgan natija:

text
Array        +50000
KursYozuvi   +50000

Ikkinchi surat oldidan 50 000 so'rov bo'lgan edi. Har so'rov — bitta KursYozuvi va uning ichidagi bitta qiymatlar massivi. Raqamlar so'rovlar soniga aynan teng. Bu — eng aniq belgi: "har so'rovda bittadan qoladi".

Kanonda xuddi shu usul (t48-heap.txt) bir xil javob bergan: KursYozuvi +50 000, Array +50 000. Kanon suratlari 7.5 va 19.3 MB edi — obyekt ichidagi maydonlar boshqacha bo'lgani uchun.

4.2 Nega konstruktor nomi muhim

KursYozuvi nomini suratda ko'rdik, chunki obyekt klass orqali yaratilgan. Agar { kod, sana, qiymatlar } oddiy obyekt literali bo'lganida, suratda nomi Object bo'lardi — minglab boshqa Object lar orasida yo'qolib ketardi.

Qoida: uzoq yashaydigan va ko'p yaratiladigan ma'lumot uchun nomli klass — sizish qidirishni ancha osonlashtiradi. Bu tezlikka ham zarar bermaydi (Hidden classes darsidagi bir xil shakl qoidasi).

Tekshirib ko'ring: Farqda Array +50000 va KursYozuvi +50000 chiqdi. Ikkalasidan qaysi biri asl sabab?

Javob

KursYozuvi. Massivlar uning ichida (this.qiymatlar) — yozuv o'chsa, massiv ham o'chadi. Farq ro'yxatida bir xil sonli qatorlar ko'pincha "ota va bola" bo'ladi. Sababni topish uchun eng "yuqoridagi" — o'z nomi bilan tanilgan obyektdan boshlang va uning retainer'ini qidiring.

5. Retainer: kim ushlab turibdi

5.1 Ildizdan yo'l

Retainer ("ushlab turuvchi") — obyektga havola qilib, uni tirik saqlayotgan obyekt. Bizga bitta retainer emas, butun zanjir kerak: GC ildizidan gumondor obyektgacha.

Zanjirni topish — graf bo'ylab kenglik bo'yicha qidiruv (Graflarda BFS va DFS): ildizdan boshlaymiz, har tugunning qirralarini ochamiz va kim qayerdan kelganini yozib boramiz. KursYozuvi ga yetganda, orqaga yurib yo'lni tiklaymiz. Zaif qirralarni (weak) tashlab ketamiz — ular obyektni ushlab turmaydi.

To'liq skript: ushlovchi.mjs (55 qator)
js
// ushlovchi.mjs — GC ildizidan obyektgacha eng qisqa yo'l
// node ushlovchi.mjs 2.heapsnapshot KursYozuvi
import { readFileSync } from "node:fs";

const [fayl, qidiruv] = process.argv.slice(2);
const s = JSON.parse(readFileSync(fayl, "utf8"));
const { node_fields: nf, edge_fields: ef } = s.snapshot.meta;
const NF = nf.length;
const EF = ef.length;
const tugunTur = s.snapshot.meta.node_types[0];
const qirraTur = s.snapshot.meta.edge_types[0];
const tur = (t) => tugunTur[s.nodes[t * NF + nf.indexOf("type")]];
const nom = (t) => s.strings[s.nodes[t * NF + nf.indexOf("name")]];

// har tugun qirralari edges massivining qaysi joyidan boshlanadi
const soni = s.nodes.length / NF;
const boshi = new Uint32Array(soni + 1);
for (let t = 0; t < soni; t++) {
  const n = s.nodes[t * NF + nf.indexOf("edge_count")];
  boshi[t + 1] = boshi[t] + n * EF;
}
function qirraNomi(e) {
  const t = qirraTur[s.edges[e + ef.indexOf("type")]];
  const v = s.edges[e + ef.indexOf("name_or_index")];
  return t === "element" || t === "hidden" ? `[${v}]` : s.strings[v];
}

// 0-tugundan (ildiz) kenglik bo'yicha qidiruv; zaif qirralar — yo'q
const ota = new Int32Array(soni).fill(-1);
const otaQirra = new Int32Array(soni);
const navbat = [0];
ota[0] = 0;
let topildi = -1;
for (let i = 0; i < navbat.length && topildi < 0; i++) {
  const t = navbat[i];
  for (let e = boshi[t]; e < boshi[t + 1]; e += EF) {
    const qt = qirraTur[s.edges[e + ef.indexOf("type")]];
    if (qt === "weak") continue;
    const u = s.edges[e + ef.indexOf("to_node")] / NF;
    if (ota[u] !== -1) continue;
    ota[u] = t;
    otaQirra[u] = e;
    navbat.push(u);
    if (tur(u) === "object" && nom(u) === qidiruv) topildi = u;
  }
}

const yol = [];
for (let t = topildi; t > 0; t = ota[t]) {
  const nomi = nom(t) || `(${tur(t)})`;
  yol.unshift(`  —${qirraNomi(otaQirra[t])}→ ${nomi}`);
}
console.log(yol.join("\n"));

Qirralar ham tugunlar kabi bitta uzun massivda. Har tugunning qirralari ketma-ket turadi, nechta ekani tugunning edge_count maydonida. Shuning uchun avval boshi massivini hisoblaymiz — "N-tugunning qirralari qayerdan boshlanadi".

Biz node ushlovchi.mjs 2.heapsnapshot KursYozuvi ni ishga tushirdik (27 MB fayl, 0.4 s):

text
  —[1]→ (GC roots)
  —[19]→ (Stack roots)
  —44→ system / Context
  —kesh→ Map
  —table→ (array)
  —212769→ KursYozuvi

5.2 Zanjirni o'qiymiz

Yuqoridan pastga:

Qadam Ma'nosi
(GC roots), (Stack roots) V8 ning ildizlari — bular doim tirik
system / Context Modul darajasidagi o'zgaruvchilar "qutisi"
—kesh→ Map O'sha qutidagi kesh o'zgaruvchisi — Map
—table→ (array) Map ning ichki jadvali
—212769→ KursYozuvi Jadvaldagi bitta yozuv

Eng muhim qator — —kesh→. Bu sizning koddagi o'zgaruvchi nomi. Sizish manbai topildi: modul darajasidagi const kesh = new Map(). Modul hech qachon "tugamaydi" — uning o'zgaruvchilari jarayon yashaguncha tirik.

Ichki nomlar (44, 212769) har ishga tushirishda boshqacha bo'ladi — ular V8 ichidagi joy raqami. Ularga e'tibor bermang, nomli qirralarni qidiring.

flowchart TD
  A["Xotira o'sadi"] --> B["Surat 1"]
  B --> C["Amalni N marta"]
  C --> D["Surat 2"]
  D --> E["Farq: +N ta KursYozuvi"]
  E --> F["Retainer: kesh → Map"]
  F --> G["Tuzatish va qayta o'lchov"]

5.3 Tuzatish va isbot

Tuzatish — keshni chegaralash. Memoization darsidagi oddiy LRU: chegaradan oshsa, eng eski yozuv chiqadi (Map qo'shilish tartibini saqlaydi):

js
const kesh = new Map(); // 1000 yozuvdan oshmaydi

function sorovniBajar(i) {
  kesh.set(`USD:${i}`, new KursYozuvi(i));
  if (kesh.size > 1000) {
    kesh.delete(kesh.keys().next().value); // eng eskisi
  }
}

Qolgan kod o'zgarmadi. Biz qayta ishga tushirdik:

text
1-surat oldidan: 4.5 MB
1.heapsnapshot
2-surat oldidan: 4.5 MB
2.heapsnapshot

farq.mjs esa hech narsa chiqarmadi — ko'paygan obyekt turi yo'q. Mana isbot: tuzatishdan keyin xuddi shu usul bilan qayta o'lchash. "Tuzatdim" deyish uchun o'lchov kerak, taxmin emas.

Tekshirib ko'ring: Retainer zanjirida —kesh→ o'rniga —listeners→ chiqdi. Qayerdan qidirasiz?

Javob

Tinglovchilardan. Ehtimol, qaysidir EventEmitter ga har safar .on(...) qo'shilyapti va hech qachon .off(...) qilinmayapti. Zanjirda listeners dan bir qadam yuqoridagi obyekt — o'sha emitter. Keyingi bo'limda aynan shunday holat.

6. Node'ga xos sizishlar

6.1 Uzoq yashaydigan EventEmitter

Brauzerdagi EventTarget misolini Xotira sizishlari darsida ko'rgansiz. Node'da ko'proq EventEmitter (EventEmitter chuqur) uchraydi. Tipik xato: butun dastur uchun bitta emitter, va har so'rov unga tinglovchi qo'shadi.

js
import { EventEmitter } from "node:events";

const kurslar = new EventEmitter(); // butun dastur uchun bitta

process.on("warning", (w) => {
  console.log(`${w.name}: ${w.message.slice(0, 60)}…`);
});

function sorovniBajar(id) {
  // har so'rov tinglovchi qo'shadi, lekin olib tashlamaydi
  kurslar.on("yangilandi", () => console.log(`so'rov ${id}`));
}

for (let i = 1; i <= 11; i++) sorovniBajar(i);
console.log("tinglovchilar:", kurslar.listenerCount("yangilandi"));

Konsolda:

text
tinglovchilar: 11
MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 yangilandi li…

process.on("warning") — ogohlantirishni o'zimiz ushlab, qisqa chiqardik. Ogohlantirish keyin keladi: Node uni asinxron yuboradi. To'liq matni:

text
(node:20780) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 yangilandi listeners added to [EventEmitter]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit

Tarjimasi: "EventEmitter'da ehtimoliy xotira sizishi aniqlandi: yangilandi hodisasiga 11 tinglovchi qo'shildi, chegara — 10". Har tinglovchi closure orqali o'z id sini ushlaydi. Server bir kunda millionta so'rov oladi — million closure.

Tuzatish — so'rov tugaganda tinglovchini olib tashlash. Eng qulay usuli — AbortSignal bilan (Node EventEmitter ham buni biladi — events.on, once ning signal opsiyasi) yoki aniq kurslar.off("yangilandi", f). setMaxListeners(100) — haroratni tushirish, kasallikni emas.

6.2 Taymerlar va modul darajasidagi holat

Ikki manba ko'p uchraydi:

  • setInterval to'xtatilmagan. Interval closure'i ushlagan hamma narsa tirik qoladi. timers darsidagi qoida: har setInterval ga clearInterval yoki AbortSignal. Fon taymeri jarayonni ushlab turmasligi uchun — .unref().
  • Modul darajasidagi to'plam. const navbat = [], const sessiyalar = {}, const kesh = new Map() — faylning eng yuqorisida. Biz yuqorida ko'rdik: ular jarayon umri bo'yi yashaydi. Har biriga savol: "bu qachon kichrayadi?" Javob yo'q bo'lsa — chegara yoki muddat (TTL) qo'ying.

Maslahat: Sizishga shubha bo'lsa, avval process.memoryUsage().heapUsed ni vaqti-vaqti bilan logga yozing (Node xotira limitlari). Arra tishi (o'sadi, GC'dan keyin tushadi) — normal. Har GC'dan keyingi "eng past nuqta" ham ko'tarilib borsa — sizish.

7. WeakRef va FinalizationRegistry

7.1 Zaif havola

WeakMap, WeakRef darsida tanishgansiz: zaif havola obyektni GC'dan to'smaydi. Node'da buni aniq ko'rish mumkin — gc() ni o'zimiz chaqiramiz:

js
// node --expose-gc
const royxat = new FinalizationRegistry((kalit) => {
  console.log(`GC o'chirdi: ${kalit}`);
});

let yozuv = { kod: "USD", kurs: 11903.15 };
const havola = new WeakRef(yozuv);
royxat.register(yozuv, "USD yozuvi");

console.log("deref:", havola.deref()?.kurs);
yozuv = null; // kuchli havola yo'q

await new Promise((r) => setTimeout(r, 0));
gc();
await new Promise((r) => setTimeout(r, 0));
console.log("deref:", havola.deref());

Konsolda:

text
deref: 11903.15
GC o'chirdi: USD yozuvi
deref: undefined

Birinchi qatorda obyekt tirik. yozuv = null dan keyin unga faqat zaif havola qoldi. gc() uni o'chirdi, FinalizationRegistry chaqiruvi keldi, deref() endi undefined qaytardi.

Nega gc() dan oldin ham setTimeout? V8 deref() qilingan obyektni joriy vazifa tugaguncha tirik saqlaydi — bu spetsifikatsiya qoidasi. Kutishsiz gc() uni o'chirmasligi mumkin.

7.2 Qachon ishlatmaslik kerak

Bu blokni biz besh marta ishga tushirdik — natija bir xil. Lekin bu faqat --expose-gc va qo'lda gc() tufayli. Oddiy dasturda GC qachon ishlashini hech kim aytolmaydi: bir daqiqada ham, hech qachon ham bo'lishi mumkin. Shuning uchun:

  • FinalizationRegistry da muhim ish qilmang (faylni yopish, pul hisobini yozish). U chaqirilmasligi mumkin — hujjat buni ochiq aytadi.
  • Zaif havola sizishni "tuzatmaydi". Chegarasiz kesh WeakRef bilan ham chegarasiz qoladi — faqat GC'ga ko'proq ish beradi. To'g'ri yo'l — aniq chegara (LRU) va muddat.
  • WeakMap — obyektga qo'shimcha ma'lumot biriktirish uchun yaxshi: kalit-obyekt o'lsa, yozuv ham ketadi.

Tekshirib ko'ring: const kesh = new Map() o'rniga new WeakMap() qo'ysak, sizish.mjs dagi muammo yo'qoladimi?

Javob

Kod umuman ishlamaydi: WeakMap kaliti faqat obyekt bo'la oladi, bizda esa "USD:1" satri. TypeError: Invalid value used as weak map key chiqadi. Kalit obyekt bo'lganida ham savol qoladi: kalitni kim ushlaydi? Agar kalitlar boshqa joyda tirik bo'lsa, yozuvlar ham qoladi. Satr kalitli kesh uchun to'g'ri yechim — chegara.

8. CLI qadami: holat yo'q — tajriba

valyuta da kod o'zgarmaydi. Sabab o'lchovda: CLI qisqa yashaydi. valyuta kurs USD ~100 ms ishlaydi va tugaydi — jarayon bilan birga butun xotira operatsion tizimga qaytadi. Kesh faylda, xotirada emas. Interaktiv rejim (valyuta i) kurslarni bir marta oladi va har qatorda yangi narsa saqlamaydi.

Kanon tajribasi (t48-heap.txt) aynan shu darsdagi usulni sinadi: cheksiz Map kesh, ikki surat (7.5 va 19.3 MB), farq — KursYozuvi +50 000, Array +50 000. Bu usul HTTP server yozganingizda kerak bo'ladi: u jarayon haftalab yashaydi. Commit yo'q.

9. Ko'p uchraydigan xatolar

Belgi yoki xato Sabab Davosi
"Suratda 100 000 ta Object — sizish!" Bitta surat o'sishni ko'rsatmaydi Har doim ikki surat va farq
ERR_UNKNOWN_SIGNAL: SIGUSR2 Windows'da SIGUSR2 yo'q («Signal bilan» bo'limi) writeHeapSnapshot ni o'zingiz chaqiring yoki --heapsnapshot-near-heap-limit
Server diski to'lib qoldi --heapsnapshot-near-heap-limit=3 — uchta yuzlab MB lik fayl N=1 va alohida papka (--diagnostic-dir)
Suratdagi token chatga tushdi Suratda xotiradagi hamma satrlar bor Ulashmang; test ma'lumoti bilan qayta yarating
gc is not defined Bayroqsiz gc yo'q gc() — faqat o'lchov uchun, prodakshn kodiga qo'shmang

Oxirgi qator haqida: bayroq bilan ham gc() ni prodakshnda chaqirmang. Majburiy GC dasturni to'xtatadi va V8 ning o'z rejasini buzadi.

10. Mashqlar

Mashqlar kurs/mashqlar/24/48-memory-leak/ da.

1-mashq (oson): bo'sh joylarni to'ldiring

  • Koddan surat olish: v8.()
  • Limitga yaqinlashganda avtomatik surat: --heapsnapshot-near-=1
  • Obyektni tirik ushlab turgan obyekt —
  • Windows'da --heapsnapshot-signal=SIGUSR2 beradigan xato kodi — ERR_UNKNOWN_
  • Tinglovchi chegarasi sukut bo'yicha —
Yechim
  • writeHeapSnapshot — node:v8 modulida, fayl nomini qaytaradi.
  • heap-limit — qulashdan oldin surat yoziladi.
  • retainer — "ushlab turuvchi"; zanjiri GC ildizidan boshlanadi.
  • SIGNAL — Windows'da SIGUSR2 yo'q.
  • 10 — MaxListenersExceededWarning 11-tinglovchida chiqadi.

2-mashq (o'rta): tinglovchi sizishini tuzating

«Uzoq yashaydigan EventEmitter» bo'limidagi kodni o'zgartiring: har so'rov tinglovchini qo'shsin, ishini qilsin va so'rov tugaganda olib tashlasin. Buning uchun sorovniBajar(id) ichida tinglovchini o'zgaruvchiga saqlab kurslar.on bilan qo'shing, kurslar.emit("yangilandi") qiling va oxirida kurslar.off bilan olib tashlang. 11 so'rovdan keyin tinglovchilar soni 0 bo'lsin va ogohlantirish chiqmasin. Yordam: off(hodisa, funksiya) — aynan o'sha funksiyani oladi (EventEmitter asoslari).

Yechim
js
import { EventEmitter } from "node:events";

const kurslar = new EventEmitter();
process.on("warning", (w) => console.log("ogohlantirish:", w.name));

function sorovniBajar(id) {
  const tinglovchi = () => console.log(`so'rov ${id}`);
  kurslar.on("yangilandi", tinglovchi);
  // ... so'rov ishi ...
  kurslar.emit("yangilandi");
  kurslar.off("yangilandi", tinglovchi); // so'rov tugadi
}

for (let i = 1; i <= 11; i++) sorovniBajar(i);
console.log("tinglovchilar:", kurslar.listenerCount("yangilandi"));

Konsolda:

text
so'rov 1
so'rov 2
so'rov 3
so'rov 4
so'rov 5
so'rov 6
so'rov 7
so'rov 8
so'rov 9
so'rov 10
so'rov 11
tinglovchilar: 0

tinglovchi o'zgaruvchiga saqlangani uchun off uni topa oladi. Anonim funksiyani ikki marta yozsangiz (on(..., () => …) va off(..., () => …)) — ikki har xil funksiya, olib tashlanmaydi. Ogohlantirish chiqmadi: bir vaqtda ko'pi bilan bitta tinglovchi bor.

3-mashq (qiyin): chegarali va muddatli kesh

ChegaraliKesh klassini yozing: new ChegaraliKesh({ chegara, muddatMs, hozir }). hozir — vaqtni qaytaradigan funksiya (sinash uchun soxta soat). Metodlar: set(kalit, qiymat) — chegaradan oshsa eng eskisini o'chiradi; get(kalit) — muddati o'tgan bo'lsa yozuvni o'chirib undefined qaytaradi; size. Sinov: chegara 2, muddat 1000 ms. USD, EUR, RUB qo'shing va hajm 2 ekanini, USD yo'qligini ko'rsating. Keyin soatni 1500 ms oldinga suring — EUR ham yo'q bo'lsin. Yordam: Map qo'shilish tartibini saqlaydi, birinchi kalit — keys().next().value.

Yechim
js
class ChegaraliKesh {
  #yozuvlar = new Map();
  constructor({ chegara, muddatMs, hozir = Date.now }) {
    this.chegara = chegara;
    this.muddatMs = muddatMs;
    this.hozir = hozir;
  }
  set(kalit, qiymat) {
    this.#yozuvlar.delete(kalit); // yangilansa — oxiriga
    this.#yozuvlar.set(kalit, { qiymat, vaqt: this.hozir() });
    if (this.#yozuvlar.size > this.chegara) {
      const eski = this.#yozuvlar.keys().next().value;
      this.#yozuvlar.delete(eski);
    }
  }
  get(kalit) {
    const y = this.#yozuvlar.get(kalit);
    if (!y) return undefined;
    if (this.hozir() - y.vaqt > this.muddatMs) {
      this.#yozuvlar.delete(kalit);
      return undefined;
    }
    return y.qiymat;
  }
  get size() {
    return this.#yozuvlar.size;
  }
}

let soat = 0;
const kesh = new ChegaraliKesh({
  chegara: 2,
  muddatMs: 1000,
  hozir: () => soat,
});
kesh.set("USD", 11903.15);
kesh.set("EUR", 13329.15);
kesh.set("RUB", 146.75);
console.log(kesh.size, kesh.get("USD"), kesh.get("EUR"));
soat += 1500;
console.log(kesh.get("EUR"), kesh.size);

Konsolda:

text
2 undefined 13329.15
undefined 1

Ikki himoya birga ishlaydi: chegara xotirani yuqoridan cheklaydi, muddat eski ma'lumotni olib tashlaydi. Diqqat: muddati o'tgan yozuv faqat get chaqirilganda o'chadi — hech kim so'ramasa, chegara baribir uni oxiri siqib chiqaradi. Soatni parametr qilib berish (Legacy kod darsidagi "tikuv" g'oyasi) testni 1.5 soniya kutishdan qutqardi.

4-mashq: CLI qadami — interaktiv rejim sizadimi?

valyuta loyihangizda o'lchang: 20 000 qatorli fayl yarating (100 usd eur qatori takrorlansin) va uni valyuta i ga quvur bilan bering. NODE_OPTIONS=--expose-gc bilan ishga tushirish shart emas — interaktiv.ts oxirida process.memoryUsage().heapUsed ni stderr'ga yozing (vaqtincha). 2 000 va 20 000 qatorda solishtiring.

Yechim

Kutilgan xulosa: heapUsed qatorlar soniga proporsional o'smaydi — interaktiv rejim har qatorni hisoblab chiqaradi va hech narsa saqlamaydi (readline historySize: 100 faqat TTY'da ishlaydi). Agar 20 000 da 2 000 dagidan ancha ko'p bo'lsa — GC hali ishlamagan bo'lishi mumkin: o'lchovni bir necha marta takrorlang yoki --expose-gc bilan gc() dan keyin o'lchang. Vaqtinchalik qatorni commit qilmang — bu o'lchov, loyiha kodi emas.

11. Real ishda

  • Prodakshn insidenti: "server har 3 kunda qayta ishga tushyapti" — eng ko'p uchraydigan sabab sizish. Jamoalar --heapsnapshot-near-heap-limit ni oldindan yoqib qo'yadi: qulash bo'lsa, surat tayyor.
  • Konteyner va xotira limiti: Docker yoki Kubernetes'da jarayonga masalan 512 MB ajratiladi. Xotira limitlari darsidagi --max-old-space-size bilan birga sozlanadi — keyingi qismlarda (Docker) ko'rasiz.
  • APM vositalari: Datadog, New Relic, Sentry kabi xizmatlar heap'ni vaqt bo'yicha grafik qilib ko'rsatadi — "eng past nuqta" ko'tarilayotganini ular birinchi sezadi. Ulanish uchun hisob kerak, biz ishlatmadik.
  • Intervyu: "Node'da memory leak'ni qanday topasiz?" — yaxshi javob: o'lchash (heapUsed trendi), ikki snapshot, farq, retainer zanjiri, tuzatish va qayta o'lchash. Misollar: global kesh, unutilgan listener, interval.

Xulosa

  • Sizish — yetib boriladigan, lekin keraksiz obyekt; serverda u haftalar ichida qulashga olib keladi.
  • Surat olish: v8.writeHeapSnapshot(), --heapsnapshot-near-heap-limit=1, --inspect + DevTools. --heapsnapshot-signal Windows'da ERR_UNKNOWN_SIGNAL beradi.
  • Surat — jarayonni to'xtatadi, katta fayl va ichida sirlar; ehtiyot bilan.
  • Usul: ikki surat → ko'paygan tur (+50 000 KursYozuvi) → retainer zanjiri (kesh → Map) → tuzatish → qayta o'lchov.
  • Node'da tipik manbalar: modul darajasidagi kesh, uzoq yashaydigan EventEmitter tinglovchilari, to'xtatilmagan interval.
  • WeakRef/FinalizationRegistry — GC'ga bog'liq va noaniq; sizishga davo emas, chegara va muddat — davo.

Keyingi dars: Diagnostika vositalari — debugger'siz tahlil: process.report, ogohlantirish va eskirish izlari, diagnostics_channel va perf_hooks.

Manbalar

Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
Node.js memory leak: heap snapshot bilan xotira sizishini topish — IlmHamroh