Java Language
Insidie di Java - Thread e concorrenza
Ricerca…
Trabocchetto: uso errato di wait () / notify ()
I metodi object.wait()
, object.notify()
e object.notifyAll()
sono pensati per essere usati in un modo molto specifico. (vedi http://stackoverflow.com/documentation/java/5409/wait-notify#t=20160811161648303307 )
Il problema "Lost Notification"
Un errore principiante comune è quello di chiamare incondizionatamente object.wait()
private final Object lock = new Object();
public void myConsumer() {
synchronized (lock) {
lock.wait(); // DON'T DO THIS!!
}
doSomething();
}
Il motivo per cui questo è sbagliato è che dipende da qualche altro thread per chiamare lock.notify()
o lock.notifyAll()
, ma nulla garantisce che l'altro thread non abbia effettuato quella chiamata prima del thread del consumatore chiamato lock.wait()
.
lock.notify()
e lock.notifyAll()
non fanno nulla se qualche altro thread non sta già aspettando la notifica. Il thread che chiama myConsumer()
in questo esempio si bloccherà per sempre se è troppo tardi per ricevere la notifica.
Il bug "Illegal Monitor State"
Se si chiama wait()
o notify()
su un oggetto senza tenere il blocco, la JVM genererà IllegalMonitorStateException
.
public void myConsumer() {
lock.wait(); // throws exception
consume();
}
public void myProducer() {
produce();
lock.notify(); // throws exception
}
(Il design per wait()
/ notify()
richiede che il blocco sia trattenuto perché è necessario per evitare condizioni di gara sistemiche.Se fosse possibile chiamare wait()
o notify()
senza bloccare, sarebbe impossibile implementare il caso d'uso principale per questi primitivi: aspettare che si verifichi una condizione).
L'attesa / notifica è troppo bassa
Il modo migliore per evitare problemi con wait()
e notify()
è di non usarli. La maggior parte dei problemi di sincronizzazione può essere risolta utilizzando gli oggetti di sincronizzazione di livello superiore (code, barriere, semafori, ecc.) Disponibili nel pacchetto java.utils.concurrent
.
Pitfall - Estensione di 'java.lang.Thread'
Javadoc per la classe Thread
mostra due modi per definire e utilizzare un thread:
Utilizzando una classe thread personalizzata:
class PrimeThread extends Thread {
long minPrime;
PrimeThread(long minPrime) {
this.minPrime = minPrime;
}
public void run() {
// compute primes larger than minPrime
. . .
}
}
PrimeThread p = new PrimeThread(143);
p.start();
Usando un Runnable
:
class PrimeRun implements Runnable {
long minPrime;
PrimeRun(long minPrime) {
this.minPrime = minPrime;
}
public void run() {
// compute primes larger than minPrime
. . .
}
}
PrimeRun p = new PrimeRun(143);
new Thread(p).start();
(Fonte: java.lang.Thread
javadoc .)
L'approccio alla classe del thread personalizzato funziona, ma presenta alcuni problemi:
È scomodo utilizzare
PrimeThread
in un contesto che utilizza un pool di thread classico, un executor o il framework ForkJoin. (Non è impossibile, perchéPrimeThread
implementa indirettamenteRunnable
, ma l'uso di una classeThread
personalizzata comeRunnable
è certamente goffo e potrebbe non essere fattibile ... a seconda di altri aspetti della classe.)Vi sono più opportunità per errori in altri metodi. Ad esempio, se hai dichiarato un
PrimeThread.start()
senza delegare aThread.start()
, si otterrebbe un "thread" eseguito sul thread corrente.
L'approccio di mettere la logica del thread in un Runnable
evita questi problemi. Infatti, se si utilizza una classe anonima (Java 1.1 in poi) per implementare il Runnable
il risultato è più sintetico e più leggibile rispetto agli esempi precedenti.
final long minPrime = ...
new Thread(new Runnable() {
public void run() {
// compute primes larger than minPrime
. . .
}
}.start();
Con un'espressione lambda (Java 8 in poi), l'esempio sopra sarebbe diventato ancora più elegante:
final long minPrime = ...
new Thread(() -> {
// compute primes larger than minPrime
. . .
}).start();
Pitfall: troppi thread rendono l'applicazione più lenta.
Un sacco di persone che sono nuove nel multi-threading pensano che l'utilizzo di thread faccia automaticamente andare più veloce un'applicazione. In realtà, è molto più complicato di così. Ma una cosa che possiamo affermare con certezza è che per qualsiasi computer esiste un limite al numero di thread che possono essere eseguiti contemporaneamente:
- Un computer ha un numero fisso di core (o hyperthreads ).
- Un thread Java deve essere programmato su un core o hyperthread per poter essere eseguito.
- Se esistono più thread Java eseguibili rispetto ai core / hyperthread (disponibili), alcuni di essi devono attendere.
Questo ci dice che la semplice creazione di un numero sempre maggiore di thread Java non può rendere l'applicazione sempre più veloce. Ma ci sono anche altre considerazioni:
Ogni thread richiede un'area di memoria fuori dallo heap per il suo stack di thread. La tipica dimensione di stack di thread (predefinita) è 512Kbytes o 1Mbytes. Se si dispone di un numero significativo di thread, l'utilizzo della memoria può essere significativo.
Ogni thread attivo farà riferimento a un numero di oggetti nell'heap. Ciò aumenta il working set di oggetti raggiungibili , che ha un impatto sulla garbage collection e sull'utilizzo della memoria fisica.
I costi generali del passaggio da un thread all'altro non sono banali. Di solito comporta un passaggio nello spazio del kernel del sistema operativo per prendere una decisione sulla schedulazione del thread.
I sovraccarichi di sincronizzazione dei thread e di segnalazione inter-thread (ad esempio wait (), notify () / notifyAll) possono essere significativi.
A seconda dei dettagli dell'applicazione, questi fattori generalmente indicano che esiste un "punto debole" per il numero di thread. Oltre a ciò, l'aggiunta di più thread offre un miglioramento minimo delle prestazioni e può peggiorare le prestazioni.
Se l'applicazione viene creata per ogni nuova attività, un aumento imprevisto del carico di lavoro (ad esempio un alto tasso di richieste) può comportare un comportamento catastrofico.
Un modo migliore per gestire questo è utilizzare il pool di thread limitato di cui è possibile controllare le dimensioni (staticamente o dinamicamente). Quando c'è troppo lavoro da fare, l'applicazione deve accodare le richieste. Se si utilizza un ExecutorService
, si prenderà cura della gestione del pool di thread e dell'accodamento delle attività.
Pitfall - La creazione del thread è relativamente costosa
Considera questi due micro-benchmark:
Il primo benchmark crea semplicemente, avvia e unisce i thread. Runnable
del thread non funziona.
public class ThreadTest {
public static void main(String[] args) throws Exception {
while (true) {
long start = System.nanoTime();
for (int i = 0; i < 100_000; i++) {
Thread t = new Thread(new Runnable() {
public void run() {
}});
t.start();
t.join();
}
long end = System.nanoTime();
System.out.println((end - start) / 100_000.0);
}
}
}
$ java ThreadTest
34627.91355
33596.66021
33661.19084
33699.44895
33603.097
33759.3928
33671.5719
33619.46809
33679.92508
33500.32862
33409.70188
33475.70541
33925.87848
33672.89529
^C
In un tipico PC moderno che esegue Linux con Java 8 u101 a 64 bit, questo benchmark mostra un tempo medio impiegato per creare, avviare e unire thread compreso tra 33,6 e 33,9 microsecondi.
Il secondo benchmark fa l'equivalente al primo ma utilizza un ExecutorService
per inviare compiti e un Future
rendere alla fine del compito.
import java.util.concurrent.*;
public class ExecutorTest {
public static void main(String[] args) throws Exception {
ExecutorService exec = Executors.newCachedThreadPool();
while (true) {
long start = System.nanoTime();
for (int i = 0; i < 100_000; i++) {
Future<?> future = exec.submit(new Runnable() {
public void run() {
}
});
future.get();
}
long end = System.nanoTime();
System.out.println((end - start) / 100_000.0);
}
}
}
$ java ExecutorTest
6714.66053
5418.24901
5571.65213
5307.83651
5294.44132
5370.69978
5291.83493
5386.23932
5384.06842
5293.14126
5445.17405
5389.70685
^C
Come puoi vedere, le medie sono comprese tra 5,3 e 5,6 microsecondi.
Mentre i tempi effettivi dipenderanno da una varietà di fattori, la differenza tra questi due risultati è significativa. È chiaramente più veloce utilizzare un pool di thread per riciclare i thread piuttosto che creare nuovi thread.
Trappola: le variabili condivise richiedono una sincronizzazione adeguata
Considera questo esempio:
public class ThreadTest implements Runnable {
private boolean stop = false;
public void run() {
long counter = 0;
while (!stop) {
counter = counter + 1;
}
System.out.println("Counted " + counter);
}
public static void main(String[] args) {
ThreadTest tt = new ThreadTest();
new Thread(tt).start(); // Create and start child thread
Thread.sleep(1000);
tt.stop = true; // Tell child thread to stop.
}
}
Lo scopo di questo programma è quello di avviare un thread, lasciarlo girare per 1000 millisecondi e quindi farlo arrestare impostando il flag stop
.
Funzionerà come previsto?
Forse sì forse no.
Un'applicazione non si ferma necessariamente quando viene restituito il metodo main
. Se è stato creato un altro thread e tale thread non è stato contrassegnato come thread daemon, l'applicazione continuerà ad essere eseguita dopo che il thread principale è terminato. In questo esempio, ciò significa che l'applicazione continuerà a essere in esecuzione fino alla fine del thread secondario. Questo dovrebbe accadere quando tt.stop
è impostato su true
.
Ma questo in realtà non è strettamente vero. In realtà, il thread figlio si fermerà dopo aver osservato stop
con il valore true
. Succederà? Forse sì forse no.
La specifica del linguaggio Java garantisce che le letture e le scritture di memoria eseguite in un thread siano visibili a quel thread, secondo l'ordine delle istruzioni nel codice sorgente. Tuttavia, in generale, questo NON è garantito quando un thread scrive e un altro thread (successivamente) legge. Per ottenere una visibilità garantita, deve esserci una catena di successi , prima delle relazioni tra una scrittura e una successiva. Nell'esempio sopra, non esiste una catena di questo tipo per l'aggiornamento al flag di stop
, e quindi non è garantito che il thread secondario vedrà il cambio di stop
su true
.
(Nota per gli autori: ci dovrebbe essere un argomento separato sul modello di memoria Java per entrare nei dettagli tecnici profondi).
Come risolviamo il problema?
In questo caso, ci sono due semplici modi per garantire che l'aggiornamento di stop
sia visibile:
Dichiara che
stop
èvolatile
; vale a direprivate volatile boolean stop = false;
Per una variabile
volatile
, il JLS specifica che esiste una relazione before-before tra una scrittura di un thread e una successiva in un secondo thread.Utilizzare un mutex per sincronizzare come segue:
public class ThreadTest implements Runnable {
private boolean stop = false;
public void run() {
long counter = 0;
while (true) {
synchronize (this) {
if (stop) {
break;
}
}
counter = counter + 1;
}
System.out.println("Counted " + counter);
}
public static void main(String[] args) {
ThreadTest tt = new ThreadTest();
new Thread(tt).start(); // Create and start child thread
Thread.sleep(1000);
synchronize (tt) {
tt.stop = true; // Tell child thread to stop.
}
}
}
Oltre a garantire l'esistenza dell'esclusione reciproca, il JLS specifica che esiste una relazione before-before tra il rilascio di un mutex in un thread e l'ottenimento dello stesso mutex in un secondo thread.
Ma non è un compito atomico?
Sì!
Tuttavia, questo fatto non significa che gli effetti dell'aggiornamento saranno visibili simultaneamente a tutti i thread. Solo una catena adeguata di relazioni -prima-prima lo garantirà.
Perché lo hanno fatto?
I programmatori che eseguono la programmazione multi-thread in Java per la prima volta scoprono che il modello di memoria è impegnativo. I programmi si comportano in modo non intuitivo perché la naturale aspettativa è che le scritture siano visibili in modo uniforme. Quindi, perché i designer Java progettano il modello di memoria in questo modo.
In realtà si tratta di un compromesso tra prestazioni e facilità d'uso (per il programmatore).
Una moderna architettura di computer è composta da più processori (core) con set di registri individuali. La memoria principale è accessibile a tutti i processori oa gruppi di processori. Un'altra proprietà dell'hardware dei computer moderni è che l'accesso ai registri è in genere un ordine di grandezza più veloce all'accesso rispetto all'accesso alla memoria principale. Con l'aumentare del numero di core, è facile vedere che leggere e scrivere sulla memoria principale può diventare il collo di bottiglia principale delle prestazioni del sistema.
Questa mancata corrispondenza viene affrontata implementando uno o più livelli di memorizzazione nella cache della memoria tra i core del processore e la memoria principale. Ogni core ha accesso alle celle di memoria tramite la sua cache. Normalmente, una lettura della memoria principale si verifica solo quando c'è un errore di cache e una scrittura della memoria principale si verifica solo quando una riga della cache deve essere scaricata. Per un'applicazione in cui il set di memoria funzionante di ciascun core si adatta alla sua cache, la velocità di core non è più limitata dalla velocità di memoria / larghezza di banda principale.
Ma questo ci dà un nuovo problema quando più core leggono e scrivono variabili condivise. L'ultima versione di una variabile può trovarsi nella cache di un core. A meno che quel core non svuota la linea della cache nella memoria principale, e altri core invalidi la copia cache delle versioni precedenti, alcuni di essi potrebbero vedere versioni stabili della variabile. Ma se le cache sono state scaricate in memoria ogni volta che c'è una scrittura cache ("nel caso in cui ci fosse una lettura da un altro core) che consumerebbe inutilmente la larghezza di banda della memoria principale.
La soluzione standard utilizzata a livello di set di istruzioni hardware consiste nel fornire istruzioni per l'invalidazione della cache e il write-through della cache e lasciare al compilatore la decisione su quando utilizzarli.
Ritorno a Java. il modello di memoria è progettato in modo che i compilatori Java non siano tenuti a inviare istruzioni di invalidazione della cache e write-through laddove non sono realmente necessarie. Il presupposto è che il programmatore utilizzerà un meccanismo di sincronizzazione appropriato (es. Mutex primitivi, classi di concorrenza volatile
, di livello superiore e così via) per indicare che ha bisogno di visibilità della memoria. In assenza di una relazione di happen-before , i compilatori Java sono liberi di presupporre che non siano richieste operazioni di cache (o simili).
Ciò ha notevoli vantaggi in termini di prestazioni per le applicazioni multi-thread, ma il lato negativo è che la scrittura di applicazioni multi-thread corrette non è una questione semplice. Il programmatore non deve capire quello che lui o lei sta facendo.
Perché non posso riprodurre questo?
Esistono diverse ragioni per cui problemi di questo tipo sono difficili da riprodurre:
Come spiegato sopra, la conseguenza di non gestire correttamente i problemi di visibilità della memoria è in genere che l'applicazione compilata non gestisce correttamente le cache di memoria. Tuttavia, come accennato sopra, le cache di memoria spesso si svuotano comunque.
Quando si modifica la piattaforma hardware, le caratteristiche delle cache di memoria potrebbero cambiare. Ciò può comportare un comportamento diverso se l'applicazione non si sincronizza correttamente.
Potresti star osservando gli effetti della sincronizzazione fortuita . Ad esempio, se si aggiungono traceprints, in genere avviene una sincronizzazione dietro le quinte nei flussi I / O che provoca il flush della cache. Quindi l'aggiunta di traceprints spesso causa un comportamento diverso dell'applicazione.
L'esecuzione di un'applicazione in un debugger fa in modo che venga compilata in modo diverso dal compilatore JIT. Punti di rottura e single stepping esacerbano questo. Questi effetti cambieranno spesso il comportamento di un'applicazione.
Queste cose rendono i bachi dovuti a una sincronizzazione inadeguata particolarmente difficili da risolvere.