Confronto nell’utilizzo di risorse tra i paradigmi imperativo e reattivo per servizi web ad alto throughput.
Nel mio primo codice scrissi quelle che a quel tempo venivano chiamate “istruzioni” o anche “comandi”.
Erano i lontani anni ‘90, e a quel tempo un “programma” era senza ambiguità una “sequenza” di “istruzioni” da eseguirsi una dopo l’altra. Oggi questo modo sequenziale di intendere la programmazione è noto come paradigma “imperativo” ed è solo uno tra quelli a disposizione dello sviluppatore.
Ora lo scenario è molto diverso, la richiesta di throughput per i servizi web è cresciuta drammaticamente, i servizi migrano verso i cloud provider dove le risorse si possono pagare in base all’utilizzo.
Quindi è necessario chiedersi se il paradigma imperativo sia ancora adeguato o se debba magari lasciare il campo ad una diversa modalità più adatta a navigare nelle turbolente acque del cloud.
Ma vediamo nel dettaglio cosa implica la scelta del paradigma imperativo nel caso di una semplice applicazione web che legge da un database e produce una pagina html:
- L’utente, interagendo tramite il proprio browser si collega al web server e richiede una risorsa sfruttando il protocollo http e rimane in attesa.
- Il web server interpreta la richiesta e cerca o crea una thread in cui l’applicazione (il “programma”) sia disponibile e le inoltra la richiesta.
- L’applicazione verifica la legittimità della richiesta quindi interroga il database per recuperare la risorsa richiesta sfruttando una connessione disponibile e rimane in attesa della risposta.
- Il database cerca la risorsa richiesta e la fornisce all’applicazione concludendo la sua attesa.
- Ottenuta la risorsa dal database l’applicazione costruisce la risposta (pagina html) per l’utente e la consegna al web server rendendosi disponibile per un’altra chiamata.
- Il web server conclude l’operazione trasmettendo la risposta al browser dell’utente e chiude la connessione http.
- Il browser interpreta la risposta e la mostra all’utente.
In questa semplice sequenza emerge come ogni richiesta ha una thread dedicata e che questa rimane bloccata (punto 3) in attesa della disponibilità di una connessione e della risposta del database. Con bloccata si intende che non può gestire nessun’altra operazione, costringendo quindi il web server a gestire qualsiasi richiesta concomitante indirizzandola verso un’altra thread.
Questo evidentemente comporta un uso non ottimale delle risorse del server, soprattutto nel caso in cui esso sia sottoposto ad un alto carico di lavoro.
La domanda è:
c’è un altro modo? Cosa si può fare per ottimizzare l’uso delle risorse e del tempo macchina?
Ad esempio mentre aspetta la risposta dal database l’applicazione potrebbe valutare la legittimità di un’altra richiesta e magari rispondere con una pagina di errore qualora non fosse legittima, un po’ come facciamo noi umani quando siamo in coda davanti ad una cassa e sfruttiamo questo “tempo morto” per rispondere a qualche messaggio whatsapp.
A questa a domanda (e non solo questa) risponde il paradigma “reactive” ben sintetizzato nel suo manifesto, nel quale si evidenzia come nell’ecosistema applicativo attuale, caratterizzato da servizi cloud ad alta resilienza e performance, sia necessario un cambio di paradigma rispetto all’architettura tradizionale che porti alla costruzione di servizi reattivi, ossia responsivi, elastici e, cosa che risponde alla nostra domanda, orientati ai messaggi, ossia allo scambio di messaggi asincroni.
Questo tuttavia cambia significativamente il modo di pensare un “programma”, vediamo come nella sequenza di sopra:
- L’utente, interagendo tramite il proprio browser si collega al web server e richiede una risorsa sfruttando il protocollo http e rimane in attesa.
- Il web server interpreta la richiesta e cerca una thread in cui l’applicazione (il “programma”) sia disponibile e si iscrive come interessato all’evento della risposta.
- L’applicazione verifica la legittimità della richiesta quindi invoca il “gestore degli eventi” dichiarando
– come interrogare il database
– il tipo di elaborazione da eseguire con le informazioni ottenute dal database nel caso in cui la richiesta vada a buon fine o meno.
per poi ritornare immediatamente disponibile per la prossima richiesta. - Il database cerca la risorsa richiesta e la fornisce al gestore degli eventi.
- Ottenuta la risorsa dal database il gestore degli eventi chiama la funzione di callback della thread dell’applicazione affinché possa eseguire le istruzioni dichiarate in precedenza e fornire la risposta alla richiesta che si era iscritta per la risposta.
- Il web server conclude l’operazione trasmettendo la risposta al browser dell’utente e chiude la connessione http.
- Il browser interpreta la risposta e la mostra all’utente.
Nel paradigma reattivo quindi la sequenza delle istruzioni scritte nel codice non coincide più con la sequenza temporale delle istruzioni eseguite runtime, ma enuncia come il programma deve reagire agli eventi previsti e per i quali il programma si è registrato come osservatore.
Questo modo di affrontare la programmazione richiede al programmatore un cambio radicale di pensiero oltre che di tecnica. Fortunatamente in internet si trovano molte guide e tutorial, come ad esempio
- il bell’articolo introduttivo di Celani e Bianco
- quello di Yuko relativo alle classe CompletableFuture introdotte in java 9
- o quello sintetico di Reynolds
Cui rimando per approfondire il tema.
Per illustrare brevemente la profondità del cambio di paradigma, tuttavia, concludo con un brevissimo esempio in java:
immaginiamo di dover eseguire una chiamata al database ma di non voler propagare l’eventuale eccezione al chiamante, in java seguendo il paradigma imperativo lo scriverei così:
String findValueFromDB(String key) {
try {
return this.dbImperativeDataSource.find(key);
} catch(Exception ex) {
return null;
}
}
In questo caso la funzione findValueFromDB ritornerà al chiamante il valore trovato, o null in caso di eccezione.
Ma vediamo come questo cambia seguendo il paradigma reactive:
Uni<String> findValueFromDB(String key) {
return this.dbReactiveDataSource.find(key)
.onFailure().recoverWithNull();
}
In questo caso, abbiamo che:
- la funzione non ritorna direttamente il valore (String) ma una promessa di valore (Uni delle librerie Mutiny)
- per definire il comportamento del programma in caso di errore viene registrata la thread principale come osservatrice dell’evento di fallimento (l’evento rappresentato dalla funzione “onFailure()”) dichiarando la funzione di callback (in questo caso la funzione predefinita: recoverWithNull()) contenente il comportamento da tenere nel caso in cui l’evento si verificasse.
Da queste poche righe di codice credo emergano in maniera evidente le profonde implicazioni che questo paradigma comporta sul modo di sviluppare.
Non è infatti semplice ragionare in termini di eventi e callback, e il debug del codice diventa anch’esso più complesso essendo difficile seguire il flusso di esecuzione del programma che è diviso su più thread.
Si può quindi concludere affermando che la promessa di maggiore efficienza fatta dal paradigma reactive viene mantenuta, ma al prezzo di una maggiore complessità del codice e quindi, in ultima istanza del suo costo, rendendo questo approccio sicuramente utile ma da utilizzarsi in quei casi in cui il carico previsto per l’applicazione renda necessaria e conveniente l’ottimizzazione delle risorse e un guadagno di performance.
Come dicevo all’inizio di questo articolo, tuttavia, ci sono anche altri paradigmi e metodi per affrontare la questione del risparmio di risorse, come ad esempio l’approccio Virtual Thread, ma questa… è un’altra storia e un buon argomento per un prossimo post.
Alessandro Marcellini – Solutions Architect