Astăzi ne vom uita sub capota motorului V8 JavaScript și vom afla cum este executat exact JavaScript.

Într-un articol anterior am aflat cum este structurat browserul și am obținut un prezentare generală la nivel înalt asupra cromului. Să recapitulăm un pic, astfel încât să fim gata să ne aruncăm cu capul aici.

fundal

Standarde Web sunt un set de reguli implementate de browser. Acestea definesc și descriu aspecte ale World Wide Web.

W3C este o comunitate internațională care dezvoltă standarde deschise pentru web. Se asigură că toată lumea respectă aceleași linii directoare și nu trebuie să accepte zeci de medii complet diferite.

Un browser modern este un software destul de complicat, cu o bază de cod de zeci de milioane de linii de cod. Deci, este împărțit într-o mulțime de module responsabile de diferite logici.

Și două dintre cele mai importante părți ale unui browser sunt motorul JavaScript și motorul de redare.

Clipiți este un motor de redare care este responsabil pentru întreaga conductă de redare, inclusiv arborii DOM, stilurile, evenimentele și integrarea V8. Analizează arborele DOM, rezolvă stiluri și determină geometria vizuală a tuturor elementelor.

În timp ce monitorizează continuu modificările dinamice prin cadre de animație, Blink pictează conținutul de pe ecran. Motorul JS este o mare parte a browserului – dar nu am intrat încă în aceste detalii.

Motor JavaScript 101

Motorul JavaScript execută și compilează JavaScript în codul mașinii native. Fiecare browser major și-a dezvoltat propriul motor JS: Chrome Google folosește V8, Safari folosește JavaScriptCore, iar Firefox folosește SpiderMonkey.

Vom lucra în special cu V8 datorită utilizării sale în Node.js și Electron, dar alte motoare sunt construite în același mod.

Fiecare pas va include un link către codul responsabil pentru acesta, astfel încât să vă puteți familiariza cu baza de cod și să continuați cercetările dincolo de acest articol.

Vom lucra cu o oglindă a lui V8 pe GitHub deoarece oferă o interfață convenabilă și bine cunoscută pentru a naviga în baza de cod.

Pregătirea codului sursă

Primul lucru pe care trebuie să-l facă V8 este să descarce codul sursă. Acest lucru se poate face prin intermediul unei rețele, cache sau lucrători de service.

Odată ce codul este primit, trebuie să-l schimbăm într-un mod pe care compilatorul îl poate înțelege. Acest proces se numește analiză și constă din două părți: scanerul și analizorul în sine.

Scanerul preia fișierul JS și îl convertește în lista de jetoane cunoscute. Există o listă cu toate jetoanele JS din fișierul keywords.txt.

analizor îl ridică și creează un Arborele de sintaxă abstract (AST): o reprezentare în arbore a codului sursă. Fiecare nod al arborelui denotă o construcție care apare în cod.

Să aruncăm o privire la un exemplu simplu:

function foo() {
  let bar = 1;
  return bar;
}

Acest cod va produce următoarea structură de arbore:

Cum functioneaza JavaScript Sub capota motorului V8
Exemplu de arbore AST

Puteți executa acest cod executând o trecere pre-comandă (rădăcină, stânga, dreapta):

  1. Definiți foo funcţie.
  2. Declarați bar variabil.
  3. Atribui 1 la bar.
  4. Întoarcere bar în afara funcției.

Vei vedea și tu VariableProxy – un element care conectează variabila abstractă la un loc din memorie. Procesul de rezolvare VariableProxy se numește Analiza domeniului de aplicare.

În exemplul nostru, rezultatul procesului ar fi totul VariableProxyarătând spre același lucru bar variabil.

Paradigma Just-in-Time (JIT)

În general, pentru ca codul dvs. să se execute, limbajul de programare trebuie să fie transformat în cod mașină. Există mai multe abordări despre cum și când se poate întâmpla această transformare.

Cel mai comun mod de transformare a codului este realizarea unei compilări anticipate. Funcționează exact așa cum sună: codul este transformat în cod mașină înainte de executarea programului dvs. în etapa de compilare.

Această abordare este utilizată de multe limbaje de programare precum C ++, Java și altele.

Pe cealaltă parte a tabelului, avem interpretare: fiecare linie a codului va fi executată în timpul rulării. Această abordare este, de obicei, luată de limbaje tastate dinamic, cum ar fi JavaScript și Python, deoarece este imposibil să se cunoască tipul exact înainte de execuție.

Deoarece compilarea anticipată poate evalua tot codul împreună, poate oferi o optimizare mai bună și, în cele din urmă, poate produce cod mai performant. Interpretarea, pe de altă parte, este mai simplă de implementat, dar este de obicei mai lentă decât opțiunea compilată.

Pentru a transforma codul mai rapid și mai eficient pentru limbaje dinamice, a fost creată o nouă abordare numită compilare Just-in-Time (JIT). Acesta combină cele mai bune din interpretare și compilare.

În timp ce folosește interpretarea ca metodă de bază, V8 poate detecta funcții care sunt utilizate mai frecvent decât altele și le poate compila folosind informații de tip din execuțiile anterioare.

Cu toate acestea, există șansa ca tipul să se schimbe. În schimb, trebuie să dez-optimizăm codul compilat și alternativ la interpretare (după aceea, putem recompila funcția după obținerea unui feedback de tip nou).

Să explorăm fiecare parte a compilării JIT în detaliu.

Interpret

V8 folosește un interpret numit Aprindere. Inițial, ia un arbore de sintaxă abstract și generează cod de octeți.

Instrucțiunile de cod de octeți au, de asemenea, metadate, cum ar fi pozițiile liniei sursă pentru depanarea viitoare. În general, instrucțiunile de cod de octeți se potrivesc cu abstracțiile JS.

Acum să luăm exemplul nostru și să generăm codul de octet pentru acesta manual:

LdaSmi #1 // write 1 to accumulator
Star r0   // read to r0 (bar) from accumulator 
Ldar r0   // write from r0 (bar) to accumulator
Return    // returns accumulator

Aprinderea are ceva numit acumulator – un loc unde puteți stoca / citi valori.

Acumulatorul evită necesitatea de a împinge și de a deschide partea de sus a stivei. Este, de asemenea, un argument implicit pentru multe coduri de octeți și de obicei deține rezultatul operației. Returnează implicit returnează acumulatorul.

Puteți verifica toate codurile de octeți disponibile în codul sursă corespunzător. Dacă sunteți interesat de modul în care alte concepte JS (cum ar fi buclele și async / await) sunt prezentate în cod de octeți, mi se pare util să le citiți testează așteptările.

Execuţie

După generație, Ignition va interpreta instrucțiunile folosind un tabel de handleruri cheie de codul de octeți. Pentru fiecare cod de octeți, Ignition poate căuta funcțiile de gestionare corespunzătoare și le poate executa cu argumentele furnizate.

După cum am menționat anterior, etapa de execuție oferă, de asemenea, feedback-ul de tip despre cod. Să ne dăm seama cum este colectat și gestionat.

În primul rând, ar trebui să discutăm despre modul în care obiectele JavaScript pot fi reprezentate în memorie. Într-o abordare naivă, putem crea un dicționar pentru fiecare obiect și îl putem lega de memorie.

1611936851 594 Cum functioneaza JavaScript Sub capota motorului V8
Prima abordare pentru păstrarea obiectului

Cu toate acestea, de obicei avem o mulțime de obiecte cu aceeași structură, deci nu ar fi eficient să stocăm o mulțime de dicționare duplicate.

Pentru a rezolva această problemă, V8 separă structura obiectului de valorile în sine Forme de obiecte (sau Hărți intern) și un vector de valori în memorie.

De exemplu, creăm un obiect literal:

let c = { x: 3 }
let d = { x: 5 }
c.y = 4

În prima linie, va produce o formă Map[c] care are proprietatea x cu un offset 0.

În a doua linie, V8 va reutiliza aceeași formă pentru o nouă variabilă.

După a treia linie, va crea o nouă formă Map[c1] pentru proprietate y cu un offset 1 și creați un link către forma anterioară Map[c] .

1611936851 456 Cum functioneaza JavaScript Sub capota motorului V8
Exemplu de forme de obiect

În exemplul de mai sus, puteți vedea că fiecare obiect poate avea un link către forma obiectului, unde pentru fiecare nume de proprietate, V8 poate găsi un offset pentru valoarea din memorie.

Formele obiectelor sunt în esență liste legate. Deci dacă scrii c.x, V8 va merge în capul listei, găsiți y acolo, treceți la forma conectată și, în cele din urmă, devine x și citește offsetul din acesta. Apoi va merge la vectorul de memorie și va returna primul element din acesta.

După cum vă puteți imagina, într-o aplicație web mare veți vedea un număr mare de forme conectate. În același timp, este nevoie de timp liniar pentru a căuta în lista legată, făcând căutările de proprietăți o operațiune foarte costisitoare.

Pentru a rezolva această problemă în V8, puteți utiliza fișierul Inline Cache (IC). Memorează informații despre unde să găsiți proprietăți pe obiecte pentru a reduce numărul de căutări.

Vă puteți gândi la asta ca la un site de ascultare în codul dvs.: urmărește toate APEL, MAGAZIN, și SARCINĂ evenimente dintr-o funcție și înregistrează toate formele care trec.

Se numește structura de date pentru păstrarea IC Vector de feedback. Este doar o matrice pentru a păstra toate CI-urile pentru funcție.

function load(a) {
  return a.key;
}

Pentru funcția de mai sus, vectorul de feedback va arăta astfel:

[{ slot: 0, icType: LOAD, value: UNINIT }]

Este o funcție simplă, cu un singur IC care are un tip de LOAD și o valoare de UNINIT. Acest lucru înseamnă că nu este inițializat și nu știm ce se va întâmpla în continuare.

Să numim această funcție cu diferite argumente și să vedem cum se va modifica cache-ul în linie.

let first = { key: 'first' } // shape A
let fast = { key: 'fast' }   // the same shape A
let slow = { foo: 'slow' }   // new shape B

load(first)
load(fast)
load(slow)

După primul apel al load funcția, memoria cache inline va primi o valoare actualizată:

[{ slot: 0, icType: LOAD, value: MONO(A) }]

Această valoare devine acum monomorfă, ceea ce înseamnă că acest cache poate rezolva doar forma A.

După al doilea apel, V8 va verifica valoarea IC-ului și va vedea că este monomorf și are aceeași formă ca fast variabil. Deci, va reveni rapid offset și îl va rezolva.

A treia oară, forma este diferită de cea stocată. Deci, V8 îl va rezolva manual și va actualiza valoarea într-o stare polimorfă cu o matrice de două forme posibile.

[{ slot: 0, icType: LOAD, value: POLY[A,B] }]

Acum, de fiecare dată când apelăm această funcție, V8 trebuie să verifice nu numai o formă, ci să itereze mai multe posibilități.

Pentru un cod mai rapid, tu poate sa inițializați obiecte cu același tip și nu modificați structura lor prea mult.

Notă: puteți ține cont de acest lucru, dar nu o faceți dacă duce la duplicarea codului sau cod mai puțin expresiv.

Memoriile cache în linie urmăresc, de asemenea, cât de des sunt chemate pentru a decide dacă este un candidat bun pentru optimizarea compilatorului – Turbofan.

Compilator

Aprinderea ne duce doar până acum. Dacă o funcție devine suficient de fierbinte, va fi optimizată în compilator, Turbofan, pentru ao face mai rapid.

Turbofanul preia codul de octeți de la aprindere și tipul de feedback (vectorul de feedback) pentru funcție, aplică un set de reduceri pe baza acestuia și produce codul mașinii.

După cum am văzut anterior, feedback-ul de tip nu garantează că nu se va schimba în viitor.

De exemplu, codul Turbofan a optimizat pe baza presupunerii că unele adăugări adaugă întotdeauna numere întregi.

Dar ce s-ar întâmpla dacă ar primi un șir? Acest proces se numește dezoptimizare. Aruncăm codul optimizat, ne întoarcem la codul interpretat, reluăm execuția și actualizăm feedback-ul de tip.

rezumat

În acest articol, am discutat despre implementarea motorului JS și pașii exacți ai modului în care este executat JavaScript.

Pentru a rezuma, să aruncăm o privire la conducta de compilare din partea de sus.

1611936852 496 Cum functioneaza JavaScript Sub capota motorului V8
Prezentare generală a V8

O vom trece peste pas cu pas:

  1. Totul începe cu obținerea codului JavaScript din rețea.
  2. V8 analizează codul sursă și îl transformă într-un arbore abstract de sintaxă (AST).
  3. Pe baza AST-ului, interpretul de aprindere poate începe să-și facă treaba și să producă bytecode.
  4. În acel moment, motorul începe să ruleze codul și să colecteze feedback de tip.
  5. Pentru a rula mai rapid, codul de octeți poate fi trimis compilatorului de optimizare împreună cu datele de feedback. Compilatorul de optimizare face anumite ipoteze pe baza acestuia și apoi produce codul mașinii foarte optimizat.
  6. Dacă, la un moment dat, una dintre ipoteze se dovedește a fi incorectă, compilatorul de optimizare dez-optimizează și revine la interpret.

Asta este! Dacă aveți întrebări despre o anumită etapă sau doriți să aflați mai multe detalii despre aceasta, puteți să vă scufundați în codul sursă sau să mă loviți Stare de nervozitate.

Lecturi suplimentare