Java – Un caso di deadlock con join() di Thread

Un po’ di mesi fa ho risposto ad una questione relativa a Java su un forum in cui un utente chiedeva sostanzialmente la seguente cosa: se un thread A acquisisce il lock su un oggetto X e mantenendo questo lock fa un join() su un thread B, si rischia di causare un deadlock? L’utente inoltre domandava anche se il join() rilascia il lock sull’oggetto.

Dal momento che è uno scenario un pochino particolare e interessante, credo sia utile descriverlo meglio in dettaglio.

Descrizione

Innanzitutto il deadlock tra thread è una situazione di “stallo” in cui due (o più) thread sono bloccati a vicenda (non possono continuare la loro esecuzione) perché ciascuno è in attesa che l’altro thread rilasci una “risorsa” (tipicamente un lock ma può essere anche altro).

Il join() è un metodo della classe java.lang.Thread che attende la terminazione del thread associato all’oggetto Thread su cui il join() è stato invocato. Se ad esempio si sta eseguendo del codice nel contesto del thread A e si esegue threadB.join() il thread A va in sospensione fino al momento in cui il thread B termina.

Nello scenario descritto all’inizio la cosa importante da sapere è che non è il join() da solo che causa il deadlock. La possibilità che avvenga un deadlock però, tecnicamente, esiste davvero e il join() è solamente una concausa, una parte del problema. Infatti, dopo che il thread A ha acquisito il lock sull’oggetto X e invocato threadB.join(), è sufficiente che il thread B prosegua la sua esecuzione e ad un certo punto tenti di acquisire il lock su quello stesso oggetto X, che invece è già stato acquisito dal thread A. Questo di fatto causa un deadlock perché:

  • il thread B è bloccato in attesa di acquisire il lock sull’oggetto X e quindi non può terminare
  • il thread A è bloccato dentro il join() che non può ritornare perché attende la terminazione del thread B che a sua volta non può terminare

Viene quindi a crearsi questo circolo “vizioso” per cui entrambi i thread sono in “stallo” e non possono continuare la loro esecuzione.

E a questo punto possiamo chiarire anche l’altra questione posta all’inizio: no, il join() non rilascia alcun lock. L’unico metodo che rilascia il lock intrinseco di un oggetto è il wait() di Object perché viene usato insieme ai metodi notify() e notifyAll() (sempre di Object) per gestire la condition-queue intrinseca degli oggetti. Questo meccanismo infatti richiede di dover rilasciare il lock in modo che un altro thread possa acquisirlo per fare “qualcosa” e poi invocare uno dei due metodi di notify.

Riproduzione del problema

Vediamo ora qualcosa di più concreto. Si può facilmente riprodurre questa situazione di deadlock? Certo, e non è nemmeno particolarmente difficile o lungo. Il seguente codice dimostra appunto questo scenario.

import java.util.Vector;

public class ProvaDeadlockConJoin {
    public static void main(String[] args) {
        final Vector<Integer> vector = new Vector<Integer>();

        final Thread threadB = new Thread(new Runnable() {
            public void run() {
                try {
                    Thread.sleep(3000);   // attende un po’
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                System.out.println("Thread B: prima di vector.add(123)");
                vector.add(123);
                System.out.println("Thread B: dopo di vector.add(123)");
            }
        }, "Thread B");

        Thread threadA = new Thread(new Runnable() {
            public void run() {
                synchronized (vector) {
                    try {
                        System.out.println("Thread A: prima di threadB.join()");
                        threadB.join();
                        System.out.println("Thread A: dopo di threadB.join()");
                        System.out.println("Thread A: vector.get(0) = " + vector.get(0));
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }, "Thread A");

        threadA.start();
        threadB.start();
    }
}

Eseguendo il programma, l’output è il seguente:

Thread A: prima di threadB.join()
Thread B: prima di vector.add(123)

Non c’è altro output perché il programma va in “stallo” appena il thread B, dopo un certo periodo di tempo (3 secondi), tenta di fare il add su quel Vector condiviso.

L’errore (inserito volutamente per dimostrare il problema) è chiaramente il fatto di aver eseguito il join() dentro il blocco synchronized. La classe Vector è una collezione thread-safe, i suoi metodi sono tutti “sincronizzati” perché utilizzano come lock l’oggetto stesso della collezione. Il synchronized(vector) acquisisce il lock sull’oggetto Vector che è lo stesso lock usato anche dai suoi metodi. Il problema quindi avviene dopo, quando il thread B cerca di fare il add su quel Vector, che necessita appunto di acquisire quello stesso lock.

Si può eventualmente uscire in qualche modo da questa situazione di stallo? Senza fare nulla, purtroppo no. Ci dovrebbe essere un terzo thread C che ad un certo punto esegue un interrupt() sul thread A in modo che il join() possa terminare lanciando fuori un InterruptedException ed infine si esca dal blocco synchronized rilasciando il lock per dare quindi la possibilità al thread B di acquisire quel lock e proseguire.

È bene far notare che non sarebbe sufficiente fare un interrupt() sul thread B, perché la sospensione per l’acquisizione di un lock è un tipo di attesa che non si può interrompere, nemmeno con un interrupt(). Per essere più chiari: un thread C può tecnicamente fare benissimo un interrupt() sul thread B ma finché B è in sospensione per l’attesa del lock, tale interruzione non può essere riconosciuta e gestita.

Conclusioni

A fronte dello scenario descritto e della spiegazione fatta, si può trarre una conclusione (e un insegnamento) abbastanza semplice ma molto importante: se si fa un join() su un thread, sarebbe bene non tenere alcun lock. Se per qualunque motivo si dovesse per forza tenere un lock, bisognerebbe almeno verificare (ammesso che la indagine sia ragionevolmente fattibile) che il thread di cui si attende la terminazione non arrivi mai a tentare di acquisire quello stesso lock.

Questa conclusione però si può anche “girare” nell’altro senso: se si tiene il lock su un oggetto, non si dovrebbero eseguire operazioni “bloccanti” che possono bloccare il thread per un tempo lungo o indefinito. Tra l’altro questo concetto è anche ben espresso dal SEI CERT Oracle Coding Standard for Java all’interno della regola LCK09-J. Do not perform operations that can block while holding a lock.