Chiusura a doppio controllo: intelligente, ma rotta

Dagli apprezzatissimi Elements of Java Style alle pagine di JavaWorld (vedere Java Tip 67), molti guru di Java ben intenzionati incoraggiano l'uso dell'idioma del blocco a doppia verifica (DCL). C'è solo un problema con esso: questo idioma apparentemente intelligente potrebbe non funzionare.

Il doppio controllo del blocco può essere pericoloso per il tuo codice!

Questa settimana JavaWorld si concentra sui pericoli dell'idioma di blocco ricontrollato. Leggi di più su come questa scorciatoia apparentemente innocua può devastare il tuo codice:
  • "Attenzione! Threading in un mondo multiprocessore," Allen Holub
  • Chiusura a doppio controllo: intelligente, ma non funzionante ", Brian Goetz
  • Per parlare di più sul blocco del doppio controllo, vai alla discussione sulla teoria e pratica della programmazione di Allen Holub

Cos'è DCL?

L'idioma DCL è stato progettato per supportare l'inizializzazione pigra, che si verifica quando una classe rinvia l'inizializzazione di un oggetto di proprietà finché non è effettivamente necessario:

class SomeClass {risorsa Risorsa privata = null; public Resource getResource () {if (resource == null) resource = new Resource (); risorsa di ritorno; }}

Perché dovresti rinviare l'inizializzazione? Forse la creazione di un Resourceè un'operazione costosa e gli utenti di SomeClasspotrebbero non chiamare effettivamente getResource()in una determinata esecuzione. In tal caso, puoi evitare di creare il file Resourceinteramente. Indipendentemente da ciò, l' SomeClassoggetto può essere creato più velocemente se non deve creare anche un Resourcein fase di costruzione. Ritardare alcune operazioni di inizializzazione fino a quando un utente ha effettivamente bisogno dei risultati può aiutare i programmi ad avviarsi più velocemente.

Cosa succede se provi a utilizzare SomeClassin un'applicazione multithread? Quindi si verifica una condizione di competizione: due thread potrebbero eseguire contemporaneamente il test per vedere se resourceè nullo e, di conseguenza, inizializzarsi resourcedue volte. In un ambiente multithread, dovresti dichiarare getResource()di essere synchronized.

Sfortunatamente, i metodi sincronizzati vengono eseguiti molto più lentamente, fino a 100 volte più lentamente, rispetto ai normali metodi non sincronizzati. Una delle motivazioni per l'inizializzazione pigra è l'efficienza, ma sembra che per ottenere un avvio più rapido del programma, sia necessario accettare tempi di esecuzione più lenti una volta avviato il programma. Non sembra un ottimo compromesso.

DCL pretende di darci il meglio di entrambi i mondi. Utilizzando DCL, il getResource()metodo sarebbe simile a questo:

class SomeClass {risorsa Risorsa privata = null; public Resource getResource () {if (resource == null) {synchronized {if (resource == null) resource = new Resource (); }} risorsa di ritorno; }}

Dopo la prima chiamata a getResource(), resourceè già inizializzato, il che evita il colpo di sincronizzazione nel percorso di codice più comune. DCL evita anche la race condition controllando resourceuna seconda volta all'interno del blocco sincronizzato; ciò garantisce che solo un thread tenterà di inizializzarsi resource. DCL sembra un'ottimizzazione intelligente, ma non funziona.

Scopri il modello di memoria Java

Più precisamente, non è garantito che DCL funzioni. Per capire perché, dobbiamo esaminare la relazione tra la JVM e l'ambiente informatico su cui gira. In particolare, dobbiamo guardare al Java Memory Model (JMM), definito nel Capitolo 17 delle Java Language Specification , da Bill Joy, Guy Steele, James Gosling e Gilad Bracha (Addison-Wesley, 2000), che descrive in dettaglio come Java gestisce l'interazione tra thread e memoria.

A differenza della maggior parte degli altri linguaggi, Java definisce la sua relazione con l'hardware sottostante attraverso un modello di memoria formale che dovrebbe valere su tutte le piattaforme Java, consentendo la promessa di Java di "Write Once, Run Anywhere". In confronto, altri linguaggi come C e C ++ mancano di un modello di memoria formale; in tali linguaggi, i programmi ereditano il modello di memoria della piattaforma hardware su cui viene eseguito il programma.

Quando viene eseguito in un ambiente sincrono (a thread singolo), l'interazione di un programma con la memoria è abbastanza semplice, o almeno lo sembra. I programmi memorizzano gli elementi nelle posizioni di memoria e si aspettano che siano ancora lì la prossima volta che tali posizioni di memoria vengono esaminate.

In realtà, la verità è abbastanza diversa, ma una complicata illusione mantenuta dal compilatore, dalla JVM e dall'hardware ce lo nasconde. Sebbene pensiamo che i programmi vengano eseguiti in modo sequenziale, nell'ordine specificato dal codice del programma, ciò non sempre accade. I compilatori, i processori e le cache sono liberi di prendersi ogni sorta di libertà con i nostri programmi e dati, a condizione che non influenzino il risultato del calcolo. Ad esempio, i compilatori possono generare istruzioni in un ordine diverso dall'ovvia interpretazione suggerita dal programma e memorizzare le variabili in registri invece che in memoria; i processori possono eseguire istruzioni in parallelo o fuori servizio; e le cache possono variare l'ordine in cui le scritture si impegnano nella memoria principale. Il JMM afferma che tutti questi vari riordini e ottimizzazioni sono accettabili,fintanto che l'ambiente si mantienesemantica as-if-serial - cioè, a patto che si ottenga lo stesso risultato che si avrebbe se le istruzioni fossero eseguite in un ambiente strettamente sequenziale.

Compilatori, processori e cache riorganizzano la sequenza delle operazioni del programma al fine di ottenere prestazioni più elevate. Negli ultimi anni abbiamo assistito a enormi miglioramenti nelle prestazioni di elaborazione. Sebbene l'aumento delle frequenze di clock del processore abbia contribuito in modo sostanziale a prestazioni più elevate, anche l'aumento del parallelismo (sotto forma di unità di esecuzione pipeline e superscalari, pianificazione dinamica delle istruzioni ed esecuzione speculativa e cache di memoria multilivello sofisticate) è stato un importante contributo. Allo stesso tempo, il compito di scrivere compilatori è diventato molto più complicato, poiché il compilatore deve proteggere il programmatore da queste complessità.

Quando si scrivono programmi a thread singolo, non è possibile vedere gli effetti di questi vari riordini di istruzioni o operazioni di memoria. Tuttavia, con i programmi multithread, la situazione è abbastanza diversa: un thread può leggere le posizioni di memoria scritte da un altro thread. Se il thread A modifica alcune variabili in un certo ordine, in assenza di sincronizzazione, il thread B potrebbe non vederle nello stesso ordine o potrebbe non vederle affatto, per quella materia. Ciò potrebbe verificarsi perché il compilatore ha riordinato le istruzioni o ha temporaneamente memorizzato una variabile in un registro e l'ha scritta in memoria in un secondo momento; o perché il processore ha eseguito le istruzioni in parallelo o in un ordine diverso da quello specificato dal compilatore; o perché le istruzioni erano in diverse regioni della memoria,e la cache aggiornava le corrispondenti posizioni di memoria principale in un ordine diverso da quello in cui erano state scritte. Qualunque siano le circostanze, i programmi multithread sono intrinsecamente meno prevedibili, a meno che non si assicuri esplicitamente che i thread abbiano una visione coerente della memoria utilizzando la sincronizzazione.

Cosa significa veramente sincronizzato?

Java tratta ogni thread come se fosse eseguito sul proprio processore con la propria memoria locale, ciascuno dei quali parla e si sincronizza con una memoria principale condivisa. Anche su un sistema a processore singolo, quel modello ha senso a causa degli effetti delle cache di memoria e dell'uso dei registri del processore per memorizzare le variabili. Quando un thread modifica una posizione nella sua memoria locale, tale modifica dovrebbe eventualmente essere visualizzata anche nella memoria principale e il JMM definisce le regole per quando la JVM deve trasferire i dati tra la memoria locale e principale. Gli architetti Java si sono resi conto che un modello di memoria eccessivamente restrittivo avrebbe seriamente compromesso le prestazioni del programma. Hanno tentato di creare un modello di memoria che consentisse ai programmi di funzionare bene sull'hardware del computer moderno pur fornendo garanzie che avrebbero consentito ai thread di interagire in modi prevedibili.

Lo strumento principale di Java per rendere prevedibili le interazioni tra i thread è la synchronizedparola chiave. Molti programmatori pensano synchronizedstrettamente in termini di imposizione di un semaforo di mutua esclusione ( mutex ) per impedire l'esecuzione di sezioni critiche da più di un thread alla volta. Sfortunatamente, quell'intuizione non descrive completamente cosa synchronizedsignifica.

La semantica di synchronizedinclude effettivamente l'esclusione reciproca dell'esecuzione basata sullo stato di un semaforo, ma include anche regole sull'interazione del thread di sincronizzazione con la memoria principale. In particolare, l'acquisizione o il rilascio di un blocco innesca una barriera di memoria : una sincronizzazione forzata tra la memoria locale del thread e la memoria principale. (Alcuni processori, come l'Alpha, hanno istruzioni macchina esplicite per eseguire barriere di memoria.) Quando un thread esce da un synchronizedblocco, esegue una barriera di scrittura: deve eliminare tutte le variabili modificate in quel blocco nella memoria principale prima di rilasciare il serratura. Allo stesso modo, quando si immette un filesynchronized block, esegue una barriera di lettura: è come se la memoria locale fosse stata invalidata e deve recuperare dalla memoria principale tutte le variabili a cui verrà fatto riferimento nel blocco.

L'uso corretto della sincronizzazione garantisce che un thread vedrà gli effetti di un altro in modo prevedibile. Solo quando i thread A e B si sincronizzano sullo stesso oggetto, il JMM garantirà che il thread B veda le modifiche apportate dal thread A e che le modifiche apportate dal thread A all'interno del synchronizedblocco appaiano atomicamente al thread B (o l'intero blocco viene eseguito o nessuno dei lo fa.) Inoltre, JMM assicura che i synchronizedblocchi che si sincronizzano sullo stesso oggetto sembrino essere eseguiti nello stesso ordine in cui si trovano nel programma.

Allora cosa c'è di rotto in DCL?

DCL relies on an unsynchronized use of the resource field. That appears to be harmless, but it is not. To see why, imagine that thread A is inside the synchronized block, executing the statement resource = new Resource(); while thread B is just entering getResource(). Consider the effect on memory of this initialization. Memory for the new Resource object will be allocated; the constructor for Resource will be called, initializing the member fields of the new object; and the field resource of SomeClass will be assigned a reference to the newly created object.

However, since thread B is not executing inside a synchronized block, it may see these memory operations in a different order than the one thread A executes. It could be the case that B sees these events in the following order (and the compiler is also free to reorder the instructions like this): allocate memory, assign reference to resource, call constructor. Suppose thread B comes along after the memory has been allocated and the resource field is set, but before the constructor is called. It sees that resource is not null, skips the synchronized block, and returns a reference to a partially constructed Resource! Needless to say, the result is neither expected nor desired.

When presented with this example, many people are skeptical at first. Many highly intelligent programmers have tried to fix DCL so that it does work, but none of these supposedly fixed versions work either. It should be noted that DCL might, in fact, work on some versions of some JVMs -- as few JVMs actually implement the JMM properly. However, you don't want the correctness of your programs to rely on implementation details -- especially errors -- specific to the particular version of the particular JVM you use.

Other concurrency hazards are embedded in DCL -- and in any unsynchronized reference to memory written by another thread, even harmless-looking reads. Suppose thread A has completed initializing the Resource and exits the synchronized block as thread B enters getResource(). Now the Resource is fully initialized, and thread A flushes its local memory out to main memory. The resource's fields may reference other objects stored in memory through its member fields, which will also be flushed out. While thread B may see a valid reference to the newly created Resource, because it didn't perform a read barrier, it could still see stale values of resource's member fields.

Volatile doesn't mean what you think, either

A commonly suggested nonfix is to declare the resource field of SomeClass as volatile. However, while the JMM prevents writes to volatile variables from being reordered with respect to one another and ensures that they are flushed to main memory immediately, it still permits reads and writes of volatile variables to be reordered with respect to nonvolatile reads and writes. That means -- unless all Resource fields are volatile as well -- thread B can still perceive the constructor's effect as happening after resource is set to reference the newly created Resource.

Alternatives to DCL

Il modo più efficace per correggere l'idioma DCL è evitarlo. Il modo più semplice per evitarlo, ovviamente, è usare la sincronizzazione. Ogni volta che una variabile scritta da un thread viene letta da un altro, è necessario utilizzare la sincronizzazione per garantire che le modifiche siano visibili ad altri thread in modo prevedibile.

Un'altra opzione per evitare i problemi con DCL è eliminare l'inizializzazione pigra e utilizzare invece l' inizializzazione desiderosa . Piuttosto che ritardare l'inizializzazione resourcefino al primo utilizzo, inizializzalo durante la costruzione. Il programma di caricamento classi, che si sincronizza Classsull'oggetto delle classi , esegue blocchi di inizializzazione statici al momento dell'inizializzazione della classe. Ciò significa che l'effetto degli inizializzatori statici è automaticamente visibile a tutti i thread non appena la classe viene caricata.