Цикл не видит значение, измененное другим потоком без оператора печати

В моем коде у меня есть цикл, который ожидает, что какое-то состояние будет изменено из другого потока. Другая нить работает, но мой цикл никогда не видит измененного значения. Он ждет всегда. Однако, когда я вставляю оператор System.out.println в цикл, он неожиданно срабатывает! Почему?


Ниже приведен пример моего кода:

class MyHouse {
    boolean pizzaArrived = false;

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

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

    void deliverPizza() {
        pizzaArrived = true;
    }
}

Пока цикл while работает, я вызываю deliverPizza() из другого потока, чтобы установить переменную pizzaArrived. Но цикл работает только тогда, когда я раскомментирую инструкцию System.out.println("waiting");. Что происходит?

Ответ 1

JVM допускает, что другие потоки не изменяют переменную pizzaArrived во время цикла. Другими словами, он может поднять тест pizzaArrived == false за пределы цикла, оптимизируя это:

while (pizzaArrived == false) {}

в это:

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

который представляет собой бесконечный цикл.

Чтобы убедиться, что изменения, сделанные одним потоком, видны другим потокам, вы всегда должны добавить между синхронизацию. Самый простой способ сделать это - сделать общую переменную volatile:

volatile boolean pizzaArrived = false;

Создание переменной volatile гарантирует, что в разных потоках будут видны эффекты друг друга. Это не позволяет JVM кэшировать значение pizzaArrived или поднимать тест вне цикла. Вместо этого он должен каждый раз считывать значение реальной переменной.

(Более формально volatile создает связь между доступом к переменной. Это означает, что все остальные работы, которые выполнял нить, перед доставкой пиццы также видимый для потока, получающего пиццу, даже если эти другие изменения не относятся к переменным volatile.)

Синхронизированные методы используются в основном для реализации взаимного исключения (одновременно предотвращая две вещи), но они также имеют все те же побочные эффекты, что volatile имеет. Использование их при чтении и записи переменной - это еще один способ сделать изменения видимыми для других потоков:

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

Эффект утверждения печати

System.out является объектом PrintStream. Методы PrintStream синхронизируются следующим образом:

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

Синхронизация предотвращает кэширование pizzaArrived во время цикла. Строго говоря, потоки обоих должны синхронизироваться на одном и том же объекте, чтобы гарантировать видимость изменений в переменной. (Например, вызов println после установки pizzaArrived и вызов его снова перед чтением pizzaArrived будет правильным.) Если только один поток синхронизируется на конкретном объекте, JVM может игнорировать его. На практике JVM недостаточно умен, чтобы доказать, что другие потоки не будут вызывать println после установки pizzaArrived, поэтому он предполагает, что они могут. Поэтому он не может кэшировать переменную во время цикла, если вы вызываете System.out.println. Вот почему такие петли, как эта работа, когда у них есть оператор печати, хотя это не правильное исправление.

Использование System.out - не единственный способ вызвать этот эффект, но это тот самый, который чаще всего обнаруживают люди, когда они пытаются отладить, почему их цикл не работает!


Большая проблема

while (pizzaArrived == false) {} - цикл ожидания ожидания. Это плохо! Пока он ждет, он запускает процессор, что замедляет работу других приложений и увеличивает потребление энергии, температуру и скорость вентилятора системы. В идеале мы бы хотели, чтобы поток цикла спал, пока он ждет, поэтому он не запускает процессор.

Вот несколько способов сделать это:

Использование wait/notify

Низкоуровневое решение использует методы wait/notify для Object:

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

В этой версии кода поток цикла вызывает wait(), который помещает поток в сон. Он не будет использовать циклы процессора во время сна. После того, как второй поток задает переменную, он вызывает notifyAll(), чтобы просыпать все/все потоки, которые ожидали на этом объекте. Это похоже на то, что парень-пицца звонит в дверь, поэтому вы можете сидеть и отдыхать, ожидая, вместо того, чтобы неуверенно стоять у двери.

При вызове wait/notify на объекте вы должны удерживать блокировку синхронизации этого объекта, что и делает вышеуказанный код. Вы можете использовать любой объект, который вам нравится, если оба потока используют один и тот же объект: здесь я использовал this (экземпляр MyHouse). Обычно два потока не смогут одновременно вводить синхронизированные блоки одного и того же объекта (который является частью цели синхронизации), но он работает здесь, потому что поток временно освобождает блокировку синхронизации, когда он находится внутри метода wait().

BlockingQueue

A BlockingQueue используется для реализации очередей производителей-потребителей. "Потребители" берут предметы с передней стороны очереди, а "продюсеры" нажимают на предметы сзади. Пример:

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

Примечание. Методы put и take BlockingQueue могут вызывать InterruptedException s, которые являются проверенными исключениями, которые необходимо обработать. В приведенном выше коде для простоты исключаются исключения. Возможно, вы предпочтете воспользоваться исключениями в методах и повторить попытку посылки или принять вызов, чтобы убедиться в его успешности. Кроме того, одна точка уродства, BlockingQueue очень проста в использовании.

Никакой другой синхронизации не требуется здесь, потому что BlockingQueue гарантирует, что все потоки, прежде чем помещать элементы в очередь, будут видны тем потокам, которые выходят из этих элементов.

Исполнители

Executor похожи на готовые BlockingQueue, которые выполняют задачи. Пример:

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

Подробнее см. в документе Executor, ExecutorService и Executors.

Обработка событий

Запутывание в ожидании того, что пользователь щелкнет что-то в пользовательском интерфейсе, неверен. Вместо этого используйте функции обработки событий инструментария пользовательского интерфейса. В Swing, например:

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

Поскольку обработчик событий работает в потоке отправки событий, длительная работа в обработчике событий блокирует другое взаимодействие с пользовательским интерфейсом, пока работа не будет завершена. Медленные операции могут быть запущены в новом потоке или отправлены в ожидающий поток с использованием одного из вышеуказанных методов (wait/notify, a BlockingQueue или Executor). Вы также можете использовать SwingWorker, который предназначен именно для этого, и автоматически поставляет поток рабочего фона:

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

Таймеры

Для выполнения периодических действий вы можете использовать java.util.Timer. Это проще в использовании, чем написание собственного тайм-цикла, и проще начать и остановить. Эта демонстрация выводит текущее время раз в секунду:

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

Каждый java.util.Timer имеет свой собственный фоновый поток, который используется для выполнения запланированных TimerTask s. Естественно, поток спит между задачами, поэтому он не запускает процессор.

В коде Swing есть также javax.swing.Timer, который похож, но он выполняет прослушиватель в потоке Swing, поэтому вы можете безопасно взаимодействовать с Swing компоненты без необходимости ручного переключения потоков:

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

Другие способы

Если вы пишете многопоточный код, стоит изучить классы в этих пакетах, чтобы узнать, что доступно:

Также см. раздел Concurrency учебных пособий Java. Многопоточность сложна, но есть много доступной справки!