Do hlubin implementací JavaScriptu: 3. díl – výkonnostně nepříjemné konstrukce

V dnešním dílu seriálu zakončíme obecné povídání o rychlosti interpretace JavaScriptu. Podíváme se na funkci eval, dále na to, jak výkonnostně nepříjemná může být možnost zjistit informace o parametrech funkcí na zásobníku a také na příkaz with. Na závěr si stručně povíme, jak jsou interprety obvykle implementované.

Seriál: Do hlubin implementací JavaScriptu (14 dílů)

  1. Do hlubin implementací JavaScriptu: 1. díl – úvod 30.10.2008
  2. Do hlubin implementací JavaScriptu: 2. díl – dynamičnost a výkon 6.11.2008
  3. Do hlubin implementací JavaScriptu: 3. díl – výkonnostně nepříjemné konstrukce 13.11.2008
  4. Do hlubin implementací JavaScriptu: 4. díl – implementace v prohlížečích 20.11.2008
  5. Do hlubin implementací JavaScriptu: 5. díl – implementace mimo prohlížeče 27.11.2008
  6. SquirrelFish: reprezentace hodnot JavaScriptu a virtuální stroj 4.12.2008
  7. SquirrelFish: optimalizace vykonávání instrukcí a nativní kód 11.12.2008
  8. SquirrelFish: regulární výrazy, vlastnosti objektů a budoucnost 18.12.2008
  9. SpiderMonkey: zpracování JavaScriptu ve Firefoxu 8.1.2009
  10. SpiderMonkey: rychlá kompilace JavaScriptu do nativního kódu 15.1.2009
  11. V8: JavaScript uvnitř Google Chrome 22.1.2009
  12. Rhino: na rozhraní JavaScriptu a Javy 29.1.2009
  13. Velký test rychlosti JavaScriptu v prohlížečích 5.2.2009
  14. Javascriptové novinky: souboj o nejrychlejší engine pokračuje 19.3.2009

Peklo jménem eval

Jednou z typických vlastností dynamických jazyků je přítomnost funkce eval, která má jako parametr řetězec s kódem v daném jazyce a vykoná ho, jako by byl napsaný přímo v místě volání. Za běhu programu si tak můžeme vygenerovat kód, který závisí na nějakých informacích, které před spuštěním programu nemáme, a spustit ho. Možných využití (i zneužití) je mnoho.

JavaScript funkci eval obsahuje také. Jaký je její vliv na výkon? Na první pohled se zdá, že funkce sice sama o sobě výkonná příliš nebude (musí se v ní volat parser, kód uvnitř nemá příliš kontextu pro případné větší optimalizace atd.), ale jinak program výkonnostně nijak neovlivní. Opravdu to tak ale je? Uvažujme následující kus kódu:

var a = 1;
function f() {
  return a;
} 

Ve funkci f se získává hodnota něčeho identifikovaného jako a. Ono něco může být podle pravidel jazyka lokální proměnná, parametr funkce nebo globální proměnná. Protože ale ve funkci před přístupem k a nejsou žádné lokální proměnné ani parametry, může interpret tyto dvě možnosti rovnou škrtnout a identifikátor a hledat jen mezi globálními proměnnými. To samozřejmě přístup k a urychlí.

Interpret může dělat i opačné úsudky – z předcházejících deklarací ve funkci může být například jasné, že nějaký identifikátor je lokální proměnná či parametr a není třeba ho hledat vně funkce. V tom případě se někdy dokonce dá zapomenout jeho jméno a hledání podle názvu převést na indexování, což je významné urychlení.

Jak tyto optimalizace může ovlivnit použití eval? Velmi negativně:

var a = 1;
function f() {
  eval("var a = 2")
  return a;
} 

Protože eval vykonává příkazy tak, jako by byly napsané přímo v místě jeho volání, může vyhodnocovaný kód ovlivnit své okolí. V našem případě deklaruje lokální proměnnou a, což znamená, že náš úsudek o tom, že identifikátor a v příkazu return znamená přístup ke globální proměnné, je nyní chybný (globální proměnná je zastíněna). Optimalizace, které na tomto úsudku závisely, nemůžeme provést.

Možná si říkáte, že to není žádná tragédie – prostě se vzdáme některých optimalizací, pokud narazíme na eval. Situace je ale ve skutečnosti mnohem horší – eval je totiž technicky vzato úplně obyčejná funkce, můžeme ji tak přiřadit třeba do proměnné a volat přes ni. Neexistuje žádný obecný způsob, jak před spuštěním programu zjistit, zda dané volání funkce ve skutečnosti nevolá eval, který může ovlivnit kód okolo! Důsledkem je, že můžeme zapomenout na značnou část optimalizací založených na úsudcích o kódu, pokud při analýze kódu narazíme na obyčejné volání funkce. Docela drsné, že?

Hrátky se zásobníkem

Podobně jako eval může některé optimalizace narušit kombinace zdánlivě nevinných metod objektu Function: arguments a caller.

První z nich nám vrátí objekt podobný poli, ve kterém je seznam parametrů dané funkce při jejím posledním vyvolání (myšleno z pohledu zásobníku, nikoliv z pohledu chronologického). Existuje i zkratka: pomocí automaticky vytvořené proměnné arguments je možné získat parametry funkce, ve které se právě nacházíme.

Důležité je, že vrácený objekt neobsahuje kopie parametrů, ale reference na ně. Pokud tedy změníme některou z jeho položek, změní se i příslušný parametr:

function argumentsDemo(x, y) {
  arguments[0] = 5;
  alert(x); // vypíše vždy 5, ať v x předáme cokoliv
  alert(y);
} 

Metoda caller nám pro změnu vrátí objekt reprezentující volajícího: funkci, která zavolala funkci, na které metodu voláme, při jejím posledním vyvolání (opět z pohledu zásobníku). Na vráceném objektu můžeme opět zavolat caller a získat tak přehled o celém zásobníku (pokud v cestě nestojí rekurzivní volání, kde dojde k cyklu).

Co je ovšem důležité: na každém objektu vráceném metodou caller lze zavolat metodu arguments a získat tak seznam parametrů. Vidíte, proč je kombinace těchto funkcí nebezpečná? Můžeme totiž „pod rukou“ změnit hodnoty parametrů funkcím výše v zásobníku. Důsledkem je, že jakmile v nějaké funkci zavoláme libovolnou jinou funkci, nemůžeme si být jisti, že se po návratu z volání nezmění hodnoty parametrů volající funkce. Negativní dopad na celé spektrum různých optimalizací je myslím zřejmý.

Příkaz with

Další částí JavaScriptu, kterou nemají jeho implementátoři rádi, je příkaz with. Tomu lze předat libovolný objekt, jehož vlastnosti se uvnitř příkazu budou přednostně brát v úvahu při rozhodování, co znamenají používané identifikátory. Odborně řečeno, objekt se přidá na konec scope chain.

Proč je to problém? Protože předem nevíme, jaké má daný objekt atributy a nemůžeme tedy před spuštěním programu usuzovat, zda nějaký identifikátor znamená přístup k atributu objektu nebo (třeba) k lokální proměnné. To brání různým optimalizacím práce s proměnnými.

V následující funkci například závisí návratová hodnota funkce na tom, zda má objekt o vlastnost a nebo nikoliv. Pokud by příkaz with ve funkci vůbec nebyl, návratová hodnota by byla konstantní a proměnnou a by bylo možné úplně vyoptimalizovat.

function withDemo(o) {
  var a = 1;
  with(o) {
    return a; // vrátí buď atribut objektu, nebo lokální proměnnou
  }
}

alert(withDemo({ a: 5 })); // vypíše 5
alert(withDemo({ b: 5 })); // vypíše 1 

Způsoby implementace interpretu

Rychlost JavaScriptu ovlivňují kromě jazykových vlastností i technické vlastnosti interpretu, především to, jakým způsobem probíhá samotná interpretace.

Způsoby interpretace

Nejjednodušší reálně používaný způsob psaní interpretu je, že se ze zdrojového kódu postaví strom reprezentující strukturu programu, takzvaný abstraktní syntaktický strom (AST). Uzly stromu představují jednotlivé elementy programu (např. příkaz if) a jejich poduzly pak součásti těchto elementů (např. součásti příkazu if jsou podmínka a obě jeho větve). Interpret pak při spuštění programu strom přímo prochází a jednotlivé příkazy vykonává.

Takto postavený interpret není příliš rychlý, ale i tak se přímé procházení AST pro jeho jednoduchost donedávna používalo např. v interpretu JavaScriptu ve WebKitu a občas ho najdeme i v jiných jazycích (např. Ruby).

Výkonnější interpret získáme, pokud vystavěný strom projdeme a vygenerujeme z něj bajtkód, což jsou sekvenční instrukce typu „zavolej funkci“, „získej hodnotu proměnné“, „sečti dvě hodnoty na zásobníku“ apod. Tyto instrukce jsou pak interpretovány virtuálním strojem. Interpretace je obvykle poměrně rychlá, zejména díky sekvenčnímu charakteru instrukcí. Výkon ale hodně závisí na návrhu virtuálního stroje (např. je-li registrový nebo zásobníkový), na zvolené instrukční sadě a na použitých optimalizacích při zpracování instrukcí (např. direct threading). O tom všem si povíme, až se budeme věnovat konkrétním interpretům.

Interpret můžeme ještě urychlit, pokud z AST nebo z bajtkódu vygenerujeme přímo strojový kód procesoru. To je oproti předchozím variantám poměrně komplikované a vnáší to do interpretu závislost na konkrétní platformě. Proto tento přístup není u běžných dynamických jazyků (Ruby, Python, PHP, Perl…) obvyklý a i u interpretů JavaScriptu se generování nativního kódu objevilo až v poslední době.

Možné jsou samozřejmě i různé hybridní přístupy (např. kompilovat do nativního kódu jen často spouštěné funkce) a je zde mnoho prostoru pro různé chytré optimalizace. Výše uvedené rozdělení a popis jsou velmi zjednodušené.

Problémy nativní kompilace

Kompilace JavaScriptu do nativního kódu má mimochodem své háčky – jedním z nich je třeba reprezentace čísel. V JavaScriptu se totiž na všechna čísla (jak celá, tak s plovoucí řádovou čárkou) používá jen jeden datový typ – Number.

Pokud bude interpret pro vnitřní reprezentaci alespoň části čísel používat nativní celočíselný typ cílového procesoru a pro operace s čísly jeho nativní instrukce (což je z výkonnostního pohledu víc než vhodné), dostane se do problémů s konverzemi a přetečeními. Řešením je samozřejmě ošetřit všechny možné okrajové případy, což ale podstatně prodlouží a zpomalí vygenerovaný kód. S JavaScriptem to zkrátka není vůbec jednoduché.

Garbage collector

Interpret JavaScriptu jakožto jazyka s automatickou správou paměti musí být samozřejmě vybaven garbage collectorem. Na jeho výkonu a dalších vlastnostech velmi záleží, protože v JavaScriptu za běhu neustále vzniká a zaniká mnoho objektů a paměť se neustále alokuje a dealokuje. Správa paměti tak musí být rychlá.

Garbage collectory u interpretů JavaScriptu obecně poněkud zaostávají oproti těm používaným například v JRE a .NETu, ale i zde se situace pomalu zlepšuje (např. V8 už má generační garbage collector). Podrobněji se této problematice budeme věnovat opět u jednotlivých interpretů.

Co nás čeká příště

Tímto dílem jsme zakončili povídání o rychlosti interpretace JavaScriptu obecně a dál už se budeme věnovat konkrétním implementacím. Příště si všechny důležité implementace stručně představíme a v dalších dílech se pak na několik vybraných podíváme podrobněji.

Používáte funkci eval?

Autor je vývojář se zájmem o programovací jazyky, webové aplikace a problémy programování jako takového. Vystudoval informatiku na MFF UK a během studií zde i trochu učil. Aktuálně pracuje v SUSE.

Komentáře: 19

Přehled komentářů

Ded Kenedy RE: Do hlubin implementací JavaScriptu: 3. díl - výkonnostně nepříjemné konstrukce
David Majda RE: Do hlubin implementací JavaScriptu: 3. díl - výkonnostně nepříjemné konstrukce
c0stra RE: Do hlubin implementací JavaScriptu: 3. díl - výkonnostně nepříjemné konstrukce
David Majda RE: Do hlubin implementací JavaScriptu: 3. díl - výkonnostně nepříjemné konstrukce
Anonym RE: Do hlubin implementací JavaScriptu: 3. díl - výkonnostně nepříjemné konstrukce
Mazarik caller
beer Re: caller
David Majda Re: caller
Anonym Re: caller
cvm Re: caller
c0stra Re: caller
Michal Aichinger Re: caller
BoodOk Eval a alternativy
dan Re: Eval a alternativy
Martin Hassman Re: Eval a alternativy
karf Re: Eval a alternativy
alblaho Díky
peppin0 Re: Díky
Anonym Je v tomto výkonovo nejaký rozdiel?
Zdroj: https://www.zdrojak.cz/?p=2865