Strona główna / Artykuły / Wyjaśnienie współbieżności w Node.js: libuv, pętla zdarzeń i zbiór wątków

Wyjaśnienie współbieżności w Node.js: libuv, pętla zdarzeń i zbiór wątków

Dowiedz się, w jaki sposób Node.js wykorzystuje prymitywy systemu operacyjnego libuv oraz zbiór wątków roboczych do obsługi asynchronicznego I/O, a także o częstych problemach związanych z zbiorem wątków i wskazówkach dotyczących ich optymalizacji.

2284 słów

Prawie każdy programista słyszy zwrot „Node.js jest jednowątkowy” już w pierwszym tygodniu nauki tej platformy. Jednak w praktyce pojedynczy proces Node.js może jednocześnie czytać setki plików, wyszukiwać tysiące rekordów DNS oraz obsługiwać dziesiątki tysięcy otwartych połączeń z bazą danych, nadal wykonywając kod bez przerwy.

Jeśli sam JavaScript działa tylko na jednym wątku, jak serwer Node.js może nadal odpowiadać na żądania, podczas gdy ładuje plik wielkości kilku gigabajtów z obracającego się dysku?

Mechanizmem stojącym za tym jest libuv – biblioteka napisana w języku C specjalnie dla Node.js, która zarządza asynchronicznym, nieblokującym się wejściem i wyjściem.

Rzeczywiste zrozumienie tego, w jaki sposób libuv przenosi obciążenia z wątku JavaScript, to nie tylko wiedza teoretyczna. To właśnie ono wyjaśnia, dlaczego zapytanie do bazy danych zachowuje się inaczej pod względem wydajności niż operacja haszowania, dlaczego zmiana pojedynczej zmiennych środowiskowej może znacząco wpłynąć na szybkość (lub wolność) reakcji Twojego API produkcyjnego, oraz jak można wykryć i uniknąć ukrytych wąskich gardeł w usługach.

Czym jest libuv?

Node.js to nie jeden monolityczny silnik — to zestaw kilku współpracujących warstw:

+-------------------------------------------------------------+
|                      Your Application                       |
+-------------------------------------------------------------+
|                    Node.js Core (JS / C++)                  |
+------------------------------+------------------------------+
|     V8 Engine (Google)       |            libuv             |
|   (Executes JavaScript)      |   (Event Loop & Async I/O)   |
+------------------------------+------------------------------+
|                Operating System Kernel                      |
+-------------------------------------------------------------+
  • V8 (Google): Ten silnik kompiluje i uruchamia Twój JavaScript. Ma dokładnie jedną stek wywołań i wykonuje kod sekwencyjnie, tylko na jednym wątku.
  • libuv: Biblioteka wieloplatformowa napisana w języku C, która odpowiada za pętlę zdarzeń, zbiór wątków roboczych, dostęp do systemu plików, timery, tworzenie procesów potomnych oraz monitorowanie sokietów sieciowych.
  • Gdy ludzie określają Node.js jako jednowątkowy, tak naprawdę mają na myśli to, że kontekst wykonywania JavaScripta działa na jednym głównym wątku. Sam libuv jest jednak napisany w języku C i z natury jest wielowątkowy. Korzysta z wszelkich narzędzi niskiego poziomu dostępnych w systemie operacyjnym, aby wykonywać zadania równolegle, przy czym nie blokuje wykonywania JavaScripta.

    Dwa sposoby, w jakie libuv obsługuje pracę asynchroniczną

    Powszechnym błędem jest przypuszczenie, że libuv kieruje każdą operację asynchroniczną do wątku w tle. To nie do końca prawda — libuv faktycznie dzieli pracę między dwie różne strategie, w zależności od rodzaju zadania:

    • Wbudowane, nieblokujące funkcje systemu operacyjnego (do obsługi danych wchodzących i wychodzących w sieci)
    • Wewnętrzna grupa wątków libuv (do dostępu do systemu plików, wyszukiwań DNS oraz operacji kryptograficznych)

    Zrozumienie tego podziału jest prawdopodobnie najbardziej przydatnym modelem myślowym do analizy wydajności backendu Node.js.

    Incoming Async Task
            │
            ├── Is it Network I/O? (TCP/UDP, HTTP sockets)
            │     └──> Handled directly by OS Kernel mechanisms (epoll / kqueue / IOCP)
            │          (Zero worker threads used)
            │
            └── Is it File I/O, DNS lookup, or CPU-bound crypto/compression?
                  └──> Handled by libuv Thread Pool (4 threads by default)
    

    1. Dane wchodzące i wychodzące w sieci: podstawowe funkcje systemu operacyjnego

    Współczesne systemy operacyjne dostarczają dedykowane, nieblokujące API do obsługi sokietów sieciowych:

    • epoll w Linux
    • kqueue w macOS i rodzinie BSD
    • IOCP (porty ukończenia dla danych wchodzących i wychodzących) w Windows

    Gdy aplikacja Node.js otwiera odbiornik TCP lub wysyła żądanie HTTPS, libuv nie przekazuje go wątkowi robociemu. Zamiast tego rejestruje deskryptor pliku socketa bezpośrednio w jądrze systemu operacyjnego, faktycznie prosząc o powiadomienie w momencie pojawienia się danych przychodzących do tego socketa lub gdy będzie gotowy na przyjęcie kolejnych zapisów.

    Od tego momentu libuv po prostu czeka. To jądro systemu operacyjnego monitoruje sprzęt sieciowy.

    Gdy pakiety rzeczywiście dotrą do interfejsu sieciowego, jądro wywołuje zdarzenie. libuv rejestruje je podczas etapu sprawdzania zdarzeń w swoim pętli zdarzeń, a następnie umieszcza odpowiadający temu callback w kolejce do wykonania. V8 ostatecznie go wybiera i uruchamia na wątku głównym.

    Ponieważ żaden wątek roboczy nie czeka bezczynnie na przybycie danych przez sieć, jeden proces Node.js może z łatwością zarządzać dziesiątkami tysięcy powolnych lub nieaktywnych połączeń, zużywając przy tym bardzo mało pamięci.

    2. Wprowadzanie i wyświetlanie plików oraz operacje systemowe: Zbiór wątków roboczych

    Biorąc pod uwagę, że gniazda sieciowe mogą być obsługiwane bez blokowania na poziomie jądra, można się zastanawiać, dlaczego odczyty i zapisy plików nie mogą funkcjonować w ten sam sposób.

    Powodem jest to, że większość systemów operacyjnych nie posiada prawdziwej, nieblokującej API do dostępu do systemu plików. W systemach opartych na POSIX, takich jak Linux i macOS, zwykłe operacje plikowe blokują wątek, który je wywołuje, aż urządzenie przechowawcze faktycznie zwróci żądane dane.

    Jeśli Node.js próbowałby bezpośrednio odczytać plik na głównej wątku JavaScript, cały czas działania programu zostałby zatrzymany, dopóki dysk nie uruchomi się, nie pobra niezbędnych bloków danych i nie przekaże bajtów. Przez cały ten okres żadne inne przychodzące żądania HTTP nie mogłyby zostać obsłużone.

    Aby uniknąć tego problemu, libuv utrzymuje wewnętrzny pool wątków roboczych.

    Oto co dzieje się, gdy twój kod wywołuje fs.readFile():

    • Wywołanie w JavaScript trafia przez wewnętrzne powiązania Node do libuv.
    • libuv pakuje żądanie odczytu pliku jako jednostkę pracy i umieszcza je na wewnętrznej kolejce zadań.
    • Jeden z tła wątków w tym poolu pobiera to żądanie z kolejki.
    • Następnie ten wątek bezpiecznie wykonywa faktyczne blokujące wywołanie systemowe — read() lub write() — z dala od głównego wątku.
  • Gdy odczyt zostanie zakończony, wątek roboczy sygnalizuje pętlę zdarzeń za pomocą mechanizmu powiadamiania między wątkami.
  • Następnie pętla zdarzeń planuje wykonanie odpowiedniej funkcji zwrotnej w JavaScript na głównym wątku, przekazując uzyskany bufor.
  • Co tak naprawdę działa w puli wątków?

    Cztery główne kategorie zadań wykorzystują pulę wątków libuv:

    • Wywołania systemu plików: wszystkie asynchroniczne metody z modułu fs, takie jak fs.readFile, fs.stat i fs.writeFile.
    • Zapytania DNS: konkretnie dns.lookup(), który polega na blokującej funkcji C getaddrinfo(). Natomiast dns.resolve() całkowicie pomija pulę wątków i komunikuje się bezpośrednio z siecią za pomocą nieblokujących wywołań.
    • Drogie operacje kryptograficzne: funkcje takie jak crypto.pbkdf2(), crypto.scrypt() oraz procedury generowania kluczy.
    • Procedury kompresji: asynchroniczne metody zlib, np. zlib.gzip().

    Nadzór nad pracą puli wątków

    Możesz potwierdzić, że libuv polega na tle puli wątków, oraz zobaczyć jej domyślną wielkość poprzez krótki eksperyment:

    // thread-pool-test.js
    const crypto = require('crypto');
    
    const start = Date.now();
    const ITERATIONS = 6;for (let i = 1; i <= ITERATIONS; i++) {
      crypto.pbkdf2('password123', 'salt-value', 100000, 64, 'sha512', () => {
        const elapsed = Date.now() - start;
        console.log(`Task ${i} completed in ${elapsed}ms`);
      });
    }
    

    crypto.pbkdf2() to dobry przypadek testowy, ponieważ celowo wykonywa on ciężkie operacje na procesorze w celu utworzenia hasła, a te operacje są przekazywane do puli wątków.

    Zrób skrypt w swoim terminalu:

    node thread-pool-test.js
    

    Wynik będzie wyglądał mniej więcej tak:

    Task 2 completed in 218ms
    Task 1 completed in 220ms
    Task 4 completed in 224ms
    Task 3 completed in 226ms
    Task 5 completed in 435ms
    Task 6 completed in 437ms
    

    Dlaczego ostatnie dwa zadania trwają dwa razy dłużej?

    Przyjrzyj się uważnie czasom wykonywania zadań. Pierwsze cztery zadania kończą się mniej więcej w tym samym momencie, około 220 ms. Natomiast piąte i szóste zadania zajmują blisko 435 ms – prawie dwa razy tyle.

    Powodem jest to, że pool wątków libuv ma domyślnie 4 wątki.

    Gdy tylko rozpoczyna się pętla, pierwsze cztery zadania zajmują wszystkie cztery dostępne wątki robocze. Piąte i szóste zadania pozostają w wewnętrznej kolejce libuv, czekając. Dopiero gdy jedno z pierwotnych czterech zadań się zakończy i uwalni wątek, zadania z kolejki mogą rozpocząć wykonywanie.

    Dostosowywanie wielkości poolu za pomocą UV_THREADPOOL_SIZE

    Możesz zmienić liczbę wątków roboczych, które uruchomi libuv, ustawiając zmienną środowiskową UV_THREADPOOL_SIZE przed uruchomieniem procesu Node.js. Przyjmuje ona wartości od 1 do 128.

    Spróbuj ponownie tego samego skryptu, tym razem prosząc o pool składający się z 8 wątków:

    # On Linux / macOS:
    UV_THREADPOOL_SIZE=8 node thread-pool-test.js
    
    # On Windows (PowerShell):
    $env:UV_THREADPOOL_SIZE=8; node thread-pool-test.js
    

    Wyniki wyglądają teraz inaczej:

    Task 1 completed in 240ms
    Task 3 completed in 242ms
    Task 2 completed in 245ms
    Task 6 completed in 249ms
    Task 4 completed in 250ms
    Task 5 completed in 252ms
    

    Gdy dostępnych jest wystarczająco dużo wątków roboczych, wszystkie sześć zadań jest wykonywanych równolegle, zamiast być kolejkowanych.

    Ważna ograniczenie: nie można zmienić rozmiaru puli wątków bezpośrednio z wnętrza skryptu poprzez ustawienie process.env.UV_THREADPOOL_SIZE = 8. libuv odczytuje i zamyka tę wartość przed uruchomieniem kodu JavaScript, więc zmienna środowiskowa musi zostać ustawiona na poziomie shella lub przez narzędzie zarządzające procesami uruchamiające Node, a nie bezpośrednio wewnątrz aplikacji.

    Powszechny problem w produkcji: współdzielone wątki przy niepowiązanych zadaniami

    Ponieważ operacje plikowe, wyszukiwania w DNS oraz prace kryptograficzne domyślnie korzystają z tej samej puli czterech wątków, intensywne wykorzystanie jednej z tych kategorii może po cichu spowolnić działanie czegoś zupełnie niepowiązanego.

    Zobaczmy tę sekwencję zdarzeń:

    • Fala logowań wywołuje kilka jednoczesnych wezwań do crypto.pbkdf2 w celu weryfikacji haseł.
    • Wszystkie cztery wątki libuv są teraz w pełni zajęte obliczaniem hashów haseł.
    • W tym samym momencie inna część aplikacji wywołuje fs.readFile(), aby załadować szablon e-maila, lub wywołuje dns.lookup(), aby rozwiązać nazwę hosta bazy danych.
    • Oba te operacje muszą czekać w kolejce na swoją kolej.

    Czytanie pliku prawie w ogóle nie wykorzystuje mocy obliczeniowej CPU, ale i tak dochodzi do opóźnień, ponieważ każdy wątek roboczy jest zajęty obliczaniem hashów. Z zewnątrz wygląda to tak, jakby dostęp do pliku lub połączenie z bazą danych zwolniło, podczas gdy prawdziwym winowajcą jest konkurencja o wątki libuv.

    Jak złagodzić konkurencję w puli wątków:

    • Daj zbiornikowi więcej wątków: jeśli twoja usługa wykonywa dużo operacji wejścia/wyjścia z plikami lub prac kryptograficznych, zwiększenie wartości UV_THREADPOOL_SIZE do 16 lub 32 może zmniejszyć konflikty, pod warunkiem że maszyna serwerowa ma wystarczające zasoby CPU do obsługi tego rozwiązania.
    • Przenieś zadania uzależnione od CPU poza zbiornik: w przypadku zadań, które sam kontrolujesz, takich jak generowanie raportów lub przetwarzanie obrazów, nie próbuj kierować ich przez libuv. Zamiast tego użyj modułu worker_threads, który uruchamia oddzielne instancje V8 na własnych wątkach systemu operacyjnego.
    • użycia dns.lookup(), jeśli to możliwe: preferuj dns.resolve4() lub utwórz zbiorniki połączeń wykorzystujące bezpośrednie adresy IP, aby rozwiązywanie nazw sieciowych nie zajmowało ograniczonych zasobów wątków libuv.

    Gdzie programiści najczęściej popełniają błędy

    Błąd numer jeden: Przypuszczanie, że async/await automatycznie przenosi obciążenie

    Dodanie przedrostka async do funkcji nie tworzy dla niej wątku w tle. async/await to po prostu bardziej czytelna składnia oparta na Promises. Jeśli ciało funkcji zawiera pętlę synchroniczną lub intensywne obliczenia, ten kod nadal jest wykonywany bezpośrednio na głównym wątku JavaScript i nadal będzie blokował serwer podczas swojego działania.

    Błąd numer dwa: Mylenie puli wątków libuv z worker_threads

    • Pula wątków libuv jest zarządzana wewnętrznie przez kod natywny w języku C. Obsługuje ona wbudowane operacje takie jak fs, crypto i zlib. Nie masz możliwości dodania własnych, dowolnych funkcji JavaScript do tej puli.
  • Moduł worker_threads, dostępny od wersji Node.js 10.5, to API na poziomie JavaScripta. Umożliwia on uruchamianie własnego kodu równolegle, przy czym każdy pracownik ma swój własny niezależny silnik V8 oraz pętlę zdarzeń.
  • Błąd numer trzy: nadmierna wielkość puli wątków

    Kuszące jest ustawienie UV_THREADPOOL_SIZE=128 we wszystkich miejscach i założenie, że im więcej, tym lepiej. Jednak wątki wiążą się z rzeczywistymi kosztami – każdy z nich potrzebuje pamięci na własną stek wykonywania, a gdy setki wątków konkujuje o zaledwie dwa lub cztery rdzenie CPU, system operacyjny traci znaczną ilość czasu na ich przechodzenie między sobą.

    Rozsądny punkt wyjścia to dostosowanie wielkości puli do liczby logicznych rdzeni CPU w przypadku obciążeń związanych z pracą na procesorze, takich jak kryptografia czy kompresja, lub użycie ilości odpowiadającej dwóm do czterech razy większej liczbie rdzeni, gdy praca polega głównie na oczekiwaniu na operacje wejścia/wyjścia z dysku.

    Streszczenie

    Prawdziwy sekret Node.js nie polega na całkowitym unikaniu równoczesności, lecz na tym, że szczegóły niskopoziomowych wątków są ukryte w prostym modelu programowania opartym na zdarzeniach.

    • Egzekucja JavaScript pozostaje jednowątkowa: logika aplikacji jest wykonywana krok po kroku, co eliminuje sytuacje konkurencyjne oraz potrzebę blokad.
    • Operacje sieciowe odbywają się za pośrednictwem mechanizmów na poziomie systemu operacyjnego w libuv: sockety są zarządzane przez systemy monitorowania jądra, takie jak epoll, kqueue lub IOCP, i w ogóle nie wykorzystują żadnych wątków roboczych.
    • Dostęp do plików, wyszukiwanie w DNS oraz operacje kryptograficzne opierają się na puli wątków: cztery tła wątki w języku C obsługują te blokujące wywołania, dzięki czemu główny wątek pozostaje wolny do przyjmowania nowych żądań.

    Gdy już dowiesz się, którą z tych dwóch ścieżek wybiera dana operacja, będziesz w o wiele lepszej pozycji, by wykrywać problemy z wydajnością, prawidłowo dobrać rozmiar serwerów oraz tworzyć usługi backendowe, które dobrze radzą sobie przy dużym obciążeniu.

    Literatura pokrewna

  • Node.js Streams wyjaśnione: Jak naprawić awarie z powodu braku pamięci przy obsłudze plików — Dowiedz się, dlaczego ładowanie całych plików do pamięci powoduje awarie serwerów Node.js oraz w jaki sposób strumienie czytelne, zapisywalne, dwukierunkowe i transformacyjne rozwiązują ten problem dzięki mechanizmowi backpressure.
  • Jak kontrolować równoczesność w Node.js: Unikanie awarii API za pomocą p-map i Bottleneck — Przeczytaj, jak połączenie narzędzi p-map i Bottleneck w Node.js zapobiega błędom związanym z ograniczeniami przepustowości oraz przeładowaniu systemu poprzez kontrolę równoczesności i czasu obsługi żądań.
  • Node.js 26: Temporal API, Map Upserts i Undici 8 wyjaśnione — Wyjaśnia główne zmiany w Node.js 26 dotyczące warstwy backendowej, w tym stabilną Temporal API, wbudowane metody Map upsert, poprawy wydajności Undici 8 oraz zmiany mogące powodować problemy, które należy sprawdzić przed aktualizacją.
  • Zrozumienie closur w JavaScript i pętli zdarzeń — Dowiedz się, jak closures przechowują zmienne zewnętrzne oraz w jaki sposób pętla zdarzeń porządkuje stos wywołań, mikrozadania i makrozadania, przy użyciu praktycznych przykładów kodu.