Loop non vede il valore modificato da un altro thread senza un'istruzione print


89

Nel mio codice ho un ciclo che attende che uno stato venga modificato da un thread diverso. L'altro thread funziona, ma il mio ciclo non vede mai il valore modificato. Aspetta per sempre. Tuttavia, quando inserisco System.out.printlnun'istruzione nel ciclo, improvvisamente funziona! Perché?


Quello che segue è un esempio del mio codice:

class MyHouse {
    boolean pizzaArrived = false;

    void eatPizza() {
        while (pizzaArrived == false) {
            //System.out.println("waiting");
        }

        System.out.println("That was delicious!");
    }

    void deliverPizza() {
        pizzaArrived = true;
    }
}

Mentre il ciclo while è in esecuzione, chiamo deliverPizza()da un thread diverso per impostare la pizzaArrivedvariabile. Ma il ciclo funziona solo quando rimuovo il commento dall'istruzione System.out.println("waiting");. Cosa sta succedendo?

Risposte:


148

La JVM può presumere che altri thread non cambino la pizzaArrivedvariabile durante il ciclo. In altre parole, può sollevare il pizzaArrived == falsetest al di fuori del loop, ottimizzando questo:

while (pizzaArrived == false) {}

in questo:

if (pizzaArrived == false) while (true) {}

che è un ciclo infinito.

Per garantire che le modifiche apportate da un thread siano visibili ad altri thread, è sempre necessario aggiungere un po 'di sincronizzazione tra i thread. Il modo più semplice per farlo è creare la variabile condivisa volatile:

volatile boolean pizzaArrived = false;

Creare una variabile volatilegarantisce che thread diversi vedranno gli effetti delle modifiche reciproche su di essa. Ciò impedisce alla JVM di memorizzare nella cache il valore pizzaArrivedo di sollevare il test all'esterno del loop. Invece, deve leggere ogni volta il valore della variabile reale.

(Più formalmente, volatilecrea una relazione accade prima tra gli accessi alla variabile. Ciò significa che tutto il resto del lavoro svolto da un thread prima di consegnare la pizza è visibile anche al thread che riceve la pizza, anche se quelle altre modifiche non riguardano le volatilevariabili.)

I metodi sincronizzati vengono utilizzati principalmente per implementare l'esclusione reciproca (impedendo che due cose accadano contemporaneamente), ma hanno anche tutti gli stessi effetti collaterali che volatileha. Usarli durante la lettura e la scrittura di una variabile è un altro modo per rendere visibili le modifiche ad altri thread:

class MyHouse {
    boolean pizzaArrived = false;

    void eatPizza() {
        while (getPizzaArrived() == false) {}
        System.out.println("That was delicious!");
    }

    synchronized boolean getPizzaArrived() {
        return pizzaArrived;
    }

    synchronized void deliverPizza() {
        pizzaArrived = true;
    }
}

L'effetto di una dichiarazione di stampa

System.outè un PrintStreamoggetto. I metodi di PrintStreamsono sincronizzati in questo modo:

public void println(String x) {
    synchronized (this) {
        print(x);
        newLine();
    }
}

La sincronizzazione impedisce la pizzaArrivedmemorizzazione nella cache durante il ciclo. In senso stretto, entrambi i thread devono sincronizzarsi sullo stesso oggetto per garantire che le modifiche alla variabile siano visibili. (Ad esempio, chiamare printlndopo l'impostazione pizzaArrivede richiamarlo di nuovo prima della lettura pizzaArrivedsarebbe corretto.) Se solo un thread si sincronizza su un particolare oggetto, la JVM può ignorarlo. In pratica, la JVM non è abbastanza intelligente da dimostrare che altri thread non chiameranno printlndopo l'impostazione pizzaArrived, quindi si presume che potrebbero farlo. Pertanto, non può memorizzare nella cache la variabile durante il ciclo se si chiama System.out.println. Ecco perché i cicli come questo funzionano quando hanno un'istruzione print, sebbene non sia una correzione corretta.

L'utilizzo System.outnon è l'unico modo per causare questo effetto, ma è quello che le persone scoprono più spesso, quando cercano di eseguire il debug, perché il loro ciclo non funziona!


Il problema più grande

while (pizzaArrived == false) {}è un ciclo di attesa impegnata. Questo è male! Mentre attende, divora la CPU, il che rallenta altre applicazioni e aumenta l'utilizzo di energia, la temperatura e la velocità della ventola del sistema. Idealmente, vorremmo che il thread del ciclo dormisse mentre attende, quindi non inghiotte la CPU.

Ecco alcuni modi per farlo:

Utilizzo di attesa / notifica

Una soluzione di basso livello consiste nell'usare i metodi di attesa / notifica diObject :

class MyHouse {
    boolean pizzaArrived = false;

    void eatPizza() {
        synchronized (this) {
            while (!pizzaArrived) {
                try {
                    this.wait();
                } catch (InterruptedException e) {}
            }
        }

        System.out.println("That was delicious!");
    }

    void deliverPizza() {
        synchronized (this) {
            pizzaArrived = true;
            this.notifyAll();
        }
    }
}

In questa versione del codice, il thread del ciclo chiama wait(), che mette il thread in sleep. Non utilizzerà alcun ciclo della CPU durante il sonno. Dopo che il secondo thread ha impostato la variabile, chiama notifyAll()per riattivare tutti i thread che erano in attesa su quell'oggetto. È come avere il ragazzo della pizza suonare il campanello, così puoi sederti e riposarti mentre aspetti, invece di stare goffamente davanti alla porta.

Quando si chiama wait / notify su un oggetto, è necessario mantenere il blocco della sincronizzazione di quell'oggetto, che è ciò che fa il codice sopra. Puoi usare qualsiasi oggetto che ti piace purché entrambi i thread utilizzino lo stesso oggetto: qui ho usato this(l'istanza di MyHouse). Di solito, due thread non sarebbero in grado di entrare simultaneamente in blocchi sincronizzati dello stesso oggetto (che fa parte dello scopo della sincronizzazione) ma qui funziona perché un thread rilascia temporaneamente il blocco di sincronizzazione quando è all'interno del wait()metodo.

BlockingQueue

A BlockingQueueviene utilizzato per implementare le code produttore-consumatore. I "consumatori" prendono gli articoli dalla parte anteriore della coda e i "produttori" li spingono sul retro. Un esempio:

class MyHouse {
    final BlockingQueue<Object> queue = new LinkedBlockingQueue<>();

    void eatFood() throws InterruptedException {
        // take next item from the queue (sleeps while waiting)
        Object food = queue.take();
        // and do something with it
        System.out.println("Eating: " + food);
    }

    void deliverPizza() throws InterruptedException {
        // in producer threads, we push items on to the queue.
        // if there is space in the queue we can return immediately;
        // the consumer thread(s) will get to it later
        queue.put("A delicious pizza");
    }
}

Nota: i metodi pute takedi BlockingQueuepossono generare InterruptedExceptions, che sono eccezioni controllate che devono essere gestite. Nel codice precedente, per semplicità, le eccezioni vengono rilanciate. Potresti preferire catturare le eccezioni nei metodi e riprovare la chiamata put o take per assicurarti che abbia successo. A parte questo punto di bruttezza, BlockingQueueè molto facile da usare.

Nessun'altra sincronizzazione è necessaria qui perché a si BlockingQueueassicura che tutto ciò che i thread hanno fatto prima di inserire gli elementi nella coda sia visibile ai thread che portano fuori quegli elementi.

Esecutori

Executorsono come messaggi già pronti BlockingQueueche eseguono compiti. Esempio:

// A "SingleThreadExecutor" has one work thread and an unlimited queue
ExecutorService executor = Executors.newSingleThreadExecutor();

Runnable eatPizza = () -> { System.out.println("Eating a delicious pizza"); };
Runnable cleanUp = () -> { System.out.println("Cleaning up the house"); };

// we submit tasks which will be executed on the work thread
executor.execute(eatPizza);
executor.execute(cleanUp);
// we continue immediately without needing to wait for the tasks to finish

Per i dettagli si veda la documentazione per Executor, ExecutorServicee Executors.

Gestione degli eventi

Il looping mentre si attende che l'utente faccia clic su qualcosa in un'interfaccia utente è sbagliato. Utilizza invece le funzionalità di gestione degli eventi del toolkit dell'interfaccia utente. In Swing , ad esempio:

JLabel label = new JLabel();
JButton button = new JButton("Click me");
button.addActionListener((ActionEvent e) -> {
    // This event listener is run when the button is clicked.
    // We don't need to loop while waiting.
    label.setText("Button was clicked");
});

Poiché il gestore eventi viene eseguito sul thread di invio dell'evento, eseguire un lavoro lungo nel gestore eventi blocca altre interazioni con l'interfaccia utente fino al termine del lavoro. Le operazioni lente possono essere avviate su un nuovo thread o inviate a un thread in attesa utilizzando una delle tecniche precedenti (wait / notify, a BlockingQueueo Executor). Puoi anche usare a SwingWorker, che è progettato esattamente per questo, e fornisce automaticamente un thread di lavoro in background:

JLabel label = new JLabel();
JButton button = new JButton("Calculate answer");

// Add a click listener for the button
button.addActionListener((ActionEvent e) -> {

    // Defines MyWorker as a SwingWorker whose result type is String:
    class MyWorker extends SwingWorker<String,Void> {
        @Override
        public String doInBackground() throws Exception {
            // This method is called on a background thread.
            // You can do long work here without blocking the UI.
            // This is just an example:
            Thread.sleep(5000);
            return "Answer is 42";
        }

        @Override
        protected void done() {
            // This method is called on the Swing thread once the work is done
            String result;
            try {
                result = get();
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
            label.setText(result); // will display "Answer is 42"
        }
    }

    // Start the worker
    new MyWorker().execute();
});

Timer

Per eseguire azioni periodiche, puoi utilizzare un file java.util.Timer. È più facile da usare che scrivere il proprio ciclo di temporizzazione e più facile da avviare e interrompere. Questa demo stampa l'ora corrente una volta al secondo:

Timer timer = new Timer();
TimerTask task = new TimerTask() {
    @Override
    public void run() {
        System.out.println(System.currentTimeMillis());
    }
};
timer.scheduleAtFixedRate(task, 0, 1000);

Ognuno java.util.Timerha il proprio thread in background che viene utilizzato per eseguire i propri programmi pianificati TimerTask. Naturalmente, il thread dorme tra le attività, quindi non inghiotte la CPU.

Nel codice Swing, c'è anche un javax.swing.Timer, che è simile, ma esegue il listener sul thread Swing, quindi puoi interagire in sicurezza con i componenti Swing senza dover cambiare manualmente i thread:

JFrame frame = new JFrame();
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
Timer timer = new Timer(1000, (ActionEvent e) -> {
    frame.setTitle(String.valueOf(System.currentTimeMillis()));
});
timer.setRepeats(true);
timer.start();
frame.setVisible(true);

Altri modi

Se stai scrivendo codice multithread, vale la pena esplorare le classi in questi pacchetti per vedere cosa è disponibile:

E vedi anche la sezione Concorrenza dei tutorial Java. Il multithreading è complicato, ma è disponibile molto aiuto!


Risposta molto professionale, dopo aver letto questo malinteso non è rimasto nella mia mente, grazie
Humoyun Ahmad

1
Risposta fantastica. Sto lavorando con i thread Java da un po 'e ho ancora imparato qualcosa qui ( wait()rilascia il blocco di sincronizzazione!).
brimborium

Grazie Boann! Ottima risposta, è come un articolo completo con esempi! Sì, è piaciuto anche "wait () rilascia il blocco di sincronizzazione"
Kiryl Ivanou

java public class ThreadTest { private static boolean flag = false; private static class Reader extends Thread { @Override public void run() { while(flag == false) {} System.out.println(flag); } } public static void main(String[] args) { new Reader().start(); flag = true; } } @ Boann, questo codice non solleva il pizzaArrived == falsetest al di fuori del loop e il loop può vedere il flag modificato dal thread principale, perché?
gaussclb

1
@gaussclb Se intendi che hai decompilato un file di classe, correggi. Il compilatore Java non esegue quasi nessuna ottimizzazione. Il sollevamento viene effettuato dalla JVM. È necessario disassemblare il codice macchina nativo. Prova: wiki.openjdk.java.net/display/HotSpot/PrintAssembly
Boann
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.