Strona główna / Artykuły

Czekanie a obliczania: współbieżność i równoległość w Node.js i Go

Przykłady w Node.js i Go, które rozróżniają współbieżność od równoległości, pokazujące, kiedy wystarczają obietnice (promises), kiedy potrzebne są wątki robocze, oraz jak różnią się goroutiny.

Wydawca: REACTAPP.TOP8 min czytania1761 słów

Źródła: Concurrency vs parallelism, explained with Node.js and Go

Traktowanie współbieżności i równoległości jako synonimów prowadzi do prawdziwych błędów, takich jak API, które ulega zatrzymaniu pod obciążeniem. Porównanie Node.js i Go uwyraźnia tę różnicę. Node.js zapewnia współbieżność bez żadnego wysiłku, zmuszając użytkownika do samodzielnego tworzenia mechanizmów równoległości, natomiast Go oferuje oba te elementy za pomocą jednego słowa kluczowego. Dzięki małym programom działającym w każdym z tych języków dowiesz się, który z nich jest potrzebny do realizacji twoich zadań.

Dwie definicje i przykład

Współbieżność oznacza, że jeden proces obsługuje wiele zadań, przechodząc do kolejnego zadania w momencie, gdy obecne jest w oczekiwaniu. Równoległość oznacza, że kilka procesów wykonuje kilka zadań dosłownie w tym samym momencie.

Załóżmy kuchnię. Jeden kucharz obsługujący trzy garnki to przykład równoczesności: kucharz mieszaje zawartość pierwszego garnka, a podczas gdy drugi się gotuje, kroi warzywa do trzeciego. W żadnym momencie nie dzieje się dwie rzeczy jednocześnie, a mimo to kucharz nigdy nie jest bezczynny. Jeśli w kuchni umieścimy trzech kucharzy, po jednym przy każdym garnku, mamy do czynienia z paralelizmem.

Node.js: równoczesność jest wbudowana

Jeden wątek, wiele bieżących żądań

Node.js wykonywa twoje skrypty JavaScript na pojedynczej nitce. Nadal dobrze radzi sobie z dużym ruchem, ponieważ większość zadań serwera polega na oczekiwaniu na bazę danych, plik lub inny serwis. Node przekazuje to oczekiwanie systemowi operacyjnemu (a w przypadku niektórych operacji – wewnętrznemu puli nitek libuv) i kontynuuje wykonywanie kolejnego fragmentu kodu JavaScript. Tym mechanizmem planowania jest pętla zdarzeń. Aby lepiej poznać ten mechanizm, sprawdź jak libuv, pętla zdarzeń i pula nitek współpracują ze sobą.

Poniższy program symuluje trzy żądania, z których każde trwa jedną sekundę, przy użyciu timerów otoczonych obietnicami:

function fakeRequest(name, ms) {
  return new Promise((resolve) => {
    setTimeout(() => resolve(`${name} done`), ms);
  });
}

async function main() {
  console.time("total");

  const results = await Promise.all([
    fakeRequest("users", 1000),
    fakeRequest("orders", 1000),
    fakeRequest("products", 1000),
  ]);

  console.log(results);
  console.timeEnd("total");
}

main();

Ponieważ Promise.all uruchamia wszystkie trzy zadania jednocześnie, a timerzy wygasają równocześnie, console.timeEnd pokazuje około jednej sekundy zamiast trzech. Jeden wątek miał trzy zadania w toku i nigdy nie czekał bezczynnie na ich zakończenie. To właśnie jest konkurencja.

Co dzieje się przy rzeczywistej pracy CPU

Pętla zdarzeń może jedynie nakładać się na czekanie. Gdy zadanie polega wyłącznie na obliczeniach, nie ma nic do przekazania systemowi operacyjnemu. Ten program trzy razy sumuje duży zakres liczb:

function countTo(n) {
  let sum = 0;
  for (let i = 0; i < n; i++) sum += i;
  return sum;
}

console.time("total");
countTo(2_000_000_000);
countTo(2_000_000_000);
countTo(2_000_000_000);
console.timeEnd("total");

Całkowity czas wykonywania wynosi mniej więcej trzy razy tyle, ile czas jednego pojedynczego wywołania. Użycie Promise.all wokół tych wywołań nie pomoże, ponieważ każda funkcja countTo jest wykonywana synchronicznie od początku do końca, zanim może zostać uruchomione cokolwiek innego. Mówiąc językiem kuchni, pojedynczy kucharz musi kolejno skończyć przygotowywanie każdego garnka. Co gorsza, na serwerze proces nie może odpowiadać na żadne żądania HTTP, dopóki pętla się wykonywa.

Osiąganie paralelizmu za pomocą wątków roboczych

Aby wykorzystać więcej niż jeden rdzeń CPU, potrzebne są więcej niż jeden wątek, a narzędziem do tego w Node jest moduł worker_threads. Najpierw wątek roboczy: oddzielny plik, który odczytuje dane wejściowe z workerData, przeprowadza obliczenia i wysyła wynik z powrotem do procesu nadrzędnego.

// worker.js
const { parentPort, workerData } = require("worker_threads");

let sum = 0;
for (let i = 0; i < workerData; i++) sum += i;

parentPort.postMessage(sum);

Główny plik otacza każdego pracownika obietnicą, która zostaje rozwiązana przy zdarzeniu message pracownika i odrzucona przy błędzie error, a następnie uruchamia trzech pracowników jednocześnie:

// main.js
const { Worker } = require("worker_threads");
const path = require("path");

function countInWorker(n) {
  return new Promise((resolve, reject) => {
    const worker = new Worker(path.join(__dirname, "worker.js"), {
      workerData: n,
    });
    worker.on("message", resolve);
    worker.on("error", reject);
  });
}

async function main() {
  console.time("total");

  await Promise.all([
    countInWorker(2_000_000_000),
    countInWorker(2_000_000_000),
    countInWorker(2_000_000_000),
  ]);

  console.timeEnd("total");
}

main();

Na maszynie z trzema lub więcej rdzeniami całkowity czas spada do mniej więcej długości jednego wywołania. Trzej kucharze, trzy patelnie, pracujące jednocześnie. Główny wątek pozostaje również wolny do obsługi żądań.

Należy jednak zwrócić uwagę na to, co to wymagało: dedykowanego pliku, kanału komunikacji oraz otulacza obietnicy. W Node.js paralelizm to coś, co musi się samemu skonstruować. W środowisku produkcyjnym należałoby również unikać tworzenia nowego pracownika na każde zadanie, ponieważ uruchomienie pracownika wiąże się z rzeczywistymi kosztami; zwykłą rozwiązaniami są ponownie używalne grupy pracowników, o których mowa w naszym przewodniku po wątkach i grupach pracowników.

Go: jedno słowo kluczowe dla obu przypadków

Goroutines zamiast pętli wydarzeń

W języku Go dodanie przedwołania funkcji słowa go uruchamia goroutine. Każda goroutine rozpoczyna się od stosu o wielkości zaledwie kilku kilobajtów, który powiększa się w miarę potrzeb, dzięki czemu uruchamianie tysięcy takich goroutine nie stanowi problemu. Planer w czasie wykonywania programów w Go mapuje goroutine na wątki systemu operacyjnego i domyślnie pozwala na jednoczesne wykonywanie tylu z nich, ile jest rdzeni CPU, przy czym to ograniczenie jest kontrolowane przez zmienną GOMAXPROCS.

Oto ponownie symulacja trzech zapytań, tym razem z użyciem sync.WaitGroup do czekania, aż wszystkie goroutine zakończą pracę:

package main

import (
 "fmt"
 "sync"
 "time"
)

func fakeRequest(name string, wg *sync.WaitGroup) {
 defer wg.Done()
 time.Sleep(time.Second)
 fmt.Println(name, "done")
}

func main() {
 start := time.Now()
 var wg sync.WaitGroup

 for _, name := range []string{"users", "orders", "products"} {
  wg.Add(1)
  go fakeRequest(name, &wg)
 }

 wg.Wait()
 fmt.Println("total:", time.Since(start))
}

Każda goroutine wywołuje wg.Done() za pomocą defer, a wg.Wait() blokuje się, dopóki wszystkie nie zakończą działania. Wynik jest identyczny jak w wersji Node.js Promise.all: łącznie około jednej sekundy.

Prace obciążone CPU nie wymagają dodatkowego sprzętu

A teraz przykład liczenia. Podczas gdy w Node.js potrzebny był plik pracownika oraz przekazywanie wiadomości, wersja w Go zachowuje dokładnie tę samą strukturę co wcześniej:

package main

import (
 "fmt"
 "sync"
 "time"
)

func countTo(n int, wg *sync.WaitGroup) {
 defer wg.Done()
 sum := 0
 for i := 0; i < n; i++ {
  sum += i
 }
 fmt.Println("sum:", sum)
}

func main() {
 start := time.Now()
 var wg sync.WaitGroup

 for i := 0; i < 3; i++ {
  wg.Add(1)
  go countTo(2_000_000_000, &wg)
 }

 wg.Wait()
 fmt.Println("total:", time.Since(start))
}

Na maszynie z wieloma rdzeniami operacja ta kończy się mniej więcej w czasie jednego wywołania. Planer znalazł trzy dostępne do uruchomienia gorutiny i uruchomił je jednocześnie na trzech wątkach. Zadanie zostało wykonane równolegle, a system operacyjny przekształcił to w wykonywanie równoczesne.

To ilustruje rozróżnienie, które przedstawia słynny wykład Roba Pike’a „Konkurencja to nie równoległość”: konkurencja to sposób strukturyzowania programu jako niezależnie działających części, natomiast równoległość to to, co system operacyjny może zrobić z tą strukturą, gdy są dostępne liczne rdzenie.

Cena wspólnej pamięci

Każdy z procesorów Node.js ma swój własny izolowany bufor pamięci i nie może czytać zmiennych innych procesorów; komunikują się poprzez przekazywanie wiadomości. (Jeśli naprawdę potrzebujesz wspólnej pamięci w Node.js, istnieje SharedArrayBuffer z Atomics, co powoduje te same problemy synchronizacji opisane poniżej.)

Z kolei goroutiny dzielą się tą samą pamięcią. Jeśli dwie goroutiny zwiększają jeden licznik bez koordynacji, dochodzi do wyścigu danych i ostateczna wartość będzie błędna. Idiomaticznym rozwiązaniem w Go jest przekazywanie danych przez kanały oraz używanie mutex tylko wtedy, gdy udział w stanie jest nieunikniony.

Ten zbiór procesorów pokazuje podejście oparte na kanałach. Trzy goroutiny odczytują zadania z jednego kanału i zapisują podwojone wartości do innego:

package main

import "fmt"

func worker(jobs <-chan int, results chan<- int) {
 for j := range jobs {
  results <- j * 2
 }
}

func main() {
 jobs := make(chan int, 100)
 results := make(chan int, 100)

 for w := 1; w <= 3; w++ {
  go worker(jobs, results)
 }

 for j := 1; j <= 9; j++ {
  jobs <- j
 }
 close(jobs)

 for r := 1; r <= 9; r++ {
  fmt.Println(<-results)
 }
}

Nic nie jest dzielone poza samymi kanałami, więc nie są potrzebne blokady, a dodanie pracowników pozwala puli wykorzystać więcej rdzeni. Wyniki pojawiają się w takiej kolejności, w jakiej pracownicy kończą swoje zadania, co jest oczekiwane w tym wzorcu. Uruchomienie programu za pomocą go run -race main.go aktywuje detektor wyścigów, który zgłasza wszelkie przypadkowe niezsynchronizowane dostępy do pamięci wspólnej.

Dwa porównywane modele

  • Jednostka współbieżności: Node.js wykorzystuje obietnice i funkcje zwrotne planowane w ramach jednego pętli zdarzeń; Go używa gorutyn.
  • Paralelizm: w Node.js jest on opcjonalny, poprzez worker_threads lub moduł cluster (który uruchamia kilka procesów); w Go jest włączony domyślnie, a planer wykorzystuje wszystkie rdzenie.
  • Zadania wymagające dużej mocy obliczeniowej CPU: w Node.js blokują one pętlę zdarzeń, chyba że przeniesie się je do workera; w Go są to po prostu kolejne goroutine.
  • Dzielenie się danymi: workerzy w Node.js wymieniają między sobą wiadomości; goroutine wykorzystują kanały lub wspólną pamięć chronioną mutexem.
  • Typowy sposób awarii: w Node.js – zablokowana pętla zdarzeń; w Go – wyścigi danych.
  • Wybór odpowiedniego narzędzia: czekanie czy obliczenia?

    Jedno pytanie rozstrzyga większość przypadków: czy wolna część Twojej pracy polega na czekaniu, czy na obliczeniach?

    Jeśli chodzi o czekanie (na bazie danych, system plików lub połączenie sieciowe), wystarczy konkurencyjność. W Node.js oznacza to obietnice i async/await. W Go oznacza to goroutine, a planer parkuje te, które są zablokowane, aby nie zużywały mocy CPU.

    Jeśli chodzi o obliczenia (modyfikację rozdzielczości obrazów, generowanie haszów, analizę bardzo dużych plików), potrzebny jest paralelizm. W Node.js przenieś pracę do wątków roboczych lub użyj cluster, aby rozproszyć obsługę żądań między procesami. W Go uruchom goroutiny i upewnij się, że wymieniają one dane za pośrednictwem kanałów lub chronią wspólny stan za pomocą mutexa.

    Paralelizm jest ograniczony przez sprzęt: w kontenerze z jednym rdzeniem trzy zadania obciążone procesorem nie będą wykonywane szybciej niż sekwencyjnie, niezależnie od używanego języka.

    Główne wnioski

    • Konkurencja dotyczy struktury: organizacji pracy tak, aby nic nie stało w bezruchu, podczas gdy coś czeka. Paralelizm dotyczy sprzętu: liczby zadań, które faktycznie działają jednocześnie.
    • W Node.js Promise.all umożliwia jednoczesne oczekiwanie, ale nie pomaga przy synchronicznym obliczaniu, które blokuje każde żądanie w danym wątku.
  • Paralelizm w Node.js wymaga wyraźnie zdefiniowanych wątków roboczych (najlepiej z grupy) lub kilku procesów.
  • Planer w Go automatycznie przekształca równoczesne goroutines w paralelną eksploatację, ale współdzielona pamięć sprawia, że trzeba uważać na wyścigi danych; lepiej używać kanałów oraz detektora wyścigów.