Neste artigo iremos explicar como pode acontecer um problema de concorrência em uma aplicação e como podemos fazer para evitar tal problema. Tornando portanto uma código thread-safe.
O problema
Em aplicações multithread, pode acontecer de duas ou mais threads acessarem um mesmo método simultaneamente e dependendo do momento em que cada uma executa em cada parte do método, isso pode levar a aplicação a erros inesperados (esse tipo de problema é bem conhecido e recebe o nome de race condition).
Resumindo o problema de forma prática, se dois usuários realizarem uma mesma ação ao mesmo tempo em um sistema e essa ação consequentemente chamar um mesmo método ao mesmo tempo para os dois usuários, dependendo do que é realizado nesse método pode ser que no final da execução do método tenhamos um resultado inesperado.
Talvez o exemplo mais simples de se entender o problema é o tão famigerado exemplo de uma transação bancária. Como faz parte da vida de quase todos e também é um exemplo simples, não tive como fugir de usar o mesmo exemplo.
Vamos supor que você precisa criar uma classe para representar uma conta bancária. Você então cria a classe abaixo. Como você é um programador esperto, você faz uma verificação no método de debitar, para não correr o risco de debitar um valor maior que o saldo e assim deixar o saldo negativo.
public class ContaBancaria {
private Double saldo;
public void debitar(Double valorADebitar) {
if (valorADebitar > 0 && saldo >= valorADebitar) {
saldo = saldo - valorADebitar;
}
}
public void depositar(Double valorADepositar) {
saldo = saldo + valorADepositar;
}
public Double getSaldo() {
return saldo;
}
}
Cenário 1: singlethread (não causa erro)
No primeiro cenário temos uma única thread. Podemos pensar como uma conta bancária onde um único usuário acessará a conta e realizará as seguintes ações:
- deposita 100 reais, ficando com saldo de 100 reais
- debita 50 reais, ficando com saldo de 50 reais
- debita 50 reais, ficando com saldo de 0 reais
- tenta debitar mais 50 reais, porém método verifica que o saldo está menor que o valor a sacar, então não realiza a ação
- no final temos um comportamento correto do sistema, onde não foi permitido sacar valores que não existiam
Cenário 2: multithread (pode causar erro)
No segundo cenário, podemos supor que a conta é uma conta conjunta, que será acessada pelo esposo (Joaquim) e a esposa (Maria), cada um realizando as seguintes ações:
- JOAQUIM: deposita 100 reais, ficando com saldo de 100 reais
- JOAQUIM: debita 50 reais, ficando com saldo de 50 reais
- JOAQUIM e MARIA: executam a ação de debitar no mesmo instante. Como o ambiente é multithread, as duas threads podem acessar o método simultaneamente. então as duas threads iniciam a execução do método “debitar” nos seguintes passos:
- THREAD DE JOAQUIM: executa linha 6 (verifica se o saldo é maior ou igual ao valor a debitar), como o saldo ainda é 50 reais, ela “entra” no if
- THREAD DE JOAQUIM: entra no estado de ESPERA
- THREAD DE MARIA: executa linha 6 (verifica se o saldo é maior ou igual ao valor a debitar), como o saldo ainda é 50 reais, ela “entra” no if
- THREAD DE MARIA: executa linha 7 (realiza o débito de 50 reais, ficando com saldo igual a 0)
- THREAD DE JOAQUIM: executa linha 7 (realiza o débito de 50 reais, como o saldo já era 0, ficará com sado de -50)
O resultado final da execução no cenário 2 foi um valor negativo de -50 reais na conta, ou seja, um valor incorreto causado pelo problema chamado de race condition.
Solução
A solução para esse tipo de problema é sincronizar a parte do código que está causando o erro. Essa sincronização significa permitir que apenas uma thread execute uma parte do código de cada vez, sendo que as outras threads que tentem executar essa parte do código fiquem aguardando a conclusão da execução por parte da primeira thread.
Iremos ver a seguir algumas formas de se conseguir essa sincronização.
Synchronized no método
Uma forma de deixar o código sincronizado é incluir a palavra-chave synchronized no método. Dessa forma o método inteiro ficará sincronizado e apenas uma thread consegue acessá-lo por vez.
public class ContaBancaria {
private Double saldo;
public synchronized void debitar(Double valorADebitar) {
if (valorADebitar > 0 && saldo >= valorADebitar) {
saldo = saldo - valorADebitar;
}
}
public void depositar(Double valorADepositar) {
saldo = saldo + valorADepositar;
}
public Double getSaldo() {
return saldo;
}
}
Cenário 2: multithread, porém com código synchronized (não causa erro)
Agora vamos repetir o segundo cenário, com o método de debitar synchronized. Para relembrar o segundo cenário, ele consiste em uma conta conjunta, onde serão realizados saques simultaneamente por JOAQUIM e MARIA.
- JOAQUIM: deposita 100 reais, ficando com saldo de 100 reais
- JOAQUIM: debita 50 reais, ficando com saldo de 50 reais
- JOAQUIM e MARIA: executam a ação de debitar no mesmo instante. Como o ambiente é multithread e o método é sincronizado, as duas threads podem tentar acessar o método simultaneamente, porém a primeira a acessar obtém o lock do método (chamado de intrinsic lock) e as outras threads ficam aguardando a execução do método pela primeira thread. Veja abaixo o passo-a-passo:
- THREAD DE JOAQUIM: inicia a execução do método na linha 5 e então obtém o lock do método. Nesse momento nenhuma thread pode acessar o método até que a thread de JOAQUIM conclua a execução do método
- THREAD DE JOAQUIM: executa linha 6 (verifica se o saldo e maior ou igual ao valor a debitar), como o saldo ainda é 50 reais, ela “entra” no if
- THREAD DE JOAQUIM: executa linha 7 (realiza o débito de 50 reais, como o saldo já era 0, ficará com sado de 0)
- THREAD DE JOAQUIM: conclui o método e libera o lock do método, permitindo que outras threads possam acessá-lo
- THREAD DE MARIA: inicia a execução do método na linha 5 e então obtém o lock do método.
- THREAD DE MARIA: executa linha 6 (verifica se o saldo e maior ou igual ao valor a debitar), como o saldo é 0, ela “não entra” no if
- THREAD DE MARIA: conclui o método e libera o lock do método, permitindo que outras threads possam acessá-lo
O resultado final da execução no cenário 2 com método sincronizado foi um valor negativo de 0 reais na conta, ou seja, um valor correto, mesmo com N threads tentando acessar o método ao mesmo tempo.
Synchronized em um bloco de código
Ao sincronizar um método inteiro pode ser criado um gargalo caso o metodo seja muito grande ou muito demorado e acessado por muitas threads simultaneamente. Então nesse caso talvez seja preferível sincronizar somente a parte do código que pode causar problema. Veja abaixo como isso pode ser feito
public class ContaBancaria {
private Double saldo;
public void debitar(Double valorADebitar) {
synchronized (this) {
if (valorADebitar > 0 && saldo >= valorADebitar) {
saldo = saldo - valorADebitar;
}
}
}
public void depositar(Double valorADepositar) {
saldo = saldo + valorADepositar;
}
public Double getSaldo() {
return saldo;
}
}
Ao sincronizar um bloco de código, devemos especificar um objeto que será utilizado para obter o lock. Normalmente para isso é utilizado o objeto atual, utilizando-se a palavra-chave this.