Test porównawczy Obietnica Node.js

Opublikowany: 2022-03-11

Żyjemy w nowym wspaniałym świecie. Świat wypełniony JavaScriptem. W ostatnich latach JavaScript zdominował sieć, szturmem podbijając całą branżę. Po wprowadzeniu Node.js społeczność JavaScript była w stanie wykorzystać prostotę i dynamikę języka, aby być jedynym językiem, który robi wszystko, obsługując stronę serwera i klienta, a nawet odważnie zajęła stanowisko w zakresie uczenia maszynowego. Ale JavaScript zmienił się drastycznie jako język w ciągu ostatnich kilku lat. Wprowadzono nowe koncepcje, których nigdy wcześniej nie było, takie jak funkcje strzałek i obietnice.

Ach, obietnice. Cała koncepcja obietnicy i wywołania zwrotnego nie miała dla mnie większego sensu, kiedy po raz pierwszy zacząłem uczyć się Node.js. Byłem przyzwyczajony do proceduralnego sposobu wykonywania kodu, ale z czasem zrozumiałem, dlaczego jest to ważne.

To prowadzi nas do pytania, dlaczego w ogóle wprowadzono oddzwonienia i obietnice? Dlaczego nie możemy po prostu napisać sekwencyjnie wykonywanego kodu w JavaScript?

Cóż, technicznie możesz. Ale czy powinieneś?

W tym artykule przedstawię krótkie wprowadzenie na temat JavaScript i jego środowiska wykonawczego, a co ważniejsze, przetestuję powszechne przekonanie społeczności JavaScript, że kod synchroniczny jest słabszy pod względem wydajności i, w pewnym sensie, jest po prostu zły i nigdy nie powinien być użytym. Czy ten mit jest naprawdę prawdziwy?

Zanim zaczniesz, w tym artykule zakładamy, że znasz już obietnice w JavaScript, jednak jeśli nie potrzebujesz lub nie potrzebujesz odświeżenia, zapoznaj się z JavaScript Promises: samouczek z przykładami

Uwaga: Ten artykuł został przetestowany na środowisku Node.js, a nie na czystym JavaScript. Uruchamianie Node.js w wersji 10.14.2. Wszystkie testy porównawcze i składnia będą w dużym stopniu polegać na Node.js. Testy przeprowadzono na MacBooku Pro 2018 z czterordzeniowym procesorem Intel i5 8. generacji, pracującym z podstawową częstotliwością zegara 2,3 GHz.

Pętla zdarzeń

Obietnica benchmarkingu Node.js: ilustracja pętli zdarzeń Node.js

Problem z pisaniem JavaScript polega na tym, że sam język jest jednowątkowy. Oznacza to, że nie możesz wykonywać więcej niż jednej procedury jednocześnie, w przeciwieństwie do innych języków, takich jak Go lub Ruby, które mają możliwość tworzenia wątków i wykonywania wielu procedur jednocześnie, zarówno w wątkach jądra, jak i w wątkach procesów .

Aby wykonać kod, JavaScript opiera się na procedurze zwanej pętlą zdarzeń, która składa się z wielu etapów. Proces JavaScript przechodzi przez każdy etap, a na końcu zaczyna się od nowa. Możesz przeczytać więcej o szczegółach w oficjalnym przewodniku node.js tutaj.

Ale JavaScript ma coś w zanadrzu, aby zwalczyć problem blokowania. Wywołania zwrotne we/wy.

Większość rzeczywistych przypadków użycia, które wymagają od nas stworzenia wątku, polega na tym, że żądamy jakiejś akcji, za którą język nie jest odpowiedzialny — na przykład żądanie pobrania niektórych danych z bazy danych. W językach wielowątkowych wątek, który utworzył żądanie, po prostu zawiesza się lub czeka na odpowiedź z bazy danych. To tylko marnowanie zasobów. Nakłada również na programistę obciążenie przy wyborze odpowiedniej liczby wątków w puli wątków. Ma to na celu zapobieganie wyciekom pamięci i przydzielaniu dużej ilości zasobów, gdy aplikacja jest bardzo potrzebna.

JavaScript przoduje w jednej rzeczy bardziej niż w jakimkolwiek innym czynniku, obsługującym operacje I/O. JavaScript umożliwia wywołanie operacji wejścia/wyjścia, takiej jak żądanie danych z bazy danych, odczytanie pliku do pamięci, zapisanie pliku na dysku, wykonanie polecenia powłoki itp. Po zakończeniu operacji wykonujesz wywołanie zwrotne. Lub w przypadku obietnic rozwiązujesz obietnicę wynikiem lub odrzucasz ją z błędem.

Społeczność JavaScript zawsze radzi nam, abyśmy nigdy nie używali kodu synchronicznego podczas wykonywania operacji I/O. Dobrze znanym powodem jest to, że NIE chcemy blokować naszego kodu przed wykonywaniem innych zadań. Ponieważ jest jednowątkowy, jeśli mamy fragment kodu, który odczytuje plik synchronicznie, kod zablokuje cały proces, dopóki odczyt nie zostanie zakończony. Zamiast tego, jeśli polegamy na kodzie asynchronicznym, możemy wykonywać wiele operacji we/wy i obsługiwać odpowiedź każdej operacji indywidualnie po jej zakończeniu. Żadnego blokowania.

Ale z pewnością w środowisku, w którym w ogóle nie zależy nam na obsłudze wielu procesów, używanie kodu synchronicznego i asynchronicznego nie ma żadnego znaczenia, prawda?

Reper

Test, który zamierzamy przeprowadzić, będzie miał na celu dostarczenie nam testów porównawczych szybkości działania kodu synchronizacji i asynchronicznego oraz różnic w wydajności.

Postanowiłem wybrać odczytanie pliku jako operację wejścia/wyjścia do testowania.

Najpierw napisałem funkcję, która zapisze losowy plik wypełniony losowymi bajtami wygenerowanymi przez moduł Node.js Crypto.

 const fs = require('fs'); const crypto = require('crypto'); fs.writeFileSync( "./test.txt", crypto.randomBytes(2048).toString('base64') )

Ten plik będzie działał jako stała dla naszego następnego kroku, którym jest odczytanie pliku. Oto kod

 const fs = require('fs'); process.on('unhandledRejection', (err)=>{ console.error(err); }) function synchronous() { console.time("sync"); fs.readFileSync("./test.txt") console.timeEnd("sync") } async function asynchronous() { console.time("async"); let p0 = fs.promises.readFile("./test.txt"); await Promise.all([p0]) console.timeEnd("async") } synchronous() asynchronous()

Uruchomienie poprzedniego kodu dało następujące wyniki:

Biegać # Synchronizuj Asynchroniczny Współczynnik asynchronii/synchronizacji
1 0,278 ms 3.829ms 13,773
2 0,335 ms 3,801 ms 11.346
3 0,403 ms 4.498ms 11.161

To było nieoczekiwane. Moje początkowe oczekiwania były takie, że powinny zająć tyle samo czasu. A może dodamy kolejny plik i odczytamy 2 pliki zamiast 1?

Zreplikowałem wygenerowany plik test.txt i nazwałem go test2.txt. Oto zaktualizowany kod:

 function synchronous() { console.time("sync"); fs.readFileSync("./test.txt") fs.readFileSync("./test2.txt") console.timeEnd("sync") } async function asynchronous() { console.time("async"); let p0 = fs.promises.readFile("./test.txt"); let p1 = fs.promises.readFile("./test2.txt"); await Promise.all([p0,p1]) console.timeEnd("async") }

Po prostu dodałem kolejną lekturę dla każdego z nich i w obietnicach czekałem na obietnice czytania, które powinny przebiegać równolegle. Oto wyniki:

Biegać # Synchronizuj Asynchroniczny Współczynnik asynchronii/synchronizacji
1 1,659 ms 6,895 ms 4.156
2 0,323 ms 4.048ms 12,533
3 0,324 ms 4.017ms 12,398
4 0,333 ms 4,271 ms 12.826

Pierwszy ma zupełnie inne wartości niż kolejne 3 przebiegi. Domyślam się, że jest to związane z kompilatorem JavaScript JIT, który optymalizuje kod przy każdym uruchomieniu.

Tak więc w przypadku funkcji asynchronicznych sprawy nie wyglądają tak dobrze. Może jeśli uczynimy rzeczy bardziej dynamicznymi i może nieco bardziej obciążymy aplikację, uzyskamy inny wynik.

Więc mój następny test polega na napisaniu 100 różnych plików, a następnie odczytaniu ich wszystkich.

Najpierw zmodyfikowałem kod, aby zapisać 100 plików przed wykonaniem testu. Pliki są różne w każdym przebiegu, chociaż zachowują prawie ten sam rozmiar, więc przed każdym uruchomieniem usuwamy stare pliki.

Oto zaktualizowany kod:

 let filePaths = []; function writeFile() { let filePath = `./files/${crypto.randomBytes(6).toString('hex')}.txt` fs.writeFileSync( filePath, crypto.randomBytes(2048).toString('base64') ) filePaths.push(filePath); } function synchronous() { console.time("sync"); /* fs.readFileSync("./test.txt") fs.readFileSync("./test2.txt") */ filePaths.forEach((filePath)=>{ fs.readFileSync(filePath) }) console.timeEnd("sync") } async function asynchronous() { console.time("async"); /* let p0 = fs.promises.readFile("./test.txt"); let p1 = fs.promises.readFile("./test2.txt"); */ // await Promise.all([p0,p1]) let promiseArray = []; filePaths.forEach((filePath)=>{ promiseArray.push(fs.promises.readFile(filePath)) }) await Promise.all(promiseArray) console.timeEnd("async") }

A do czyszczenia i wykonania:

 let oldFiles = fs.readdirSync("./files") oldFiles.forEach((file)=>{ fs.unlinkSync("./files/"+file) }) if (!fs.existsSync("./files")){ fs.mkdirSync("./files") } for (let index = 0; index < 100; index++) { writeFile() } synchronous() asynchronous()

I biegnijmy.

Oto tabela wyników:

Biegać # Synchronizuj Asynchroniczny Współczynnik asynchronii/synchronizacji
1 4,999 ms 12.890ms 2,579
2 5.077ms 16,267 ms 3.204
3 5.241ms 14,571 ms 2.780
4 5.086ms 16,334 ms 3.213

Te wyniki zaczynają tutaj wyciągać wnioski. Wskazuje, że wraz ze wzrostem popytu lub współbieżności obietnice ogólne nabierają sensu. Dla wyjaśnienia, jeśli prowadzimy serwer WWW, który ma obsługiwać setki, a może tysiące żądań na sekundę na serwer, uruchamianie operacji we/wy przy użyciu synchronizacji zacznie dość szybko tracić swoją korzyść.

Tylko na potrzeby eksperymentów, zobaczmy, czy rzeczywiście jest to problem z samymi obietnicami, czy jest to coś innego. W tym celu napisałem funkcję, która obliczy czas rozwiązania jednej obietnicy, która absolutnie nic nie robi, a drugiej, która rozwiązuje 100 pustych obietnic.

Oto kod:

 function promiseRun() { console.time("promise run"); return new Promise((resolve)=>resolve()) .then(()=>console.timeEnd("promise run")) } function hunderedPromiseRuns() { let promiseArray = []; console.time("100 promises") for(let i = 0; i < 100; i++) { promiseArray.push(new Promise((resolve)=>resolve())) } return Promise.all(promiseArray).then(()=>console.timeEnd("100 promises")) } promiseRun() hunderedPromiseRuns()
Biegać # Pojedyncza obietnica 100 obietnic
1 1,651ms 3,293 ms
2 0,758 ms 2,575 ms
3 0,814ms 3.127ms
4 0,788 ms 2,623 ms

Ciekawe. Wygląda na to, że obietnice nie są główną przyczyną opóźnień, co pozwala przypuszczać, że źródłem opóźnień są wątki jądra wykonujące rzeczywisty odczyt. Może to wymagać nieco więcej eksperymentów, aby dojść do rozstrzygającego wniosku na temat głównej przyczyny opóźnienia.

Ostatnie słowo

Więc czy powinieneś używać obietnic, czy nie? Moja opinia byłaby następująca:

Jeśli piszesz skrypt, który będzie działał na jednej maszynie z określonym przepływem wyzwalanym przez potok lub pojedynczego użytkownika, przejdź do kodu synchronizacji. Jeśli piszesz serwer sieci Web, który będzie odpowiedzialny za obsługę dużego ruchu i żądań, obciążenie wynikające z wykonywania asynchronicznego przewyższy wydajność kodu synchronizacji.

Kod wszystkich funkcji z tego artykułu znajdziesz w repozytorium.

Logicznym następnym krokiem w Twojej podróży programisty JavaScript, od obietnic, jest składnia async/await. Jeśli chcesz dowiedzieć się więcej na ten temat i jak tu dotarliśmy, zobacz Asynchronous JavaScript: From Callback Hell to Async and Await .