Se devi porre questa domanda, probabilmente non hai familiarità con ciò che fanno la maggior parte delle applicazioni / servizi web. Probabilmente stai pensando che tutto il software faccia questo:
user do an action
│
v
application start processing action
└──> loop ...
└──> busy processing
end loop
└──> send result to user
Tuttavia, non è così che funzionano le applicazioni Web o qualsiasi applicazione con un database come back-end. Le app Web fanno questo:
user do an action
│
v
application start processing action
└──> make database request
└──> do nothing until request completes
request complete
└──> send result to user
In questo scenario, il software impiega la maggior parte del tempo di esecuzione utilizzando lo 0% del tempo di CPU in attesa del ritorno del database.
App di rete multithread:
Le app di rete multithread gestiscono il carico di lavoro sopra indicato in questo modo:
request ──> spawn thread
└──> wait for database request
└──> answer request
request ──> spawn thread
└──> wait for database request
└──> answer request
request ──> spawn thread
└──> wait for database request
└──> answer request
Quindi il thread trascorre la maggior parte del tempo usando CPU allo 0% in attesa che il database restituisca i dati. Nel fare ciò, hanno dovuto allocare la memoria richiesta per un thread che include uno stack di programma completamente separato per ogni thread, ecc. Inoltre, avrebbero dovuto avviare un thread che, sebbene non sia costoso come l'avvio di un processo completo, non è ancora esattamente economico.
Ciclo di eventi con filetto singolo
Dato che impieghiamo la maggior parte del nostro tempo utilizzando la CPU allo 0%, perché non eseguire un po 'di codice quando non utilizziamo la CPU? In questo modo, ogni richiesta otterrà comunque la stessa quantità di tempo CPU delle applicazioni multithread ma non è necessario avviare un thread. Quindi facciamo questo:
request ──> make database request
request ──> make database request
request ──> make database request
database request complete ──> send response
database request complete ──> send response
database request complete ──> send response
In pratica entrambi gli approcci restituiscono dati con approssimativamente la stessa latenza poiché è il tempo di risposta del database a dominare l'elaborazione.
Il vantaggio principale qui è che non abbiamo bisogno di generare un nuovo thread, quindi non abbiamo bisogno di fare un sacco di malloc che ci rallenterebbero.
Threading magico e invisibile
La cosa apparentemente misteriosa è come entrambi gli approcci sopra riescono a eseguire il carico di lavoro in "parallelo"? La risposta è che il database è thread. Quindi la nostra app a thread singolo sta effettivamente sfruttando il comportamento multi-thread di un altro processo: il database.
Laddove l'approccio a singlethreaded fallisce
Un'app con singlethreading ha esito negativo se è necessario eseguire molti calcoli della CPU prima di restituire i dati. Ora, non intendo un ciclo for che elabora il risultato del database. Questo è ancora principalmente O (n). Voglio dire cose come fare trasformata di Fourier (ad esempio codifica mp3), ray tracing (rendering 3D) ecc.
Un altro inconveniente delle app con filetto singolo è che utilizzerà solo un singolo core della CPU. Quindi, se si dispone di un server quad-core (non raro oggi) non si utilizzano gli altri 3 core.
Dove l'approccio multithreading fallisce
Un'app multithreading fallisce se devi allocare molta RAM per thread. Innanzitutto, l'utilizzo della RAM in sé significa che non è possibile gestire tutte le richieste di un'app basata su singlet. Peggio ancora, il malloc è lento. Allocare un sacco di oggetti (che è comune per i moderni framework Web) significa che possiamo potenzialmente finire per essere più lenti delle app a singolo canale. Qui di solito vince node.js.
Un caso d'uso che alla fine peggiora il multithreading è quando è necessario eseguire un altro linguaggio di scripting nel thread. Prima di solito devi mallocare l'intero runtime per quella lingua, quindi devi mallocare le variabili usate dal tuo script.
Quindi, se stai scrivendo app di rete in C o go o java, l'overhead del threading di solito non sarà troppo male. Se stai scrivendo un server web C per servire PHP o Ruby, è molto facile scrivere un server più veloce in javascript o Ruby o Python.
Approccio ibrido
Alcuni server Web utilizzano un approccio ibrido. Nginx e Apache2, ad esempio, implementano il loro codice di elaborazione di rete come un pool di thread di loop di eventi. Ogni thread esegue un loop di eventi contemporaneamente elaborando le richieste a thread singolo ma le richieste vengono bilanciate in base al carico tra più thread.
Alcune architetture a thread singolo utilizzano anche un approccio ibrido. Invece di avviare più thread da un singolo processo è possibile avviare più applicazioni, ad esempio 4 server node.js su una macchina quad-core. Quindi si utilizza un bilanciamento del carico per distribuire il carico di lavoro tra i processi.
In effetti i due approcci sono immagini speculari tecnicamente identiche l'una dell'altra.