Conteúdo


Padrões de erro de simultaneidade de Java para sistemas com vários núcleos

Seis padrões de erro de simultaneidade de Java pouco conhecidos

Comments

Para programadores sem experiência em programação multiencadeada, o desafio de adaptar software para sistemas com vários núcleos é duplo: primeiro, a simultaneidade introduz uma nova categoria de erros nos programas Java, tais como disputa de dados e conflito, que são muito difíceis de reproduzir e diagnosticar. Em segundo lugar, muitos programadores não conhecem as sutilezas de certas expressões de programação multiencadeadas, o que pode levar a erros no código.

Para evitar que erros sejam introduzidos em programas simultâneos, os programadores Java devem aprender a reconhecer as junções críticas do código multiencadeado nas quais é provável que erros aconteçam, e então criar um software que seja à prova de erros. Neste artigo, nós nos voltamos para desenvolvedores Java em estágios básico e intermediário do entendimento do que torna a programação simultânea diferente. Em vez de focar em padrões de erro de simultaneidade de Java bem conhecidos, como bloqueio de verificação dupla, spin wait e wait-not-in-loop, nós apresentamos seis padrões que são menos conhecidos, mas que aparecem com frequência em aplicativos Java reais. Aliás, nossos dois primeiros exemplos são erros reais encontrados em dois populares servidores da Web.

1. Um antipadrão de Jetty

Nosso primeiro erro de simultaneidade é encontrado no servidor HTTP de software livre altamente usado, Jetty. É um erro real, que foi confirmado pela comunidade do Jetty (consulte Recursos para ver o relatório do erro).

Listagem 1. Operações não atômicas em um campo volátil sem um bloqueio
// Jetty 7.1.0,
// org.eclipse.jetty.io.nio,
// SelectorManager.java, line 105

private volatile int _set;
......
public void  register(SocketChannel channel, Object att)
{
   int s=_set++;
   ......
}
......
public void  addChange(Object point)
{
   synchronized (_changes)
   {
      ......
   }
}

O erro na Listagem 1 é a soma das partes:

  • Primeiro, _set é declarado como volatile, o que implica que o campo pode ser acesso por vários encadeamentos.
  • Mas _set++ não é atômico, o que significa que ele não é necessariamente executado como uma operação única, indivisível. Ao contrário, é um substituto para uma sequência de três operações discretas: read-modify-write.
  • Por fim, _set++ não está protegido por um bloqueio. Se o método register for invocado simultaneamente por vários encadeamentos, isso pode resultar em uma condição de disputa, levando a um valor de _set incorreto.

Um erro desse tipo poderia aparecer tão facilmente no seu código como apareceu no do Jetty. por isso, vamos examinar mais de perto como isso aconteceu.

Elementos em um padrão de erro

Seguir o código até sua sequência lógica ajuda a esclarecer este padrão de erro. Operações em uma variável i, tais como

i++
--i
i += 1
i -= 1
i *= 2

e assim por diante não são atômicas (ou seja, read-modify-write). Se você sabe que a palavra-chave volatile na linguagem Java garante apenas visibilidade da variável, mas não atomicidade, esse fato deve fazer você parar e pensar. Uma operação não atômica em um campo volátil que não é protegido por um bloqueio poderia resultar em uma condição de disputa, — mas apenas se a operação não atômica for acessada simultaneamente por vários encadeamentos.

Em um programa thread-safe, apenas um encadeamento que está gravando pode modificar a variável. Outros encadeamentos podem ler os valores atualizados declarando a variável volatile.

Portanto, o fato de um código ter erro ou não depende de quantos encadeamentos podem acessar a operação simultaneamente. Se a operação não atômica for chamada por apenas um encadeamento, devido a uma relação start-join ou bloqueio externo, a expressão de código será thread-safe.

Observe que a palavra-chave volatile em código Java pode apenas garantir que uma variável é visível. Essa palavra-chave não garante atomicidade. Em casos nos quais a operação de variável não é atômica e pode ser acessada por vários encadeamentos, não conte com recursos de sincronização volátil. Em vez disso, use blocos sincronizados, classes de bloqueio e classes atômicas do pacote java.util.concurrent. Eles são projetados para garantir a segurança de encadeamento dos programas.

2. Sincronização em campos mutáveis

Na linguagem Java, nós usamos blocos sincronizados para adquirir bloqueios mutex, que protegem acesso a recursos compartilhados em sistemas multiencadeados. No entanto, há uma brecha ao sincronizar em um campo mutável, que pode quebrar a mutex. A solução é sempre declarar o campo sincronizado como sendo private final. Vamos examinar o problema em mais detalhes para entender por quê.

Bloqueio simultâneo em campos atualizados

Blocos sincronizados são protegidos pelo objeto referenciado a partir do campo sincronizado, e não pelo próprio campo. Se um campo sincronizado for mutável (o que significa que ele pode ser designado em qualquer parte do programa além de sua inicialização), é difícil que ele tenha semântica útil, pois encadeamentos diferentes podem sincronizar em objetos diferentes.

Você pode observar o problema na Listagem 2, que é um fragmento de código do Tomcat, um servidor de aplicativos da Web de software livre:

Listagem 2. Erro no Tomcat
96: public void  addInstanceListener(InstanceListener listener) {
97:
98:    synchronized (listeners) {
99:       InstanceListener results[] =
100:        new InstanceListener[listeners.length + 1];
101:      for (int i = 0; i < listeners.length; i++)
102:          results[i] = listeners[i];
103:      results[listeners.length] = listener;
104:      listeners = results;
105:   }
106:
107:}

Suponha que listeners refira-se à array A, e que o encadeamento T1 adquire o bloqueio da Array A e depois se ocupa com a criação da Array B. No meio tempo, T2 vem e bloqueia para travar a Array A. Quando T1 conclui a configuração de listeners na Array B e retira o bloqueio, T2 bloqueia a Array A e começa a fazer uma cópia da Array B. T3 então vem e bloqueia a Array B. Como eles obtiveram bloqueios diferentes, T2 e T3 estão agora simultaneamente fazendo cópias da Array B.

A Figura 1 ilustra melhor essa sequência

Figura 1. Ausência de mutex devido à sincronização em um campo mutável
Ausência de mutex devido à sincronização em um campo mutável
Ausência de mutex devido à sincronização em um campo mutável

Vários comportamentos indesejáveis podem resultar de uma configuração como essa. No mínimo, um dos listeners pode se perder ou um dos encadeamentos pode receber um ArrayIndexOutOfBoundsException (devido ao fato de que os listeners referenciam e seu comprimento pode mudar em qualquer parte do método).

Uma boa prática é sempre declarar o campo sincronizado como private final, o que garante que o objeto bloqueado permaneça inalterado e que mutex seja garantido.

3. Fuga de bloqueio de java.util.concurrent

Um bloqueio que implementa a interface java.util.concurrent.locks.Lock controla a maneira como diversos encadeamentos acessam um recurso compartilhado. Esses bloqueios não exigem estruturas de bloco, por isso são mais flexíveis que métodos ou instruções sincronizadas. Entretanto, essa flexibilidade pode levar a erros de código, pois um bloqueio sem um bloco nunca é liberado automaticamente. Se uma chamada Lock.lock() não tiver uma chamada unlock() correspondente na mesma instância, o resultado pode ser uma fuga de bloqueio.

É fácil introduzir o erro de fuga de bloqueio java.util.concurrent ignorando o comportamento do método em código crítico, tais como exceções que podem ser lançadas. Isso pode ser visto na Listagem 3, na qual o método accessResource lança uma InterruptedException , enquanto acessa o recurso compartilhado. Como resultado, unlock() não é chamado.

Listagem 3. Anatomia de uma fuga de bloqueio
private final Lock lock = new ReentrantLock();

public void  lockLeak() {
   lock.lock();
   try {
      // access the shared resource
      accessResource();
      lock.unlock();
   } catch (Exception e) {}

public void  accessResource() throws InterruptedException {...}

Para garantir que todos os bloqueios sejam liberados, basta associar cada método lock com um método unlock, que deve ser colocado em um bloco try-finally. Essa associação é ilustrada na Listagem 4:

Listagem 4. Sempre coloque a chamada de desbloqueio no bloco finally
private final Lock lock = new ReentrantLock();

public void  lockLeak() {
   lock.lock();
   try {
      // access the shared resource
      accessResource();
   } catch (Exception e) {}
   finally {
      lock.unlock();
   }

public void accessResource() throws InterruptedException {...}

4. Ajustando o desempenho de blocos sincronizados

Alguns erros de simultaneidade não quebram seu código, mas podem gerar desempenho precário do aplicativo. Considere o bloco synchronized na Listagem 5:

Listagem 5. Código invariável de bloco sincronizado
public class Operator {
   private int generation = 0; //shared variable
   private float totalAmount = 0; //shared variable
   private final Object lock = new Object();

   public void workOn(List<Operand> operands) {
      synchronized (lock) {
         int curGeneration = generation; //requires synch
         float amountForThisWork = 0;
         for (Operand o : operands) {
            o.setGeneration(curGeneration);
            amountForThisWork += o.amount;
         }
         totalAmount += amountForThisWork; //requires synch
         generation++; //requires synch
      }
   }
}

O acesso às duas variáveis compartilhadas na Listagem 5 está sincronizado corretamente, mas, se você examinar com atenção, perceberá que o bloco synchronized exige mais cálculo do que deveria. Podemos consertar isso reorganizando as linhas, como mostra a Listagem 6:

Listagem 6. Bloco sincronizado sem o código invariável
public void workOn(List<Operand> operands) {
   int curGeneration;
   float amountForThisWork = 0;
   synchronized (lock) {
      int curGeneration = generation++;
   }
   for (Operand o : operands) {
      o.setGeneration(curGeneration);
      amountForThisWork += o.amount;
   }
   synchronized (lock)
      totalAmount += amountForThisWork;
   }
}

A segunda versão terá um desempenho muito melhor em máquinas com vários núcleos. O motivo é que o bloco sincronizado na Listagem 5 evita execução paralela. O tempo de cálculo nesse método deve ser provavelmente utilizado no loop. Na Listagem 6, o loop está fora do bloco sincronizado, por isso pode ser executado por vários encadeamentos em paralelo. Em geral, tente fazer com que seus blocos sincronizados sejam o menor possível, sem afetar a segurança dos encadeamentos.

5. Acesso de várias etapas

Suponha que você está trabalhando em um aplicativo que contém duas tabelas: uma relaciona nomes de funcionários a um número de série, e a outra relaciona o número serial a um salário. Esses dados precisam suportar acessos e atualizações simultâneas, que você habilita pelo thread-safe ConcurrentHashMap, como mostra a Listagem 7:

Listagem 7. Acesso em duas etapas
public class Employees {
   private final ConcurrentHashMap<String,Integer> nameToNumber;
   private final ConcurrentHashMap<Integer,Salary> numberToSalary;

   ... various methods for adding, removing, getting, etc...

   public int geBonusFor(String name) {
      Integer serialNum = nameToNumber.get(name);
      Salary salary = numberToSalary.get(serialNum);
      return salary.getBonus();
   }
}

Essa solução parece ser thread-safe, mas não é. O problema é que o método getBonusFor não é thread-safe. Entre a obtenção do número de série e seu uso para obter o salário, outro encadeamento poderia remover o funcionário de ambas as tabelas. Nesse caso, o acesso ao segundo mapa retornaria null e uma exceção seria lançada.

Tornar cada mapa thread-safe em si não é suficiente. Há uma dependência entre eles, e algumas operações que acessam ambos os mapas requerem acesso atômico. Você poderia obter segurança de encadeamentos neste caso usando contêineres que não thread-safe (tais como java.util.HashMap), e utilizando sincronização explícita para proteger cada acesso. O bloco sincronizado poderia então abranger ambos os acessos, se necessário.

6. Conflito de bloqueio simétrico

Imagine uma classe de contêiner thread-safe — uma estrutura de dados que garante segurança de encadeamento para seus clientes. (Isso é diferente da maioria dos contêineres em java.util, que requer que o cliente sincronize em relação aos usos do contêiner.) Na Listagem 8, um membro modificável armazena os dados e um objeto de bloqueio protege todo o acesso a ele.

Listagem 8. Um contêiner thread-safe
public <E> class ConcurrentHeap {
   private E[] elements;
   private final Object lock = new Object(); //protects elements

   public void add (E newElement) {
      synchronized(lock) {
         ... //manipular elementos
      }
   }

   public E removeTop() {
      synchronized(lock) {
         E top = elements[0];
         ... //manipular elementos
         return top;
      }
   }
}

Agora vamos adicionar um método que toma outra instância e adiciona todos os seus elementos à instância atual. Esse método precisa acessar o membro elements em ambas as instâncias, por isso ele toma ambos os bloqueios, como mostra a Listagem 9:

Listagem 9. Esta estrada leva ao conflito
public void addAll(ConcurrentHeap other) {
   synchronized(other.lock) {
      synchronized(this.lock) {
         ... //manipular other.elements e this.elements
      }
   }
}

Você vê o potencial para conflito? Imagine que um programa contenha duas instâncias, heap1 e heap2. Se um encadeamento chamar heap1.addAll(heap2), e outro encadeamento ao mesmo tempo chamar heap2.addAll(heap1), os encadeamentos podem acabar em conflito. Em outros termos: imagine que o primeiro encadeamento bloqueia heap2, mas que, antes disso, o segundo encadeamento começa a executar o método, também bloqueando heap1. Como resultado, cada encadeamento termina esperando um bloqueio sustentado pelo outro encadeamento.

O conflito de bloqueio simétrico pode ser evitado determinando-se uma ordem entre as instâncias, de modo que quando bloqueios de duas instâncias precisem ser realizados juntos, a ordem é calculada dinamicamente e determina qual bloqueio é realizado primeiro. Brian Goetz discute essa solução alternativa em seu livro Java Concurrency in Practice (consulte Recursos ).

Conclusão

Muitos desenvolvedores Java estão nas etapas iniciais do aprendizado de como escrever programas simultâneos para ambientes com vários núcleos. Nesse processo, estamos abandonando as expressões de programação de um único encadeamento pelas expressões multiencadeadas, que são inerentemente mais complexas. Estudar padrões de erro de simultaneidade é uma boa maneira de descobrir os perigos da programação multiencadeada, e irá ajudar você a dominar as sutilezas de suas expressões.

Você pode aprender a reconhecer padrões de erro como a soma de suas partes, de modo que alguns sinais soarão o alerta quando você estiver escrevendo código ou durante revisões de código. Também é possível usar ferramentas de análise estática para esse fim. FindBugs é uma ferramenta de análise estática de software livre que procura por prováveis padrões de erros no código. Aliás, FindBugs poderia ser usado para detectar o segundo e o terceiro padrões de erros discutidos neste artigo.

Uma desvantagem conhecida das ferramentas de análise estática é que elas geram alarmes falsos, de modo que você gasta mais tempo do que gostaria verificando padrões de código que não contêm erros. Uma classe emergente de ferramentas de análise dinâmica é adequada especificamente para testar programas simultâneos. Duas dessas ferramentas, IBM® Multicore Software Development Kit (MSDK) e ConcurrentTesting (ConTest), estão disponíveis gratuitamente no alphaWorks.


Recursos para download


Temas relacionados


Comentários

Acesse ou registre-se para adicionar e acompanhar os comentários.

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=80
Zone=Tecnologia Java
ArticleID=608064
ArticleTitle=Padrões de erro de simultaneidade de Java para sistemas com vários núcleos
publish-date=01132011