Smyčka událostí:mikroúkoly a makroúlohy

Proces provádění JavaScriptu v prohlížeči, stejně jako v Node.js, je založen na smyčce událostí .

Pochopení toho, jak smyčka událostí funguje, je důležité pro optimalizaci a někdy i pro správnou architekturu.

V této kapitole nejprve pokryjeme teoretické podrobnosti o tom, jak věci fungují, a poté uvidíme praktické aplikace těchto znalostí.

Smyčka událostí

smyčka událostí koncept je velmi jednoduchý. Existuje nekonečná smyčka, ve které stroj JavaScript čeká na úkoly, provede je a poté usne a čeká na další úkoly.

Obecný algoritmus motoru:

  1. I když existují úkoly:
    • proveďte je, počínaje nejstarší úlohou.
  2. Spěte, dokud se nezobrazí úkol, a poté přejděte na 1.

To je formalizace toho, co vidíme při procházení stránky. JavaScript engine většinu času nedělá nic, spouští se pouze v případě, že se aktivuje skript/obslužná rutina/událost.

Příklady úloh:

  • Při externím skriptu <script src="..."> načte, úkolem je provést jej.
  • Když uživatel pohne myší, jeho úkolem je odeslat mousemove obsluhy událostí a spouštění.
  • Když nastane plánovaný čas setTimeout , úkolem je spustit jeho zpětné volání.
  • …a tak dále.

Úkoly jsou nastaveny – engine je zpracovává – pak čeká na další úkoly (při spánku a spotřebě téměř nulového CPU).

Může se stát, že úkol přijde, když je motor zaneprázdněn, a pak se zařadí do fronty.

Úkoly tvoří frontu, takzvanou „frontu makroúloh“ (termín v8):

Například, když je motor zaneprázdněn prováděním script , uživatel může pohnout myší, což způsobí mousemove a setTimeout mohou být splatné atd., tyto úkoly tvoří frontu, jak je znázorněno na obrázku výše.

Úkoly z fronty jsou zpracovávány na principu „kdo dřív přijde, je dřív na řadě“. Když je prohlížeč motoru hotový s script , zpracovává mousemove událost a poté setTimeout handler a tak dále.

Zatím docela jednoduché, že?

Další dva podrobnosti:

  1. K vykreslování nikdy nedochází, když stroj provádí úlohu. Nezáleží na tom, zda úkol trvá dlouho. Změny DOM se vykreslí až po dokončení úkolu.
  2. Pokud úkol trvá příliš dlouho, prohlížeč nemůže provádět jiné úkoly, jako je zpracování uživatelských událostí. Po chvíli tedy vyvolá výstrahu jako „Stránka nereaguje“, což navrhuje ukončení úkolu s celou stránkou. To se stane, když existuje mnoho složitých výpočtů nebo chyba v programování vedoucí k nekonečné smyčce.

To byla teorie. Nyní se podívejme, jak můžeme tyto znalosti aplikovat.

Případ použití 1:rozdělení úloh náročných na CPU

Řekněme, že máme úkol náročný na CPU.

Například zvýraznění syntaxe (používá se k vybarvení příkladů kódu na této stránce) je poměrně náročné na CPU. Pro zvýraznění kódu provede analýzu, vytvoří mnoho barevných prvků, přidá je do dokumentu – pro velké množství textu, který zabere spoustu času.

Zatímco je engine zaneprázdněn zvýrazňováním syntaxe, nemůže dělat další věci související s DOM, zpracovávat uživatelské události atd. Může to dokonce způsobit, že prohlížeč „škytá“ nebo dokonce „zasekne“, což je nepřijatelné.

Problémům se můžeme vyhnout tím, že velký úkol rozdělíme na kousky. Zvýrazněte prvních 100 řádků a poté naplánujte setTimeout (s nulovým zpožděním) pro dalších 100 řádků a tak dále.

Abychom tento přístup demonstrovali, v zájmu jednoduchosti namísto zvýrazňování textu vezměme funkci, která počítá od 1 na 1000000000 .

Pokud spustíte níže uvedený kód, motor se na nějakou dobu „zasekne“. U JS na straně serveru je to jasně patrné, a pokud jej spouštíte v prohlížeči, zkuste kliknout na jiná tlačítka na stránce – uvidíte, že dokud počítání neskončí, nebudou zpracovány žádné další události.

let i = 0;

let start = Date.now();

function count() {

 // do a heavy job
 for (let j = 0; j < 1e9; j++) {
 i++;
 }

 alert("Done in " + (Date.now() - start) + 'ms');
}

count();

Prohlížeč může dokonce zobrazit varování „skript trvá příliš dlouho“.

Rozdělme úlohu pomocí vnořených setTimeout volání:

let i = 0;

let start = Date.now();

function count() {

 // do a piece of the heavy job (*)
 do {
 i++;
 } while (i % 1e6 != 0);

 if (i == 1e9) {
 alert("Done in " + (Date.now() - start) + 'ms');
 } else {
 setTimeout(count); // schedule the new call (**)
 }

}

count();

Nyní je rozhraní prohlížeče během procesu „počítání“ plně funkční.

Jeden běh count provede část úlohy (*) a poté se znovu naplánuje (**) v případě potřeby:

  1. Počet prvního spuštění:i=1...1000000 .
  2. Počet druhých spuštění:i=1000001..2000000 .
  3. …a tak dále.

Nyní, pokud nový vedlejší úkol (např. onclick událost) se objeví, když je stroj zaneprázdněn prováděním části 1, zařadí se do fronty a poté se spustí, když skončí část 1, před další částí. Periodický návrat do smyčky událostí mezi count provádění poskytuje právě tolik „vzduchu“ pro JavaScript engine, aby mohl dělat něco jiného, ​​reagovat na další akce uživatele.

Pozoruhodné je, že obě varianty – s a bez rozdělení úlohy podle setTimeout – jsou srovnatelné v rychlosti. V celkové době počítání není velký rozdíl.

Abychom je přiblížili, pojďme provést vylepšení.

Přesuneme plánování na začátek count() :

let i = 0;

let start = Date.now();

function count() {

 // move the scheduling to the beginning
 if (i < 1e9 - 1e6) {
 setTimeout(count); // schedule the new call
 }

 do {
 i++;
 } while (i % 1e6 != 0);

 if (i == 1e9) {
 alert("Done in " + (Date.now() - start) + 'ms');
 }

}

count();

Nyní, když začneme count() a uvidíme, že budeme potřebovat count() více, naplánujeme to bezprostředně před provedením práce.

Pokud jej spustíte, snadno si všimnete, že to trvá výrazně méně času.

Proč?

To je jednoduché:jak si pamatujete, u mnoha vnořených setTimeout je v prohlížeči minimální zpoždění 4 ms hovory. I když nastavíme 0 , je to 4ms (nebo trochu víc). Takže čím dříve to naplánujeme – tím rychleji to poběží.

Nakonec jsme úkol náročný na CPU rozdělili na části – nyní neblokuje uživatelské rozhraní. A jeho celková doba realizace není o mnoho delší.

Případ použití 2:indikace průběhu

Další výhodou rozdělení náročných úloh pro skripty prohlížeče je to, že můžeme zobrazit indikaci průběhu.

Jak již bylo zmíněno dříve, změny DOM se vykreslí až po dokončení aktuálně spuštěné úlohy, bez ohledu na to, jak dlouho to trvá.

Na jednu stranu je to skvělé, protože naše funkce může vytvářet mnoho prvků, přidávat je do dokumentu jeden po druhém a měnit jejich styly – návštěvník neuvidí žádný „mezi“, nedokončený stav. Důležitá věc, že?

Zde je ukázka, změny na i se nezobrazí, dokud funkce neskončí, takže uvidíme pouze poslední hodnotu:

<div id="progress"></div>

<script>

 function count() {
 for (let i = 0; i < 1e6; i++) {
 i++;
 progress.innerHTML = i;
 }
 }

 count();
</script>

…Ale také můžeme chtít během úkolu něco ukázat, např. ukazatel průběhu.

Pokud rozdělíme těžký úkol na části pomocí setTimeout , pak se mezi nimi vykreslí změny.

Tohle vypadá hezčí:

<div id="progress"></div>

<script>
 let i = 0;

 function count() {

 // do a piece of the heavy job (*)
 do {
 i++;
 progress.innerHTML = i;
 } while (i % 1e3 != 0);

 if (i < 1e7) {
 setTimeout(count);
 }

 }

 count();
</script>

Nyní <div> ukazuje rostoucí hodnoty i , jakýsi ukazatel průběhu.

Případ použití 3:udělat něco po události

V obslužném programu události se můžeme rozhodnout odložit některé akce, dokud událost neprobublá a nebude zpracována na všech úrovních. Můžeme to udělat zabalením kódu do nulového zpoždění setTimeout .

V kapitole Odesílání vlastních událostí jsme viděli příklad:vlastní událost menu-open je odeslána v setTimeout , takže k tomu dojde až po úplném zpracování události „click“.

menu.onclick = function() {
 // ...

 // create a custom event with the clicked menu item data
 let customEvent = new CustomEvent("menu-open", {
 bubbles: true
 });

 // dispatch the custom event asynchronously
 setTimeout(() => menu.dispatchEvent(customEvent));
};

Makroúlohy a mikroúlohy

Spolu s makroúlohami , popsané v této kapitole, existují mikroúlohy , zmíněný v kapitole Mikroúlohy.

Mikroúlohy pocházejí výhradně z našeho kódu. Obvykle jsou vytvořeny sliby:provedení .then/catch/finally handler se stává mikroúlohou. Mikroúlohy se používají „pod krytem“ await stejně jako je to další forma vyřizování slibů.

K dispozici je také speciální funkce queueMicrotask(func) který se řadí do fronty func pro provedení ve frontě mikroúloh.

Ihned po každém makroúkolu , engine provádí všechny úlohy z mikroúloh fronty, před spuštěním jakýchkoli jiných makroúloh, vykreslování nebo čehokoli jiného.

Podívejte se například:

setTimeout(() => alert("timeout"));

Promise.resolve()
 .then(() => alert("promise"));

alert("code");

Jaké zde bude pořadí?

  1. code se zobrazí jako první, protože se jedná o běžné synchronní volání.
  2. promise zobrazuje se jako druhý, protože .then prochází frontou mikroúloh a běží po aktuálním kódu.
  3. timeout zobrazuje poslední, protože se jedná o makroúkol.

Bohatší obrázek smyčky událostí vypadá takto (pořadí je shora dolů, to znamená:nejprve skript, pak mikroúlohy, vykreslování a tak dále):

Všechny mikroúlohy jsou dokončeny předtím, než dojde k jakékoli jiné manipulaci s událostmi nebo vykreslení nebo k jakémukoli jinému makroúkolu.

To je důležité, protože to zaručuje, že aplikační prostředí je v podstatě stejné (žádné změny souřadnic myši, žádná nová síťová data atd.) mezi mikroúkoly.

Pokud bychom chtěli provést funkci asynchronně (po aktuálním kódu), ale před vykreslením změn nebo zpracováním nových událostí, můžeme ji naplánovat pomocí queueMicrotask .

Zde je příklad s „ukazatelem průběhu počítání“, podobný tomu, který byl zobrazen dříve, ale queueMicrotask se používá místo setTimeout . Vidíte, že se vykresluje až na samém konci. Stejně jako synchronní kód:

<div id="progress"></div>

<script>
 let i = 0;

 function count() {

 // do a piece of the heavy job (*)
 do {
 i++;
 progress.innerHTML = i;
 } while (i % 1e3 != 0);

 if (i < 1e6) {
 queueMicrotask(count);
 }

 }

 count();
</script>

Shrnutí

Podrobnější algoritmus smyčky událostí (i když ve srovnání se specifikací stále zjednodušený):

  1. Zařaďte a spusťte nejstarší úlohu z makroúlohy fronta (např. „skript“).
  2. Proveďte všechny mikroúlohy :
    • I když fronta mikroúloh není prázdná:
      • Vyřaďte z fronty a spusťte nejstarší mikroúlohu.
  3. Pokud existují, změny vykreslení.
  4. Pokud je fronta makroúloh prázdná, počkejte, až se objeví makroúloha.
  5. Přejděte na krok 1.

Chcete-li naplánovat nový makroúlohu :

  • Použijte nulové zpoždění setTimeout(f) .

To lze použít k rozdělení velkého úkolu náročného na výpočty na části, aby prohlížeč mohl reagovat na uživatelské události a zobrazovat pokroky mezi nimi.

Také se používá v obslužných programech událostí k naplánování akce poté, co je událost plně zpracována (probublávání je hotovo).

Chcete-li naplánovat nový mikroúkol

  • Použijte queueMicrotask(f) .
  • Také obslužné programy projdou frontou mikroúloh.

Mezi mikroúkoly není žádné uživatelské rozhraní ani zpracování síťových událostí:spouštějí se okamžitě jeden po druhém.

Takže jeden může chtít queueMicrotask k provedení funkce asynchronně, ale v rámci stavu prostředí.

Web Workers

Pro dlouhé náročné výpočty, které by neměly blokovat smyčku událostí, můžeme použít Web Workers.

To je způsob, jak spustit kód v jiném paralelním vláknu.

Web Workers si mohou vyměňovat zprávy s hlavním procesem, ale mají své vlastní proměnné a vlastní smyčku událostí.

Web Workers nemají přístup k DOM, takže jsou užitečné, hlavně pro výpočty, k použití více jader CPU současně.