Evita i deadlock di sincronizzazione

Nel mio precedente articolo "Double-Checked Locking: Clever, but Broken" ( JavaWorld,Febbraio 2001), ho descritto come diverse tecniche comuni per evitare la sincronizzazione siano in realtà pericolose e ho consigliato una strategia di "In caso di dubbio, sincronizza". In generale, dovresti sincronizzare ogni volta che stai leggendo una variabile che potrebbe essere stata scritta in precedenza da un thread diverso o ogni volta che stai scrivendo una variabile che potrebbe essere letta successivamente da un altro thread. Inoltre, mentre la sincronizzazione comporta una riduzione delle prestazioni, la penalità associata alla sincronizzazione incontrollata non è così grande come suggerito da alcune fonti e si è ridotta costantemente con ogni successiva implementazione della JVM. Quindi sembra che ora ci siano meno ragioni che mai per evitare la sincronizzazione. Tuttavia, un altro rischio è associato a una sincronizzazione eccessiva: deadlock.

Cos'è un deadlock?

Diciamo che un insieme di processi o thread è in deadlock quando ogni thread è in attesa di un evento che solo un altro processo nel set può causare. Un altro modo per illustrare un deadlock è costruire un grafo diretto i cui vertici sono thread o processi e i cui bordi rappresentano la relazione "è in attesa". Se questo grafico contiene un ciclo, il sistema è in deadlock. A meno che il sistema non sia progettato per eseguire il ripristino da deadlock, un deadlock causa il blocco del programma o del sistema.

Deadlock di sincronizzazione nei programmi Java

I deadlock possono verificarsi in Java perché la synchronizedparola chiave causa il blocco del thread in esecuzione durante l'attesa del blocco o del monitoraggio associato all'oggetto specificato. Poiché il thread potrebbe già contenere blocchi associati ad altri oggetti, due thread potrebbero essere ciascuno in attesa che l'altro rilasci un blocco; in tal caso, finiranno per aspettare per sempre. Nell'esempio seguente viene illustrato un set di metodi che potrebbero causare un deadlock. Entrambi i metodi acquisiscono blocchi su due oggetti blocco cacheLocke tableLock, prima di procedere. In questo esempio, gli oggetti che agiscono come blocchi sono variabili globali (statiche), una tecnica comune per semplificare il comportamento di blocco dell'applicazione eseguendo il blocco a un livello più grossolano di granularità:

Listato 1. Un potenziale deadlock di sincronizzazione

public static Object cacheLock = new Object (); public static Object tableLock = new Object (); ... public void oneMethod () {synchronized (cacheLock) {synchronized (tableLock) {doSomething (); }}} public void anotherMethod () {synchronized (tableLock) {synchronized (cacheLock) {doSomethingElse (); }}}

Ora, immagina che il thread A chiami oneMethod()mentre il thread B chiama simultaneamente anotherMethod(). Immagina inoltre che il thread A acquisisca il blocco cacheLocke, allo stesso tempo, il thread B acquisisca il blocco tableLock. Ora i thread sono in deadlock: nessuno dei thread rinuncerà al suo blocco fino a quando non acquisirà l'altro blocco, ma nessuno dei due sarà in grado di acquisire l'altro blocco fino a quando l'altro thread non lo abbandonerà. Quando un programma Java si blocca, i thread di deadlock aspettano semplicemente per sempre. Mentre altri thread potrebbero continuare a funzionare, alla fine dovrai terminare il programma, riavviarlo e sperare che non si blocchi di nuovo.

Il test dei deadlock è difficile, poiché i deadlock dipendono dalla tempistica, dal carico e dall'ambiente e pertanto potrebbero verificarsi raramente o solo in determinate circostanze. Il codice può avere il potenziale di deadlock, come il listato 1, ma non esibire deadlock fino a quando non si verifica una combinazione di eventi casuali e non casuali, come il programma sottoposto a un certo livello di carico, eseguito su una determinata configurazione hardware o esposto a un determinato mix di azioni dell'utente e condizioni ambientali. I deadlock assomigliano a bombe a orologeria in attesa di esplodere nel nostro codice; quando lo fanno, i nostri programmi semplicemente si bloccano.

Un ordine di blocco incoerente causa deadlock

Fortunatamente, possiamo imporre un requisito relativamente semplice sull'acquisizione del blocco che può impedire i deadlock di sincronizzazione. I metodi del listato 1 hanno il potenziale per deadlock perché ogni metodo acquisisce i due lock in un ordine diverso. Se il Listato 1 fosse stato scritto in modo che ogni metodo acquisisse i due lock nello stesso ordine, due o più thread che eseguono questi metodi non potrebbero deadlock, indipendentemente dalla tempistica o da altri fattori esterni, perché nessun thread potrebbe acquisire il secondo lock senza già tenere il primo. Se puoi garantire che i blocchi verranno sempre acquisiti in un ordine coerente, il tuo programma non si bloccherà.

I deadlock non sono sempre così evidenti

Una volta sintonizzato sull'importanza dell'ordinamento dei blocchi, puoi facilmente riconoscere il problema del listato 1. Tuttavia, problemi analoghi potrebbero rivelarsi meno ovvi: forse i due metodi risiedono in classi separate, o forse i lock coinvolti vengono acquisiti implicitamente chiamando metodi sincronizzati invece che esplicitamente tramite un blocco sincronizzato. Considera queste due classi cooperanti Modele View, in un framework MVC (Model-View-Controller) semplificato:

Listato 2. Un potenziale deadlock di sincronizzazione più sottile

public class Model {private View myView; public synchronized void updateModel (Object someArg) {doSomething (someArg); myView.somethingChanged (); } Oggetto pubblico sincronizzato getSomething () {return someMethod (); }} public class View {private Model sottostanteModel; public synchronized void somethingChanged () {doSomething (); } updateView () void public synchronized {Object o = myModel.getSomething (); }}

Il listato 2 ha due oggetti cooperanti che hanno metodi sincronizzati; ogni oggetto chiama i metodi sincronizzati dell'altro. Questa situazione è simile al Listato 1: due metodi acquisiscono blocchi sugli stessi due oggetti, ma in ordini diversi. Tuttavia, l'ordinamento del blocco incoerente in questo esempio è molto meno ovvio di quello nel Listato 1 perché l'acquisizione del blocco è una parte implicita della chiamata al metodo. Se un thread chiama Model.updateModel()mentre un altro thread chiama simultaneamente View.updateView(), il primo thread potrebbe ottenere il Modelblocco di e attendere il Viewblocco di, mentre l'altro ottiene il Viewblocco di e attende per sempre il Modelblocco di.

Puoi seppellire il potenziale di blocco della sincronizzazione ancora più in profondità. Considera questo esempio: hai un metodo per trasferire fondi da un conto a un altro. Desideri acquisire blocchi su entrambi gli account prima di eseguire il trasferimento per assicurarti che il trasferimento sia atomico. Considera questa implementazione dall'aspetto innocuo:

Listato 3. Un potenziale deadlock di sincronizzazione ancora più sottile

 public void transferMoney (Account fromAccount, Account toAccount, DollarAmount amountToTransfer) {synchronized (fromAccount) {synchronized (toAccount) {if (fromAccount.hasSufficientBalance (amountToTransfer) {fromAccount.debit (amountToTransfer); toAccount.creditTransfer); toAccount.credit} } 

Anche se tutti i metodi che operano su due o più account utilizzano lo stesso ordinamento, il Listato 3 contiene i semi dello stesso problema di deadlock degli elenchi 1 e 2, ma in un modo ancora più sottile. Considera cosa succede quando il thread A viene eseguito:

 transferMoney (accountOne, accountTwo, importo); 

Allo stesso tempo, il thread B esegue:

 transferMoney (accountTwo, accountOne, anotherAmount); 

Di nuovo, i due thread tentano di acquisire gli stessi due blocchi, ma in ordini diversi; il rischio di deadlock incombe ancora, ma in una forma molto meno ovvia.

Come evitare i deadlock

Uno dei modi migliori per prevenire il potenziale deadlock è evitare di acquisire più di un blocco alla volta, il che è spesso pratico. Tuttavia, se ciò non è possibile, è necessaria una strategia che assicuri l'acquisizione di più blocchi in un ordine coerente e definito.

A seconda di come il programma utilizza i blocchi, potrebbe non essere complicato assicurarsi di utilizzare un ordine di blocco coerente. In alcuni programmi, come nel Listato 1, tutti i blocchi critici che potrebbero partecipare a blocchi multipli vengono estratti da un piccolo insieme di oggetti di blocco singleton. In tal caso, è possibile definire un ordine di acquisizione dei blocchi sul set di blocchi e assicurarsi di acquisire sempre i blocchi in quell'ordine. Una volta definito l'ordine di blocco, è sufficiente che sia ben documentato per incoraggiare un uso coerente in tutto il programma.

Riduci i blocchi sincronizzati per evitare blocchi multipli

Nel listato 2, il problema si complica perché, come risultato della chiamata di un metodo sincronizzato, i lock vengono acquisiti implicitamente. Di solito è possibile evitare il tipo di potenziali deadlock che derivano da casi come il listato 2 restringendo l'ambito della sincronizzazione al più piccolo blocco possibile. Fa Model.updateModel()davvero bisogno di tenere il Modelblocco mentre si chiamaView.somethingChanged()? Spesso non lo fa; l'intero metodo è stato probabilmente sincronizzato come scorciatoia, piuttosto che perché l'intero metodo doveva essere sincronizzato. Tuttavia, se si sostituiscono metodi sincronizzati con blocchi sincronizzati più piccoli all'interno del metodo, è necessario documentare questo comportamento di blocco come parte del Javadoc del metodo. I chiamanti devono sapere che possono chiamare il metodo in modo sicuro senza sincronizzazione esterna. I chiamanti dovrebbero anche conoscere il comportamento di blocco del metodo in modo da poter garantire che i blocchi vengano acquisiti in un ordine coerente.

Una tecnica di ordinamento dei blocchi più sofisticata

In altre situazioni, come l'esempio del conto bancario del listato 3, l'applicazione della regola dell'ordine fisso diventa ancora più complicata; è necessario definire un ordinamento totale sull'insieme di oggetti idonei per il blocco e utilizzare questo ordinamento per scegliere la sequenza di acquisizione del blocco. Sembra disordinato, ma in realtà è semplice. Il Listato 4 illustra questa tecnica; utilizza un numero di conto numerico per indurre un ordine sugli Accountoggetti. (Se l'oggetto che devi bloccare manca di una proprietà di identità naturale come un numero di conto, puoi utilizzare il Object.identityHashCode()metodo per generarne uno).

Listato 4. Utilizzare un ordine per acquisire i blocchi in una sequenza fissa

public void transferMoney (Account fromAccount, Account toAccount, DollarAmount amountToTransfer) {Account firstLock, secondLock; if (fromAccount.accountNumber () == toAccount.accountNumber ()) genera una nuova eccezione ("Impossibile trasferire dall'account a se stesso"); else if (fromAccount.accountNumber () <toAccount.accountNumber ()) {firstLock = fromAccount; secondLock = toAccount; } else {firstLock = toAccount; secondLock = fromAccount; } synchronized (firstLock) {synchronized (secondLock) {if (fromAccount.hasSufficientBalance (amountToTransfer) {fromAccount.debit (amountToTransfer); toAccount.credit (amountToTransfer);}}}}

Ora l'ordine in cui sono specificati gli account nella chiamata a transferMoney()non ha importanza; le serrature vengono acquisite sempre nello stesso ordine.

La parte più importante: la documentazione

Un elemento critico, ma spesso trascurato, di qualsiasi strategia di blocco è la documentazione. Sfortunatamente, anche nei casi in cui viene prestata molta attenzione alla progettazione di una strategia di blocco, spesso viene impiegato molto meno impegno per documentarla. Se il tuo programma utilizza un piccolo insieme di blocchi singleton, dovresti documentare le tue ipotesi di ordinamento dei blocchi il più chiaramente possibile in modo che i futuri manutentori possano soddisfare i requisiti di ordinamento dei blocchi. Se un metodo deve acquisire un blocco per eseguire la sua funzione o deve essere chiamato con un blocco specifico mantenuto, Javadoc del metodo dovrebbe tenerne conto. In questo modo, i futuri sviluppatori sapranno che la chiamata a un determinato metodo potrebbe comportare l'acquisizione di un blocco.

Pochi programmi o librerie di classi documentano adeguatamente il loro utilizzo del blocco. Come minimo, ogni metodo dovrebbe documentare i blocchi che acquisisce e se i chiamanti devono tenere un blocco per chiamare il metodo in sicurezza. Inoltre, le classi dovrebbero documentare se o meno, o in quali condizioni, sono thread-safe.

Concentrarsi sul comportamento di blocco in fase di progettazione

Poiché i deadlock spesso non sono evidenti e si verificano raramente e in modo imprevedibile, possono causare seri problemi nei programmi Java. Prestando attenzione al comportamento di blocco del programma in fase di progettazione e definendo regole per quando e come acquisire più blocchi, è possibile ridurre notevolmente la probabilità di deadlock. Ricordarsi di documentare attentamente le regole di acquisizione dei blocchi del programma e il suo utilizzo della sincronizzazione; il tempo speso per documentare semplici presupposti di blocco verrà ripagato riducendo notevolmente la possibilità di deadlock e altri problemi di concorrenza in un secondo momento.

Brian Goetz è uno sviluppatore di software professionista con oltre 15 anni di esperienza. È consulente principale presso Quiotix, una società di consulenza e sviluppo software con sede a Los Altos, in California.