JavaRush /Java Blog /Random-IT /Fondamenti di concorrenza: deadlock e monitoraggio degli ...
Snusmum
Livello 34
Хабаровск

Fondamenti di concorrenza: deadlock e monitoraggio degli oggetti (sezioni 1, 2) (traduzione dell'articolo)

Pubblicato nel gruppo Random-IT
Articolo di origine: http://www.javacodegeeks.com/2015/09/concurrency-fundamentals-deadlocks-and-object-monitors.html Inserito da Martin Mois Questo articolo fa parte del nostro corso Java Concurrency Fundamentals . In questo corso approfondirai la magia del parallelismo. Imparerai le basi del parallelismo e del codice parallelo e acquisirai familiarità con concetti come atomicità, sincronizzazione e sicurezza dei thread. Dai un'occhiata qui !

Contenuto

1. Vitalità  1.1 Deadlock  1.2 Starvation 2. Monitoraggio degli oggetti con wait() e notify()  2.1 Blocchi sincronizzati nidificati con wait() e notify()  2.2 Condizioni nei blocchi sincronizzati 3. Progettazione per il multi-threading  3.1 Oggetto immutabile  3.2 Progettazione API  3.3 Archiviazione del thread locale
1. Vitalità
Quando si sviluppano applicazioni che utilizzano il parallelismo per raggiungere i propri obiettivi, è possibile che si verifichino situazioni in cui thread diversi possono bloccarsi a vicenda. Se in questa situazione l'applicazione funziona più lentamente del previsto, diremmo che non funziona come previsto. In questa sezione esamineremo più da vicino i problemi che possono minacciare la sopravvivenza di un'applicazione multi-thread.
1.1 Blocco reciproco
Il termine deadlock è ben noto tra gli sviluppatori di software e anche gli utenti più comuni lo utilizzano di tanto in tanto, anche se non sempre nel senso corretto. A rigor di termini, questo termine significa che ciascuno dei due (o più) thread è in attesa che l'altro thread rilasci una risorsa da esso bloccata, mentre il primo thread stesso ha bloccato una risorsa a cui il secondo è in attesa di accedere: Per capire meglio il problema, dai un'occhiata al Thread 1: locks resource A, waits for resource B Thread 2: locks resource B, waits for resource A seguente codice: public class Deadlock implements Runnable { private static final Object resource1 = new Object(); private static final Object resource2 = new Object(); private final Random random = new Random(System.currentTimeMillis()); public static void main(String[] args) { Thread myThread1 = new Thread(new Deadlock(), "thread-1"); Thread myThread2 = new Thread(new Deadlock(), "thread-2"); myThread1.start(); myThread2.start(); } public void run() { for (int i = 0; i < 10000; i++) { boolean b = random.nextBoolean(); if (b) { System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 1."); synchronized (resource1) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 1."); System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 2."); synchronized (resource2) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 2."); } } } else { System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 2."); synchronized (resource2) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 2."); System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 1."); synchronized (resource1) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 1."); } } } } } } Come puoi vedere dal codice sopra, vengono avviati due thread che tentano di bloccare due risorse statiche. Ma per il deadlock abbiamo bisogno di una sequenza diversa per entrambi i thread, quindi utilizziamo un'istanza dell'oggetto Random per scegliere quale risorsa il thread desidera bloccare per prima. Se la variabile booleana b è vera, allora la risorsa1 viene prima bloccata e poi il thread tenta di acquisire il blocco per la risorsa2. Se b è falso, il thread blocca risorsa2 e quindi tenta di acquisire risorsa1. Non è necessario che questo programma venga eseguito a lungo per raggiungere il primo stallo, ad es. Il programma si bloccherà per sempre se non lo interrompiamo: [thread-1] Trying to lock resource 1. [thread-1] Locked resource 1. [thread-1] Trying to lock resource 2. [thread-1] Locked resource 2. [thread-2] Trying to lock resource 1. [thread-2] Locked resource 1. [thread-1] Trying to lock resource 2. [thread-1] Locked resource 2. [thread-2] Trying to lock resource 2. [thread-1] Trying to lock resource 1. In questa esecuzione, tread-1 ha acquisito il blocco di Resource2 ed è in attesa del blocco di Resource1, mentre tread-2 ha il blocco di Resource1 ed è in attesa di Resource2. Se dovessimo impostare il valore della variabile booleana b nel codice sopra su true, non potremmo osservare alcun deadlock perché la sequenza in cui thread-1 e thread-2 richiedono i lock sarebbe sempre la stessa. In questa situazione uno dei due thread otterrebbe prima il lock e poi richiederebbe il secondo, che è ancora disponibile perché l'altro thread è in attesa del primo lock. In generale, possiamo distinguere le seguenti condizioni necessarie affinché si verifichi un deadlock: - Esecuzione condivisa: esiste una risorsa a cui può accedere un solo thread alla volta. - Blocco delle risorse: durante l'acquisizione di una risorsa, un thread tenta di acquisire un altro blocco su una risorsa univoca. - Nessuna prelazione: non esiste alcun meccanismo per rilasciare una risorsa se un thread mantiene il blocco per un certo periodo di tempo. - Attesa circolare: durante l'esecuzione, si verifica una raccolta di thread in cui due (o più) thread attendono l'uno dall'altro per rilasciare una risorsa che è stata bloccata. Anche se l'elenco delle condizioni sembra lungo, non è raro che applicazioni multi-thread ben eseguite abbiano problemi di deadlock. Ma puoi prevenirli se puoi rimuovere una delle condizioni di cui sopra: - Esecuzione condivisa: questa condizione spesso non può essere rimossa quando la risorsa deve essere utilizzata da una sola persona. Ma non deve essere questo il motivo. Quando si utilizzano sistemi DBMS, una possibile soluzione, invece di utilizzare un blocco pessimistico su alcune righe della tabella che devono essere aggiornate, è utilizzare una tecnica chiamata Blocco Ottimistico . - Un modo per evitare di trattenere una risorsa mentre si attende un'altra risorsa esclusiva è bloccare tutte le risorse necessarie all'inizio dell'algoritmo e rilasciarle tutte se è impossibile bloccarle tutte in una volta. Naturalmente, questo non è sempre possibile; forse le risorse che richiedono il blocco non sono note in anticipo, oppure questo approccio porterà semplicemente ad uno spreco di risorse. - Se il blocco non può essere acquisito immediatamente, un modo per aggirare un possibile stallo è introdurre un timeout. Ad esempio, la classe ReentrantLockdall'SDK offre la possibilità di impostare una data di scadenza per il blocco. - Come abbiamo visto dall'esempio precedente, il deadlock non si verifica se la sequenza delle richieste non differisce tra i diversi thread. Questo è facile da controllare se puoi inserire tutto il codice di blocco in un metodo che tutti i thread devono seguire. Nelle applicazioni più avanzate, potresti anche prendere in considerazione l'implementazione di un sistema di rilevamento dei deadlock. Qui dovrai implementare una parvenza di monitoraggio del thread, in cui ogni thread segnala di aver acquisito con successo il blocco e sta tentando di acquisire il blocco. Se thread e blocchi vengono modellati come un grafico diretto, è possibile rilevare quando due thread diversi contengono risorse mentre tentano di accedere contemporaneamente ad altre risorse bloccate. Se poi si riesce a forzare i thread bloccanti a rilasciare le risorse richieste, è possibile risolvere automaticamente la situazione di stallo.
1.2 Digiuno
Lo scheduler decide quale thread nello stato RUNNABLE dovrà essere eseguito successivamente. La decisione si basa sulla priorità del thread; pertanto, i thread con priorità più bassa ricevono meno tempo CPU rispetto a quelli con priorità più alta. Quella che sembra una soluzione ragionevole può anche causare problemi in caso di abuso. Se i thread ad alta priorità sono in esecuzione per la maggior parte del tempo, i thread a bassa priorità sembrano morire di fame perché non hanno abbastanza tempo per svolgere correttamente il proprio lavoro. Pertanto, si consiglia di impostare la priorità del thread solo quando esiste un motivo convincente per farlo. Un esempio non ovvio di thread starvation è dato, ad esempio, dal metodo finalize(). Fornisce un modo per il linguaggio Java di eseguire il codice prima che un oggetto venga sottoposto a Garbage Collection. Ma se guardi la priorità del thread di finalizzazione, noterai che non viene eseguito con la priorità più alta. Di conseguenza, la carenza di thread si verifica quando i metodi finalize() dell'oggetto trascorrono troppo tempo rispetto al resto del codice. Un altro problema con il tempo di esecuzione deriva dal fatto che non è definito in quale ordine i thread attraversano il blocco sincronizzato. Quando molti thread paralleli attraversano del codice inquadrato in un blocco sincronizzato, può accadere che alcuni thread debbano attendere più a lungo di altri prima di entrare nel blocco. In teoria, potrebbero non arrivarci mai. La soluzione a questo problema è il cosiddetto blocco “equo”. I fair lock tengono conto dei tempi di attesa del thread quando determinano chi passare dopo. Un esempio di implementazione del fair lock è disponibile in Java SDK: java.util.concurrent.locks.ReentrantLock. Se un costruttore viene utilizzato con un flag booleano impostato su true, ReentrantLock dà accesso al thread che è in attesa da più tempo. Ciò garantisce l’assenza della fame ma, allo stesso tempo, pone il problema di ignorare le priorità. Per questo motivo, i processi con priorità più bassa che spesso attendono presso questa barriera potrebbero essere eseguiti con maggiore frequenza. Ultimo ma non meno importante, la classe ReentrantLock può considerare solo i thread che sono in attesa di un lock, cioè thread che sono stati lanciati abbastanza spesso e hanno raggiunto la barriera. Se la priorità di un thread è troppo bassa, ciò non accadrà spesso e pertanto i thread ad alta priorità supereranno comunque il blocco più spesso.
2. Monitoraggio degli oggetti insieme a wait() e notify()
Nell'elaborazione multi-thread, una situazione comune è quella di avere alcuni thread di lavoro in attesa che il loro produttore crei del lavoro per loro. Ma, come abbiamo appreso, attendere attivamente in un ciclo mentre si controlla un determinato valore non è una buona opzione in termini di tempo della CPU. Anche l'utilizzo del metodo Thread.sleep() in questa situazione non è particolarmente adatto se vogliamo iniziare il nostro lavoro subito dopo l'arrivo. A questo scopo, il linguaggio di programmazione Java ha un'altra struttura che può essere utilizzata in questo schema: wait() e notify(). Il metodo wait(), ereditato da tutti gli oggetti della classe java.lang.Object, può essere utilizzato per sospendere il thread corrente e attendere finché un altro thread non ci sveglia utilizzando il metodo notify(). Per funzionare correttamente, il thread che chiama il metodo wait() deve mantenere un blocco precedentemente acquisito utilizzando la parola chiave sincronizzata. Quando viene chiamato wait(), il blocco viene rilasciato e il thread attende finché un altro thread che ora detiene il blocco chiama notify() sulla stessa istanza dell'oggetto. In un'applicazione multi-thread, potrebbe esserci naturalmente più di un thread in attesa di notifica su qualche oggetto. Pertanto, esistono due metodi diversi per riattivare i thread: notify() e notifyAll(). Mentre il primo metodo riattiva uno dei thread in attesa, il metodo notifyAll() li riattiva tutti. Ma tieni presente che, come con la parola chiave sincronizzata, non esiste una regola che determini quale thread verrà attivato successivamente quando viene chiamato notify(). In un semplice esempio con un produttore e un consumatore, questo non ha importanza, poiché non ci interessa quale thread viene risvegliato. Il codice seguente mostra come wait() e notify() possono essere utilizzati per far sì che i thread consumer attendano che il nuovo lavoro venga messo in coda da un thread produttore: package a2; import java.util.Queue; import java.util.concurrent.ConcurrentLinkedQueue; public class ConsumerProducer { private static final Queue queue = new ConcurrentLinkedQueue(); private static final long startMillis = System.currentTimeMillis(); public static class Consumer implements Runnable { public void run() { while (System.currentTimeMillis() < (startMillis + 10000)) { synchronized (queue) { try { queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } if (!queue.isEmpty()) { Integer integer = queue.poll(); System.out.println("[" + Thread.currentThread().getName() + "]: " + integer); } } } } public static class Producer implements Runnable { public void run() { int i = 0; while (System.currentTimeMillis() < (startMillis + 10000)) { queue.add(i++); synchronized (queue) { queue.notify(); } try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } synchronized (queue) { queue.notifyAll(); } } } public static void main(String[] args) throws InterruptedException { Thread[] consumerThreads = new Thread[5]; for (int i = 0; i < consumerThreads.length; i++) { consumerThreads[i] = new Thread(new Consumer(), "consumer-" + i); consumerThreads[i].start(); } Thread producerThread = new Thread(new Producer(), "producer"); producerThread.start(); for (int i = 0; i < consumerThreads.length; i++) { consumerThreads[i].join(); } producerThread.join(); } } Il metodo main() avvia cinque thread consumer e un thread produttore e quindi attende che finiscano. Il thread produttore aggiunge quindi il nuovo valore alla coda e notifica a tutti i thread in attesa che è successo qualcosa. I consumatori ottengono un blocco della coda (cioè un consumatore casuale) e poi vanno in modalità sospensione, per essere ripristinati in seguito quando la coda sarà di nuovo piena. Quando il produttore termina il suo lavoro, avvisa tutti i consumatori per svegliarli. Se non eseguissimo l'ultimo passaggio, i thread del consumatore aspetterebbero per sempre la notifica successiva perché non abbiamo impostato un timeout di attesa. Possiamo invece utilizzare il metodo wait(long timeout) per essere svegliati almeno dopo che è trascorso un po' di tempo.
2.1 Blocchi sincronizzati annidati con wait() e notify()
Come affermato nella sezione precedente, la chiamata wait() sul monitor di un oggetto rilascia solo il blocco su quel monitor. Gli altri blocchi mantenuti dallo stesso thread non vengono rilasciati. Come è facile intuire, nel lavoro quotidiano può succedere che il thread che chiama wait() mantenga ulteriormente il lock. Se anche altri thread attendono questi blocchi, potrebbe verificarsi una situazione di stallo. Diamo un'occhiata al blocco nel seguente esempio: public class SynchronizedAndWait { private static final Queue queue = new ConcurrentLinkedQueue(); public synchronized Integer getNextInt() { Integer retVal = null; while (retVal == null) { synchronized (queue) { try { queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); } retVal = queue.poll(); } } return retVal; } public synchronized void putInt(Integer value) { synchronized (queue) { queue.add(value); queue.notify(); } } public static void main(String[] args) throws InterruptedException { final SynchronizedAndWait queue = new SynchronizedAndWait(); Thread thread1 = new Thread(new Runnable() { public void run() { for (int i = 0; i < 10; i++) { queue.putInt(i); } } }); Thread thread2 = new Thread(new Runnable() { public void run() { for (int i = 0; i < 10; i++) { Integer nextInt = queue.getNextInt(); System.out.println("Next int: " + nextInt); } } }); thread1.start(); thread2.start(); thread1.join(); thread2.join(); } } Come abbiamo appreso in precedenza , aggiungere sincronizzato alla firma di un metodo equivale a creare un blocco sincronizzato(questo){}. Nell'esempio sopra, abbiamo accidentalmente aggiunto la parola chiave sincronizzato al metodo e quindi sincronizzato la coda con il monitor dell'oggetto coda per mandare questo thread in modalità di sospensione mentre aspettava il valore successivo dalla coda. Quindi, il thread corrente rilascia il blocco sulla coda, ma non il blocco su questa. Il metodo putInt() notifica al thread dormiente che è stato aggiunto un nuovo valore. Ma per caso abbiamo aggiunto anche la parola chiave sincronizzata a questo metodo. Ora che il secondo thread si è addormentato, mantiene ancora il lucchetto. Pertanto, il primo thread non può accedere al metodo putInt() mentre il blocco è mantenuto dal secondo thread. Di conseguenza, ci troviamo in una situazione di stallo e con un programma congelato. Se esegui il codice sopra, ciò avverrà immediatamente dopo l'avvio del programma. Nella vita di tutti i giorni, questa situazione potrebbe non essere così ovvia. I blocchi mantenuti da un thread possono dipendere da parametri e condizioni incontrati in fase di esecuzione e il blocco sincronizzato che causa il problema potrebbe non essere così vicino nel codice a dove abbiamo inserito la chiamata wait(). Ciò rende difficile individuare tali problemi, soprattutto perché potrebbero verificarsi nel tempo o sotto carico elevato.
2.2 Condizioni nei blocchi sincronizzati
Spesso è necessario verificare che alcune condizioni siano soddisfatte prima di eseguire qualsiasi azione su un oggetto sincronizzato. Quando hai una coda, ad esempio, vuoi aspettare che si riempia. Pertanto, puoi scrivere un metodo che controlli se la coda è piena. Se è ancora vuoto, mandi il thread corrente in modalità di sospensione finché non viene riattivato: public Integer getNextInt() { Integer retVal = null; synchronized (queue) { try { while (queue.isEmpty()) { queue.wait(); } } catch (InterruptedException e) { e.printStackTrace(); } } synchronized (queue) { retVal = queue.poll(); if (retVal == null) { System.err.println("retVal is null"); throw new IllegalStateException(); } } return retVal; } Il codice sopra si sincronizza con la coda prima di chiamare wait() e poi attende in un ciclo while finché almeno un elemento appare in coda. Il secondo blocco sincronizzato utilizza nuovamente la coda come monitor oggetto. Chiama il metodo poll() della coda per ottenere il valore. A scopo dimostrativo, viene generata un'IllegalStateException quando il poll restituisce null. Ciò accade quando la coda non ha elementi da recuperare. Quando esegui questo esempio, vedrai che IllegalStateException viene lanciata molto spesso. Sebbene abbiamo eseguito la sincronizzazione correttamente utilizzando il monitoraggio della coda, è stata generata un'eccezione. Il motivo è che abbiamo due blocchi diversi sincronizzati. Immaginiamo di avere due thread arrivati ​​al primo blocco sincronizzato. Il primo thread è entrato nel blocco ed è andato in stop perché la coda era vuota. Lo stesso vale per il secondo thread. Ora che entrambi i thread sono svegli (grazie alla chiamata notifyAll() chiamata dall'altro thread per il monitor), entrambi vedono il value(item) nella coda aggiunto dal produttore. Poi entrambi arrivarono alla seconda barriera. Qui il primo thread è entrato e ha recuperato il valore dalla coda. Quando entra il secondo thread, la coda è già vuota. Pertanto, riceve null come valore restituito dalla coda e genera un'eccezione. Per evitare tali situazioni, è necessario eseguire tutte le operazioni che dipendono dallo stato del monitor nello stesso blocco sincronizzato: public Integer getNextInt() { Integer retVal = null; synchronized (queue) { try { while (queue.isEmpty()) { queue.wait(); } } catch (InterruptedException e) { e.printStackTrace(); } retVal = queue.poll(); } return retVal; } Qui eseguiamo il metodo poll() nello stesso blocco sincronizzato del metodo isEmpty(). Grazie al blocco sincronizzato, siamo sicuri che solo un thread alla volta stia eseguendo un metodo per questo monitor. Pertanto, nessun altro thread può rimuovere elementi dalla coda tra le chiamate a isEmpty() e poll(). Continua la traduzione qui .
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION