Pętla nie widzi wartości zmienionej przez inny wątek bez instrukcji print

91

W moim kodzie mam pętlę, która czeka na zmianę stanu z innego wątku. Drugi wątek działa, ale moja pętla nigdy nie widzi zmienionej wartości. Czeka wiecznie. Jednak kiedy umieszczam System.out.printlninstrukcję w pętli, nagle działa! Czemu?


Oto przykład mojego kodu:

class MyHouse {
    boolean pizzaArrived = false;

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

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

    void deliverPizza() {
        pizzaArrived = true;
    }
}

Podczas gdy pętla while jest uruchomiona, dzwonię deliverPizza()z innego wątku, aby ustawić pizzaArrivedzmienną. Ale pętla działa tylko wtedy, gdy odkomentuję System.out.println("waiting");instrukcję. Co się dzieje?

Boann
źródło

Odpowiedzi:

152

JVM może założyć, że inne wątki nie zmieniają pizzaArrivedzmiennej podczas pętli. Innymi słowy, może przenieść pizzaArrived == falsetest poza pętlę, optymalizując to:

while (pizzaArrived == false) {}

zaangażowany w to:

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

która jest nieskończoną pętlą.

Aby upewnić się, że zmiany wprowadzone przez jeden wątek są widoczne dla innych wątków, należy zawsze dodać synchronizację między wątkami. Najprostszym sposobem na to jest utworzenie wspólnej zmiennej volatile:

volatile boolean pizzaArrived = false;

Utworzenie zmiennej volatilegwarantuje, że różne wątki zobaczą efekty wzajemnych zmian w niej. Zapobiega to buforowaniu wartości pizzaArrivedtestu przez maszynę JVM lub przenoszeniu go poza pętlę. Zamiast tego musi za każdym razem odczytywać wartość zmiennej rzeczywistej.

(Bardziej formalnie, volatiletworzy relację dzieje się przed dostępami do zmiennej. Oznacza to, że cała inna praca wykonana przez wątek przed dostarczeniem pizzy jest również widoczna dla wątku otrzymującego pizzę, nawet jeśli te inne zmiany nie dotyczą volatilezmiennych).

Zsynchronizowane metody są stosowane głównie do wdrażania wzajemnego wykluczania (zapobiegania dwóm rzeczom występującym w tym samym czasie), ale mają również te same skutki uboczne, które volatilemają. Używanie ich podczas czytania i pisania zmiennej to kolejny sposób na pokazanie zmian innym wątkom:

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;
    }
}

Efekt oświadczenia drukowanego

System.outjest PrintStreamprzedmiotem. Metody PrintStreamsą zsynchronizowane w następujący sposób:

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

Synchronizacja zapobiega pizzaArrivedbuforowaniu podczas pętli. Ściśle mówiąc, oba wątki muszą zsynchronizować się na tym samym obiekcie, aby zagwarantować, że zmiany w zmiennej są widoczne. (Na przykład wywołanie printlnpo ustawieniu pizzaArrivedi ponowne wywołanie przed odczytem pizzaArrivedbyłoby poprawne). Jeśli tylko jeden wątek synchronizuje się z określonym obiektem, maszyna JVM może go zignorować. W praktyce JVM nie jest wystarczająco inteligentny, aby udowodnić, że inne wątki nie będą dzwonić printlnpo ustawieniu pizzaArrived, więc zakłada, że ​​tak. Dlatego nie może buforować zmiennej podczas pętli, jeśli wywołasz System.out.println. Dlatego pętle takie jak ta działają, gdy mają instrukcję print, chociaż nie jest to poprawna poprawka.

Używanie System.outnie jest jedynym sposobem wywołania tego efektu, ale jest to ten, który ludzie odkrywają najczęściej, kiedy próbują debugować, dlaczego ich pętla nie działa!


Większy problem

while (pizzaArrived == false) {}to pętla zajętego oczekiwania. To źle! Podczas oczekiwania obciąża procesor, co spowalnia inne aplikacje i zwiększa zużycie energii, temperaturę i prędkość wentylatora systemu. W idealnym przypadku chcielibyśmy, aby wątek pętli spał podczas oczekiwania, aby nie obciążał procesora.

Oto kilka sposobów, aby to zrobić:

Korzystanie z funkcji czekaj / powiadamiaj

Niskopoziomowym rozwiązaniem jest użycie metod czekania / powiadamianiaObject :

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();
        }
    }
}

W tej wersji kodu wywołania wątku pętli wait(), co powoduje uśpienie wątku. Podczas snu nie będzie używać żadnych cykli procesora. Po ustawieniu zmiennej przez drugi wątek wywołuje notifyAll()ona obudzenie wszystkich / wszystkich wątków, które czekały na ten obiekt. To tak, jakby facet od pizzy dzwonił do drzwi, abyś mógł usiąść i odpocząć podczas oczekiwania, zamiast stać niezgrabnie przy drzwiach.

Podczas wywoływania funkcji wait / notification na obiekcie musisz przytrzymać blokadę synchronizacji tego obiektu, co robi powyższy kod. Możesz użyć dowolnego obiektu, który ci się podoba, o ile oba wątki używają tego samego obiektu: tutaj użyłem this(wystąpienie MyHouse). Zwykle dwa wątki nie byłyby w stanie jednocześnie wprowadzić zsynchronizowanych bloków tego samego obiektu (co jest częścią celu synchronizacji), ale działa to tutaj, ponieważ wątek tymczasowo zwalnia blokadę synchronizacji, gdy znajduje się wewnątrz wait()metody.

BlockingQueue

A BlockingQueuesłuży do implementowania kolejek producent-konsument. „Konsumenci” pobierają pozycje z początku kolejki, a „producenci” przesuwają pozycje z tyłu. Przykład:

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");
    }
}

Uwaga: metody puti mogą zgłaszać s, które są sprawdzanymi wyjątkami, które muszą być obsługiwane. W powyższym kodzie dla uproszczenia wyjątki zostały ponownie zgłoszone. Możesz chcieć przechwycić wyjątki w metodach i ponowić wywołanie put lub take, aby upewnić się, że się powiedzie. Poza tym jednym punktem brzydoty, jest bardzo łatwy w użyciu.takeBlockingQueueInterruptedExceptionBlockingQueue

Żadna inna synchronizacja nie jest tutaj potrzebna, ponieważ BlockingQueuezapewnia, że ​​wszystko, co zrobiły wątki przed umieszczeniem elementów w kolejce, jest widoczne dla wątków pobierających te elementy.

Egzekutorzy

Executorsą jak gotowe, BlockingQueuektóre wykonują zadania. Przykład:

// 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

Szczegółowe informacje można znaleźć na doc Executor, ExecutorServicei Executors.

Obsługa zdarzeń

Zapętlanie się podczas oczekiwania na kliknięcie czegoś w interfejsie użytkownika jest błędne. Zamiast tego użyj funkcji obsługi zdarzeń z zestawu narzędzi interfejsu użytkownika. Na przykład w Swingu :

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");
});

Ponieważ program obsługi zdarzeń działa w wątku wysyłania zdarzeń, wykonywanie długiej pracy w programie obsługi zdarzeń blokuje inne interakcje z interfejsem użytkownika do momentu zakończenia pracy. Powolne operacje można rozpocząć w nowym wątku lub wysłać do oczekującego wątku przy użyciu jednej z powyższych technik (czekaj / powiadamiaj, a BlockingQueuelub Executor). Możesz również użyć SwingWorker, który jest zaprojektowany dokładnie do tego i automatycznie dostarcza wątek roboczy w tle:

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();
});

Timery

Aby wykonywać czynności okresowe, możesz użyć pliku java.util.Timer. Jest łatwiejszy w użyciu niż pisanie własnej pętli czasowej i łatwiejszy do rozpoczęcia i zakończenia. To demo drukuje bieżący czas raz na sekundę:

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

Każdy java.util.Timerma swój własny wątek w tle, który jest używany do wykonywania zaplanowanych TimerTaskoperacji. Oczywiście wątek śpi między zadaniami, więc nie obciąża procesora.

W kodzie Swing istnieje również javax.swing.Timer, który jest podobny, ale wykonuje nasłuchiwanie w wątku Swing, dzięki czemu można bezpiecznie współdziałać z komponentami Swing bez konieczności ręcznego przełączania wątków:

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);

Inaczej

Jeśli piszesz kod wielowątkowy, warto zapoznać się z klasami w tych pakietach, aby zobaczyć, co jest dostępne:

Zobacz także sekcję Współbieżność w samouczkach Java. Wielowątkowość jest skomplikowana, ale dostępna jest duża pomoc!

Boann
źródło
Bardzo profesjonalna odpowiedź, po przeczytaniu tego nie myślę o błędnych przekonaniach, dziękuję
Humoyun Ahmad
1
Świetna odpowiedź. Pracuję z wątkami Java już od jakiegoś czasu i nadal czegoś się tutaj nauczyłem ( wait()zwalnia blokadę synchronizacji!).
brimborium
Dziękuję, Boann! Świetna odpowiedź, to jak pełny artykuł z przykładami! Tak, podobało mi się też „wait () zwalnia synchronizację”
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, ten kod nie przenosi pizzaArrived == falsetestu poza pętlę, a pętla może zobaczyć flagę zmienioną przez główny wątek, dlaczego?
gaussclb
1
@gaussclb Jeśli masz na myśli, że zdekompilowałeś plik klasy, popraw. Kompilator Java prawie nie przeprowadza optymalizacji. Podnoszenie jest wykonywane przez JVM. Musisz zdemontować natywny kod maszynowy. Wypróbuj: wiki.openjdk.java.net/display/HotSpot/PrintAssembly
Boann