Prestazioni I/O lato server: Node vs. PHP vs. Java vs. Go
Pubblicato: 2022-03-11Comprendere il modello Input/Output (I/O) della tua applicazione può fare la differenza tra un'applicazione che si occupa del carico a cui è soggetta e una che si accartoccia di fronte ai casi d'uso del mondo reale. Forse, sebbene la tua applicazione sia piccola e non serva carichi elevati, potrebbe essere molto meno importante. Ma con l'aumento del carico di traffico della tua applicazione, lavorare con il modello di I/O sbagliato può farti finire in un mondo di danni.
E come la maggior parte delle situazioni in cui sono possibili approcci multipli, non è solo questione di quale sia il migliore, è questione di capire i compromessi. Facciamo una passeggiata nel panorama I/O e vediamo cosa possiamo spiare.
In questo articolo confronteremo Node, Java, Go e PHP con Apache, discutendo come i diversi linguaggi modellano il loro I/O, i vantaggi e gli svantaggi di ciascun modello e concluderemo con alcuni benchmark rudimentali. Se sei preoccupato per le prestazioni I/O della tua prossima applicazione web, questo articolo fa per te.
Nozioni di base sugli I/O: un rapido aggiornamento
Per comprendere i fattori coinvolti con l'I/O, dobbiamo prima rivedere i concetti a livello di sistema operativo. Sebbene sia improbabile che debbano affrontare direttamente molti di questi concetti, li gestisci indirettamente attraverso l'ambiente di runtime della tua applicazione tutto il tempo. E i dettagli contano.
Chiamate di sistema
In primo luogo, abbiamo le chiamate di sistema, che possono essere descritte come segue:
- Il tuo programma (in "terra degli utenti", come si suol dire) deve chiedere al kernel del sistema operativo di eseguire un'operazione di I/O per suo conto.
- Una "syscall" è il mezzo con cui il tuo programma chiede al kernel di fare qualcosa. Le specifiche di come viene implementato variano tra i sistemi operativi, ma il concetto di base è lo stesso. Ci saranno alcune istruzioni specifiche che trasferiscono il controllo dal tuo programma al kernel (come una chiamata di funzione ma con una salsa speciale specifica per affrontare questa situazione). In generale, le syscall si bloccano, il che significa che il tuo programma attende che il kernel torni al tuo codice.
- Il kernel esegue l'operazione di I/O sottostante sul dispositivo fisico in questione (disco, scheda di rete, ecc.) e risponde alla syscall. Nel mondo reale, il kernel potrebbe dover fare una serie di cose per soddisfare la tua richiesta, incluso attendere che il dispositivo sia pronto, aggiornare il suo stato interno, ecc., ma come sviluppatore di applicazioni, non ti interessa. Questo è il lavoro del kernel.
Chiamate bloccanti e non bloccanti
Ora, ho appena detto sopra che le syscall stanno bloccando, e questo è vero in senso generale. Tuttavia, alcune chiamate sono classificate come "non bloccanti", il che significa che il kernel accetta la tua richiesta, la mette in coda o nel buffer da qualche parte e quindi ritorna immediatamente senza attendere che si verifichi l'I/O effettivo. Quindi si "blocca" solo per un brevissimo periodo di tempo, giusto il tempo necessario per accodare la tua richiesta.
Alcuni esempi (di syscall di Linux) potrebbero aiutare a chiarire: - read()
è una chiamata di blocco - gli passi un handle che dice quale file e un buffer di dove consegnare i dati che legge, e la chiamata ritorna quando i dati sono lì. Nota che questo ha il vantaggio di essere carino e semplice. - epoll_create()
, epoll_ctl()
ed epoll_wait()
sono chiamate che, rispettivamente, consentono di creare un gruppo di handle su cui ascoltare, aggiungere/rimuovere gestori da quel gruppo e quindi bloccare fino a quando non c'è alcuna attività. Ciò ti consente di controllare in modo efficiente un gran numero di operazioni di I/O con un singolo thread, ma sto andando avanti. Questo è ottimo se hai bisogno della funzionalità, ma come puoi vedere è sicuramente più complesso da usare.
È importante capire l'ordine di grandezza della differenza di tempistica qui. Se un core della CPU funziona a 3GHz, senza entrare nelle ottimizzazioni che la CPU può fare, esegue 3 miliardi di cicli al secondo (o 3 cicli al nanosecondo). Una chiamata di sistema non bloccante potrebbe richiedere l'ordine di 10 secondi di cicli per essere completata o "relativamente pochi nanosecondi". Una chiamata che blocca la ricezione di informazioni sulla rete potrebbe richiedere molto più tempo, ad esempio 200 millisecondi (1/5 di secondo). E diciamo, ad esempio, che la chiamata senza blocco ha richiesto 20 nanosecondi e la chiamata di blocco ha richiesto 200.000.000 di nanosecondi. Il tuo processo ha appena aspettato 10 milioni di volte in più per la chiamata di blocco.
Il kernel fornisce i mezzi per eseguire sia l'I/O bloccante ("leggere da questa connessione di rete e fornirmi i dati") sia l'I/O non bloccante ("avvisami quando una di queste connessioni di rete ha nuovi dati"). E quale meccanismo viene utilizzato bloccherà il processo di chiamata per periodi di tempo notevolmente diversi.
Programmazione
La terza cosa fondamentale da seguire è cosa succede quando hai molti thread o processi che iniziano a bloccarsi.
Per i nostri scopi, non c'è una grande differenza tra un thread e un processo. Nella vita reale, la differenza più evidente in termini di prestazioni è che, poiché i thread condividono la stessa memoria e i processi hanno ciascuno il proprio spazio di memoria, la creazione di processi separati tende a occupare molta più memoria. Ma quando parliamo di pianificazione, ciò a cui si riduce davvero è un elenco di cose (thread e processi allo stesso modo) di cui ciascuna ha bisogno per ottenere una fetta di tempo di esecuzione sui core della CPU disponibili. Se hai 300 thread in esecuzione e 8 core su cui eseguirli, devi dividere il tempo in modo che ognuno abbia la sua quota, con ogni core in esecuzione per un breve periodo di tempo e poi passare al thread successivo. Questo viene fatto attraverso un "cambio di contesto", che fa passare la CPU dall'esecuzione di un thread/processo al successivo.
Questi cambi di contesto hanno un costo ad essi associato: richiedono del tempo. In alcuni casi veloci, potrebbero essere inferiori a 100 nanosecondi, ma non è raro che impieghino 1000 nanosecondi o più a seconda dei dettagli di implementazione, velocità/architettura del processore, cache della CPU, ecc.
E più thread (o processi), maggiore è il cambio di contesto. Quando si parla di migliaia di thread e centinaia di nanosecondi per ciascuno, le cose possono diventare molto lente.
Tuttavia, le chiamate non bloccanti in sostanza dicono al kernel "chiamami solo quando hai nuovi dati o eventi su una di queste connessioni". Queste chiamate non bloccanti sono progettate per gestire in modo efficiente carichi di I/O di grandi dimensioni e ridurre il cambio di contesto.
Con me finora? Perché ora arriva la parte divertente: diamo un'occhiata a cosa fanno alcuni linguaggi popolari con questi strumenti e traiamo alcune conclusioni sui compromessi tra facilità d'uso e prestazioni... e altre curiosità interessanti.
Come nota, mentre gli esempi mostrati in questo articolo sono banali (e parziali, con solo i bit rilevanti mostrati); accesso al database, sistemi di memorizzazione nella cache esterni (memcache, et. All) e tutto ciò che richiede I/O finirà per eseguire una sorta di chiamata I/O nascosta che avrà lo stesso effetto dei semplici esempi mostrati. Inoltre, per gli scenari in cui l'I/O è descritto come "blocco" (PHP, Java), le letture e le scritture di richieste e risposte HTTP sono esse stesse che bloccano le chiamate: ancora una volta, più I/O nascosti nel sistema con i relativi problemi di prestazioni tener conto.
Ci sono molti fattori che concorrono alla scelta di un linguaggio di programmazione per un progetto. Ci sono anche molti fattori quando si considerano solo le prestazioni. Ma, se temi che il tuo programma sia limitato principalmente dall'I/O, se le prestazioni di I/O sono buone o meno per il tuo progetto, queste sono cose che devi sapere.
L'approccio "Keep It Simple": PHP
Negli anni '90, molte persone indossavano scarpe Converse e scrivevano script CGI in Perl. Poi è arrivato PHP e, per quanto ad alcune persone piaccia parlarne, ha reso molto più semplice la creazione di pagine web dinamiche.
Il modello utilizzato da PHP è abbastanza semplice. Ci sono alcune variazioni, ma il tuo server PHP medio è simile a:
Una richiesta HTTP arriva dal browser di un utente e colpisce il tuo server web Apache. Apache crea un processo separato per ogni richiesta, con alcune ottimizzazioni per riutilizzarle al fine di ridurre al minimo il numero di operazioni da eseguire (la creazione di processi è, relativamente parlando, lenta). Apache chiama PHP e gli dice di eseguire il file .php
appropriato sul disco. Il codice PHP viene eseguito e blocca le chiamate I/O. Si chiama file_get_contents()
in PHP e sotto il cofano fa read()
syscall e attende i risultati.
E ovviamente il codice effettivo è semplicemente incorporato direttamente nella tua pagina e le operazioni stanno bloccando:
<?php // blocking file I/O $file_data = file_get_contents('/path/to/file.dat'); // blocking network I/O $curl = curl_init('http://example.com/example-microservice'); $result = curl_exec($curl); // some more blocking network I/O $result = $db->query('SELECT id, data FROM examples ORDER BY id DESC limit 100'); ?>
In termini di come questo si integra con il sistema, è così:
Abbastanza semplice: un processo per richiesta. Le chiamate I/O si bloccano. Vantaggio? È semplice e funziona. Svantaggio? Colpiscilo con 20.000 client contemporaneamente e il tuo server prenderà fuoco. Questo approccio non si adatta bene perché gli strumenti forniti dal kernel per la gestione di volumi elevati di I/O (epoll, ecc.) non vengono utilizzati. E per aggiungere la beffa al danno, l'esecuzione di un processo separato per ogni richiesta tende a utilizzare molte risorse di sistema, in particolare la memoria, che spesso è la prima cosa che si esaurisce in uno scenario come questo.
Nota: l'approccio utilizzato per Ruby è molto simile a quello di PHP e, in un modo ampio, generale, ondulatorio, possono essere considerati gli stessi per i nostri scopi.
L'approccio multithread: Java
Quindi Java arriva, proprio nel momento in cui hai acquistato il tuo primo nome di dominio ed è stato bello dire casualmente "dot com" dopo una frase. E Java ha il multithreading integrato nel linguaggio, che (soprattutto per quando è stato creato) è davvero fantastico.
La maggior parte dei server Web Java funziona avviando un nuovo thread di esecuzione per ogni richiesta che arriva e quindi in questo thread alla fine chiamando la funzione che tu, come sviluppatore dell'applicazione, hai scritto.
L'esecuzione di I/O in un servlet Java tende ad assomigliare a:
public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // blocking file I/O InputStream fileIs = new FileInputStream("/path/to/file"); // blocking network I/O URLConnection urlConnection = (new URL("http://example.com/example-microservice")).openConnection(); InputStream netIs = urlConnection.getInputStream(); // some more blocking network I/O out.println("..."); }
Poiché il nostro metodo doGet
sopra corrisponde a una richiesta ed è eseguito nel proprio thread, invece di un processo separato per ogni richiesta che richiede la propria memoria, abbiamo un thread separato. Questo ha alcuni vantaggi interessanti, come la possibilità di condividere lo stato, i dati memorizzati nella cache, ecc. Tra i thread perché possono accedere alla memoria dell'altro, ma l'impatto su come interagisce con la pianificazione è ancora quasi identico a ciò che viene fatto in PHP esempio in precedenza. Ogni richiesta ottiene un nuovo thread e le varie operazioni di I/O si bloccano all'interno di quel thread fino a quando la richiesta non viene completamente gestita. I thread vengono raggruppati per ridurre al minimo i costi di creazione e distruzione, ma comunque migliaia di connessioni significano migliaia di thread, il che è dannoso per lo scheduler.
Un'importante pietra miliare è che nella versione 1.4 Java (e un aggiornamento significativo di nuovo nella 1.7) ha acquisito la capacità di eseguire chiamate I/O non bloccanti. La maggior parte delle applicazioni, web e non, non lo usa, ma almeno è disponibile. Alcuni server web Java cercano di trarne vantaggio in vari modi; tuttavia, la stragrande maggioranza delle applicazioni Java distribuite funziona ancora come descritto sopra.
Java ci avvicina e sicuramente ha alcune buone funzionalità pronte all'uso per l'I/O, ma non risolve ancora il problema di cosa succede quando si ha un'applicazione fortemente legata all'I/O che viene martellata in il terreno con molte migliaia di fili bloccanti.
I/O non bloccante come cittadino di prima classe: Node
Il ragazzo popolare sul blocco quando si tratta di un migliore I/O è Node.js. A chiunque abbia avuto anche la più breve introduzione a Node è stato detto che è "non bloccante" e che gestisce l'I/O in modo efficiente. E questo è vero in senso generale. Ma il diavolo è nei dettagli e nei mezzi con cui questa stregoneria è stata realizzata contano quando si tratta di prestazioni.
In sostanza, il cambio di paradigma implementato da Node è che invece di dire essenzialmente "scrivi il tuo codice qui per gestire la richiesta", dicono invece "scrivi il codice qui per iniziare a gestire la richiesta". Ogni volta che devi fare qualcosa che coinvolge l'I/O, fai la richiesta e dai una funzione di callback che Node chiamerà al termine.

Il tipico codice del nodo per eseguire un'operazione di I/O in una richiesta è il seguente:
http.createServer(function(request, response) { fs.readFile('/path/to/file', 'utf8', function(err, data) { response.end(data); }); });
Come puoi vedere, qui ci sono due funzioni di callback. Il primo viene chiamato all'avvio di una richiesta e il secondo quando i dati del file sono disponibili.
Ciò che fa sostanzialmente è dare a Node l'opportunità di gestire in modo efficiente l'I/O tra questi callback. Uno scenario in cui sarebbe ancora più rilevante è dove stai facendo una chiamata al database in Node, ma non mi preoccuperò dell'esempio perché è lo stesso identico principio: avvii la chiamata al database e dai a Node una funzione di callback, esso esegue le operazioni di I/O separatamente utilizzando chiamate non bloccanti e quindi richiama la funzione di richiamata quando i dati richiesti sono disponibili. Questo meccanismo per mettere in coda le chiamate I/O e lasciare che Node lo gestisca e quindi ottenere una richiamata è chiamato "Event Loop". E funziona abbastanza bene.
C'è tuttavia un problema in questo modello. Sotto il cofano, il motivo ha molto più a che fare con il modo in cui il motore JavaScript V8 (il motore JS di Chrome utilizzato da Node) è implementato 1 che con qualsiasi altra cosa. Il codice JS che scrivi viene eseguito tutto in un singolo thread. Pensaci un momento. Significa che mentre l'I/O viene eseguito utilizzando tecniche efficienti non bloccanti, il tuo JS può eseguire operazioni legate alla CPU in un singolo thread, ogni blocco di codice blocca il successivo. Un esempio comune di dove ciò potrebbe verificarsi è il ciclo sui record del database per elaborarli in qualche modo prima di inviarli al client. Ecco un esempio che mostra come funziona:
var handler = function(request, response) { connection.query('SELECT ...', function (err, rows) { if (err) { throw err }; for (var i = 0; i < rows.length; i++) { // do processing on each row } response.end(...); // write out the results }) };
Sebbene Node gestisca l'I/O in modo efficiente, quel ciclo for
nell'esempio sopra utilizza i cicli della CPU all'interno del tuo unico thread principale. Ciò significa che se hai 10.000 connessioni, quel ciclo potrebbe portare l'intera applicazione a una scansione, a seconda del tempo impiegato. Ogni richiesta deve condividere una porzione di tempo, una alla volta, nel thread principale.
La premessa su cui si basa l'intero concetto è che le operazioni di I/O sono la parte più lenta, quindi è molto importante gestirle in modo efficiente, anche se ciò significa eseguire altre elaborazioni in serie. Questo è vero in alcuni casi, ma non in tutti.
L'altro punto è che, e sebbene questa sia solo un'opinione, può essere piuttosto noioso scrivere un mucchio di callback nidificati e alcuni sostengono che rende il codice molto più difficile da seguire. Non è raro vedere callback nidificati a quattro, cinque o anche più livelli all'interno del codice Node.
Torniamo di nuovo ai compromessi. Il modello Node funziona bene se il tuo problema di prestazioni principale è l'I/O. Tuttavia, il suo tallone d'Achille è che puoi entrare in una funzione che sta gestendo una richiesta HTTP e inserire codice ad alta intensità di CPU e portare ogni connessione a una scansione se non stai attento.
Naturalmente non bloccante: vai
Prima di entrare nella sezione di Go, è appropriato per me rivelare che sono un fan di Go. L'ho usato per molti progetti e sono apertamente un sostenitore dei suoi vantaggi in termini di produttività, e li vedo nel mio lavoro quando lo uso.
Detto questo, diamo un'occhiata a come si occupa dell'I/O. Una caratteristica fondamentale del linguaggio Go è che contiene il proprio scheduler. Invece di ogni thread di esecuzione corrispondente a un singolo thread del sistema operativo, funziona con il concetto di "goroutine". E il runtime Go può assegnare una goroutine a un thread del sistema operativo e farlo eseguire, oppure sospenderlo e non associarlo a un thread del sistema operativo, in base a ciò che sta facendo quella goroutine. Ogni richiesta che arriva dal server HTTP di Go viene gestita in una Goroutine separata.
Il diagramma di come funziona lo scheduler si presenta così:
Sotto il cofano, questo è implementato da vari punti nel runtime Go che implementano la chiamata I/O effettuando la richiesta di scrittura/lettura/connessione/ecc., mettono in sospensione la goroutine corrente, con le informazioni per riattivare la goroutine quando è possibile intraprendere ulteriori azioni.
In effetti, il runtime Go sta facendo qualcosa di non molto dissimile da quello che sta facendo Node, tranne per il fatto che il meccanismo di callback è integrato nell'implementazione della chiamata I/O e interagisce automaticamente con lo scheduler. Inoltre, non soffre della restrizione di dover eseguire tutto il codice del gestore nello stesso thread, Go mapperà automaticamente le tue Goroutine su tutti i thread del sistema operativo che ritiene appropriati in base alla logica nel suo scheduler. Il risultato è un codice come questo:
func ServeHTTP(w http.ResponseWriter, r *http.Request) { // the underlying network call here is non-blocking rows, err := db.Query("SELECT ...") for _, row := range rows { // do something with the rows, // each request in its own goroutine } w.Write(...) // write the response, also non-blocking }
Come puoi vedere sopra, la struttura del codice di base di ciò che stiamo facendo assomiglia a quella degli approcci più semplicistici, eppure raggiunge l'I/O non bloccante sotto il cofano.
Nella maggior parte dei casi, questo finisce per essere "il meglio di entrambi i mondi". L'I/O non bloccante viene utilizzato per tutte le cose importanti, ma il codice sembra bloccante e quindi tende ad essere più semplice da comprendere e mantenere. L'interazione tra lo scheduler Go e lo scheduler del sistema operativo gestisce il resto. Non è una magia completa e, se costruisci un sistema di grandi dimensioni, vale la pena dedicare del tempo per capire più dettagli su come funziona; ma allo stesso tempo, l'ambiente che ottieni "out-of-the-box" funziona e si adatta abbastanza bene.
Go potrebbe avere i suoi difetti, ma in generale, il modo in cui gestisce l'I/O non è tra questi.
Bugie, maledette bugie e benchmark
È difficile fornire tempi esatti sul cambio di contesto coinvolto in questi vari modelli. Potrei anche sostenere che ti è meno utile. Quindi, invece, ti fornirò alcuni benchmark di base che confrontano le prestazioni complessive del server HTTP di questi ambienti server. Tieni presente che molti fattori sono coinvolti nelle prestazioni dell'intero percorso di richiesta/risposta HTTP end-to-end e i numeri presentati qui sono solo alcuni esempi che ho messo insieme per fornire un confronto di base.
Per ciascuno di questi ambienti, ho scritto il codice appropriato da leggere in un file da 64k con byte casuali, ho eseguito un hash SHA-256 su di esso N numero di volte (N è stato specificato nella stringa di query dell'URL, ad esempio .../test.php?n=100
) e stampa l'hash risultante in formato esadecimale. L'ho scelto perché è un modo molto semplice per eseguire gli stessi benchmark con un I/O coerente e un modo controllato per aumentare l'utilizzo della CPU.
Consulta queste note di benchmark per ulteriori dettagli sugli ambienti utilizzati.
Per prima cosa, diamo un'occhiata ad alcuni esempi di bassa concorrenza. L'esecuzione di 2000 iterazioni con 300 richieste simultanee e un solo hash per richiesta (N=1) fornisce questo:
È difficile trarre una conclusione da questo solo grafico, ma questo mi sembra che, a questo volume di connessioni e calcoli, stiamo vedendo tempi che hanno più a che fare con l'esecuzione generale dei linguaggi stessi, molto più che il I/O. Si noti che le lingue che sono considerate "linguaggi di scripting" (dattilografia libera, interpretazione dinamica) hanno le prestazioni più lente.
Ma cosa succede se aumentiamo N a 1000, sempre con 300 richieste simultanee: lo stesso carico ma 100 volte più iterazioni hash (carico della CPU significativamente maggiore):
All'improvviso, le prestazioni del nodo diminuiscono in modo significativo, perché le operazioni ad alta intensità di CPU in ogni richiesta si bloccano a vicenda. E abbastanza interessante, le prestazioni di PHP migliorano molto (rispetto alle altre) e battono Java in questo test. (Vale la pena notare che in PHP l'implementazione SHA-256 è scritta in C e il percorso di esecuzione trascorre molto più tempo in quel ciclo, dal momento che stiamo facendo 1000 iterazioni hash ora).
Ora proviamo 5000 connessioni simultanee (con N = 1) - o il più vicino possibile. Sfortunatamente, per la maggior parte di questi ambienti, il tasso di errore non è stato insignificante. Per questo grafico, esamineremo il numero totale di richieste al secondo. Più alto è meglio è :
E l'immagine sembra abbastanza diversa. È un'ipotesi, ma sembra che con un volume di connessione elevato l'overhead per connessione coinvolto nella generazione di nuovi processi e la memoria aggiuntiva ad esso associata in PHP+Apache sembra diventare un fattore dominante e riempie le prestazioni di PHP. Chiaramente, Go è il vincitore qui, seguito da Java, Node e infine PHP.
Mentre i fattori coinvolti con la tua produttività complessiva sono molti e variano ampiamente da un'applicazione all'altra, più capisci le viscere di ciò che sta succedendo sotto il cofano e i compromessi coinvolti, meglio starai.
In sintesi
Con tutto quanto sopra, è abbastanza chiaro che con l'evoluzione dei linguaggi, le soluzioni per gestire applicazioni su larga scala che eseguono molti I/O si sono evolute con esso.
Ad essere onesti, sia PHP che Java, nonostante le descrizioni in questo articolo, hanno implementazioni di I/O non bloccanti disponibili per l'uso nelle applicazioni web. Ma questi non sono così comuni come gli approcci descritti sopra e il conseguente sovraccarico operativo della manutenzione dei server che utilizzano tali approcci dovrebbe essere preso in considerazione. Per non parlare del fatto che il tuo codice deve essere strutturato in modo da funzionare con tali ambienti; la tua "normale" applicazione web PHP o Java di solito non funzionerà senza modifiche significative in un tale ambiente.
A titolo di confronto, se consideriamo alcuni fattori significativi che influiscono sulle prestazioni e sulla facilità d'uso, otteniamo questo:
Lingua | Thread vs. Processi | I/O non bloccanti | Facilità d'uso |
---|---|---|---|
PHP | Processi | No | |
Giava | Fili | A disposizione | Richiede richiamate |
Node.js | Fili | sì | Richiede richiamate |
andare | Discussioni (Goroutine) | sì | Non sono necessarie richiamate |
I thread saranno generalmente molto più efficienti in termini di memoria rispetto ai processi, poiché condividono lo stesso spazio di memoria mentre i processi no. Combinando ciò con i fattori relativi all'I/O non bloccante, possiamo vedere che almeno con i fattori considerati sopra, man mano che scendiamo nell'elenco, l'impostazione generale relativa all'I/O migliora. Quindi, se dovessi scegliere un vincitore nel concorso di cui sopra, sarebbe sicuramente Go.
Anche così, in pratica, la scelta di un ambiente in cui costruire la tua applicazione è strettamente connessa alla familiarità che il tuo team ha con tale ambiente e alla produttività complessiva che puoi ottenere con esso. Quindi potrebbe non avere senso che ogni team si tuffi e inizi a sviluppare applicazioni e servizi Web in Node o Go. In effetti, la ricerca di sviluppatori o la familiarità del tuo team interno è spesso citata come la ragione principale per non utilizzare un linguaggio e/o un ambiente diverso. Detto questo, i tempi sono cambiati negli ultimi quindici anni o giù di lì, molto.
Si spera che quanto sopra aiuti a dipingere un quadro più chiaro di ciò che sta accadendo sotto il cofano e ti dia alcune idee su come gestire la scalabilità del mondo reale per la tua applicazione. Buon input e output!