El bucle no ve el valor cambiado por otro hilo sin una declaración de impresión

91

En mi código tengo un bucle que espera a que se cambie algún estado desde un hilo diferente. El otro hilo funciona, pero mi bucle nunca ve el valor cambiado. Espera por siempre. Sin embargo, cuando pongo una System.out.printlndeclaración en el bucle, ¡de repente funciona! ¿Por qué?


El siguiente es un ejemplo de mi código:

class MyHouse {
    boolean pizzaArrived = false;

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

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

    void deliverPizza() {
        pizzaArrived = true;
    }
}

Mientras se ejecuta el ciclo while, llamo deliverPizza()desde un hilo diferente para establecer la pizzaArrivedvariable. Pero el ciclo solo funciona cuando elimino el comentario de la System.out.println("waiting");declaración. ¿Que esta pasando?

Boann
fuente

Respuestas:

152

La JVM puede asumir que otros subprocesos no cambian la pizzaArrivedvariable durante el ciclo. En otras palabras, puede elevar la pizzaArrived == falseprueba fuera del bucle, optimizando esto:

while (pizzaArrived == false) {}

dentro de esto:

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

que es un bucle infinito.

Para asegurarse de que los cambios realizados por un hilo sean visibles para otros hilos, siempre debe agregar alguna sincronización entre los hilos. La forma más sencilla de hacer esto es crear la variable compartida volatile:

volatile boolean pizzaArrived = false;

Hacer una variable volatilegarantiza que los diferentes hilos verán los efectos de los cambios de los demás. Esto evita que la JVM almacene en caché el valor pizzaArrivedo eleve la prueba fuera del bucle. En cambio, debe leer el valor de la variable real cada vez.

(De manera más formal, volatilecrea una relación de pasa antes de los accesos a la variable. Esto significa que todo el trabajo que hizo un hilo antes de entregar la pizza también es visible para el hilo que recibe la pizza, incluso si esos otros cambios no son para las volatilevariables).

Los métodos sincronizados se utilizan principalmente para implementar la exclusión mutua (evitando que sucedan dos cosas al mismo tiempo), pero también tienen los mismos efectos secundarios que volatiletiene. Usarlos al leer y escribir una variable es otra forma de hacer que los cambios sean visibles para otros hilos:

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

El efecto de una declaración impresa

System.outes un PrintStreamobjeto. Los métodos de PrintStreamse sincronizan así:

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

La sincronización evita que pizzaArrivedse almacene en caché durante el ciclo. Estrictamente hablando, ambos hilos deben sincronizarse en el mismo objeto para garantizar que los cambios en la variable sean visibles. (Por ejemplo, llamar printlndespués de configurar pizzaArrivedy volver a llamarlo antes de leer pizzaArrivedsería correcto). Si solo un hilo se sincroniza en un objeto en particular, la JVM puede ignorarlo. En la práctica, la JVM no es lo suficientemente inteligente como para demostrar que otros subprocesos no llamarán printlndespués de la configuración pizzaArrived, por lo que se supone que sí. Por lo tanto, no puede almacenar en caché la variable durante el bucle si llama System.out.println. Es por eso que los bucles como este funcionan cuando tienen una declaración impresa, aunque no es una solución correcta.

Usar System.outno es la única forma de causar este efecto, pero es la que las personas descubren con más frecuencia cuando intentan depurar por qué su ciclo no funciona.


El mayor problema

while (pizzaArrived == false) {}es un bucle de espera ocupado. ¡Eso es malo! Mientras espera, acapara la CPU, lo que ralentiza otras aplicaciones y aumenta el uso de energía, la temperatura y la velocidad del ventilador del sistema. Idealmente, nos gustaría que el hilo de bucle duerma mientras espera, para que no acabe con la CPU.

A continuación se muestran algunas formas de hacerlo:

Usar esperar / notificar

Una solución de bajo nivel es utilizar los métodos de espera / notificación deObject :

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

En esta versión del código, el hilo de bucle llama wait(), lo que pone al hilo en suspensión. No utilizará ningún ciclo de CPU mientras está inactivo. Después de que el segundo subproceso establece la variable, llama notifyAll()para despertar todos los subprocesos que estaban esperando ese objeto. Esto es como hacer que el pizzero toque el timbre, para que puedas sentarte y descansar mientras esperas, en lugar de pararte torpemente en la puerta.

Al llamar a esperar / notificar en un objeto, debe mantener el bloqueo de sincronización de ese objeto, que es lo que hace el código anterior. Puede usar cualquier objeto que desee siempre que ambos hilos usen el mismo objeto: aquí usé this(la instancia de MyHouse). Por lo general, dos subprocesos no podrían ingresar bloques sincronizados del mismo objeto simultáneamente (que es parte del propósito de la sincronización), pero funciona aquí porque un subproceso libera temporalmente el bloqueo de sincronización cuando está dentro del wait()método.

BlockingQueue

A BlockingQueuese utiliza para implementar colas de productor-consumidor. Los "consumidores" toman los artículos del frente de la cola y los "productores" empujan los artículos al final. Un ejemplo:

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: Los métodos puty takede BlockingQueuecan throw InterruptedExceptions, que son excepciones comprobadas que deben manejarse. En el código anterior, por simplicidad, se vuelven a lanzar las excepciones. Es posible que prefiera detectar las excepciones en los métodos y volver a intentar la llamada put o take para asegurarse de que se realiza correctamente. Aparte de ese punto de fealdad, BlockingQueuees muy fácil de usar.

Aquí no se necesita ninguna otra sincronización porque BlockingQueuegarantiza que todo lo que hicieron los subprocesos antes de colocar elementos en la cola sea visible para los subprocesos que eliminan esos elementos.

Ejecutores

ExecutorLos correos electrónicos son como correos electrónicos listos para BlockingQueueusar que ejecutan tareas. Ejemplo:

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

Para más detalles véase el documento de Executor, ExecutorServicey Executors.

Manejo de eventos

Hacer un bucle mientras espera que el usuario haga clic en algo en una interfaz de usuario es incorrecto. En su lugar, utilice las funciones de manejo de eventos del kit de herramientas de la interfaz de usuario. En Swing , por ejemplo:

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

Dado que el controlador de eventos se ejecuta en el subproceso de despacho de eventos, realizar un trabajo prolongado en el controlador de eventos bloquea otras interacciones con la interfaz de usuario hasta que finaliza el trabajo. Las operaciones lentas pueden iniciarse en un nuevo hilo o enviarse a un hilo en espera utilizando una de las técnicas anteriores (esperar / notificar, a BlockingQueue, o Executor). También puede usar a SwingWorker, que está diseñado exactamente para esto, y proporciona automáticamente un hilo de trabajo en segundo plano:

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

Temporizadores

Para realizar acciones periódicas, puede utilizar un java.util.Timer. Es más fácil de usar que escribir su propio ciclo de tiempo, y más fácil de iniciar y detener. Esta demostración imprime la hora actual una vez por segundo:

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

Cada uno java.util.Timertiene su propio hilo de fondo que se utiliza para ejecutar sus mensajes de correo electrónico programados TimerTask. Naturalmente, el hilo duerme entre tareas, por lo que no acapara la CPU.

En el código Swing, también hay un javax.swing.Timer, que es similar, pero ejecuta el oyente en el hilo Swing, por lo que puede interactuar de forma segura con los componentes Swing sin necesidad de cambiar manualmente los hilos:

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

Otras maneras

Si está escribiendo código multiproceso, vale la pena explorar las clases en estos paquetes para ver qué está disponible:

Y también consulte la sección Concurrencia de los tutoriales de Java. El subproceso múltiple es complicado, ¡pero hay mucha ayuda disponible!

Boann
fuente
Respuesta muy profesional, después de leer esto no me queda ningún error en la mente, gracias
Humoyun Ahmad
1
Respuesta impresionante. Estoy trabajando con subprocesos de Java durante bastante tiempo y todavía aprendí algo aquí (¡ wait()libera el bloqueo de sincronización!).
Brimborium
¡Gracias, Boann! Gran respuesta, ¡es como un artículo completo con ejemplos! Sí, también me gustó "wait () libera el bloqueo de sincronización"
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, este código no eleva la pizzaArrived == falseprueba fuera del bucle, y el bucle puede ver la bandera cambiada por el hilo principal, ¿por qué?
gaussclb
1
@gaussclb Si quiere decir que descompiló un archivo de clase, corrija. El compilador de Java casi no realiza ninguna optimización. El izado lo realiza la JVM. Necesita desmontar el código de máquina nativo. Prueba: wiki.openjdk.java.net/display/HotSpot/PrintAssembly
Boann