Risposta alla domanda.
Storia della domanda.
Il ReentrantReadWriteLock introdotto in Java 5 ha fornito un notevole miglioramento della concorrenza rispetto ai mutex singoli consentendo lettori concorrenti multipli. Tuttavia, il suo design proibisce esplicitamente l'aggiornamento del blocco: acquisire un blocco di scrittura mentre si detiene un blocco di lettura, poiché l'implementazione tiene traccia dei conteggi di possesso delle letture per ogni thread. Quando un thread che detiene un blocco di lettura tenta di acquisire il blocco di scrittura, si blocca: il blocco di scrittura richiede un possesso esclusivo, che non può essere concesso mentre rimangono posseduti blocchi di lettura (incluso quello del thread stesso). StampedLock, introdotto in Java 8 come alternativa non reentrante, ha affrontato questa limitazione tramite timbri di lettura ottimistica che non richiedono alcun possesso del blocco durante la fase di lettura, accompagnati da meccanismi di validazione e conversione atomici.
Il problema.
Il pericolo fondamentale deriva dall'asimmetria nella semantica di acquisizione dei blocchi. In ReentrantReadWriteLock, l'aggiornamento richiede di rilasciare il blocco di lettura prima di acquisire il blocco di scrittura, creando una finestra vulnerabile in cui altri thread potrebbero acquisire il blocco di scrittura o modificare lo stato tra il rilascio e la riacquisizione. Questo costringe gli sviluppatori a implementare complessi schemi di blocco a doppio controllo o cicli di ripetizione, aumentando la complessità del codice e la latenza. Inoltre, se uno sviluppatore prova erroneamente un aggiornamento diretto (writeLock().lock() mentre detiene readLock()), il thread entra in uno stato di deadlock irreversibile aspettando che si rilasci il permesso di lettura.
La soluzione.
StampedLock elimina questo pericolo attraverso tryOptimisticRead(), che restituisce un lungo timbro senza acquisire alcun blocco o incrementare i conteggi dei lettori. Il thread esegue le proprie operazioni di lettura e successivamente chiama validate(stamp); se il timbro rimane valido (non è avvenuta alcuna scrittura intermedia), la lettura è stata coerente senza bloccarsi. Se il thread rileva la necessità di scrivere, tenta tryConvertToWriteLock(stamp), che convalida atomicamente il timbro e acquisisce il blocco di scrittura solo se lo stato non è cambiato dall'inizio della lettura ottimistica. Questo approccio previene il deadlock perché il thread non tiene mai un blocco di lettura conflittuale durante la transizione e evita la finestra di gara delle strategie di rilascio e riacquisizione rendendo l'aggiornamento condizionale sulla coerenza dello stato.
Esempio di codice.
import java.util.concurrent.locks.StampedLock; public class AtomicUpgradeCache { private final StampedLock lock = new StampedLock(); private int value = 0; public void conditionalUpdate(int threshold, int newValue) { long stamp = lock.tryOptimisticRead(); int current = value; // Valida prima di agire if (!lock.validate(stamp)) { stamp = lock.readLock(); try { current = value; } finally { lock.unlockRead(stamp); } } if (current < threshold) { // Tentativo di aggiornamento atomico stamp = lock.tryConvertToWriteLock(stamp); if (stamp == 0L) { // La conversione è fallita, acquisisci un nuovo blocco di scrittura stamp = lock.writeLock(); } try { // Controlla di nuovo la condizione sotto il blocco esclusivo if (value < threshold) { value = newValue; } } finally { lock.unlock(stamp); } } } }
Situazione della vita reale
Descrizione del problema.
Una piattaforma di trading ad alta frequenza manteneva una cache di ordine in memoria rappresentante la profondità di mercato in tempo reale, richiedendo circa 50.000 letture al secondo da centinaia di thread ma solo aggiornamenti occasionali all'arrivo dei tick di prezzo. L'implementazione iniziale utilizzava blocchi synchronized, causando catastrofiche picchi di latenza durante la volatilità del mercato quando i thread contendevano il monitor, con la latenza di lettura che occasionalmente superava i 500 millisecondi. Il team ingegneristico doveva eliminare completamente la contesa sul lato lettura pur assicurando che gli aggiornamenti dei prezzi potessero verificare in modo atomico le condizioni di mercato e modificare il libro senza bloccarsi durante l'aggiornamento da osservazione a mutazione.
Diverse soluzioni considerate.
Soluzione 1: ReentrantReadWriteLock con rilascio e riacquisizione.
Questo approccio prevedeva di acquisire il blocco di lettura per ispezionare le condizioni di mercato, rilasciarlo, quindi tentare immediatamente di acquisire il blocco di scrittura se era necessario un aggiornamento. Sebbene questo prevenga il deadlock, introduce una significativa condizione di gara: tra il rilascio del blocco di lettura e l'acquisizione del blocco di scrittura, i thread concorrenti potrebbero osservare la stessa condizione obsoleta e avviare query di database ridondanti o scambi di chiamate API, risultando in un comportamento di gregge fragoroso e sprecando risorse computazionali. Inoltre, il costante cambio di contesto tra modalità di lettura e scrittura aggiungeva un overhead misurabile durante i periodi di trading ad alto volume.
Soluzione 2: Snapshot immutabili con riferimenti volatili.
Questa soluzione abbandonava completamente i blocchi a favore del mantenimento del libro degli ordini come una struttura dati immutabile referenziata da un campo volatile. I lettori semplicemente dereferenziavano il volatile per ottenere uno snapshot coerente, mentre gli scrittori creavano copie completamente nuove del libro degli ordini ed eseguivano operazioni atomiche di confronto e impostazione sul riferimento. Questo eliminava completamente la contesa di lettura e forniva ottime prestazioni di lettura. Tuttavia, generava una massiccia pressione di allocazione: ogni piccolo aggiornamento di prezzo richiedeva di copiare l'intera struttura del libro degli ordini, attivando frequenti pause di garbage collection della giovane generazione che violavano i requisiti di latenza di 10 millisecondi dell'applicazione durante le condizioni di mercato volatili.
Soluzione 3: StampedLock con letture ottimistiche e conversione condizionale.
La soluzione scelta ha utilizzato StampedLock per fornire accesso in lettura ottimistica per il percorso caldo: i thread avrebbero letto in modo ottimistico lo stato del libro degli ordini usando tryOptimisticRead(), convalidato il timbro e proceduto solo se non era avvenuta alcuna scrittura concorrente. Per le rare operazioni di scrittura, il sistema tentava di convertire direttamente il timbro ottimistico in un blocco di scrittura usando tryConvertToWriteLock(), convalidando atomicamente che lo stato osservato rimanesse attuale e acquisendo accesso esclusivo solo se valido. Se la conversione falliva, il sistema tornava all'acquisizione esplicita del blocco di scrittura con logiche di ripetizione tradizionali. Questo approccio forniva un sovraccarico quasi nullo per le letture (simile all'accesso volatile raw) prevenendo i rischi di deadlock insiti negli aggiornamenti del ReentrantReadWriteLock.
Quale soluzione è stata scelta (e perché).
Il team ha selezionato Soluzione 3 perché bilanciava in modo unico le estreme esigenze di throughput delle letture (le letture ottimistiche scalano linearmente con il numero di thread) con i requisiti di sicurezza atomica per aggiornamenti condizionali. A differenza di Soluzione 1, eliminava la finestra di gara tra il rilascio della lettura e l'acquisizione della scrittura attraverso il meccanismo di validazione del timbro. A differenza di Soluzione 2, evitava la pressione di allocazione della memoria consentendo modifiche in loco sotto la protezione del blocco di scrittura convertito, evitando di richiedere copie strutturali complete per ogni piccolo aggiustamento di prezzo. La capacità di convalidare e convertire in modo atomico garantiva che gli aggiornamenti dei prezzi avvenissero solo se lo stato del mercato corrispondeva esattamente ai criteri di decisione, prevenendo le violazioni di coerenza che avevano afflitto i prototipi precedenti.
Il risultato.
Dopo l'implementazione, l'applicazione ha sostenuto 50.000 letture concorrenti al secondo con latenze p99.9 inferiori a 15 microsecondi, rappresentando un miglioramento di 30 volte rispetto all'approccio sincronizzato precedente. Durante la volatilità simulata del mercato con 1.000 aggiornamenti di prezzo concorrenti al secondo, il sistema ha mantenuto zero incidenti di deadlock e le pause di garbage collection sono rimaste inferiori a 2 millisecondi. L'implementazione di StampedLock ha gestito con successo sei mesi di trading in produzione senza un singolo incidente o conflitto di concorrenza, convalidando la decisione architettonica di utilizzare il locking ottimistico per scenari di lettura ad alta frequenza.
Cosa spesso perde di vista i candidati
Perché StampedLock non supporta la reentranza e quale modalità di fallimento catastrofica si verifica se un thread tenta di acquisire ricorsivamente lo stesso blocco?
StampedLock è esplicitamente progettato come un blocco non reentrante per ridurre al minimo il tracciamento dello stato interno e massimizzare il throughput. A differenza di ReentrantReadWriteLock, che mantiene una mappa dei thread proprietari e dei conteggi di possesso, StampedLock tiene traccia solo se qualche thread ha accesso, non quale specifico thread lo possiede. Di conseguenza, se un thread che tiene un blocco di lettura tenta di acquisire un altro blocco di lettura (o un blocco di scrittura) sulla stessa istanza di StampedLock, si blocca immediatamente: la chiamata di acquisizione si blocca in attesa che tutti i blocchi esistenti vengano rilasciati, ma il thread bloccato stesso detiene uno di quei blocchi, creando una dipendenza circolare irrisolvibile. Gli sviluppatori devono rifattorizzare il codice per passare il timbro corrente come parametro di metodo piuttosto che tentare acquisizioni di blocco nidificate, il che spesso richiede significativi cambiamenti architettonici alle API interne che precedentemente si basavano sullo stato del blocco locale al thread.
Come differiscono le semantiche di visibilità della memoria della modalità di lettura ottimistica di StampedLock dal suo blocco di lettura pessimistica, e perché validate() da solo non è sufficiente per garantire coerenza senza adeguate relazioni di happens-before?
La lettura ottimistica tramite tryOptimisticRead() non fornisce alcuna garanzia di happens-before da sola; cattura semplicemente un timbro di versione senza emettere barriere di memoria o prevenire il riordino delle istruzioni. I dati osservati durante la fase ottimistica potrebbero riflettere linee di cache CPU obsolete o oggetti parzialmente costruiti poiché il modello di memoria JVM tratta le letture ottimistiche come accessi a variabili ordinarie senza semantiche di sincronizzazione. Solo quando validate(stamp) restituisce true si stabilisce che non è stato acquisito alcun blocco di scrittura da quando è iniziata la lettura ottimistica, creando così l'edge happens-before necessario rispetto al rilascio del blocco di scrittura più recente. Tuttavia, i candidati spesso trascurano che validate() garantisce solo lo stato del blocco, non la coerenza interna della struttura dati: se i dati protetti contengono riferimenti non volatili a oggetti mutabili, la lettura ottimistica potrebbe osservare un riferimento a un oggetto i cui campi stanno ancora venendo inizializzati da un altro thread (pubblicazione non sicura). Pertanto, le letture ottimistiche richiedono che lo stato protetto consista completamente di riferimenti volatili o oggetti immutabili per garantire una pubblicazione sicura indipendentemente dalle semantiche di memoria del blocco.
Qual è l'incompatibilità fondamentale tra StampedLock e Virtual Threads (Project Loom), e perché ciò richiede di evitare StampedLock nelle moderne applicazioni ad alta concorrenza che utilizzano thread virtuali?
Le implementazioni di StampedLock si basano su operazioni LockSupport.park che bloccano il Platform Thread sottostante (thread portante) quando un thread virtuale si blocca mentre detiene il blocco. Quando un thread virtuale tenta di acquisire un StampedLock conteso (sia in lettura che in scrittura), la JVM non può smontare il thread virtuale dal suo portante poiché gli interni del blocco utilizzano primitive di sincronizzazione native non ancora adattate per il cedimento dei thread virtuali. Questa fissazione contraddice la promessa di scalabilità fondamentale dei thread virtuali, che multiplexa migliaia di thread virtuali su pochi thread portanti. Se più thread virtuali si bloccano simultaneamente sulla contesa di StampedLock, monopolizzano l'intero pool di thread portanti, congelando l'applicazione anche se milioni di thread virtuali sono teoricamente ancora disponibili. Al contrario, ReentrantLock e Semaphore sono stati adattati per evitare il blocco utilizzando algoritmi non bloccanti o meccanismi di cedimento specializzati quando invocati da thread virtuali. Di conseguenza, le moderne applicazioni che utilizzano esecutori VirtualThread devono sostituire StampedLock con ReentrantLock o strutture dati concorrenti per prevenire la fame dei thread portanti.