Strona główna / Artykuły / Jedna nit JavaScripta kontra silnik wieloprocesowy przeglądarki

Jedna nit JavaScripta kontra silnik wieloprocesowy przeglądarki

Dowiedz się, dlaczego JavaScript działa na jednym wątku, podczas gdy przeglądarki jednocześnie zajmują się sieciowaniem, renderowaniem i timerami za pomocą mechanizmu pętli zdarzeń.

1692 słów

JavaScript działa na pojedynczej nitce.

Prawdopodobnie czytałeś to stwierdzenie więcej razy, niż możesz zliczyć.

A jednak gdy ładujesz nowoczesną stronę internetową, możesz zobaczyć, jak jednocześnie zachodzą następujące rzeczy:

Wysyłany jest żądanie sieciowe.

Ładowane i dekodowane jest zdjęcie.

Płynnie odtwarzana jest animacja CSS.

Strona natychmiast reaguje na kliknięcie.

Treść jest wyświetlana na ekranie.

A Twój kod JavaScript nadal się wykonywał przez cały czas.

Zatem co tak naprawdę dzieje się w tle?

Jeśli JavaScript ma tylko jedną nitkę, kto zajmuje się wszystkim innym?

Odpowiedź na to pytanie ujawnia jedną z najważniejszych koncepcji dotyczących tego, jak faktycznie działają przeglądarki:

JavaScript i przeglądarka to nie to samo.

JavaScript ma główną nitkę

Gdy programiści określają JavaScript jako jednowątkowy, mają na myśli konkretny sposób wykonywania kodu JavaScript.

Kod jest uruchamiany na pojedynczym głównym wątku JavaScript.

Weźmy jako przykład ten fragment kodu:

console.log("One");
console.log("Two");
console.log("Three");

Każda linia jest wykonywana ściśle po poprzedniej.

JavaScript nie będzie wykonywał tych trzech instrukcji jednocześnie na tym samym wątku.

Istnieje tylko jedna pila wywołań do obsługi.

W danym momencie wykonywany jest tylko jeden fragment kodu JavaScript.

To właśnie stanowi istotę pojęcia jednowątkowości w tym kontekście.

Jednak tutaj sytuacja staje się bardziej złożona.

Sam przeglądarka odpowiada za znacznie więcej niż tylko wykonywanie skryptów.

Przeglądarka ma więcej zadań niż tylko wykonywanie JavaScript

Przeglądarka to znacznie większy system niż sam silnik JavaScript.

Musí zarządzać takimi zadaniami jak:

  • Ładowanie danych przez sieć
  • Planowanie timerów
  • Zapisywanie danych z klawiatury i wskaźnika
  • Tworzenie ramek na ekranie
  • Dekodowanie obrazów
  • Odtwarzanie dźwięku
  • Zarządzanie odtwarzaniem wideo
  • Zapisywanie danych na dysku
  • Obliczanie układu strony
  • Rysowanie pikseli podczas malowania
  • Łączenie warstw podczas kompozycji
  • Różne inne zadania realizowane przez przeglądarkę i system operacyjny

Twój kod JavaScript nie jest tym, które bezpośrednio wykonywa wszystko to.

Zamiast tego JavaScript może przekazać zadania przeglądarce, pozwalając jej zająć się szczegółami.

Naprzимер:

fetch("/api/users");

Twój skrypt inicjuje żądanie.

Jednak twój JavaScript nie otwiera ręcznie socketu ani nie przesyła pojedynczych bajtów bezpośrednio z serwera.

Ta odpowiedzialność leży po stronie przeglądarki oraz systemów, które pod nią działają.

Gdy odpowiedź wraca, przeglądarka planuje wykonanie funkcji callback lub kontynuacji obietnicy na wątku JavaScript.

To zupełnie inne podejście niż wyobrażanie sobie, że:

"JavaScript zajmuje się absolutnie wszystkim."

Rozważaj JavaScript jako pojedynczego pracownika w znacznie większym systemie

Pomyśl o restauracji jako o modelu mentalnym.

JavaScript to pojedynczy serwer przyjmujący zamówienia.

Przeglądarka reprezentuje całą działalność restauracji.

W tle wiele innych pracowników jest odpowiedzialnych za różne zadania.

JavaScript może powiedzieć:

"Potrzebuję tych danych od serwera."

W tym momencie przeglądarka przejmuje kontrolę nad operacją sieciową.

JavaScript nie musi się zatrzymywać i czekać, aż dotrze każdy bajt.

Może swobodnie przejść do innych zadań.

Gdy operacja zostanie zakończona i będzie gotowa do obsługi przez JavaScript, odpowiadające jej zadanie trafia do kolejki dla wątku JavaScript.

To jest podstawa działania asynchronicznych API przeglądarki.

A co się dzieje z fetch()?

Weźmy ten przykład:

console.log("Start");
fetch("/api/users")
  .then(() => {
    console.log("Users received");
  });
console.log("End");

Początkujący często wyobrażają sobie coś w tym rodzaju:

Start
   ↓
fetch()
   ↓
wait for server
   ↓
Users received
   ↓
End

Ale to nie jest dokładny obraz tego, co się dzieje.

Egzekucja JavaScript nie zatrzymuje się na wywołaniu fetch(), czekając na odpowiedź.

Bardziej dokładny obraz wygląda tak:

JavaScript
   │
   ├── Start fetch
   │
   ▼
Browser handles network work
   │
   │
   └───────────────┐
                   │
JavaScript         │
continues          │
                   │
   ▼               │
console.log("End") │
                   │
                   ▼
        Response becomes available
                   │
                   ▼
        Promise continuation
        gets scheduled
                   │
                   ▼
        JavaScript runs it

Biorąc pod uwagę ten tok działań, wyjście z konsoli zazwyczaj wygląda tak:

Start
End
Users received

Kluczowym elementem nie jest tylko to, że fetch() działa asynchronicznie.

Najważniejsze jest to, kto faktycznie czeka.

Nici JavaScripta nigdy nie są blokowane podczas oczekiwania na odpowiedź z sieci.

Pętla wydarzeń łączy wszystko

Dokładnie tutaj wchodzi w grę pętla wydarzeń.

Można sobie wyobrazić środowisko JavaScripta jako miejsce, w którym wykonywany jest kod, wraz z mechanizmami koordynacji decydującymi o tym, kiedy można wznowić prace asynchroniczne.

Społeczna wersja tego systemu wygląda następująco:

Browser
                │
     ┌──────────┼───────────┐
     │          │           │
 Network     Timers      User Input
     │          │           │
     └──────────┼───────────┘
                │
                ▼
         Scheduling queues
                │
                ▼
           Event Loop
                │
                ▼
          Call Stack
                │
                ▼
           JavaScript

Pamiętaj, że ten diagram jest uproszczeniem.

Rzeczywiste mechanizmy w przeglądarce są znacznie bardziej złożone, a różne silniki implementują te mechanizmy na swój własny sposób.

Mimo to oddaje on istotę problemu:

Ruch JavaScripta to zaledwie jedna część znacznie większego systemu.

Czasy nie uruchamiają potajemnie twojego kodu w tle

Weźmy ten przykład:

setTimeout(() => {
  console.log("Done");
}, 1000);

Kuszący sposób na to przedstawienie jest następujący:

„JavaScript uruchamia oddzielny wątek, który odlicza jedną sekundę.”

Taki model myślowy wprowadza cię w błąd.

To przeglądarka, a nie sam JavaScript, implementuje zachowanie timerów. Gdy upłynie określony opóźnienie, funkcja zwrotna staje się kandydatem do zaplanowania. Dopiero wtedy JavaScript faktycznie ją uruchamia — i to tylko wtedy, gdy główny wątek jest wolny, by ją przetworzyć.

To oznacza, że kod taki jak ten to zła decyzja:

setTimeout(() => {
  console.log("Done");
}, 1000);

while (true) {}

Dlaczego to psuje funkcjonowanie?

Gdy opóźnienie się skończy, funkcja callback jest oznaczana jako gotowa do wykonania, ale główny wątek utknął w nieskończonej pętli i nigdy nie staje się dostępny. Funkcja callback nie ma sposobu, by się wcisnąć i przerwać to, co aktualnie jest wykonywane w JavaScript.

Brauzer z pewnością może wiedzieć, że:

„Timer wystrzelił.”

Ale sama ta wiedza nie wystarczy — JavaScript nadal potrzebuje możliwości, by ją faktycznie wykonać:

Main JavaScript thread

while (true) {
   // never finishes
}
        ↓
Timer becomes ready
        ↓
Callback waits
        ↓
JavaScript never becomes available

To jest jedna z kluczowych różnic, które warto zapamiętać.

To, że coś jest asynchroniczne, nie oznacza, że funkcja callback jest wykonywana na innym wątku.

Dlaczego więc strona nie jest ciągle zamarznięta?

Ponieważ wykonywanie JavaScript jest tylko jedną z wielu rzeczy, którymi brauzer musi się jednocześnie zajmować w danym momencie.

Jest jednak jeden istotny haczyk, na który warto zwrócić uwagę.

Bardzo wiele zadań, które wpływają na szybkość reakcji strony — w tym wykonywanie kodu JavaScript oraz niektóre etapy procesu renderowania — odbywa się na tym samym wątku głównym.

Dlatego kod taki nadal może zablokować stronę:

const start = performance.now();

while (performance.now() - start < 5000) {
  // expensive work
}

Przez całe pięć sekund wątek główny jest zajęty wykonywaniem tego pętli. W międzyczasie wszelkie operacje obsługi interakcji lub kroki renderowania, które również wymagają wątku głównego, muszą czekać na swoją kolej.

To właśnie ma na myśli ludzie, mówiąc:

"JavaScript jest jednowątkowy."

Konkurencja na poziomie przeglądarki, jednowątkowość na poziomie JavaScript

To kluczowe rozróżnienie, o którym należy pamiętać.

Brauzer jako całość może rozkładać różne zadania między kilka wątków. Specyfika ta różni się w zależności od brauzera i systemu operacyjnego, ale w gruncie rzeczy nowoczesne brauzery są zaprojektowane tak, aby obsługiwać wiele zadań jednocześnie.

Mówiąc ogólnie, zadania mogą być rozdzielone w ten sposób:

Browser
│
├── JavaScript execution
├── Network activity
├── Rendering-related work
├── Image/media processing
├── Browser services
└── Other internal tasks

Jednak nic z tego nie daje możliwości napisania czegoś takiego jak:

runThisFunctionOnAnotherBrowserThread();

i sprawienia, by dowolny kod JavaScript przeniósł się na inny wątek.

Zwykły JavaScript nadal działa zgodnie z modelem wykonywania opartym na jednym wątku, który zawsze był jego cechą charakterystyczną.

Jeśli koniecznie musisz przenieść obciążone obliczenia w JavaScript z głównego wątku, do tego służą Web Workers.

Wniosek

Język JavaScript oparty na jednym wątku nie oznacza, że cały brauzer jest ograniczony do jednego wątku.

Twój kod jest wykonywany na pojedynczej głównej nitce, podczas gdy sam przeglądarka zajmuje się szerokim spektrum innych zadań za pomocą swoich wewnętrznych mechanizmów.

Różne operacje, takie jak połączenia sieciowe, timerzy, obsługa wprowadzanych przez użytkownika danych, renderowanie oraz dekodowanie mediów, mogą przebiegać niezależnie od wykonywania kodu JavaScript.

Gdy jakiekolwiek z tych tła zadań będzie gotowe do kontynuacji, przeglądarka przygotowuje odpowiedni callback lub kontynuację obietnicy, aby Twój kod JavaScript mógł je przejąć.

Mimo to główna nitka nadal ma duże znaczenie.

Długo trwający kod JavaScript na tej nitce opóźni wszystkie inne zadania konkurujące o jej zasoby – interakcje, renderowanie oraz inne czekające na wykonanie zadania.

W rezultacie mamy przeglądarkę, która jest ogólnie dość konwergencyjna, ale wykonywanie kodu JavaScript pozostaje ściśle jednoliniowe.

Zrozumienie tej podziału wyjaśnia zarówno to, na co jest zdolny JavaScript w przeglądarce, jak i gdzie leżą jego ograniczenia.

Główne wnioski

Pamiętaj o tych trzech punktach:

  1. Sam JavaScript jest jednowątkowy. Twój kod jest wykonywany sekwencyjnie, krok po kroku, na głównym wątku.
  2. Przeglądarka to większe, równoległe środowisko. Oprócz wykonywania twojego JavaScriptu, zarządza ona równolegle siecią, timerami, renderowaniem, danymi wejściowymi oraz obsługą mediów.
  3. Asynchroniczność nie oznacza wielowątkowego wykonywania funkcji callback. Przeglądarka sama czeka, a następnie zwraca kontrolę do JavaScriptu, gdy główny wątek ma na to miejsce.

Pomocny sposób sformułowania tego zagadnienia:

Browser
│
├── JavaScript execution
├── Network activity
├── Timers
├── User input
├── Rendering
├── Media processing
└── Other browser systems

JavaScript to tylko jeden komponent działający w ramach tego większego systemu, a nie sam system.

Gdy ta struktura jest już jasna, takie koncepcje jak pętla zdarzeń, asynchroniczne API, zamrożenie interfejsu użytkownika oraz równoczesność na poziomie przeglądarki stają się znacznie łatwiejsze do zrozumienia.

Literatura pokrewna