Вопрос-ответ

Loop doesn't see value changed by other thread without a print statement

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

В моем коде есть цикл, который ожидает изменения некоторого состояния из другого потока. Другой поток работает, но мой цикл никогда не видит измененного значения. Он ждет вечно. Однако, когда я помещаю в цикл инструкцию 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;
}
}

Эффект инструкции print

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. Вот почему циклы, подобные этому, работают, когда у них есть инструкция print, хотя это неправильное исправление.

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


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

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

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

Использование 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, которые являются проверяемыми исключениями, которые должны быть обработаны. В приведенном выше коде для простоты исключения перезаписываются. Возможно, вы предпочтете перехватить исключения в методах и повторить вызов put или take, чтобы убедиться в успехе. За исключением этого одного уродства, BlockingQueue очень прост в использовании.

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

Исполнители

Executors подобны готовым BlockingQueues, которые выполняют задачи. Пример:

// 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 операций. Естественно, поток переходит в спящий режим между задачами, поэтому он не перегружает процессор.

В коде 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);

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

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

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

java multithreading