Unii dintre noi sunt de părere că javascript nu este un limbaj de programare, alții îi apreciază foarte mult flexibilitatea, dar cei mai mulți cred că popularitatea masivă, de care s-a bucurat node în ultimii ani, este înscrisă pe un trend ascendent.
La finalul lunii octombrie s-a lansat versiunea 12 Long Term Support (4) a cunoscutului runtime. După multe teste și modificări ale acestei versiuni, se lansează oficial și production ready, modulul de worker threads, care le va permite programatorilor să scrie aplicații multi-threaded (7) în Node.js. În acest fel, îi facilitează intrarea pe un segment de piață, unde în trecut n-a avut nicio șansă în fața giganților Java, .NET ș.a.m.d.
Un număr semnificativ de programatori crede că node este single-threaded. Presupunerea vine de la faptul că javascript este single-threaded. Dar din punctul meu de vedere, node este parțial multi-threaded. În favoarea acestei afirmații sunt argumentele de mai jos.
Dacă intrăm pe pagina de GitHub a proiectului și privim tehnologiile folosite împărțite pe procentaje, în momentul scrierii acestui articol erau: Javascript 51.5%, C++ 28.8, Python 14.5%, C 2.8%, plus altele. Foarte multe părți scrise în C++. De ce?
Există multe module precum: filesystem (5), I/O (folosind libuv), crypto ș.a. care sunt implementate în C++. Când apelezi o metodă de node, ea apelează implementarea metodei din C++ prin ajutorul unei funcții internalBinding.
Un lucru important de reținut este că atunci când folosim aceste module, instrucțiunile lor se execută într-un context multi-threaded; în schimb, codul javascript (if-urile, while-urile ș.a.m.d) se execută într-adevăr într-o manieră single-threaded pe firul de execuție principal.
În majoritatea cazurilor implementării unui API pentru o aplicație scrisă în React, Angular sau Vue, totul este modularizat pe rute, modele și controlere. Așadar, nu avem un codebase foarte mare de executat. Pe lângă aceasta, primim puncte bonus la performanță, dacă aplicația noastră folosește pachete care au implementări multi-threaded în C++.
Probabil acesta este unul dintre motivele pentru care node a prins atât de repede la public. Adevăratul motiv a fost de fapt avantajul de a scrie frontend și backend în același limbaj, fără context switching. Însă aș vrea să vorbim puțin despre elefantul din cameră...
Precum bine știm, codul javascript se execută pe procesor, iar operațiile cpu-intensive nu au fost niciodată punctul forte al lui node. Una din soluțiile acestei probleme este crearea unui process pool care să proceseze în background aceste operații, aceasta dacă utilizatorul nu are nevoie imediată de rezultatul operației. Dacă da? Să spunem doar că se complică puțin lucrurile.
Cu introducerea modulului worker threads se deschide o nouă lume plină de fire de execuție pe A.I (8), I.o.T (9), machine learning (10) și operații cpu-intensive.
Decizia folosirii lui este în mâna programatorului. Cu ajutorul unui canal de comunicare, modulul permite să trimită execuția unei bucăți de instrucțiuni pe un alt fir de execuție, evitând blocarea firului de execuție principal (cel care ne face aplicația single-threaded). După finalizarea execuției, firul de execuție principal este notificat pe canalul de comunicare de faptul că operația s-a terminat, primind rezultatul ei.
Sumarul ofertei ecosistemului javascript este reprezentat de:
programare frontend/backend fără schimbarea contextului;
programare asincronă pe firul de execuție principal favorizând răspunsuri cu o concurență mărită;
multi-threading (7) nativ pe majoritatea modulelor uzual folosite;
Un procesor are un număr fizic limitat de fire de execuție, de obicei, dublul numărului de nuclee.
Nu insistăm asupra detaliilor despre felul în care sistemul de operare prioritizează instrucțiunile pe aceste fire de execuție, însă trebuie să fim atenți la modul în care ne alocăm resursele pe ele, pentru că acest lucru poate să ne afecteze performanța aplicației.
Dacă în trecut foloseam pm2 (6) ca să pornim câte o instanță de node pentru fiecare fir de execuție disponibil, acum trebuie să privim diferit problema.
Performanța unui server de node scade în momentul în care ne creăm o instanță pentru fiecare fir de execuție disponibil, din cauza proceselor de node care rulează pe ele, forțăm semaforul sistemului de operare să ne ruleze bucăți de instrucțiuni, în funcție de disponibilitatea redusă a firelor de execuție, pierzând avantajul multi-threadingului oferit de pachetele scrise în C++.
Am rulat câteva teste pentru a evidenția acest lucru:
Pentru 100.000 de request-uri cu o concurență de 100 folosind apache benchmark:
ab -n 100000 -c 100 -k -H
"Accept-Encoding: gzip, deflate"
http://localhost:3000/users/5d99e41af25745db0265bb88
Am rulat instrucțiunea de mai sus în două situații: una în care am pornit câte o aplicație de node pentru fiecare fir de execuție, cealaltă, în care am lăsat jumătate din firele de execuție libere. Am rulat fiecare caz de cinci ori, salvând rezultatele cele mai bune.
Precum am anticipat, deoarece modulul de http/network e multi-threaded, pornirea unei aplicații de node pentru fiecare thread ne va încetini atât aplicația cât și timpii de răspuns pentru o rută simplă GET, care îmi aduce informațiile unui utilizator. Din cauza disponibilității reduse a firelor de execuție, modulul multi-threaded este încetinit de către semaforul sistemului de operare.
Pentru a rezolva această problemă, trebuie să ne gândim cum alocăm resursele și să ne asigurăm că avem fire de execuție cu disponibilitate mare pentru a spori performanțele aplicației noastre.
Această decizie trebuie luată în funcție de arhitectura procesorului pe care rulează aplicația noastră, dar pentru a ne asigura că rulează performant ar trebui să alocăm cel puțin:
Am creat un git repository (3) cu o implementare simplistă a unei funcții cpu-intensive care rulează pe un fir de execuție diferit. La finalizarea operației, notifică firul de execuție principal cu rezultatul.
Părțile importante sunt:
const {
Worker, MessageChannel, isMainThread, parentPort
} = require('worker_threads');
if (isMainThread) {
// dacă suntem pe firul de execuție principal
const worker = new Worker(__filename);
/* instanțiem un worker nou avand ca și cod sursă
fișierul curent */
const subChannel = new MessageChannel();
/* un canal de communicare între firul principal de
execuție și workerul creat */
/* trimitem mesaj pe canalul workerului cu un
obiect care conține portul lui propriu ca să ne
fie mai ușor să răspundem înapoi */
worker.postMessage({ myPort: subChannel.port1 },
[subChannel.port1]);
console.log("Lets start making some work on" +
" a different thread!");
/* ascultăm după răspunsul firului de
execuție secundar (workerului) */
subChannel.port2.on('message', (value) => {
console.log('received:', value);
// primim rezultatul operației de la firul de
// execuție secundar
});
// putem executa orice altceva pe firul de execuție
// principal
} else {
/* aici ajunge workerul la instrucțiunea new
Worker(__filename) tot aici putem executa orice
dorim, separat de firul de execuție principal */
const result = myIntensiveOperation();
// operația
/* când primim un mesaj de la firul de execuție
părinte (în acest caz, principal) */
parentPort.once('message', (message) => {
/* după ce am terminat de procesat operația
transmitem rezultatul ei înapoi firului de
execuție principal care decide ce să facă cu ea*/
message.myPort.postMessage(result);
/* folosim obiectul transmis din firul de execuție
principal { myPort: subChannel.port1 } pentru a transmite
rezultatul inapoi */
message.myPort.close();
});
}
Citisem un articol (1) unde era prezentat un proiect care se baza pe funcții lambda în AWS. Folosirea worker threads a redus timpii de procesare, iar același codebase se execută cu 75% mai repede față de varianta fără worker threads, salvând timp de procesare în AWS. În final, costurile s-au redus cu 75%.
Un alt articol (2) prezintă dezavantajul folosirii unui singur worker. Canalul de comunicare și trimiterea mesajelor de pe un fir de execuție pe celălalt este scumpă, din acest motiv este indicat să distribuim o procedură cpu-intensive pe mai multe fire de execuție pentru a putea profita de avantajele acestui modul.
Articolul mai prezintă o implementare folosind modulul "worker-threads-pool" pentru gestionarea firelor de execuție disponibile fizic, plus câteva teste pe un set mare de date.
Cu cât avem de procesat mai multe date, cu atât devine mai mare avantajul folosirii acestui modul.