Dlaczego serwisy na nodejs są szybkie i powolne jednocześnie?

Pewnie słyszeliście, że nodejs jest jednowątkowym środowiskiem do uruchomienia JS na serwerze, i być może zastanawialiście się, dlaczego serwisy uruchomione w tym jednowątkowym środowisku są takie szybkiej w porównaniu do innych środowisk uruchomieniowych.

Odpowiedź jest prosta – Event Loop. Jest to jednocześnie zaletą i wadą tego całego noda.

Choć twierdzenie, że nodejs jest jednowątkowym środowiskiem z pierwszego zdania tego wpisu, już nie jest aktualnym, gdyż GC i kilka innych krytycznych funkcjonalności już działają w swoich osobnych wątkach, ale dla programistów piszących na tej platformie jest dostępny tylko jeden wątek. Wątek ten uruchamia kod umieszczony w stacku wywołań – są to wszystkie callbacki i kod JS umieszczony w tych callbackach. Dzięki temu, że jest jeden wątek na uruchomienia tego kodu dla całego kodu serwisu, procesor traci mniej czasu na przełączenia się między zadaniami i optymalnej wykorzystuje czas w przypadku obsługiwania dużej ilości połączeń do naszego http serwera uruchomionego na nodejs.

Otóż, callbacki – nodejs odkrył jeden prosty trick i wszyscy go nienawidzą. Każdy callback rejestruje się w tzw macroqueue skąd callback trafia do event loopa (wątku event loopa) kiedy przed nim nie ma innych zadań w macroqueue. Z nazwy widzimy że to jest stos, więc cała struktura jest oparta na FIFO – im szybciej zarejestruje się zadanie tym szybciej zostanie wykonane. Ale, zawsze jest jakieś małe „ale” – w V8 jest coś takiego jak microqueue, są to callbacki z wszelakich promisów, try/catch/finaly, async/await (przecież to promisy tbh). Wszystkie te zadania z microqueue mają priorytet przed zadaniami z macroqueue i są wykonywane przez silnik przed podjęciem się innego zadania z macroqueue – wszystkie są wykonywane naraz.

Pamiętając kilka tych szczegółach opisanych wyżej, możemy dojść do odpowiedzi na pytanie z tytułu tego wpisu:

  1. Jest szybkim, ponieważ a) procesor nie traci czas na przełączenie się pomiędzy wątkami; b) macroqueue pozwala wykonywać kod tak szybko jak on pojawia się w kolejce do wykonania; c) microqueue – odpowiednio zaprogramowany serwis pozwala na zwrócenie odpowiedzi do klienta na żądanie szybciej, bo microqueue ma priorytet przed innymi zadaniami; d) mniej instrukcji do wykonania – szybciej odpowiedź z serwisu zwróci się do klienta (dt microqueue i macroqueu).
  2. jest powolnym, ponieważ a) intensywne operacje wyliczeniowe efektywnie zabierają czas procesora i wydłużają czas odpowiedzi serwisu; b) niewiedza o macro/micro queue zabija wydajność node, którą ten ma out the box.

No a więc, jak pisać na node tak, żeby potem opowiadać na konferencjach jak to Wasz zespół techniczny wytrzymuje wyprzedaży na black friday zwracając odpowiedzi do klientów w mniej niż sekunda? Odpowiedz jest prosta, i dotyczy nie tylko node ale ogółem każdego frameworka czy środowiska – trzeba znać szczegóły platformy i wykorzystywać je na swoją korzyść, dla node np – cały kod który nie wpływa na wynik zwrócony klientowi, np. logi – wynieść do osobnego wątku/procesu, skomplikowane wyliczenia przenieść na zewnątrz – najlepiej nie do nodejs a jakiś C/C++/C#/Java, wykorzystywać klasteryzację aplikacji oraz nauczyć się skalować aplikację horyzontalnie.

Na koniec mały przykład: pobieranie danych z bazy postgresql wraz z JOINami tablic za pomocą ręcznego napisanego zapytania i biblioteki node-progres oraz wykorzystanie prisma.io ORM dla tego samego celu – różnica circa 2 razy mnie obsłużonych requestów:

~/src/test/src  master ✗
»  autocannon http://localhost:8080/submission/09a9eb11-5ebc-4dba-b871-2323dd500715
Running 10s test @ http://localhost:8080/submission/09a9eb11-5ebc-4dba-b871-2323dd500715
10 connections

┌─────────┬────────┬────────┬────────┬────────┬───────────┬──────────┬────────┐
│ Stat    │ 2.5%   │ 50%    │ 97.5%  │ 99%    │ Avg       │ Stdev    │ Max    │
├─────────┼────────┼────────┼────────┼────────┼───────────┼──────────┼────────┤
│ Latency │ 335 ms │ 405 ms │ 526 ms │ 535 ms │ 410.01 ms │ 45.32 ms │ 571 ms │
└─────────┴────────┴────────┴────────┴────────┴───────────┴──────────┴────────┘
┌───────────┬─────────┬─────────┬───────┬─────────┬─────────┬─────────┬─────────┐
│ Stat      │ 1%      │ 2.5%    │ 50%   │ 97.5%   │ Avg     │ Stdev   │ Min     │
├───────────┼─────────┼─────────┼───────┼─────────┼─────────┼─────────┼─────────┤
│ Req/Sec   │ 20      │ 20      │ 24    │ 28      │ 23.7    │ 2.8     │ 20      │
├───────────┼─────────┼─────────┼───────┼─────────┼─────────┼─────────┼─────────┤
│ Bytes/Sec │ 10.9 kB │ 10.9 kB │ 13 kB │ 15.2 kB │ 12.9 kB │ 1.52 kB │ 10.9 kB │
└───────────┴─────────┴─────────┴───────┴─────────┴─────────┴─────────┴─────────┘

Req/Bytes counts sampled once per second.

247 requests in 10.02s, 129 kB read
~/src/test/src  master ✗
» autocannon http://localhost:8080/submission/09a9eb11-5ebc-4dba-b871-2323dd500715
Running 10s test @ http://localhost:8080/submission/09a9eb11-5ebc-4dba-b871-2323dd500715
10 connections

┌─────────┬────────┬────────┬────────┬────────┬───────────┬──────────┬────────┐
│ Stat    │ 2.5%   │ 50%    │ 97.5%  │ 99%    │ Avg       │ Stdev    │ Max    │
├─────────┼────────┼────────┼────────┼────────┼───────────┼──────────┼────────┤
│ Latency │ 524 ms │ 564 ms │ 622 ms │ 630 ms │ 563.58 ms │ 27.68 ms │ 630 ms │
└─────────┴────────┴────────┴────────┴────────┴───────────┴──────────┴────────┘
┌───────────┬─────┬──────┬───────┬─────────┬─────────┬─────────┬───────┐
│ Stat      │ 1%  │ 2.5% │ 50%   │ 97.5%   │ Avg     │ Stdev   │ Min   │
├───────────┼─────┼──────┼───────┼─────────┼─────────┼─────────┼───────┤
│ Req/Sec   │ 0   │ 0    │ 1     │ 20      │ 8.31    │ 8.56    │ 1     │
├───────────┼─────┼──────┼───────┼─────────┼─────────┼─────────┼───────┤
│ Bytes/Sec │ 0 B │ 0 B  │ 543 B │ 10.9 kB │ 4.51 kB │ 4.65 kB │ 543 B │
└───────────┴─────┴──────┴───────┴─────────┴─────────┴─────────┴───────┘

Req/Bytes counts sampled once per second.

93 requests in 10.04s, 45.1 kB read