JavaRush /Java Blog /Random-IT /Deadlock in Java e metodi per combatterlo
articles
Livello 15

Deadlock in Java e metodi per combatterlo

Pubblicato nel gruppo Random-IT
Quando si sviluppano applicazioni multi-thread spesso sorge un dilemma: ciò che è più importante è l'affidabilità o le prestazioni dell'applicazione. Ad esempio, utilizziamo la sincronizzazione per la sicurezza dei thread e, nei casi in cui l'ordine di sincronizzazione non è corretto, possiamo causare deadlock. Utilizziamo anche pool di thread e semafori per limitare il consumo di risorse e un errore in questa progettazione può portare a un punto morto a causa della mancanza di risorse. In questo articolo parleremo di come evitare lo stallo e altri problemi nell'esecuzione dell'applicazione. Vedremo anche come scrivere un'applicazione in modo tale da poterla ripristinare in caso di stallo. Deadlock in Java e metodi per combatterlo - 1Il deadlock è una situazione in cui due o più processi che occupano alcune risorse cercano di acquisire altre risorse occupate da altri processi e nessuno dei processi può occupare la risorsa di cui ha bisogno e, di conseguenza, rilasciare quella occupata. Questa definizione è troppo generale e quindi di difficile comprensione; per una migliore comprensione vedremo le tipologie di deadlock tramite esempi.

Blocco reciproco dell'ordine di sincronizzazione

Considera il seguente compito: devi scrivere un metodo che esegua una transazione per trasferire una certa somma di denaro da un conto a un altro. La soluzione potrebbe assomigliare a questa:
public void transferMoney(Account fromAccount, Account toAccount, Amount amount) throws InsufficientFundsException {
	synchronized (fromAccount) {
		synchronized (toAccount) {
			if (fromAccount.getBalance().compareTo(amount) < 0)
				throw new InsufficientFundsException();
			else {
				fromAccount.debit(amount);
				toAccount.credit(amount);
			}
		}
	}
}
A prima vista, questo codice viene sincronizzato abbastanza normalmente; abbiamo un'operazione atomica di controllo e modifica dello stato dell'account di origine e di modifica dell'account di destinazione. Tuttavia, con questa strategia di sincronizzazione, potrebbe verificarsi una situazione di stallo. Diamo un'occhiata a un esempio di come ciò accade. È necessario effettuare due transazioni: trasferire x denaro dal conto A al conto B e trasferire y denaro dal conto B al conto A. Spesso questa situazione non causerà una situazione di stallo, tuttavia, in una serie di circostanze sfortunate, la transazione 1 occuperà il monitor del conto A, la transazione 2 occuperà il monitor del conto B. Il risultato è una situazione di stallo: la transazione 1 attende che la transazione 2 rilasci il monitor del conto B, ma la transazione 2 deve accedere al monitor A, che è occupato dalla transazione 1. Uno dei grossi problemi con i deadlock è che non sono facili da trovare durante i test. Anche nella situazione descritta nell'esempio, i thread potrebbero non bloccarsi, ovvero questa situazione non verrà riprodotta costantemente, il che complica notevolmente la diagnostica. In generale, il problema descritto del non determinismo è tipico del multithreading (sebbene ciò non lo renda affatto più semplice). Pertanto, la revisione del codice gioca un ruolo importante nel migliorare la qualità delle applicazioni multi-thread, poiché consente di identificare errori difficili da riprodurre durante i test. Ciò ovviamente non significa che l’applicazione non debba essere testata; semplicemente non dobbiamo dimenticarci della revisione del codice. Cosa devo fare per evitare che questo codice causi un deadlock? Questo blocco è causato dal fatto che la sincronizzazione dell'account può avvenire in un ordine diverso. Di conseguenza, se introduci un certo ordine nei conti (questa è una regola che ti consente di dire che il conto A è inferiore al conto B), il problema verrà eliminato. Come farlo? In primo luogo, se i conti hanno una sorta di identificatore univoco (ad esempio, un numero di conto) numerico, minuscolo o qualche altro con un concetto naturale di ordine (le stringhe possono essere confrontate in ordine lessicografico, allora possiamo considerarci fortunati e lo faremo sempre possiamo occupare prima il monitor dell'account più piccolo, e poi quello più grande (o viceversa).
private void doTransfer(final Account fromAcct, final Account toAcct, final DollarAmount amount) throws InsufficientFundsException {
	if (fromAcct.getBalance().compareTo(amount) < 0)
		throw new InsufficientFundsException();
	else {
		fromAcct.debit(amount);
		toAcct.credit(amount);
	}
}
public void transferMoney(final Account fromAcct, final Account toAcct, final DollarAmount amount) throws InsufficientFundsException {
	int fromId= fromAcct.getId();
	int toId = fromAcct.getId();
	if (fromId < toId) {
		synchronized (fromAcct) {
			synchronized (toAcct) {
				doTransfer(fromAcct, toAcct, amount)}
			}
		}
	} else  {
		synchronized (toAcct) {
			synchronized (fromAcct) {
				doTransfer(fromAcct, toAcct, amount)}
			}
		}
	}
}
La seconda opzione, se non disponiamo di un tale identificatore, dovremo inventarlo noi stessi. Possiamo, in prima approssimazione, confrontare gli oggetti tramite il codice hash. Molto probabilmente saranno diversi. Ma cosa succede se risultano essere la stessa cosa? Quindi dovrai aggiungere un altro oggetto per la sincronizzazione. Potrebbe sembrare un po’ sofisticato, ma cosa puoi fare? Inoltre, il terzo oggetto verrà utilizzato abbastanza raramente. Il risultato sarà simile a questo:
private static final Object tieLock = new Object();
private void doTransfer(final Account fromAcct, final Account toAcct, final DollarAmount amount) throws InsufficientFundsException {
	if (fromAcct.getBalance().compareTo(amount) < 0)
		throw new InsufficientFundsException();
	else {
		fromAcct.debit(amount);
		toAcct.credit(amount);
	}
}
public void transferMoney(final Account fromAcct, final Account toAcct, final DollarAmount amount) throws InsufficientFundsException {
	int fromHash = System.identityHashCode(fromAcct);
	int toHash = System.identityHashCode(toAcct);
	if (fromHash < toHash) {
		synchronized (fromAcct) {
			synchronized (toAcct) {
				doTransfer(fromAcct, toAcct, amount);
			}
		}
	} else if (fromHash > toHash) {
		synchronized (toAcct) {
			synchronized (fromAcct) {
				doTransfer(fromAcct, toAcct, amount);
			}
		}
	} else {
		synchronized (tieLock) {
			synchronized (fromAcct) {
				synchronized (toAcct) {
					doTransfer(fromAcct, toAcct, amount)
				}
			}
		}
	}
}

Deadlock tra gli oggetti

Le condizioni di blocco descritte rappresentano il caso di stallo più semplice da diagnosticare. Spesso nelle applicazioni multi-thread oggetti diversi tentano di accedere agli stessi blocchi sincronizzati. Ciò potrebbe causare una situazione di stallo. Considera il seguente esempio: un'applicazione per il dispatcher di volo. Gli aerei comunicano al controllore quando sono arrivati ​​a destinazione e chiedono il permesso di atterrare. Il controller memorizza tutte le informazioni sugli aerei che volano nella sua direzione e può tracciarne la posizione sulla mappa.
class Plane {
	private Point location, destination;
	private final Dispatcher dispatcher;

	public Plane(Dispatcher dispatcher) {
		this.dispatcher = dispatcher;
	}
	public synchronized Point getLocation() {
		return location;
	}
	public synchronized void setLocation(Point location) {
		this.location = location;
		if (location.equals(destination))
		dispatcher.requestLanding(this);
	}
}

class Dispatcher {
	private final Set<Plane> planes;
	private final Set<Plane> planesPendingLanding;

	public Dispatcher() {
		planes = new HashSet<Plane>();
		planesPendingLanding = new HashSet<Plane>();
	}
	public synchronized void requestLanding(Plane plane) {
		planesPendingLanding.add(plane);
	}
	public synchronized Image getMap() {
		Image image = new Image();
		for (Plane plane : planes)
			image.drawMarker(plane.getLocation());
		return image;
	}
}
Capire che c'è un bug in questo codice che può portare a un deadlock è più difficile che nel precedente. A prima vista, non ha la risincronizzazione, ma non è così. Probabilmente hai già notato che setLocationla classe Planee getMapi suoi metodi Dispatchersono sincronizzati e chiamano metodi sincronizzati di altre classi al loro interno. Questa è generalmente una cattiva pratica. Come correggere questo problema verrà discusso nella sezione successiva. Di conseguenza, se l'aereo arriva sul posto, nel momento in cui qualcuno decide di ritirare la carta, può verificarsi una situazione di stallo. Cioè, verranno chiamati i metodi getMape , setLocationche occuperanno rispettivamente i monitor dell'istanza Dispatchere Plane. Il metodo getMapchiamerà quindi plane.getLocation(in particolare sull'istanza Planeattualmente occupata) che attenderà che il monitor si liberi per ciascuna delle istanze Plane. Allo stesso tempo, setLocationverrà chiamato il metodo dispatcher.requestLanding, mentre il monitor dell'istanza Dispatcherrimane occupato a disegnare la mappa. Il risultato è una situazione di stallo.

Chiamate aperte

Per evitare situazioni come quella descritta nella sezione precedente, si consiglia di utilizzare chiamate pubbliche a metodi di altri oggetti. Cioè, chiama metodi di altri oggetti all'esterno del blocco sincronizzato. Se i metodi vengono riscritti utilizzando il principio delle chiamate aperte setLocation, getMapla possibilità di stallo verrà eliminata. Apparirà, ad esempio, così:
public void setLocation(Point location) {
	boolean reachedDestination;
	synchronized(this){
		this.location = location;
		reachedDestination = location.equals(destination);
	}
	if (reachedDestination)
		dispatcher.requestLanding(this);
}
………………………………………………………………………………
public Image getMap() {
	Set<Plane> copy;
	synchronized(this){
		copy = new HashSet<Plane>( planes);
	}
	Image image = new Image();
	for (Plane plane : copy)
		image.drawMarker(plane.getLocation());
	return image;
}

Blocco delle risorse

Possono verificarsi deadlock anche quando si tenta di accedere ad alcune risorse che solo un thread alla volta può utilizzare. Un esempio potrebbe essere un pool di connessioni al database. Se alcuni thread devono accedere a due connessioni contemporaneamente e vi accedono in ordini diversi, ciò può portare a una situazione di stallo. Fondamentalmente, questo tipo di blocco non è diverso dal blocco dell'ordine di sincronizzazione, tranne per il fatto che si verifica non quando si tenta di eseguire del codice, ma quando si tenta di accedere alle risorse.

Come evitare le situazioni di stallo?

Naturalmente, se il codice viene scritto senza errori (ne abbiamo visti esempi nelle sezioni precedenti), non ci saranno deadlock al suo interno. Ma chi può garantire che il suo codice sia scritto senza errori? Naturalmente, il test aiuta a identificare una parte significativa degli errori, ma come abbiamo visto in precedenza, gli errori nel codice multi-thread non sono facili da diagnosticare e anche dopo il test non si può essere sicuri che non ci siano situazioni di stallo. Possiamo in qualche modo proteggerci dal blocco? La risposta è si. Tecniche simili vengono utilizzate nei motori di database, che spesso necessitano di ripristino da situazioni di stallo (associate al meccanismo di transazione nel database). L'interfaccia Locke le sue implementazioni disponibili nel pacchetto java.util.concurrent.lockspermettono di provare ad occupare il monitor associato ad un'istanza di questa classe utilizzando il metodo tryLock(restituisce true se era possibile occupare il monitor). Supponiamo di avere una coppia di oggetti che implementano un'interfaccia Locke di dover occupare i loro monitor in modo tale da evitare il blocco reciproco. Puoi implementarlo in questo modo:
public void twoLocks(Lock A,  Lock B){
	while(true){
		if(A.tryLock()){
			if(B.tryLock())
			{
				try{
					//do something
				} finally{
					B.unlock();
					A.unlock();
				}
			} else{
				A.unlock();
			}
		}
	}
}
Come puoi vedere in questo programma, occupiamo due monitor, eliminando la possibilità di blocco reciproco. Tieni presente che il blocco try- finallyè necessario poiché le classi nel pacchetto java.util.concurrent.locksnon rilasciano automaticamente il monitor e se si verifica qualche eccezione durante l'esecuzione dell'attività, il monitor verrà bloccato in uno stato bloccato. Come diagnosticare i deadlock? La JVM consente di diagnosticare i deadlock visualizzandoli nei dump dei thread. Tali dump includono informazioni sullo stato in cui si trova il thread. Se è bloccato, il dump contiene informazioni sul monitor che il thread attende per essere rilasciato. Prima di scaricare i thread, la JVM esamina il grafico dei monitor in attesa (occupati) e, se trova cicli, aggiunge informazioni sul deadlock, indicando i monitor e i thread partecipanti. Un dump dei thread bloccati si presenta così:
Found one Java-level deadlock:
=============================
"ApplicationServerThread":
waiting to lock monitor 0x0f0d80cc (a MyDBConnection),
which is held by "ApplicationServerThread"
"ApplicationServerThread":
waiting to lock monitor 0x0f0d8fed (a MyDBCallableStatement),
which is held by "ApplicationServerThread"
Java stack information for the threads listed above:
"ApplicationServerThread":
at MyDBConnection.remove_statement
- waiting to lock <0x6f50f730> (a MyDBConnection)
at MyDBStatement.close
- locked <0x604ffbb0> (a MyDBCallableStatement)
...
"ApplicationServerThread":
at MyDBCallableStatement.sendBatch
- waiting to lock <0x604ffbb0> (a MyDBCallableStatement)
at MyDBConnection.commit
- locked <0x6f50f730> (a MyDBConnection)
Il dump sopra mostra chiaramente che due thread che lavorano con il database si sono bloccati a vicenda. Per diagnosticare i deadlock utilizzando questa funzionalità JVM, è necessario effettuare chiamate all'operazione di dump del thread in vari punti del programma e testare l'applicazione. Successivamente, dovresti analizzare i log risultanti. Se indicano che si è verificata una situazione di stallo, le informazioni del dump aiuteranno a rilevare le condizioni in cui si è verificata. In generale, dovresti evitare situazioni come quelle degli esempi di deadlock. In questi casi, molto probabilmente l'applicazione funzionerà stabilmente. Ma non dimenticare i test e la revisione del codice. Ciò aiuterà a identificare i problemi se si verificano. Nei casi in cui si sta sviluppando un sistema per il quale il ripristino del campo deadlock è critico, è possibile utilizzare il metodo descritto nella sezione “Come evitare i deadlock?”. In questo caso, il metodo lockInterruptiblydi interfaccia Lockda java.util.concurrent.locks. Permette di interrompere il thread che ha occupato il monitor utilizzando questo metodo (e quindi liberare il monitor).
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION