JavaRush /Blogue Java /Random-PT /Fundamentos de Simultaneidade: Deadlocks e Monitores de O...
Snusmum
Nível 34
Хабаровск

Fundamentos de Simultaneidade: Deadlocks e Monitores de Objetos (seções 1, 2) (tradução do artigo)

Publicado no grupo Random-PT
Artigo fonte: http://www.javacodegeeks.com/2015/09/concurrency-fundamentals-deadlocks-and-object-monitors.html Postado por Martin Mois Este artigo faz parte de nosso curso Java Concurrency Fundamentals . Neste curso, você mergulhará na magia do paralelismo. Você aprenderá os conceitos básicos de paralelismo e código paralelo e se familiarizará com conceitos como atomicidade, sincronização e segurança de thread. Dê uma olhada nisto aqui !

Contente

1. Liveness  1.1 Deadlock  1.2 Starvation 2. Monitores de objetos com wait() e notify()  2.1 Blocos sincronizados aninhados com wait() e notify()  2.2 Condições em blocos sincronizados 3. Design para multi-threading  3.1 Objeto imutável  3.2 Design de API  3.3 Armazenamento de thread local
1. Vitalidade
Ao desenvolver aplicações que utilizam paralelismo para atingir seus objetivos, você poderá encontrar situações em que diferentes threads podem bloquear uns aos outros. Se o aplicativo estiver rodando mais lentamente do que o esperado nesta situação, diríamos que ele não está rodando conforme o esperado. Nesta seção, examinaremos mais de perto os problemas que podem ameaçar a capacidade de sobrevivência de um aplicativo multithread.
1.1 Bloqueio mútuo
O termo impasse é bem conhecido entre os desenvolvedores de software e até mesmo a maioria dos usuários comuns o utiliza de vez em quando, embora nem sempre no sentido correto. A rigor, este termo significa que cada um dos dois (ou mais) threads está esperando que o outro thread libere um recurso bloqueado por ele, enquanto o próprio primeiro thread bloqueou um recurso que o segundo está esperando para acessar: Para entender melhor o problema, dê uma olhada no Thread 1: locks resource A, waits for resource B Thread 2: locks resource B, waits for resource A seguinte código: public class Deadlock implements Runnable { private static final Object resource1 = new Object(); private static final Object resource2 = new Object(); private final Random random = new Random(System.currentTimeMillis()); public static void main(String[] args) { Thread myThread1 = new Thread(new Deadlock(), "thread-1"); Thread myThread2 = new Thread(new Deadlock(), "thread-2"); myThread1.start(); myThread2.start(); } public void run() { for (int i = 0; i < 10000; i++) { boolean b = random.nextBoolean(); if (b) { System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 1."); synchronized (resource1) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 1."); System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 2."); synchronized (resource2) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 2."); } } } else { System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 2."); synchronized (resource2) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 2."); System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 1."); synchronized (resource1) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 1."); } } } } } } Como você pode ver no código acima, dois threads são iniciados e tentam bloquear dois recursos estáticos. Mas para o deadlock, precisamos de uma sequência diferente para ambos os threads, então usamos uma instância do objeto Random para escolher qual recurso o thread deseja bloquear primeiro. Se a variável booleana b for verdadeira, o recurso1 será bloqueado primeiro e, em seguida, o encadeamento tentará adquirir o bloqueio para o recurso2. Se b for falso, o encadeamento bloqueará o recurso2 e tentará adquirir o recurso1. Este programa não precisa ser executado por muito tempo para atingir o primeiro impasse, ou seja, O programa irá travar para sempre se não o interrompermos: [thread-1] Trying to lock resource 1. [thread-1] Locked resource 1. [thread-1] Trying to lock resource 2. [thread-1] Locked resource 2. [thread-2] Trying to lock resource 1. [thread-2] Locked resource 1. [thread-1] Trying to lock resource 2. [thread-1] Locked resource 2. [thread-2] Trying to lock resource 2. [thread-1] Trying to lock resource 1. Nesta execução, tread-1 adquiriu o bloqueio do recurso2 e está aguardando o bloqueio do recurso1, enquanto o tread-2 possui o bloqueio do recurso1 e está aguardando o recurso2. Se definissemos o valor da variável booleana b no código acima como true, não seríamos capazes de observar nenhum deadlock porque a sequência na qual os bloqueios de solicitação de thread-1 e thread-2 seriam sempre a mesma. Nessa situação, um dos dois threads obteria o bloqueio primeiro e depois solicitaria o segundo, que ainda está disponível porque o outro thread está aguardando o primeiro bloqueio. Em geral, podemos distinguir as seguintes condições necessárias para que ocorra o deadlock: - Execução compartilhada: Existe um recurso que pode ser acessado por apenas uma thread por vez. - Resource Hold: Ao adquirir um recurso, um thread tenta adquirir outro bloqueio em algum recurso exclusivo. - Sem preempção: Não há mecanismo para liberar um recurso se um thread mantiver o bloqueio por um determinado período de tempo. - Espera Circular: Durante a execução ocorre uma coleção de threads em que duas (ou mais) threads esperam uma pela outra para liberar um recurso que foi bloqueado. Embora a lista de condições pareça longa, não é incomum que aplicativos multithread bem executados tenham problemas de deadlock. Mas você pode evitá-los se remover uma das condições acima: - Execução compartilhada: esta condição muitas vezes não pode ser removida quando o recurso deve ser usado por apenas uma pessoa. Mas esse não precisa ser o motivo. Ao utilizar sistemas SGBD, uma possível solução, ao invés de utilizar um bloqueio pessimista em alguma linha da tabela que precisa ser atualizada, é utilizar uma técnica chamada Bloqueio Otimista . - Uma forma de evitar a retenção de um recurso enquanto espera por outro recurso exclusivo é bloquear todos os recursos necessários no início do algoritmo e liberá-los todos caso seja impossível bloqueá-los todos de uma vez. É claro que isso nem sempre é possível; talvez os recursos que requerem bloqueio sejam desconhecidos antecipadamente, ou esta abordagem simplesmente levará a um desperdício de recursos. - Se o bloqueio não puder ser adquirido imediatamente, uma forma de contornar um possível impasse é introduzir um timeout. Por exemplo, a classe ReentrantLockdo SDK oferece a capacidade de definir uma data de expiração para o bloqueio. - Como vimos no exemplo acima, o deadlock não ocorre se a sequência de solicitações não diferir entre os diferentes threads. Isso é fácil de controlar se você puder colocar todo o código de bloqueio em um método pelo qual todos os threads terão que passar. Em aplicações mais avançadas, você pode até considerar a implementação de um sistema de detecção de deadlock. Aqui você precisará implementar alguma aparência de monitoramento de thread, em que cada thread relata que adquiriu o bloqueio com sucesso e está tentando adquiri-lo. Se threads e bloqueios forem modelados como um gráfico direcionado, você poderá detectar quando dois threads diferentes estão retendo recursos enquanto tentam acessar outros recursos bloqueados ao mesmo tempo. Se você puder forçar os threads de bloqueio a liberar os recursos necessários, poderá resolver a situação de conflito automaticamente.
1.2 Jejum
O escalonador decide qual thread no estado RUNNABLE deve ser executada em seguida. A decisão é baseada na prioridade do thread; portanto, threads com prioridade mais baixa recebem menos tempo de CPU em comparação com aqueles com prioridade mais alta. O que parece ser uma solução razoável também pode causar problemas se for abusada. Se threads de alta prioridade estão sendo executados na maior parte do tempo, então os threads de baixa prioridade parecem morrer de fome porque não têm tempo suficiente para fazer seu trabalho corretamente. Portanto, é recomendado definir a prioridade do thread somente quando houver um motivo convincente para fazê-lo. Um exemplo não óbvio de privação de thread é dado, por exemplo, pelo método finalize(). Ele fornece uma maneira para a linguagem Java executar código antes que um objeto seja coletado como lixo. Mas se você observar a prioridade do thread de finalização, notará que ele não é executado com a prioridade mais alta. Conseqüentemente, a falta de thread ocorre quando os métodos finalize() do seu objeto gastam muito tempo em relação ao restante do código. Outro problema com o tempo de execução surge do fato de não ser definida a ordem em que as threads percorrem o bloco sincronizado. Quando muitos threads paralelos atravessam algum código enquadrado em um bloco sincronizado, pode acontecer que alguns threads tenham que esperar mais do que outros antes de entrar no bloco. Em teoria, eles podem nunca chegar lá. A solução para este problema é o chamado bloqueio “justo”. Os bloqueios justos levam em consideração os tempos de espera do thread ao determinar quem passar em seguida. Um exemplo de implementação de bloqueio justo está disponível no Java SDK: java.util.concurrent.locks.ReentrantLock. Se um construtor for usado com um sinalizador booleano definido como verdadeiro, então ReentrantLock dará acesso ao thread que está esperando há mais tempo. Isto garante a ausência de fome mas, ao mesmo tempo, leva ao problema de ignorar prioridades. Por causa disso, os processos de prioridade mais baixa que muitas vezes aguardam nesta barreira podem ser executados com mais frequência. Por último, mas não menos importante, a classe ReentrantLock só pode considerar threads que estão aguardando um bloqueio, ou seja, tópicos que foram lançados com bastante frequência e atingiram a barreira. Se a prioridade de um thread for muito baixa, isso não acontecerá com frequência e, portanto, os threads de alta prioridade ainda passarão pelo bloqueio com mais frequência.
2. Monitores de objetos junto com wait() e notify()
Na computação multithread, uma situação comum é ter alguns threads de trabalho aguardando que seu produtor crie algum trabalho para eles. Mas, como aprendemos, esperar ativamente em um loop enquanto verifica um determinado valor não é uma boa opção em termos de tempo de CPU. Usar o método Thread.sleep() nesta situação também não é particularmente adequado se quisermos iniciar nosso trabalho imediatamente após a chegada. Para tanto, a linguagem de programação Java possui outra estrutura que pode ser utilizada neste esquema: wait() e notify(). O método wait(), herdado por todos os objetos da classe java.lang.Object, pode ser usado para suspender o thread atual e esperar até que outro thread nos acorde usando o método notify(). Para funcionar corretamente, o thread que chama o método wait() deve conter um bloqueio que adquiriu anteriormente usando a palavra-chave sincronizada. Quando wait() é chamado, o bloqueio é liberado e o thread espera até que outro thread que agora contém o bloqueio chame notify() na mesma instância do objeto. Em uma aplicação multithread, naturalmente pode haver mais de um thread aguardando notificação sobre algum objeto. Portanto, existem dois métodos diferentes para ativar threads: notify() e notifyAll(). Enquanto o primeiro método desperta um dos threads em espera, o método notifyAll() desperta todos eles. Mas esteja ciente de que, assim como acontece com a palavra-chave sincronizada, não existe uma regra que determine qual thread será ativada em seguida quando notificar() for chamado. Num exemplo simples com um produtor e um consumidor, isso não importa, pois não nos importamos com qual thread é despertado. O código a seguir mostra como wait() e notify() podem ser usados ​​para fazer com que threads consumidores esperem que um novo trabalho seja enfileirado por um thread produtor: package a2; import java.util.Queue; import java.util.concurrent.ConcurrentLinkedQueue; public class ConsumerProducer { private static final Queue queue = new ConcurrentLinkedQueue(); private static final long startMillis = System.currentTimeMillis(); public static class Consumer implements Runnable { public void run() { while (System.currentTimeMillis() < (startMillis + 10000)) { synchronized (queue) { try { queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } if (!queue.isEmpty()) { Integer integer = queue.poll(); System.out.println("[" + Thread.currentThread().getName() + "]: " + integer); } } } } public static class Producer implements Runnable { public void run() { int i = 0; while (System.currentTimeMillis() < (startMillis + 10000)) { queue.add(i++); synchronized (queue) { queue.notify(); } try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } synchronized (queue) { queue.notifyAll(); } } } public static void main(String[] args) throws InterruptedException { Thread[] consumerThreads = new Thread[5]; for (int i = 0; i < consumerThreads.length; i++) { consumerThreads[i] = new Thread(new Consumer(), "consumer-" + i); consumerThreads[i].start(); } Thread producerThread = new Thread(new Producer(), "producer"); producerThread.start(); for (int i = 0; i < consumerThreads.length; i++) { consumerThreads[i].join(); } producerThread.join(); } } O método main() inicia cinco threads consumidores e um thread produtor e então espera que eles terminem. O thread produtor então adiciona o novo valor à fila e notifica todos os threads em espera que algo aconteceu. Os consumidores recebem um bloqueio de fila (ou seja, um consumidor aleatório) e depois vão dormir, para ser acionado mais tarde, quando a fila estiver cheia novamente. Quando o produtor termina o seu trabalho, avisa todos os consumidores para acordá-los. Se não realizássemos a última etapa, os threads do consumidor esperariam eternamente pela próxima notificação porque não definimos um tempo limite de espera. Em vez disso, podemos usar o método wait(long timeout) para acordar pelo menos depois de algum tempo.
2.1 Blocos sincronizados aninhados com wait() e notify()
Conforme declarado na seção anterior, chamar wait() no monitor de um objeto apenas libera o bloqueio desse monitor. Outros bloqueios mantidos pelo mesmo thread não são liberados. Como é fácil de entender, no trabalho diário pode acontecer que o thread que chama wait() mantenha o bloqueio ainda mais. Se outros threads também estiverem aguardando esses bloqueios, poderá ocorrer uma situação de deadlock. Vejamos o bloqueio no exemplo a seguir: public class SynchronizedAndWait { private static final Queue queue = new ConcurrentLinkedQueue(); public synchronized Integer getNextInt() { Integer retVal = null; while (retVal == null) { synchronized (queue) { try { queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); } retVal = queue.poll(); } } return retVal; } public synchronized void putInt(Integer value) { synchronized (queue) { queue.add(value); queue.notify(); } } public static void main(String[] args) throws InterruptedException { final SynchronizedAndWait queue = new SynchronizedAndWait(); Thread thread1 = new Thread(new Runnable() { public void run() { for (int i = 0; i < 10; i++) { queue.putInt(i); } } }); Thread thread2 = new Thread(new Runnable() { public void run() { for (int i = 0; i < 10; i++) { Integer nextInt = queue.getNextInt(); System.out.println("Next int: " + nextInt); } } }); thread1.start(); thread2.start(); thread1.join(); thread2.join(); } } Como aprendemos anteriormente , adicionar sincronizado a uma assinatura de método é equivalente a criar um bloco sincronizado(este){}. No exemplo acima, adicionamos acidentalmente a palavra-chave sincronizada ao método e, em seguida, sincronizamos a fila com o monitor do objeto da fila para colocar esse thread em suspensão enquanto aguardava o próximo valor da fila. Então, o thread atual libera o bloqueio na fila, mas não o bloqueio nesta. O método putInt() notifica o thread adormecido que um novo valor foi adicionado. Mas por acaso também adicionamos a palavra-chave sincronizada a este método. Agora que o segundo thread adormeceu, ele ainda mantém a trava. Portanto, o primeiro thread não pode entrar no método putInt() enquanto o bloqueio é mantido pelo segundo thread. Como resultado, temos uma situação de impasse e um programa congelado. Se você executar o código acima, isso acontecerá imediatamente após o início da execução do programa. Na vida cotidiana, esta situação pode não ser tão óbvia. Os bloqueios mantidos por um thread podem depender de parâmetros e condições encontrados em tempo de execução, e o bloco sincronizado que causa o problema pode não estar tão próximo no código de onde colocamos a chamada wait(). Isto torna difícil encontrar tais problemas, especialmente porque eles podem ocorrer ao longo do tempo ou sob carga elevada.
2.2 Condições em blocos sincronizados
Muitas vezes você precisa verificar se alguma condição foi atendida antes de executar qualquer ação em um objeto sincronizado. Quando você tem uma fila, por exemplo, você quer esperar que ela fique cheia. Portanto, você pode escrever um método que verifique se a fila está cheia. Se ainda estiver vazio, você coloca o thread atual em suspensão até que seja acordado: public Integer getNextInt() { Integer retVal = null; synchronized (queue) { try { while (queue.isEmpty()) { queue.wait(); } } catch (InterruptedException e) { e.printStackTrace(); } } synchronized (queue) { retVal = queue.poll(); if (retVal == null) { System.err.println("retVal is null"); throw new IllegalStateException(); } } return retVal; } O código acima sincroniza com a fila antes de chamar wait() e então espera em um loop while até que pelo menos um elemento apareça na fila. O segundo bloco sincronizado usa novamente a fila como monitor de objeto. Ele chama o método poll() da fila para obter o valor. Para fins de demonstração, uma IllegalStateException é lançada quando a pesquisa retorna nulo. Isso acontece quando a fila não possui elementos para buscar. Ao executar este exemplo, você verá que IllegalStateException é lançada com muita frequência. Embora tenhamos sincronizado corretamente usando o monitor de fila, uma exceção foi lançada. A razão é que temos dois blocos sincronizados diferentes. Imagine que temos dois threads que chegaram ao primeiro bloco sincronizado. O primeiro thread entrou no bloco e adormeceu porque a fila estava vazia. O mesmo se aplica ao segundo tópico. Agora que ambos os threads estão ativos (graças à chamada notifyAll() chamada pelo outro thread para o monitor), ambos veem o valor (item) na fila adicionada pelo produtor. Então ambos chegaram à segunda barreira. Aqui o primeiro thread entrou e recuperou o valor da fila. Quando o segundo thread entra, a fila já está vazia. Portanto, ele recebe null como valor retornado da fila e lança uma exceção. Para evitar tais situações, é necessário realizar todas as operações que dependem do estado do monitor no mesmo bloco sincronizado: public Integer getNextInt() { Integer retVal = null; synchronized (queue) { try { while (queue.isEmpty()) { queue.wait(); } } catch (InterruptedException e) { e.printStackTrace(); } retVal = queue.poll(); } return retVal; } Aqui executamos o método poll() no mesmo bloco sincronizado do método isEmpty(). Graças ao bloco sincronizado, temos certeza de que apenas um thread está executando um método para este monitor em um determinado momento. Portanto, nenhum outro thread pode remover elementos da fila entre chamadas para isEmpty() e poll(). Tradução continuada aqui .
Comentários
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION