Přejít k navigační liště

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

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

Články JavaScript, Různé

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é.

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?

Komentáře

Subscribe
Upozornit na
guest
19 Komentářů
Nejstarší
Nejnovější Most Voted
Inline Feedbacks
View all comments
Ded Kenedy

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í.

mate to nejak overene, nebo to je jen domenka?

v pripade, ze jednotlive identifikatory jsou sdilene (coz je vcelku trivialni optimalizace), pak rozdil mezi pouzitim indexu a prohledavanim tabulky lokalnich promennych je radove v jednotkach instrukci daneho stroje, coz pro vetsinu rozumnych programu funkce ma (< 10 promennych/parametru) predstavuje zrychleni na urovni statisticke odchylky (+ samozrejme neco sezere jeste staticka analyza)

c0stra

Stalo by za to ujasnit, co znamena podle vas v JS lokalni promenna, globalni promenna a parametr. Vse se sice zda trivialni, ale co treba rozebrat takovy priklad:

function base(a)
{
var b = 4;

return function sum()
{
return a + b;
}
}

var a = 3;

a = sum();

var sum = base(4);

a = sum();

Anonymní

No je možné z funkcie vrátiť vnorenú funkciu ako objekt.
Nižšie som dal príklad (22.2).
Môžete posúdiť, ako by to bolo vtedy s optimalizáciami?

Mazarik

Pokial viem, tak caller je depricated https://developer.mozilla.org/En/Core_JavaScript_1.5_Reference/Functions_and_function_scope/arguments/caller a myslim, ze som to videl napisane aj v Referncnej prirucke Javascriptu.
Je pravda, ze eval je nielen nebezpecny, ale aj komplikuje chod kodu, ale niekedy je to jedina moznost. napr. spracovanie JSON a podobne veci.

beer

Pokud zpracovavam JSON data prichazejici pres AJAX, tak me, vzhledem k odezve serveru, zpomaleni evalem moc nezajima :) Ale jinak se mu vyhybam, znecitelnuje kod a tim je i zdrojem chyb (spousteny kod v promenne? mfg).

Anonymní

A nejblbější je, že Opera na obě varianty hází bobek jako jediná. Takže kdo chce psát objektově s dedičností v JS tak to musí šíleně obcházet. Kdyby to radši uzákonili v nějaký normě bylo by vše mnohem jednodušší.

cvm

V Opere 9.6b bola pridana podpora Function:caller.

c0stra

Jak souvisi Function.caller nebo arguments.caller s objektovym programovanim a dedicnosti v JS?

Michal Aichinger

Pokud chceme implementovat funkci volani metody predka rekurzivne (avsak stale v kontextu nejake konkretni instance), je nutne "kamsi" ukladat referenci na tridu, jejiz metodu prave volame (to proto, aby bylo mozne pri dalsim volani predka "postoupit vyse" k predchozi tride). Vhodne misto k tomuto je napr. staticka vlatnost volane metody. Abychom se z teoreticky funkce "callSuperMethod" dostali k teto vlastnosti, potrebujeme mit prave arguments.caller (tj. referenci na funckci, ze ktere jsme volani). Pripadnou diskuzi na toto tema doporucuji presunout do urovne e-mailove korespondence s priklady, protoze v teto diskuzi je obtizno pokracovat bez konkretnich ukazek.

BoodOk

Jaké jsou tedy možnosti jak eval obejít? Možná by nebylo od věci pár typických příkladů rozebrat, protože ono se té funkce občas zbavuje velmi obtížně (samozřejmě je to často i o pohodlnosti).

dan

jeden priklad pro vyse zmineny JSON:

zle:
v = eval(jsonsrc)

dobre:
<script type="text/javascript">
v = jsonsrc
</script>

(jde to nahradit v iframe "ajaxu", pro xmlhttprequest uz to nepujde)
(taky to jde pouzit pro "boot" volby, konfigurace a pod.. generovane serverem pri startu aplikace)

Martin Hassman

O JSON, nepoužívání eval a lepších variantách tu máme dokonce malý seriál 8-)
http://zdrojak.root.cz/serialy/json-pro-vymenu-dat-na-webu/

karf

Tak pro parsování JSON by se eval stejně neměl používat – raději JSON parser, ale to hlavně z bezpečnostních důvodů (eval je v tomto případě rychlejší). Jinak já bych odpověděl protiotázkou – k čemu eval vůbec potřebujete? Já jsem jej snad nikdy seoriózně nepoužil.

alblaho

Já chci jen poděkovat za dobrý článek. Od té doby, co root tak nějak zmainstreamovatěl už tam vývojářské věci nikdo moc nepublikuje. Proto díky za sekci zdroják a za zajímavé povídání o javascriptu.

peppin0

+1

Anonymní

var id1 = function(){var n = 0; return function(){return n++;};}();
with({n:0}) var id2 = function(){return n++;};
// Je výkonovo nejaký rozdiel medzi id1 a id2, prípadne čo sa týka optimalizácií?

Enum a statická analýza kódu

Mám jednu univerzální radu pro začínající programátorty. V učení sice neexistují rychlé zkratky, ovšem tuhle radu můžete snadno začít používat a zrychlit tak tempo učení. Tou tajemnou ingrediencí je statická analýza kódu. Ukážeme si to na příkladu enum.

Pocta C64

Za prvopočátek své programátorské kariéry vděčím počítači Commodore 64. Tehdy jsem genialitu návrhu nemohl docenit. Dnes dokážu lehce nahlédnout pod pokličku. Chtěl bych se o to s vámi podělit a vzdát mu hold.