kleindan.dev

Asynchroniczność w JavaScript: callbacki, taski, Promise

W dzisiejszej lekcji omówimy dokładnie jak JavaScript wykonuje nasz kod. Jeżeli tłumaczenie wyda się Wam zbyt drobiazgowe - wybaczcie, ale zależy mi na dobrym zrozumieniu tego tematu przez Was. Dlatego proszę o cierpliwość i dokładne przerobienie materiału z dzisiejszych zajęć. Będziemy analizować dużo dydaktycznego kodu i spróbujemy dokładnie zrozumieć dlaczego działa, tak jak działa. Spodoba się Wam, zaufajcie mi.

Jeżeli moje tłumaczenie do Was nie przemawia, może spróbujcie sprawdzić ten kurs

Wideo towarzyszące

Kolejność wykonywania kodu w JavaScript

W normalnych warunkach, póki piszemy zwyczajny synchroniczny kod wszystkie instrukcje będą wykonywane w tej kolejności w jakiej je czytamy. Bardziej szczegółowo omawiam to w kolejnym rozdziale.

Schody zaczynają się w momencie, w którym zaczynamy korzystać z funkcji asynchronicznych. Pierwszym mechanizmem asynchronicznym jaki poznamy są taski oraz callbacki.

Taski i callbacki

Najprostszym sposobem zlecenia JavaScript wykonania kody asynchronicznie jest użycie funkcji setTimeout, której dokumentację znajdziecie tutaj. Ściśle rzecz biorąc nie jest to część języka JavaScript, ale API dostępne zarówno w przeglądarce jak i w środowisku Node.js. Popatrzmy na użycie tej funkcji i jej efekty:

> 01_callbacks/index.js
1
2
3
4
5
6
console.log("This will print first");

setTimeout(() => console.log("Finally!"), 10);
setTimeout(() => console.log("This will print third"), 0);

console.log("This will print second");

Wykonanie tego kodu wydrukuje następujący tekst na ekran:

This will print first
This will print second
This will print third
Finally!

Co się dokładnie wydarzyło i jak to działa? Funkcja setTimeout przyjmuje jako argument funkcję, która będzie wykonana po tym, jak upłynie czas (w milisekundach) podany jako drugi argument.

Taka funkcja podana jako argument do wykonania później, jest zazwyczaj nazywana callbackiem.

Ale zaraz, jeżeli funkcja z linii 4 miała się wykonać po upływie 0 milisekund, to dlaczego nie wydrukowała swojej wartości od razu?

Pierwsza i podstawowa zasada asynchronicznego wykonania kodu w JavaScript jest taka, że obecnie wykonywany kod synchroniczny musi się zakończyć zanim uruchomione zostaną jakiekolwiek funkcje asynchroniczne.

Upewnijmy się, że tak jest. Użyjemy w tym celu funkcji kryptograficznych dostępnych w Node.js:

> 01_callbacks/long_synch.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import { createHmac, randomBytes } from "node:crypto";

console.log("This will print first");

setTimeout(() => console.log("Finally!"), 10);
setTimeout(() => console.log("This will print third"), 0);

// Let's waste some time
const hmacStart = Date.now();
const sha256 = createHmac("sha256", "super secret key");
for (let i = 0; i < 1_000_000; i++) {
   sha256.update(randomBytes(16));
}
const hmacEnd = Date.now();

console.log("This will print second. Hmac took", hmacEnd - hmacStart, "ms. Sha256 digest:", sha256.digest("hex"));

Na mojej maszynie wykonanie programu dało taki efekt:

This will print first
This will print second. Hmac took 723 ms. Sha256 digest: 123a779dbb3106c01bc6960b303e9200d2967f5669b3a8ad002072f7018f917d
This will print third
Finally!

Jak widać liczenie HMAC z 16 milionów bajtów losowych danych zajęło prawie sekundę, a mimo to kolejność wykonania zadań się nie zmieniła. Jak to dokładnie działa?

Kolejka zadań

W JavaScript istnieje koncept pętli zdarzeń (ang. event loop), która odpowiada za wykonywanie kodu. Jednym z elementów pętli zdarzeń jest kolejka tasków (ang. task queue). W uproszczaniu jest to kolejka funkcji oczekujących na wykonanie. Jednym ze sposobów umieszczenia funkcji w tej kolejce jest właśnie funkcja setTimeout. Pętla zdarzeń JS, kiedy wszystkie synchroniczne zadania się zakończą, zacznie wykonywać zadania z kolejki, jak sama nazwa wskazuje, w kolejności, w której do niej trafiły.

Zwróć uwagę, że w naszym kodzie wywołaliśmy setTimeout w “odwrotnej” kolejności: najpierw zleciliśmy zadanie do wykonania za 10ms, a następnie zadanie do wykonania bez dodatkowych opóźnień. Drugie zadanie mogło trafić do kolejki od razu, więc tak się stało, ale pierwsze zostało dodane do niej dopiero po upływie zleconych 10 milisekund. Dlatego te zadania wykonały się w takiej a nie innej kolejności.

Niezależnie od tego, gdzie w kodzie pojawia się wywołanie setTimeout, taski będą lądowały w kolejce wtedy, kiedy spełnione zostaną odpowiednie warunki. W tym przypadku upływ odpowiedniego czasu. I niezależnie od tego, czy są one gotowe do wykonania - muszą czekać na swoją kolej póki nie zakończy się wykonanie obecne wykonywanego kodu.

Asynchroniczne czytanie plików w Node.js

W Node.js istnieje wiele asynchronicznych interfejsów, które pozwalają na zlecenie zadania i wywołanie callbacku w momencie zakończenia. Standardową konwencją dla funkcji callbackowych w Node.js jest zwracanie błędu jako pierwszy argument a wyników operacji jako kolejne argumenty. Jeżeli zadanie udało się zrealizować bez problemów, argument z błędem będzie zawierał wartość null. W przeciwnym wypadku musimy to odpowiednio obsłużyć.

Przyjrzyjmy się temu na przykładzie interfejsu do odczytu plików:

> 01_callbacks/callback_file_read.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { readFile } from "node:fs";

const filename = process.argv.length >= 3 ? process.argv[2] : "./data.json";

function handleFileRead(error, data) {
   const readEnd = Date.now();
   const parsedData = error == null ? JSON.parse(data) : null;
   const parseEnd = Date.now();
   console.log("This will print once the file read is done. Who knows, when that will be?");
   console.log(`Error is ${error}`);
   if (process.env.PRINT_DATA) {
      console.log("Data is", parsedData);
   }
   console.log(`Read took ${readEnd - readStart}ms, parse took ${parseEnd - readEnd}ms`);
}

const readStart = Date.now();
readFile(filename, handleFileRead);

console.log("This will print first");

setTimeout(() => console.log("Finally?"), 10);
setTimeout(() => console.log("This will print third?"), 0);

console.log("This will print second");

Na mojej maszynie wykonanie programu z ustawioną zmienną środowiskową PRINT_DATA dało taki efekt:

This will print first
This will print second
This will print once the file read is done. Who knows, when that will be?
Error is null
Data is { multiplier: 6, foo: 7 }
Read took 4ms, parse took 0ms
This will print third?
Finally?

Przeanalizuj na spokojnie powyższy kod i postaraj się zrozumieć jego działanie. W repozytorium w folderze “lekcja19/01_callbacks” znajdują się pliki z danymi, które możesz wykorzystać to własnych eksperymentów. Możesz też spróbować uruchomić program podając ścieżkę do nieistniejącego pliku i zobaczyć, co się wydarzy.

Z moich prób przy nieco większym pliku z danymi, udało mi się czasem uzyskać wydrukowanie komunikatów z callbacku handleFileRead przed stringiem This will be third?, a czasem po nim. Gdyby plik znajdował się na wolniejszym dysku albo na dysku sieciowym, pewnie wydruk z callbacku byłby zawsze ostatni. Sęk w tym, że w przypadku funkcji asynchronicznych nie da się przewidzieć w jakiej kolejności zostaną one wykonane.

Promises

O ile callbacki są nadal wykorzystywanym mechanizmem asynchronicznego wykonania kodu, o tyle trochę współcześniejszą wersją są tzw. Promises.

Promise to obiekt JavaScript, który możne znajdować się trzech stanach:

Do obiektu typu Promise możemy podpiąć przy pomocy metody then callback, który zostanie asynchronicznie uruchomiony, jeżeli operacja stojąca za obiektem się powiedzie. Możemy też podać drugi callback, który zostanie uruchomiony w przypadku wystąpienia błędu. Metoda then zwraca kolejny obiekt typu Promise, na którym możemy znów wywołać metodę then - argumenty podane do tej drugiej metody zostaną zakolejkowane do wykonania po zakończeniu się callbacków podanych do pierwszej Promise. To pozwala na tworzenie całych ciągów przetwarzania danych.

Zanim przejdziemy dalej, spróbujmy przeanalizować jeden przykład wykorzystania Promises. Przy okazji omówimy jak Promises wchodzą w interakcję z taskami.

Callbacki Promises mają dla siebie osobną kolejkę: kolejkę mikrotasków (ang. microtask queue). Nie jest to ujęte w żaden sposób w specyfikacji JavaScript, ale jest to fakt zarówno w popularnych przeglądarkach i Node.js. Pętla zdarzeń w tych przypadkach zawsze będzie wykonywała w pierwszej kolejności zadania z kolejki mikrotasków. Tylko jeżeli nie ma niczego w kolejce mikrotasków pętla przejdzie do realizacji zadań z kolejki tasków.

Przeanalizuj poniższy plik wraz z wynikiem wykonania:

> 02_promises/index.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
console.log("This will print first");

setTimeout(() => console.log("This will print last"), 0);

Promise.resolve()
   .then(() => console.log("This will print third"))
   .then(() => console.log("This will print fifth"))

Promise.resolve()
   .then(() => console.log("This will print fourth"))

console.log("This will print second");

Wydruk wykonania wygląda w ten sposób.

This will print first
This will print second
This will print third
This will print fourth
This will print fifth
This will print last

Jeżeli przy pierwszym czytaniu nie ma to dla Ciebie żadnego sensu, to raczej zdrowo. Przejdźmy przez wykonanie programu krok po kroku:

  1. Linia 1, synchronicznie wypisujemy na ekran string
  2. Linia 3, dodajemy task do kolejki tasków, będzie on pierwszy w tej kolejce.
  3. Linia 5, tworzymy Promise, które jest od razu w stanie “resolved”
  4. Linia 6, do powyższego Promise podpinamy callback, który może zostać wywołany przy najbliższej okazji, bo Promise jest w stanie resolved. Callback ląduje w kolejce mikrotasków na samym przodzie. W ten sposób tworzymy drugą Promise, która będzie rozwiązana po tym, jak nasz callback zostanie uruchomiony.
  5. Linia 7, do drugiej Promise podpinamy callback, ale nie może on być od razu uruchomiony, więc nie trafia do kolejki
  6. Linia 9-10, tworzymy trzecią Promise w stanie resolved i podpinamy pod nią callback. Callback trafia do kolejki mikrotasków na pozycji 2.
  7. Linia 12, synchronicznie wypisujemy na ekran string
  8. Skończyliśmy wykonywanie synchroniczego kodu. Pętla zdarzeń zagląda do kolejki mikrotasków i bierze pierwszy z nich: ten z linii 6.
  9. Uruchamiamy callback z linii 6. Po wypisaniu stringu na ekran Promise związana z tym callbackiem przechodzi w stan resolved. Callback z linii 7 ląduje w kolejce mikrotasków.
  10. Pętla zdarzeń zagląda do kolejki mikrotasków i bierze pierwszy z nich: ten z linii 10.
  11. Uruchamiamy callback z linii 10, wypisujemy string na ekran
  12. Pętla zdarzeń zagląda do kolejki mikrotasków i bierze pierwszy z nich: ten z linii 7.
  13. Uruchamiamy callback z linii 7, wypisujemy string na ekran
  14. Pętla zdarzeń zagląda do kolejki mikrotasków, kolejka jest pusta. Pętla zagląda do kolejki tasków i bierze pierwszy z nich.
  15. Uruchamiamy callback z linii 3, wypisujemy string na ekran
  16. Pętla zdarzeń zagląda do kolejki mikrotasków, kolejka jest pusta. Pętla zagląda do kolejki tasków, kolejka jest pusta. Nie ma więcej kodu do wykonania, program się kończy.

Jak na program wypisujący 6 linijek tekstu na ekranie, to sporo się wydarzyło, co?

Jeżeli jeszcze nie jest to jasne, to chciałbym podkreślić jedną rzecz: próba porządkowania tych zdarzeń chronologicznie nie ma dużego sensu praktycznego. Jeżeli jesteś w stanie wytłumaczyć, dlaczego te stringi zostały wydrukowane w takiej kolejności na ekran, to świetnie, to oznacza że masz dobre zrozumienie działania callbacków i Promises. Z punktu widzenia praktycznego najistotniejsza zależność w powyższym programie jest pomiędzy linią 6 i 7 - wiem na pewno, że callback w linii 7 uruchomi się po zakończeniu działania callbacku z funkcji 6.

Drugą istotną informacją wyniesioną z powyższego przykładu powinna być zależność między taskami i mikrotaskami. Ponieważ mikrotaski mogą generować w trakcie swojego działania kolejne mikrotaski, może się zdarzyć sytuacja, kiedy task oczekujący na wykonanie będzie uruchomiony z dużym opóźnieniem. Warto mieć to na uwadze.

Czytanie plików z wykorzystaniem Promises

Ponieważ Promises stały się popularnym rozwiązaniem jeżeli chodzi o programowanie asynchroniczne, coraz więcej bibliotek i środowisk oferuje interfejsy z nich korzystające. Nie inaczej jest w przypadku Node.js, które oferuje m.in. bliźniacze interfejsy oparte o Promises do czytania z pliku:

> 02_promises/promise_file_read.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { readFile } from "node:fs/promises";

const filename = process.argv.length >= 3 ? process.argv[2] : "./data.json";

function handleFileRead(data) {
   const readEnd = Date.now();
   const parsedData = JSON.parse(data);
   const parseEnd = Date.now();
   console.log("This will print once the file read is done. Who knows, when that will be?");
   if (process.env.PRINT_DATA) {
      console.log("Data is", parsedData);
   }
   console.log(`Read took ${readEnd - readStart}ms, parse took ${parseEnd - readEnd}ms`);
}
function handleFileReadError(error) {
   const readEnd = Date.now();
   console.log("This will print if the file read fails. Who knows, when that will be?");
   console.log("Error is", error);
   console.log(`Read took ${readEnd - readStart}ms`);
}

const readStart = Date.now();
readFile(filename)
   .then(handleFileRead, handleFileReadError);

console.log("This will print first");

setTimeout(() => console.log("Finally?"), 10);
setTimeout(() => console.log("This will print third?"), 0);

console.log("This will print second");

Wynik wykonania programu na moim komputerze:

This will print first
This will print second
This will print once the file read is done. Who knows, when that will be?
Read took 5ms, parse took 0ms
This will print third?
Finally?

O ile na pierwszy rzut oka nie jest to dużo czytelniejsza wersja naszego poprzedniego programu, o tyle rozbicie obsługi błędów i przetwarzania danych na osobne funkcje spowodowało, że w samych callbackach trochę łatwiej jest prześledzić logikę działania programu.

Zobaczmy, na ile program da się uprościć, jeżeli wyrzucimy z niego wszystkie nasze dydaktyczne dodatki i zastosujemy jeszcze jedną ciekawą technikę obsługi błędów.

Chaining i obsługa błędów w Promises

Oprócz metody then, gdzie możemy jednocześnie podać callback obsługujący błąd w przypadku przejścia Promise w stan “rejected”, istnieje także metoda catch, która pozwala dodanie obsługi błędu dla całego łańcucha Promises. W powyższym pliku moglibyśmy wywołanie readFile zapisać w ten sposób:

readFile(filename)
   .then(handleFileRead)
   .catch(handleFileReadError);

W przypadku pojedynczego wywołania to nie ma znaczenia, ale jeżeli mamy kilka kroków do wykonania, to może być całkiem wygodny sposób obsłużenia błędu, jeżeli jest nam obojętne, na jakim etapie proces się nie powiódł, chcemy po prostu wiedzieć, że całość operacji się nie udała.

Popatrzmy na poniższy program:

> 02_promises/chaining.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { readFile } from "node:fs/promises";

const filename = process.argv.length >= 3 ? process.argv[2] : "./data.json";

function parseData(fileContents) {
   return JSON.parse(fileContents);
}

function alterData(data) {
   return {
      result: data.foo * data.multiplier,
      createdAt: Date.now(),
      ...data
   };
}

function printData(data) {
   console.log(data)
}

readFile(filename)
   .then(parseData)
   .then(alterData)
   .then(printData)
   .catch((error) => console.error("FAILED due to", error));

Wydrukowanie danych na ekran może się nie udać z wielu przyczyn: może plik nie istnieje? Albo zawiera źle sformatowane dane zamiast poprawnego JSONa? Albo nie zawiera tych wartości, których się w nim spodziewam? Mogę osobno obsłużyć każdą z tych opcji i może powinienem, ale jeżeli zdecyduję, że nie ma to dla mnie znaczenia i wystarczy mi wydrukowanie na ekran komunikatu o niepowodzeniu, to obsłużenie wszystkiego pojedynczym wywołaniem metody then na ostatniej Promise w łańcuchu jest bardzo wygodną opcją.

W repozytorium są dostępne pliki z różnymi problemami z danymi wejściowymi. Uruchom program z tymi danymi albo podając nazwę nieistniejącego pliku żeby się przekonać, jaki będzie komunikat wyjściowy z programu.

Chaining operacji tak jak w powyższym przykładzie ma w przypadku serwerów HTTP Node.js jedną zasadniczą zaletę. Ponieważ każdy etap jest zamknięty w osobnym callbacku powiązanym z poprzednią Promise, jeżeli w międzyczasie pojawią się ważne wydarzenia do obsłużenia w pętli zdarzeń, mogą one być obsługiwane pomiędzy etapami przetwarzania pliku. Dzięki temu nasz serwer może pozostać responsywny na przychodzące do niego żądania.

Model wykonania kodu w JavaScript

Pojęcia użyte w tym rozdziale odnoszą się do specyfikacji języka JavaScript w moim tłumaczeniu. Postaram się wskazywać na konkretne sekcje specyfikacji w odpowiednich miejscach, ale takie wyrywkowe zaglądanie bez szerszego kontekstu może generować więcej odpowiedzi, niż pytań. Jeżeli chcesz naprawdę dogłębnie zrozumieć działanie dowolnego języka programowania, zazwyczaj nie da się tego zrobić bez sięgnięcia do specyfikacji i przeczytania przynajmniej części z niej.

Wątek wykonania

Pierwszym pojęciem, które powinniśmy sobie przyswoić, jest wątek wykonujący (ang. executing thread), czyli sposób wykonywania kodu przez środowisko uruchomieniowe JavaScript. Wątek wykonujący przetwarza wyrażenia w kodzie JavaScript w kolejności i synchronicznie, co oznacza że każde wyrażenie w kodzie JavaScript musi się zakończyć i wszystkie jego efekty uboczne muszą się wydarzyć, aby zaczęło się wykonanie następnego wyrażenia.

Powyższe zdanie o kolejności wykonania wyrażeń w JS nie jest do końca prawdziwe, ponieważ w popularnych przeglądarkach i w Node.js kod JS jest kompilowany do instrukcji maszynowych, które procesor może wykonywać poza kolejnością. Na szczęście nie musimy się tym martwić, ponieważ reguły rządzące tymi przetasowaniami nie pozwolą na zmianę kolejności, która doprowadziłaby do zmiany ostatecznego wyniku wykonania programu.

W normalnym trybie wykonywania kodu, czyli np. poprzez uruchomienie pliku z kodem JS poleceniem node albo podczas uruchamiania pliku z tagu <script> w przeglądarce internetowej istnieje tylko jeden wątek wykonujący. Na współczesnym sprzęcie zazwyczaj dostępne jest wiele rdzeni wykonawczych pozwalających zrównoleglić pracę programów, ale JavaScript nie korzysta domyślnie z tych dobrodziejstw i jest jednowątkowy. Zaletą tego podejścia jest to, że wykonanie programów odbywa się w sposób przewidywalny i łatwy do zrozumienia.

Definicje funkcji nie mają wpływu na wątek wykonania - możesz przyjąć że kompilacja instrukcji zawartych w funkcjach odbywa się na wcześniejszym etapie, przed wykonaniem kodu. Z punktu widzenia wątku wykonania definicja funkcji to po prostu wartość w kodzie, która nie wymaga dodatkowej pracy. Zupełnie inaczej jest z wywołaniami funkcji, które w JavaScript ma znaczący wpływ na wątek wykonania, bo tworzy nowy kontekst wykonania.

Kontekst wykonania

Kontekst wykonania (ang. execution context) przechowuje informacje na temat zmiennych dostępnych podczas wykonania oraz ich obecnych wartości. Podczas uruchomienia programu tworzony jest tzw. globalny kontekst wykonania. Dodatkowo każde wykonanie funkcji tworzy nowy kontekst wykonania, który istnieje tak długo, jak wykonywana jest dana funkcja. W momencie zakończenia wykonywania funkcji jej kontekst wykonania przestaje istnieć.

Specyfikacja mówi też o Stosie kontekstów wykonania (ang. *execution context stack), który w skrócie będziemy nazywać stosem. Stos, ogólnie, jest strukturą danych typu Last In First Out (LIFO) i jest używany w językach programowania do śledzenia miejsca wykonania programu i do przechowywania danych używanych przez funkcje. W JavaScript w momencie wywołania funkcji tworzony jest nowy kontekst wykonania, który następnie ląduje na szczycie stosu, stając się aktywnym kontekstem wykonania. Kiedy funkcja kończy działanie, aktywny kontekst wywołania jest usuwany ze stosu i zniszczony, a aktywnym kontekstem staje się kontekst na szczycie stosu.

Ewaluacja wyrażeń JavaScript

Wyrażenie JavaScript może być prostą wartością, albo złożonym zestawem składającym się z wielu wyrażeń. Przykładowo to jest kompletne wyrażenie w języku JavaScript:

5;

Wykonanie takiego wyrażenia zwróci wartość 5. Nie ma tu większego zaskoczenia.

Wyrażenia przypisujące wartości do zmiennych składają się z dwóch etapów:

Wyrażenie wywołania funkcji natomiast może się składać z bardzo wielu etapów, w zależności od tego, w jakiej formie przekazujemy do funkcji argumenty, ponieważ każdy argument podlega ewaluacji przed przekazaniem wartości jako argumentu do funkcji.

Na chwilę obecną to rozdrobnienie może wydawać się niepotrzebne, ale na następnej lekcji okaże się, że mogą się tu czaić na nas pułapki, jeżeli zaczynami myśleć o wykonaniu złożonych wyrażeń w funkcjach asynchronicznych.

Podsumowanie

Mam nadzieję, że udało mi się nakreślić podstawy działania asynchroniczności w JavaScript. Na następnej lekcji omówimy kolejny duży temat z tym związany, czyli funkcje async oraz działanie słowa kluczowego await.