O loop não vê o valor alterado por outro thread sem uma instrução de impressão


89

No meu código, tenho um loop que espera que algum estado seja alterado em um segmento diferente. O outro thread funciona, mas meu loop nunca vê o valor alterado. Isso espera para sempre. No entanto, quando coloco uma System.out.printlninstrução no loop, de repente ela funciona! Por quê?


A seguir está um exemplo do meu 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;
    }
}

Enquanto o loop while está em execução, eu chamo deliverPizza()de um thread diferente para definir a pizzaArrivedvariável. Mas o loop só funciona quando eu descomento a System.out.println("waiting");instrução. O que está acontecendo?

Respostas:


148

A JVM pode assumir que outros encadeamentos não alteram a pizzaArrivedvariável durante o loop. Em outras palavras, ele pode içar o pizzaArrived == falseteste fora do circuito, otimizando isso:

while (pizzaArrived == false) {}

nisso:

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

que é um loop infinito.

Para garantir que as alterações feitas por um thread sejam visíveis para outros threads, você deve sempre adicionar alguma sincronização entre os threads. A maneira mais simples de fazer isso é tornar a variável compartilhada volatile:

volatile boolean pizzaArrived = false;

Criar uma variável volatilegarante que diferentes threads verão os efeitos das alterações uns dos outros. Isso evita que a JVM armazene em cache o valor pizzaArrivedou eleve o teste fora do loop. Em vez disso, ele deve ler o valor da variável real todas as vezes.

(Mais formalmente, volatilecria uma relação acontece-antes entre os acessos à variável. Isso significa que todos os outros trabalhos que um encadeamento fez antes de entregar a pizza também são visíveis para o encadeamento que recebe a pizza, mesmo que essas outras alterações não sejam nas volatilevariáveis.)

Os métodos sincronizados são usados ​​principalmente para implementar a exclusão mútua (evitando que duas coisas aconteçam ao mesmo tempo), mas também têm todos os mesmos efeitos colaterais volatile. Usá-los ao ler e escrever uma variável é outra maneira de tornar as alterações visíveis para outros threads:

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

O efeito de uma declaração impressa

System.outé um PrintStreamobjeto. Os métodos de PrintStreamsão sincronizados assim:

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

A sincronização evita o pizzaArrivedarmazenamento em cache durante o loop. A rigor, os dois threads devem ser sincronizados no mesmo objeto para garantir que as alterações na variável sejam visíveis. (Por exemplo, chamar printlnapós a configuração pizzaArrivede chamá-lo novamente antes da leitura pizzaArrivedseria correto.) Se apenas um encadeamento sincroniza em um objeto específico, o JVM tem permissão para ignorá-lo. Na prática, a JVM não é inteligente o suficiente para provar que outros encadeamentos não chamarão printlnapós a configuração pizzaArrived, portanto, presume que sim. Portanto, ele não pode armazenar em cache a variável durante o loop se você chamar System.out.println. É por isso que loops como este funcionam quando têm uma instrução de impressão, embora não seja uma correção correta.

Usar System.outnão é a única maneira de causar esse efeito, mas é a que as pessoas descobrem com mais frequência, quando estão tentando depurar por que o loop não funciona!


O maior problema

while (pizzaArrived == false) {}é um loop de espera ocupada. Isso é ruim! Enquanto espera, ele consome a CPU, o que desacelera outros aplicativos e aumenta o uso de energia, a temperatura e a velocidade do ventilador do sistema. Idealmente, gostaríamos que o thread de loop dormisse enquanto espera, para que não monopolize a CPU.

Aqui estão algumas maneiras de fazer isso:

Usando esperar / notificar

Uma solução de baixo nível é usar os métodos de espera / notificação 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();
        }
    }
}

Nesta versão do código, o thread de loop chama wait(), o que coloca o thread em suspensão. Ele não usará nenhum ciclo de CPU durante o sono. Depois que o segundo encadeamento define a variável, ele chama notifyAll()para acordar qualquer / todos os encadeamentos que estavam esperando por esse objeto. É como ter o entregador da pizza tocando a campainha, para que você possa sentar e descansar enquanto espera, em vez de ficar parado sem jeito na porta.

Ao chamar esperar / notificar em um objeto, você deve manter o bloqueio de sincronização desse objeto, que é o que o código acima faz. Você pode usar qualquer objeto que desejar, desde que ambos os threads usem o mesmo objeto: aqui eu usei this(a instância de MyHouse). Normalmente, dois threads não seriam capazes de inserir blocos sincronizados do mesmo objeto simultaneamente (o que é parte do propósito da sincronização), mas funciona aqui porque um thread libera temporariamente o bloqueio de sincronização quando está dentro do wait()método.

BlockingQueue

A BlockingQueueé usado para implementar filas produtor-consumidor. Os "consumidores" pegam os itens da frente da fila e os "produtores" os empurram para trás. Um exemplo:

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: Os métodos pute takede BlockingQueuepodem lançar InterruptedExceptions, que são exceções verificadas que devem ser tratadas. No código acima, para simplificar, as exceções são relançadas. Você pode preferir capturar as exceções nos métodos e tentar novamente a chamada put ou take para ter certeza de que terá êxito. Tirando aquele ponto de feiura, BlockingQueueé muito fácil de usar.

Nenhuma outra sincronização é necessária aqui porque um BlockingQueuegarante que tudo o que os threads fizeram antes de colocar itens na fila esteja visível para os threads que estão retirando esses itens.

Executores

Executors são como s prontos para BlockingQueueexecutar tarefas. Exemplo:

// 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 mais detalhes ver o doc para Executor, ExecutorService, e Executors.

Manipulação de eventos

O loop enquanto espera o usuário clicar em algo em uma IU está errado. Em vez disso, use os recursos de manipulação de eventos do kit de ferramentas de UI. No Swing , por exemplo:

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

Como o manipulador de eventos é executado no thread de despacho de eventos, um longo trabalho no manipulador de eventos bloqueia outra interação com a IU até que o trabalho seja concluído. Operações lentas podem ser iniciadas em um novo encadeamento ou despachadas para um encadeamento em espera usando uma das técnicas acima (esperar / notificar, a BlockingQueueou Executor). Você também pode usar um SwingWorker, que é projetado exatamente para isso e fornece automaticamente um thread de trabalho em 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();
});

Cronômetros

Para realizar ações periódicas, você pode usar a java.util.Timer. É mais fácil de usar do que escrever seu próprio loop de tempo e mais fácil de iniciar e parar. Esta demonstração imprime a hora atual uma 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 um java.util.Timertem seu próprio thread de segundo plano que é usado para executar seus programas programados TimerTask. Naturalmente, o thread dorme entre as tarefas, portanto, não monopoliza a CPU.

No código Swing, há também um javax.swing.Timer, que é semelhante, mas executa o ouvinte no encadeamento Swing, para que você possa interagir com segurança com os componentes Swing sem precisar alternar os encadeamentos manualmente:

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

Outras maneiras

Se você estiver escrevendo código multithread, vale a pena explorar as classes desses pacotes para ver o que está disponível:

E também consulte a seção Simultaneidade dos tutoriais Java. O multithreading é complicado, mas há muita ajuda disponível!


Resposta muito profissional, depois de ler isso nenhum equívoco é deixado em minha mente, obrigado
Humoyun Ahmad

1
Resposta incrível. Estou trabalhando com threads Java há um bom tempo e ainda aprendi algo aqui ( wait()libera o bloqueio de sincronização!).
brimborium

Obrigado, Boann! Ótima resposta, é como um artigo completo com exemplos! Sim, também gostei de "wait () libera o bloqueio de sincronização"
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 não levanta o pizzaArrived == falseteste fora do loop, e o loop pode ver o sinalizador alterado pelo thread principal, por quê?
gaussclb

1
@gaussclb Se você quer dizer que descompilou um arquivo de classe, corrija. O compilador Java quase não faz otimização. O içamento é feito pela JVM. Você precisa desmontar o código de máquina nativo. Experimente: wiki.openjdk.java.net/display/HotSpot/PrintAssembly
Boann
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.