W3docs

Java ReadWriteLock

Permetti lettori concorrenti e scrittori esclusivi in Java con ReadWriteLock — e quando StampedLock è la scelta migliore.

Un ReentrantLock (o un blocco synchronized) concede a un thread l'accesso esclusivo — lettori e scrittori condividono lo stesso slot. Per i carichi di lavoro a lettura intensiva questo è uno spreco: se cento thread vogliono leggere un valore e uno vuole scriverlo, non c'è alcun conflitto reale tra i lettori, solo tra lettori e lo scrittore. L'interfaccia ReadWriteLock e la sua implementazione standard ReentrantReadWriteLock dividono il lock in due parti — un read lock che più thread possono tenere simultaneamente, e un write lock che è esclusivo rispetto a tutto. Usato correttamente, riduce drasticamente la contesa. Usato in modo errato, è più lento di un lock semplice.

L'interfaccia

public interface ReadWriteLock {
  Lock readLock();
  Lock writeLock();
}

Due Lock collegati rigidamente dal loro genitore: il read lock e il write lock rispettano la regola che o il write lock è tenuto da esattamente un thread oppure il read lock è tenuto da zero o più thread. Mai entrambi, mai uno di ciascuno.

L'implementazione standard:

ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
Lock r = rw.readLock();
Lock w = rw.writeLock();

Entrambi i lock hanno l'API Lock standard — lock, tryLock, lockInterruptibly, unlock. Il contratto per la coppia rw è:

  • Molti thread possono tenere r contemporaneamente. Nessuno di loro blocca gli altri.
  • Solo un thread può tenere w alla volta.
  • Un thread che tenta di acquisire w aspetta che tutti i lettori correnti rilascino.
  • I thread che tentano di acquisire r aspettano se w è tenuto oppure (a seconda della politica) se uno scrittore è già in coda.

L'ultimo punto è la politica di prevenzione della starvation degli scrittori — trattata di seguito.

Il caso d'uso della cache

L'esempio classico. Una cache letta continuamente e aggiornata occasionalmente:

class ConfigCache {
  private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
  private final Lock r = rw.readLock();
  private final Lock w = rw.writeLock();
  private Map<String, String> data = new HashMap<>();

  public String get(String k) {
    r.lock();
    try {
      return data.get(k);                               // many readers, no contention
    } finally { r.unlock(); }
  }

  public void reload(Map<String, String> fresh) {
    w.lock();
    try {
      data = new HashMap<>(fresh);                       // exclusive: blocks readers and other writers
    } finally { w.unlock(); }
  }
}

Con un carico di lavoro tipico al 99% di letture, questo scala molto meglio di un singolo ReentrantLock. I lettori non si bloccano a vicenda; il raro scrittore ferma brevemente il mondo, poi tutti continuano.

La stessa disciplina try/finally del Lock si applica — ogni lock() deve essere abbinato a unlock() in un finally. Il read lock non è più indulgente del write lock riguardo alle perdite.

Fairness e starvation degli scrittori

ReentrantReadWriteLock ha due politiche:

new ReentrantReadWriteLock();          // non-fair (default)
new ReentrantReadWriteLock(true);      // fair (FIFO)

La politica non-fair predefinita permette ai nuovi lettori in arrivo di acquisire anche se uno scrittore è già in attesa — alto throughput, ma gli scrittori possono subire starvation sotto carico di lettura continuo. La politica fair mette in coda ogni richiedente in ordine FIFO: uno scrittore in attesa blocca i lettori successivi, e i lettori aspettano il loro turno.

Il default corretto rimane non-fair. Se osservi in produzione degli scrittori che rimangono in coda per sempre (una delle cose che getQueueLength espone), passa a fair.

C'è anche una protezione più sottile. Anche in modalità non-fair, se uno scrittore è "il prossimo in lista" (in testa alla coda), i lettori in arrivo vengono bloccati. Questo previene la forma peggiore di starvation; i nuovi lettori possono ancora passare avanti se nessuno scrittore è in coda.

Downgrading del lock: write → read

Un trucco utile: puoi tenere il write lock, acquisire anche il read lock, poi rilasciare il write lock — senza mai far entrare altri scrittori. Questo si chiama downgrading:

w.lock();
try {
  data = recompute();                                   // exclusive write
  r.lock();                                              // before releasing w
} finally { w.unlock(); }
// now holding only r — readers can join, but no writer can sneak in until I release r
try {
  process(data);                                         // read-only work, multiple threads can do it
} finally { r.unlock(); }

Il punto del downgrading: esegui la vera mutazione sotto il write lock, poi continua a leggere il risultato senza tenere gli altri fuori dai dati. Il passaggio intermedio "acquisisci read mentre tieni write" funziona perché il lock lo permette — stai declassando la prenotazione del write lock da "esclusivo" a "condiviso con te specificamente ancora ammesso."

Il contrario — upgrading da read → write — non funziona. Tentare di acquisire w mentre tieni r causa un deadlock: il write lock aspetta che tutti i lettori rilascino, e tu sei uno di loro. Il lock ti bloccherà per sempre aspettando te stesso.

r.lock();
try {
  if (needsRefresh()) {
    w.lock();                                            // DEADLOCK on the same thread
    ...
  }
} finally { r.unlock(); }

Per passare da read → write devi prima rilasciare il read lock, poi acquisire il write lock e ricontrollare la condizione (qualcun altro potrebbe aver aggiornato mentre eri sbloccato).

Quando ReadWriteLock batte ReentrantLock

Una regola approssimativa. ReentrantReadWriteLock vince quando:

  • Le letture superano di gran lunga le scritture (diciamo, 100:1 o più).
  • La sezione protetta da lettura è non banale — abbastanza lunga da far sì che permettere a molti thread di eseguirla concorrentemente sia un guadagno significativo.
  • Anche la scrittura è abbastanza lunga da rendere accettabile bloccare brevemente i lettori.

Perde (o va in pari) quando:

  • Le letture sono estremamente brevi (una singola ricerca nella mappa). L'overhead di acquisizione del lock è paragonabile al lavoro; saresti meglio con un semplice ReentrantLock o uno snapshot immutabile tramite AtomicReference.
  • Il rapporto lettori/scrittori non è estremo.
  • Hai molti thread. La contabilità interna che il lock lettura/scrittura fa per contare i lettori diventa più costosa man mano che scala. Per strutture dati a lettura molto intensa su molti core, StampedLock o la copia-su-scrittura è solitamente una scelta migliore.

StampedLock — l'alternativa moderna

Java 8 ha aggiunto java.util.concurrent.locks.StampedLock con tre modalità — write, read e optimistic read. La modalità ottimistica permette a un lettore di procedere senza acquisire alcun lock; dopo la lettura, verifica che il valore non sia cambiato tramite uno stamp. Se è cambiato, il lettore ricade su una vera acquisizione del read lock.

StampedLock sl = new StampedLock();
long stamp = sl.tryOptimisticRead();
String val = data.get(k);                                // read without locking
if (!sl.validate(stamp)) {                                // somebody wrote during our read
  stamp = sl.readLock();
  try {
    val = data.get(k);                                    // re-read under proper lock
  } finally { sl.unlockRead(stamp); }
}

Per i carichi di lavoro dominati dalla lettura, StampedLock è solitamente più veloce di ReentrantReadWriteLock. Il costo: non è rientrante, non supporta Condition, e l'API è molto più facile da usare in modo errato. Ricorrici quando hai un profiler che punta a un ReadWriteLock; usa ReentrantReadWriteLock come default per l'ergonomia.

Un esempio pratico: cache a lettura intensa, tre contendenti

Il programma seguente mette a confronto tre implementazioni della stessa cache a lettura intensa con 16 lettori e 2 scrittori: una mappa synchronized, una mappa protetta da ReentrantLock, e una mappa protetta da ReentrantReadWriteLock.

java— editable, runs on the server

Cosa ricavare dall'esecuzione — e il risultato probabilmente non è quello che ti aspetteresti:

  • La sezione critica qui è una singola HashMap.get — qualche nanosecondo. A quella dimensione, ReentrantReadWriteLock perde effettivamente rispetto a un semplice ReentrantLock (e rispetto a synchronized). In un'esecuzione tipica il rwlock fa meno letture, non di più, perché il lavoro all'interno del lock è trascurabile rispetto al costo di acquisirlo. Questa è la cosa più importante da interiorizzare: un read-write lock non è un upgrade gratuito.
  • Perché perde qui: r.lock() deve incrementare atomicamente un contatore di lettori condiviso, e quel contatore in contesa diventa il nuovo collo di bottiglia. Sedici core che martellano tutti un campo di tipo AtomicInteger causano cache-bounce tanto quanto farebbero su un singolo mutex — a volte peggio, perché la contabilità del rwlock è più pesante di quella di un lock semplice. Il guadagno teorico "i lettori non si bloccano a vicenda" non si materializza mai quando la lettura è troppo breve per sovrapporsi in modo significativo.
  • Il rwlock supera gli altri solo quando ogni lettura tiene il lock abbastanza a lungo da rendere proficua la vera sovrapposizione — pensa a una ricerca, una deserializzazione, o qualsiasi cosa nell'ordine dei microsecondi e oltre, non una singola ricerca nella mappa. Sostituisci il corpo di get() con lavoro di sola lettura genuinamente costoso e riesegui: ora i lettori concorrenti vincono in modo decisivo. Profila il tuo carico di lavoro reale prima di ricorrere a un ReadWriteLock.
  • Il costo di ReadWriteLock è più stato da mantenere (un contatore di lettori, un flag di attesa degli scrittori, la politica di fairness) — ogni r.lock() è più costoso di un ReentrantLock.lock(). Per bassa contesa o sezioni critiche molto brevi, il lock più semplice è più veloce. Per carichi a lettura estrema su molti core, StampedLock (le letture ottimistiche non acquisiscono nulla) o uno snapshot immutabile dietro un AtomicReference solitamente batte entrambi.
  • Qualunque lock tu scelga, gli scrittori ottengono comunque accesso esclusivo tramite il percorso di scrittura e non corrompono mai la mappa — la correttezza è la stessa in tutti e tre. La differenza è puramente il throughput, e il throughput dipende interamente da cosa c'è all'interno del lock.
  • Il downgrading (wr → rilascio w) è ciò che il codice in produzione usa quando la ricostruzione deve avvenire sotto un write lock ma il resto della richiesta può rimanere sotto un read lock. L'upgrade nell'altra direzione causa deadlock; rilascia prima r, prendi w, ricontrolla il mondo, poi continua.

Cosa segue

Il capitolo successivo, Java Thread Pools, inizia la storia del framework executor ad alto livello — l'idea che smetti di creare thread a mano e invece invii lavoro a un pool che gestisce i thread per te.

Pratica

Pratica
Tieni il read lock di un `ReentrantReadWriteLock` e decidi di aggiornare al write lock chiamando `w.lock()` mentre tieni ancora `r`. Cosa succede?
Tieni il read lock di un `ReentrantReadWriteLock` e decidi di aggiornare al write lock chiamando `w.lock()` mentre tieni ancora `r`. Cosa succede?
Was this page helpful?