Цикл не бачить значення, зміненого іншим потоком без оператора друку


91

У моєму коді у мене є цикл, який чекає зміни стану з іншого потоку. Інший потік працює, але мій цикл ніколи не бачить зміненого значення. Це чекає вічно. Однак, коли я вкладаю 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");твердження. Що відбувається?

Відповіді:


152

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) {}є циклом зайнятого очікування. Це погано! Поки він чекає, він затримує центральний процесор, що уповільнює інші програми та збільшує споживання енергії, температуру та швидкість вентилятора системи. В ідеалі ми хотіли б, щоб потік циклу спав, поки він чекає, тому він не затримує центральний процесор.

Ось кілька способів зробити це:

За допомогою очікування / повідомлення

Рішення низького рівня полягає у використанні методів очікування / сповіщення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()будь-який / усі потоки, які чекали на цей об'єкт. Це все одно, що хлопець з піци подзвонить у двері, тож ви можете сісти і відпочити, чекаючи, замість того, щоб незграбно стояти біля дверей.

Під час виклику очікування / повідомлення про об'єкт ви повинні утримувати блокування синхронізації цього об'єкта, що і робить наведений вище код. Ви можете використовувати будь-який вподобаний об'єкт, якщо обидва потоки використовують один і той же об'єкт: тут я використовував 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методи BlockingQueuecan throw InterruptedExceptions, які перевіряють винятки, з якими потрібно обробляти. У наведеному вище коді, для простоти, винятки відновлено. Можливо, ви віддасте перевагу ловити винятки в методах і повторити спробу виклику путів або прийому, щоб переконатися, що це вдалося. Окрім цього, один момент негарності 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");
});

Оскільки обробник подій працює на потоці диспетчеризації подій, тривала робота в обробнику подій блокує іншу взаємодію з інтерфейсом користувача, доки робота не буде закінчена. Повільні операції можна розпочати з новим потоком або відправити до потоку очікування, використовуючи один із вищезазначених методів (очікування / повідомлення, a BlockingQueueабо Executor). Ви також можете використовувати a 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();
});

Таймери

Щоб виконувати періодичні дії, ви можете використовувати a 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має свій власний фоновий потік, який використовується для виконання запланованих TimerTasks. Природно, що нитка перебуває в режимі між завданнями, тому вона не затримує процесор.

У коді Swing також є a 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. Багатопотоковість складна, але доступна велика допомога!


Дуже професійна відповідь, після прочитання цього в мене на думці не залишилося жодної помилки, дякую
Гумоюн Ахмад

1
Чудова відповідь. Я працюю з потоками Java досить давно, і все-таки чомусь навчився тут ( wait()звільняє блокування синхронізації!).
бримборій

Дякую, Боан! Чудова відповідь, це як повна стаття з прикладами! Так, також сподобався "wait () звільняє синхронізацію"
Кирил Івану

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, цей код не виводить pizzaArrived == falseтест поза цикл, і цикл може бачити прапор, змінений основним потоком, чому?
gaussclb

1
@gaussclb Якщо ви маєте на увазі, що ви декомпілювали файл класу, виправте. Компілятор Java практично не оптимізує. Піднімання виконується JVM. Вам потрібно розібрати власний машинний код. Спробуйте: wiki.openjdk.java.net/display/HotSpot/PrintAssembly
Боанн
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.