Depanarea pierderilor de memorie în aplicațiile Node.js

Publicat: 2022-03-11

Am condus odată un Audi cu un motor V8 twin-turbo în interior, iar performanța lui a fost incredibilă. Conduceam cu aproximativ 140 MPH pe autostrada IL-80, lângă Chicago, la 3 dimineața, când nu era nimeni pe drum. De atunci, termenul „V8” a devenit asociat cu performanța înaltă pentru mine.

Node.js este o platformă construită pe motorul JavaScript V8 al Chrome pentru crearea ușoară de aplicații de rețea rapide și scalabile.

Deși V8-ul Audi este foarte puternic, încă ești limitat cu capacitatea rezervorului tău de benzină. Același lucru este valabil și pentru Google V8 - motorul JavaScript din spatele Node.js. Performanța sa este incredibilă și există multe motive pentru care Node.js funcționează bine pentru multe cazuri de utilizare, dar ești întotdeauna limitat de dimensiunea heap-ului. Când trebuie să procesați mai multe solicitări în aplicația dvs. Node.js, aveți două opțiuni: fie scalați vertical, fie scalați orizontal. Scalare orizontală înseamnă că trebuie să rulați mai multe instanțe de aplicație concurente. Când ați făcut bine, ajungeți să puteți servi mai multe solicitări. Scalare verticală înseamnă că trebuie să îmbunătățiți utilizarea memoriei și performanța aplicației sau să creșteți resursele disponibile pentru instanța aplicației.

Depanarea pierderilor de memorie în aplicațiile Node.js

Depanarea pierderilor de memorie în aplicațiile Node.js
Tweet

Recent, mi s-a cerut să lucrez la o aplicație Node.js pentru unul dintre clienții mei Toptal pentru a remedia o problemă de scurgere de memorie. Aplicația, un server API, a fost menită să poată procesa sute de mii de solicitări în fiecare minut. Aplicația originală a ocupat aproape 600 MB de RAM și, prin urmare, am decis să luăm punctele finale API fierbinți și să le reimplementam. Cheltuielile generale devin foarte costisitoare atunci când trebuie să satisfaci multe cereri.

Pentru noul API am ales restify cu driverul MongoDB nativ și Kue pentru joburi de fundal. Sună ca o stivă foarte ușoară, nu? Nu chiar. În timpul sarcinii de vârf, o nouă instanță de aplicație poate consuma până la 270 MB de RAM. Prin urmare, visul meu de a avea două aplicații pentru 1X Heroku Dyno a dispărut.

Arsenal de depanare a pierderilor de memorie Node.js

Memwatch

Dacă căutați „cum să găsiți scurgeri în nod”, primul instrument pe care îl veți găsi probabil este memwatch . Pachetul original a fost abandonat cu mult timp in urma si nu mai este intretinut. Cu toate acestea, puteți găsi cu ușurință versiuni mai noi ale acestuia în lista de furculițe a GitHub pentru depozit. Acest modul este util deoarece poate emite evenimente de scurgere dacă vede că heap-ul crește peste 5 colecții de gunoi consecutive.

Heapdump

Instrument grozav care le permite dezvoltatorilor Node.js să facă instantanee heap și să le inspecteze ulterior cu Chrome Developer Tools.

Nod-inspector

Chiar și o alternativă mai utilă la heapdump, deoarece vă permite să vă conectați la o aplicație care rulează, să luați heap dump și chiar să o depanați și să o recompilați din mers.

Luând „node-inspector” pentru un Spin

Din păcate, nu vă veți putea conecta la aplicațiile de producție care rulează pe Heroku, deoarece nu permite trimiterea semnalelor către procesele care rulează. Cu toate acestea, Heroku nu este singura platformă de găzduire.

Pentru a experimenta node-inspector în acțiune, vom scrie o aplicație simplă Node.js folosind Restify și vom pune o mică sursă de scurgere de memorie în ea. Toate experimentele de aici sunt făcute cu Node.js v0.12.7, care a fost compilat împotriva V8 v3.28.71.19.

 var restify = require('restify'); var server = restify.createServer(); var tasks = []; server.pre(function(req, res, next) { tasks.push(function() { return req.headers; }); // Synchronously get user from session, maybe jwt token req.user = { id: 1, username: 'Leaky Master', }; return next(); }); server.get('/', function(req, res, next) { res.send('Hi ' + req.user.username); return next(); }); server.listen(3000, function() { console.log('%s listening at %s', server.name, server.url); });

Aplicația de aici este foarte simplă și are o scurgere foarte evidentă. Sarcinile de matrice ar crește pe durata de viață a aplicației, ceea ce face ca aceasta să încetinească și, în cele din urmă, să se prăbușească. Problema este că nu scurgem doar închidere, ci și obiecte întregi de solicitare.

GC în V8 folosește strategia stop-the-world, prin urmare înseamnă mai multe obiecte pe care le aveți în memorie, cu atât va dura mai mult pentru a colecta gunoiul. În jurnalul de mai jos puteți vedea clar că la începutul duratei de viață a aplicației ar dura în medie 20 ms pentru a colecta gunoiul, dar câteva sute de mii de solicitări mai târziu durează aproximativ 230 ms. Persoanele care încearcă să acceseze aplicația noastră ar trebui să aștepte cu 230 ms mai mult acum din cauza GC. De asemenea, puteți vedea că GC este invocat la fiecare câteva secunde, ceea ce înseamnă că la fiecare câteva secunde utilizatorii ar avea probleme la accesarea aplicației noastre. Și întârzierea va crește până când aplicația se blochează.

 [28093] 7644 ms: Mark-sweep 10.9 (48.5) -> 10.9 (48.5) MB, 25.0 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 7717 ms: Mark-sweep 10.9 (48.5) -> 10.9 (48.5) MB, 18.0 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 7866 ms: Mark-sweep 11.0 (48.5) -> 10.9 (48.5) MB, 23.2 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 8001 ms: Mark-sweep 11.0 (48.5) -> 10.9 (48.5) MB, 18.4 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. ... [28093] 633891 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 357.3 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 635672 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 331.5 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 637508 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 357.2 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested].

Aceste linii de jurnal sunt tipărite atunci când o aplicație Node.js este pornită cu indicatorul –trace_gc :

 node --trace_gc app.js

Să presupunem că am pornit deja aplicația noastră Node.js cu acest flag. Înainte de a conecta aplicația cu node-inspector, trebuie să-i trimitem semnalul SIGUSR1 procesului care rulează. Dacă rulați Node.js în cluster, asigurați-vă că vă conectați la unul dintre procesele slave.

 kill -SIGUSR1 $pid # Replace $pid with the actual process ID

Făcând acest lucru, facem ca aplicația Node.js (mai precis V8) să intre în modul de depanare. În acest mod, aplicația deschide automat portul 5858 cu V8 Debugging Protocol.

Următorul nostru pas este să rulăm node-inspector care se va conecta la interfața de depanare a aplicației care rulează și se va deschide o altă interfață web pe portul 8080.

 $ node-inspector Node Inspector v0.12.2 Visit http://127.0.0.1:8080/?ws=127.0.0.1:8080&port=5858 to start debugging.

În cazul în care aplicația rulează în producție și aveți un firewall instalat, putem efectua un tunel portul de la distanță 8080 către localhost:

 ssh -L 8080:localhost:8080 [email protected]

Acum puteți deschide browserul web Chrome și puteți obține acces complet la Instrumentele de dezvoltare Chrome atașate aplicației dvs. de producție de la distanță. Din păcate, Chrome Developer Tools nu va funcționa în alte browsere.

Să găsim o scurgere!

Scurgerile de memorie în V8 nu sunt scurgeri reale de memorie, așa cum le cunoaștem din aplicațiile C/C++. În JavaScript, variabilele nu dispar în gol, ci doar sunt „uitate”. Scopul nostru este să găsim aceste variabile uitate și să le reamintim că Dobby este liber.

În Chrome Developer Tools avem acces la mai mulți profileri. Suntem interesați în special de Record Heap Allocations , care rulează și preia mai multe instantanee heap de-a lungul timpului. Acest lucru ne oferă o privire clară în care obiectele se scurg.

Începeți să înregistrați alocările heap și să simulăm 50 de utilizatori concurenți pe pagina noastră de pornire folosind Apache Benchmark.

Captură de ecran

 ab -c 50 -n 1000000 -k http://example.com/

Înainte de a face noi instantanee, V8 ar efectua colectarea gunoiului cu marcaj, așa că știm cu siguranță că nu există gunoi vechi în instantaneu.

Remedierea scurgerii din zbor

După colectarea instantaneelor ​​de alocare a heap-urilor pe o perioadă de 3 minute , ajungem la ceva de genul următor:

Captură de ecran

Putem vedea clar că există câteva matrice gigantice, o mulțime de obiecte IncomingMessage, ReadableState, ServerResponse și Domain, precum și în grămada. Să încercăm să analizăm sursa scurgerii.

La selectarea diferențelor heap pe diagramă de la 20 la 40 de secunde, vom vedea doar obiectele care au fost adăugate după 20 de secunde de când ați pornit profiler. În acest fel, puteți exclude toate datele normale.

Ținând seama de câte obiecte de fiecare tip sunt în sistem, extindem filtrul de la 20s la 1min. Putem vedea că matricele, deja destul de gigantice, continuă să crească. Sub „(matrice)” putem vedea că există o mulțime de obiecte „(proprietăți ale obiectului)” cu distanță egală. Aceste obiecte sunt sursa scurgerii noastre de memorie.

De asemenea, putem observa că și obiectele „(închidere)” cresc rapid.

Ar putea fi util să te uiți și la corzi. Sub lista de șiruri există o mulțime de fraze „Hi Leaky Master”. Acestea ne-ar putea oferi și un indiciu.

În cazul nostru, știm că șirul „Hi Leaky Master” poate fi asamblat doar pe ruta „GET /”.

Dacă deschideți calea retenerelor, veți vedea că acest șir este cumva referit prin req , atunci există context creat și toate acestea adăugate la o serie uriașă de închideri.

Captură de ecran

Deci, în acest moment, știm că avem un fel de gamă gigantică de închideri. De fapt, să dăm un nume tuturor închiderilor noastre în timp real, în fila surse.

Captură de ecran

După ce am terminat de editat codul, putem apăsa CTRL+S pentru a salva și recompila codul din mers!

Acum să înregistrăm un alt instantaneu al alocărilor heap și să vedem ce închideri ocupă memoria.

Este clar că SomeKindOfClojure() este ticălosul nostru. Acum putem vedea că închiderile SomeKindOfClojure() sunt adăugate unor sarcini numite matrice din spațiul global.

Este ușor de văzut că această matrice este pur și simplu inutilă. O putem comenta. Dar cum eliberăm memoria pe care o ocupam deja? Foarte ușor, atribuim o matrice goală sarcinilor și la următoarea solicitare va fi suprascrisă, iar memoria va fi eliberată după următorul eveniment GC.

Captură de ecran

Dobby este liber!

Viața gunoiului în V8

Ei bine, V8 JS nu are scurgeri de memorie, ci doar variabile uitate.

Ei bine, V8 JS nu are scurgeri de memorie, ci doar variabile uitate.
Tweet

Heap-ul V8 este împărțit în mai multe spații diferite:

  • Spațiu nou : acest spațiu este relativ mic și are o dimensiune între 1MB și 8MB. Cele mai multe dintre obiecte sunt alocate aici.
  • Old Pointer Space : Are obiecte care pot avea pointeri către alte obiecte. Dacă obiectul supraviețuiește suficient de mult în Spațiul Nou, acesta este promovat în Spațiul Pointer Vechi.
  • Spațiu de date vechi : conține numai date brute, cum ar fi șiruri de caractere, numere în casete și matrice de duble fără casete. Obiectele care au supraviețuit suficient de mult timp GC în Noul Spațiu sunt mutate și aici.
  • Spațiu obiect mare : în acest spațiu sunt create obiecte care sunt prea mari pentru a încăpea în alte spații. Fiecare obiect are propria sa regiune mmap în memorie
  • Spațiu cod : Conține codul de asamblare generat de compilatorul JIT.
  • Spațiu de celule, spațiu de celule de proprietate, spațiu de hartă : acest spațiu conține Cell , celule de PropertyCell și Map . Acesta este folosit pentru a simplifica colectarea gunoiului.

Fiecare spațiu este compus din pagini. O pagină este o regiune de memorie alocată de sistemul de operare cu mmap. Fiecare pagină are întotdeauna o dimensiune de 1 MB, cu excepția paginilor din spațiu mare pentru obiecte.

V8 are două mecanisme de colectare a gunoiului încorporate: Scavenge, Mark-Sweep și Mark-Compact.

Scavenge este o tehnică foarte rapidă de colectare a gunoiului și funcționează cu obiecte în New Space . Scavenge este implementarea algoritmului lui Cheney. Ideea este foarte simplă, New Space este împărțit în două semi-spații egale: To-Space și From-Space. Scavenge GC are loc când To-Space este plin. Schimbă pur și simplu spațiile Către și De la și copiază toate obiectele vii în To-Space sau le promovează într-unul dintre vechile spații dacă au supraviețuit la două scavenge și este apoi ștearsă complet din spațiu. Recuperările sunt foarte rapide, totuși au sarcina de a păstra grămada de dimensiuni duble și de a copia constant obiectele în memorie. Motivul pentru a folosi scavenges este că majoritatea obiectelor mor de tineret.

Mark-Sweep & Mark-Compact este un alt tip de colector de gunoi folosit în V8. Celălalt nume este colector complet de gunoi. Acesta marchează toate nodurile active, apoi mătură toate nodurile moarte și defragmentează memoria.

Sfaturi de performanță și depanare GC

În timp ce pentru aplicațiile web, performanța ridicată ar putea să nu fie o problemă atât de mare, veți dori totuși să evitați scurgerile cu orice preț. În timpul fazei de marcare în GC complet, aplicația este de fapt întreruptă până la finalizarea colectării gunoiului. Aceasta înseamnă că, cu cât aveți mai multe obiecte în heap, cu atât va dura mai mult pentru a efectua GC și utilizatorii vor trebui să aștepte mai mult.

Dați întotdeauna nume închiderilor și funcțiilor

Este mult mai ușor să inspectați urmele și grămezile de stive atunci când toate închiderile și funcțiile dvs. au nume.

 db.query('GIVE THEM ALL', function GiveThemAllAName(error, data) { ... })

Evitați obiectele mari în funcțiile fierbinți

În mod ideal, doriți să evitați obiectele mari în interiorul funcțiilor fierbinți, astfel încât toate datele să fie încadrate în New Space . Toate operațiunile legate de CPU și memorie ar trebui să fie executate în fundal. Evitați, de asemenea, declanșatoarele de deoptimizare pentru funcțiile fierbinți, funcția optimă fierbinte utilizează mai puțină memorie decât cele neoptimizate.

Funcțiile calde ar trebui optimizate

Funcțiile fierbinți care rulează mai repede, dar consumă și mai puțină memorie fac ca GC să ruleze mai rar. V8 oferă câteva instrumente utile de depanare pentru a identifica funcțiile neoptimizate sau funcțiile deoptimizate.

Evitați polimorfismul pentru CI în funcțiile fierbinți

Cache-urile inline (IC) sunt folosite pentru a accelera execuția unor bucăți de cod, fie prin memorarea în cache a proprietății obiectului de acces obj.key , fie prin anumite funcții simple.

 function x(a, b) { return a + b; } x(1, 2); // monomorphic x(1, “string”); // polymorphic, level 2 x(3.14, 1); // polymorphic, level 3

Când x(a,b) este rulat pentru prima dată, V8 creează un IC monomorf. Când apelați x a doua oară, V8 șterge vechiul IC și creează un nou IC polimorf care acceptă ambele tipuri de operanzi întregi și șir. Când apelați IC a treia oară, V8 repetă aceeași procedură și creează un alt IC polimorf de nivelul 3.

Cu toate acestea, există o limitare. După ce nivelul IC ajunge la 5 (ar putea fi schimbat cu flag –max_inlining_levels ) funcția devine megamorfă și nu mai este considerată optimizabilă.

Este de înțeles intuitiv că funcțiile monomorfe rulează cel mai rapid și au, de asemenea, o amprentă de memorie mai mică.

Nu adăugați fișiere mari în memorie

Acesta este evident și bine cunoscut. Dacă aveți fișiere mari de procesat, de exemplu un fișier CSV mare, citiți-l rând cu linie și procesați-l în bucăți mici în loc să încărcați întregul fișier în memorie. Există cazuri destul de rare în care o singură linie de csv ar fi mai mare de 1 MB, permițându-vă astfel să o potriviți în New Space .

Nu blocați firul serverului principal

Dacă aveți un API fierbinte care necesită ceva timp pentru procesare, cum ar fi un API pentru redimensionarea imaginilor, mutați-l într-un fir separat sau transformați-l într-o lucrare de fundal. Operațiunile intensive ale procesorului ar bloca firul principal, forțând toți ceilalți clienți să aștepte și să continue să trimită cereri. Datele de solicitare neprocesate s-ar stivui în memorie, forțând astfel GC complet să dureze mai mult timp pentru finalizare.

Nu creați date inutile

Am avut odată o experiență ciudată cu Restify. Dacă trimiteți câteva sute de mii de solicitări către o adresă URL nevalidă, atunci memoria aplicației va crește rapid până la o sută de megaocteți până când un GC complet va începe în câteva secunde mai târziu, atunci când totul va reveni la normal. Se pare că pentru fiecare adresă URL nevalidă, restify generează un nou obiect de eroare care include urme lungi de stivă. Acest lucru a forțat ca obiectele nou create să fie alocate în Spațiu obiect mare și nu în Spațiu nou .

Accesul la astfel de date ar putea fi foarte util în timpul dezvoltării, dar evident că nu este necesar în producție. Prin urmare, regula este simplă - nu generați date decât dacă aveți nevoie cu siguranță de ele.

Cunoaște-ți instrumentele

Ultimul, dar cu siguranță nu în ultimul rând, este să vă cunoașteți instrumentele. Există diverse dispozitive de depanare, generatoare de scurgeri și generatoare de grafice de utilizare. Toate aceste instrumente vă pot ajuta să vă faceți software-ul mai rapid și mai eficient.

Concluzie

Înțelegerea modului în care funcționează colectarea gunoiului și optimizatorul de cod de la V8 este cheia performanței aplicației. V8 compilează JavaScript în asamblarea nativă și, în unele cazuri, codul bine scris ar putea atinge performanțe comparabile cu aplicațiile compilate GCC.

Și în cazul în care vă întrebați, noua aplicație API pentru clientul meu Toptal, deși este loc de îmbunătățire, funcționează foarte bine!

Joyent a lansat recent o nouă versiune de Node.js care utilizează una dintre cele mai recente versiuni de V8. Este posibil ca unele aplicații scrise pentru Node.js v0.12.x să nu fie compatibile cu noua versiune v4.x. Cu toate acestea, aplicațiile vor experimenta performanțe extraordinare și îmbunătățiri ale utilizării memoriei în noua versiune a Node.js.