Certamente você já concorreu com outras pessoas em sua vida. Uma vaga na faculdade, um emprego ou mesmo a fila do banco.
Sistemas de software também enfrentam certas restrições e competem por recursos computacionais. Na verdade, cada vez que você acessa um site está concorrendo, de alguma forma, com centenas ou milhares de pessoas. Um dos maiores desafios das empresas que disponibilizam serviços na Internet é atender a todos os acessos simultâneos.
Como um engenheiro de software, pretendo agora fazer uma breve análise microscópica sobre esse problema.
O que pode dar errado?
Em geral, um sistema que visa atender vários usuários ao mesmo tempo irá processar as solicitações paralelamente em vários processos ou threads. O problema começa quando as ações de dois ou mais usuários afetam o mesmo conjunto de dados.
Vamos supor que temos um objeto com um contador compartilhado entre diversas threads:
int contador = 0;
public int incrementa() {
contador++;
return contador;
}
Suponha ainda que duas threads T1
e T2
estão rodando em dois processadores de uma mesma CPU. Em um dado momento elas chamam, ao mesmo tempo, o método incrementa()
e ocorre a seguinte sequência de execução:
- Processo
T1
executa a linha contador++
(total = 1)
- Processo
T2
executa a linha contador++
(total = 2)
- Processo
T2
retorna o valor de total
, ou seja, 2
- Processo
T1
retorna o valor de total
, ou seja, 2
Obviamente isso irá causar um efeito colateral indesejado! 🙁
Note ainda que problemas podem ocorrer mesmo que apenas uma das threads faça modificações e as demais estejam lendo valores, já que elas podem ler dados desatualizados e incorrer em exceções.
Um exemplo básico seria a iteração sobre uma lista. Se uma thread remove um item da lista, outras threads que estão no meio de uma iteração podem acabar em uma exceção ArrayIndexOutOfBoundsException
.
Os exemplos são inesgotáveis e os efeitos colaterais os mais bizarros!
Objetos thread-safe
Para resolvermos a situação acima, onde um objeto é acessado por múltiplas threads, precisamos construir um objeto que seja à prova de acesso simultâneo, ou seja, um objeto thread-safe. O que diacho é isso? É um objeto que, em dado contexto de uso, garante o acesso seguro a dados compartilhados por várias threads de forma concorrente sem efeitos colaterais indesejados.
Bem, na verdade, essa é metade da história. O ideal do ponto de vista de desempenho seria que não houvessem acessos concorrentes. Isso significa que, num mundo perfeito, nós conseguiríamos distribuir as tarefas igualmente entre as várias threads e cada uma poderia concluir seu trabalho independentemente, sem uso de recursos compartilhados.
Na prática, é comum precisarmos compartilhar dados, então temos que aplicar um conceito muito popular da vida real: o semáforo.
Em nossas ruas, um semáforo é o que garante que somente os veículos de uma via por vez tenham o direito de atravessar um cruzamento. Em computação, um semáforo nada mais é do que uma variável ou objeto que controla o acesso a um determinado recurso. Com isso, estamos fazendo sincronização do acesso de várias threads, analogamente à sincronização dos vários semáforos de um cruzamento.
Em Java, podemos sincronizar o acesso a um método ou bloco de código. Veja o exemplo:
int contador = 0;
public synchronized int incrementa() {
contador++;
return contador;
}
Neste novo exemplo, as duas threads não iriam executar o método incrementa()
ao mesmo tempo e no mesmo objeto. Somente uma de cada vez adentraria o bloco com o incremento, evitando o efeito colateral descrito no tópico anterior.
Não se engane! (Ressalvas importantes)
É importante notar que a sincronização no método é equivalente a um bloco synchronized(this)
, o que significa que o bloqueio é feito na instância do objeto que contém o método. Se houverem duas instâncias, não haverá sincronização.
Um erro muito comum é o desenvolvedor achar que o synchronized
irá resolver os problemas de concorrência dos sistemas. Isso não é verdade.
Alguns frameworks web, por exemplo, podem criar várias instâncias das classes para tratar requisições, dependendo de seu escopo, então a sincronização não iria ocorrer como esperado. Além disso, aplicações distribuídas em clusters (dois ou mais servidores) também não seriam sincronizadas mesmo usando singletons.
Mesmo que synchronized
fizesse o que alguns esperam, ainda incorreríamos em um grande problema.
Gargalos!
A sincronização resolve alguns problemas, mas pode gerar outros. Se houverem muitos métodos sincronizados, gargalos começarão a surgir no sistema.
Gargalos são pontos do sistema que geram lentidão, pois várias threads precisam ficar esperando sua vez. Quanto mais sincronização fizermos, mais gargalos.
Sincronizando somente o necessário
Para resolver isso, podemos usar uma forma mais adequada de sincronização. No exemplo anterior, usamos um semáforo “global”, isto é, o próprio objeto. Isso faz com que todo acesso ao objeto seja sincronizado por um único semáforo!
A solução para isso é usar semáforos mais específicos de forma que sincronizemos somente o que é necessário. Veja o exemplo:
Integer contador = 0;
Object semaforo = new Object();
public Integer incrementa() {
synchronized (semaforo) {
contador++;
return contador;
}
}
Este simples exemplo não traz muitas vantagens em si mesmo. Mas considere um cenário onde existam outros métodos nesse objeto que não modificam a mesma variável. Poderíamos criar um semáforo para cada variável, limitando ao máximo o escopo da sincronização.
A boa prática diz que o bloqueio deve ser referente aos dados acessados e não ao objeto que os contém. Isso evita muitos bloqueios desnecessários e pode fazer toda a diferença em cenários um pouco mais complexos.
E se não houver modificações?
Objetos imutáveis são, por natureza, seguros para uso em múltiplas threads, pois não há risco de efeitos colaterais. Eles não precisam ser sincronizados para serem seguramente usados em multithreading e podem ajudar muito na melhoria do desempenho da aplicação.
Este é um dos motivos pelos quais as classes básicas do Java, como String
e Integer
, são imutáveis. Observe que eu estou falando que não é possível modificar o valor de uma instância dessas classes, não que você não pode atribuir outra instância a uma variável.
Então nunca haverá problemas com objetos imutáveis?
Sim e não! Não é por acaso que a definição de thread-safe fala sobre o contexto de uso.
Apenas para citar um exemplo, suponha que um mapa imutável contenha elementos do tipo ArrayList
mutáveis. Se threads diferentes acessarem e modificarem essas listas, estaremos sujeitos novamente a efeitos colaterais indesejados. Então, mesmo uma coleção imutável ou adequadamente sincronizada não cobre todos os casos, pois se ela contém um elemento mutável, ainda que seja um simples POJO, o resultado final não será thread-safe.
É preciso também tomar cuidado com classes do Java que, apesar de aparentarem inocência, na realidade são inerentemente thread-unsafe. Um exemplo é classe SimpleDateFormat
, que estende a classe DateFormat
, a qual possui um atributo do tipo Calendar
, que é mutável. Aliás, ter atributos mutáveis como o Calendar
é uma fonte de problemas em potencial.
Em resumo, devemos analisar todos os objetos compartilhados por threads, incluindo seu conteúdo.
Um pouquinho da API do Java
Para tentar ajudar, a API Collections do Java nos mune com várias implementações imutáveis e thread-safe para todos os gostos.
As versões sincronizadas de classes de coleções são encontradas no pacote java.util.concurrent
. Eis alguns substitutos para as classes convencionais:
A classe ConcurrentHashMap
é uma versão melhorada de mapa sincronizado em relação ao HashTable
, suportando manipulação por várias threads, mas sem bloquear as operações de leitura.
O método utilitário Collections.synchronizedMap(map)
retorna um wrapper sincronizado de um mapa qualquer. Entretanto, mesmo métodos de leitura são sincronizados, resultando em mais gargalos com relação ao ConcurrentHashMap
.
Alguns dos wrappers sincronizados disponíveis são:
public static <T> Collection<T> synchronizedCollection(Collection<T> c);
public static <T> Set<T> synchronizedSet(Set<T> s);
public static <T> List<T> synchronizedList(List<T> list);
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m);
public static <T> SortedSet<T> synchronizedSortedSet(SortedSet<T> s);
public static <K,V> SortedMap<K,V> synchronizedSortedMap(SortedMap<K,V> m);
Além disso, também existe uma lista de wrappers imutáveis, isto é, que retornam uma versão imutável do objeto:
public static <T> Collection<T> unmodifiableCollection(Collection<? extends T> c);
public static <T> Set<T> unmodifiableSet(Set<? extends T> s);
public static <T> List<T> unmodifiableList(List<? extends T> list);
public static <K,V> Map<K, V> unmodifiableMap(Map<? extends K, ? extends V> m);
public static <T> SortedSet<T> unmodifiableSortedSet(SortedSet<? extends T> s);
public static <K,V> SortedMap<K, V> unmodifiableSortedMap(SortedMap<K, ? extends V> m);
A referência oficial dos métodos que retornam wrappers imutáveis e sincronizados está aqui.
Este artigo foi baseado na minha resposta no StackOverflow em Português!