Block Image

Con la versione di Spring Boot 3.2, è stato ufficialmente aggiunto il supporto ai virtual threads!
Ho pensato quindi di effettuare dei test di carico su due applicazioni Spring Boot, una che utilizza il paradigma reattivo, l'altra che utilizza appunto i virtual thread. Ho creato due applicazioni semplici che effettuano operazioni comuni; recupero dati da un database e chiamata REST verso un servizio esterno.

In particolare, le due applicazioni hanno la seguente API REST in GET:
/users?name=${name}&surname=${surname}.
Questa API:

  1. effettua la seguente query su una tabella di Postgres:
    SELECT * FROM USERS WHERE NAME=${name} AND SURNAME=${surname}
  2. per ogni riga risultante della query (che nella pratica sarà sempre una), viene effettuata una chiamata REST a un client HTTP che dato in input il surname, restituisce quest'ultimo in uppercase. Questo servizio HTTP risponde in circa 300 millisecondi
  3. infine viene restituito al client una lista di coppie name,surname col surname in uppercase.

Due righe su WebFlux

Spring WebFlux utilizza la libreria Reactor, una implementazione di Reactive Streams. Queste API permettono di creare applicazioni non bloccanti. Invece di utilizzare il classico paradigma "thread per request", utilizza quello de "l'event-loop" tanto caro a Javascript. Con questo approccio è possibile gestire le risorse computazioni con più ottimizzazione, poiché non abbiamo thread che "aspettano".
L'approccio reactive implica un tipo di programmazione funzionale, che per chi viene dal mondo di Java puro, non è facile da digerire.

Due righe sui Virtual Threads

I virtual thread sono disponibili ufficialmente con la versione 21 di Java, grazie al progetto Loom.
In sintesi, prima di questa feature, i thread Java erano associati 1:1 con i thread del sistema operativo.
Creare thread in Java quindi era dispendioso, così come tenerli bloccati in attesa di qualcosa!

Con i Virtual Threads questo non è più vero! I virtual thread sono sempre istanze di java.lang.Thread che però non sono legati 1:1 ai thread del sistema operativo. Quando il codice di un virtual thread incontra un'operazione bloccante, la JVM sospende quel thread virtuale finché il risultato dell'operazione non sarà disponibile. Il thread del sistema operativo associato a quel virtual thread viene "liberato" per essere utilizzato da un altro virtual thread.
I virtual thread sono oggetti leggeri, ne possiamo creare a migliaia.

Sono adatti per applicazioni che hanno molte operazioni bloccanti. Non sono adatte per applicazioni CPU-intensive.
Inoltre, col fatto di poter creare migliaia di thread, potrebbe essere rischioso abusare di loro quando vogliamo memorizzare qualcosa nel contesto dei thread (si pensi all'uso dei ThreadLocal).

Dati sulla preparazione dei test

  • Macchina utilizzata: AWS t3.micro (2 vCPU, 1 GiB di RAM) con sistema operativo Amazon Linux 2023.
  • Database: Tabella di Postgres con circa 1800 record.
  • Rete: l'applicazione server (quella WebFlux e quella "virtual thread"), applicazione mock-client e database Postgres sulla stessa VPC.
  • HTTP Client utilizzati: WebClient per WebFlux, RestClient per virtual thread.
  • Driver connessione database: R2DBC per WebFlux (con Spring Data R2DBC), JDBC per "virtual thread" (con Spring Data JDBC).
  • Versione Spring Boot: 3.2.0
  • Codice applicazioni: spring-virtual-thread-test
  • Tool utilizzato per i test di carico: jMeter

Per i risultati dei test, verrà preso in considerazione il parametro Throughput (puoi trovare la definizione del parametro qui) e il tempo medio di risposta, espresso in millisecondi.
Più è alto il valore di Throughput, meglio è.
Più è basso il valore del tempo medio di risposta, meglio è.

Il test utilizza dei valori casuali di name e surname (valori però presenti nel Database) per ogni chiamata HTTP effettuata.

In ogni test, per ogni tipologia di parametro, il vincitore verrà colorato di verde, il perdente di rosso. Verranno colorati entrambi di grigio se non c'è un vincitore.

Update: Sono stati aggiunti anche i grafici di CPU e Heap utilizzata. Essendo due valori diversi dello stesso grafico, non ho decretato un vincitore per ogni test. Tuttavia a fine articolo ti darò le mie considerazioni anche su questi due aspetti.

Primo test di carico: 100 utenti concorrenti, ripetuto 20 volte

  • Number of Threads (users): 100.
  • Loop Count: 20.
  • Total requests: 2000.

Average Response Time: WebFlux 351 - VT 350 (nessun vincitore)

Block Image
WebFlux
Block Image
Virtual Threads

Throughput: WebFlux 270.4 - VT 258.2 (+ 12.2 per WebFlux)

Block Image
WebFlux
Block Image
Virtual Threads

CPU/Heap:

Block Image
WebFlux
Block Image
Virtual Threads

Secondo test di carico: 200 utenti concorrenti, ripetuto 20 volte

  • Number of Threads (users): 200.
  • Loop Count: 20.
  • Total requests: 4000.

Average Response Time: WebFlux 374 - VT 436 (- 62 per WebFlux)

Block Image
WebFlux
Block Image
Virtual Threads

Throughput: WebFlux 453.6/sec - VT 390.9/sec (+ 62.7 per WebFlux)

Block Image
WebFlux
Block Image
Virtual Threads

CPU/Heap:

Block Image
WebFlux
Block Image
Virtual Threads

Terzo test di carico: 400 utenti concorrenti, ripetuto 20 volte

  • Number of Threads (users): 400.
  • Loop Count: 20.
  • Total requests: 8000.

Average Response Time: WebFlux 528 - VT 549 (- 21 per WebFlux)

Block Image
WebFlux
Block Image
Virtual Threads

Throughput: WebFlux 611.7/sec - VT 595.8/sec (+ 15.9 per WebFlux)

Block Image
WebFlux
Block Image
Virtual Threads

CPU and heap usage:

Block Image
WebFlux
Block Image
Virtual Threads

Quarto test di carico: 800 utenti concorrenti, ripetuto 20 volte

  • Number of Threads (users): 800.
  • Loop Count: 20.
  • Total requests: 16000.

Average Response Time: WebFlux 999 - VT 998 (nessun vincitore)

Block Image
WebFlux
Block Image
Virtual Threads

Throughput: WebFlux 653.2/sec - VT 614.8/sec (+ 38.4 per WebFlux)

Block Image
WebFlux
Block Image
Virtual Threads

CPU and heap usage:

Block Image
WebFlux
Block Image
Virtual Threads

Conclusioni

Da questi test, si evince il fatto che più aumentano le richieste concorrenti, più WebFlux prende vantaggio sui Virtual Threads, in termini di Throughput. Sui tempi medi di risposta, anche qui vince di poco WebFlux, ma non ho trovato un pattern come per il Throughput.

Sulla CPU, sembra che WebFlux utilizzi più CPU a basso carico rispetto a VT. Aumentando il carico di richieste, i valori di WebFlux e VT sono equiparabili. Infine, per quanto riguarda l'utilizzo dell'Heap, sembra che VT utilizzi più memoria rispetto a WebFlux.

A queste condizioni, il vincitore di questa sfida è WebFlux!
Tuttavia, questo articolo non vuole esprimere un preferenza sull'uso di WebFlux a discapito dei virtual thread. L'utilizzo dell'uno o dell'altro dipende da progetto a progetto, e da team a team (quanto siete confidenti nell'approccio reactive).

Altri articoli su Spring: Spring.
Articoli su Docker: Docker.

Libri consigliati su Spring, Docker e Kubernetes: