Memória transacional de software em Java usando o Multiverse
*1. Visão geral *
Neste artigo, veremos a biblioteca https://github.com/pveentjer/Multiverse [Multiverse] _ - que nos ajuda a implementar o conceito de _Software Transactional Memory 'em Java.
Usando construções fora desta biblioteca, podemos criar um mecanismo de sincronização no estado compartilhado - que é uma solução mais elegante e legível do que a implementação padrão com a biblioteca principal do Java.
===* 2. Dependência Maven *
Para começar, precisamos adicionar o https://search.maven.org/classic/#search%7Cgav%7C1%7Cg%3A%22org.multiverse%22%20AND%20a%3A%22multiverse-core%22 biblioteca [multiverse-core] em nosso pom:
<dependency>
<groupId>org.multiverse</groupId>
<artifactId>multiverse-core</artifactId>
<version>0.7.0</version>
</dependency>
===* 3. API multiverso *
Vamos começar com alguns dos princípios básicos.
Memória Transacional de Software (STM) é um conceito transportado do mundo do banco de dados SQL - onde cada operação é executada dentro de transações que satisfazem as propriedades _ACID (Atomicidade, Consistência, Isolamento, Durabilidade) _. Aqui,* apenas Atomicidade, Consistência e Isolamento são satisfeitos porque o mecanismo é executado na memória. *
*A interface principal na biblioteca Multiverse é o* *_TxnObject_* - cada objeto transacional precisa implementá-lo, e a biblioteca nos fornece várias subclasses específicas que podemos usar.
Cada operação que precisa ser colocada em uma seção crítica, acessível por apenas um encadeamento e usando qualquer objeto transacional - precisa ser agrupada no método _StmUtils.atomic () _. Uma seção crítica é o local de um programa que não pode ser executado por mais de um thread simultaneamente; portanto, o acesso a ele deve ser protegido por algum mecanismo de sincronização.
Se uma ação dentro de uma transação for bem-sucedida, a transação será confirmada e o novo estado estará acessível para outros threads. Se ocorrer algum erro, a transação não será confirmada e, portanto, o estado não será alterado.
Por fim, se dois encadeamentos quiserem modificar o mesmo estado em uma transação, apenas um terá êxito e confirmará suas alterações. O próximo encadeamento poderá executar sua ação dentro de sua transação.
*4. Implementando a lógica da conta usando o STM *
Vamos agora dar uma olhada em um exemplo.
Digamos que desejamos criar uma lógica de conta bancária usando o STM fornecido pela biblioteca Multiverse. Nosso objeto Account terá o registro de data e hora lastUpadate que é do tipo TxnLong e o campo balance que armazena o saldo atual de uma determinada conta e é do tipo TxnInteger.
O TxnLong e TxnInteger são classes do Multiverse. Eles devem ser executados dentro de uma transação. Caso contrário, uma exceção será lançada. Precisamos usar o StmUtils para criar novas instâncias dos objetos transacionais:
public class Account {
private TxnLong lastUpdate;
private TxnInteger balance;
public Account(int balance) {
this.lastUpdate = StmUtils.newTxnLong(System.currentTimeMillis());
this.balance = StmUtils.newTxnInteger(balance);
}
}
Em seguida, criaremos o método _adjustBy () _ - que aumentará o saldo pelo valor especificado. Essa ação precisa ser executada dentro de uma transação.
Se alguma exceção for lançada dentro dela, a transação será encerrada sem confirmar nenhuma alteração:
public void adjustBy(int amount) {
adjustBy(amount, System.currentTimeMillis());
}
public void adjustBy(int amount, long date) {
StmUtils.atomic(() -> {
balance.increment(amount);
lastUpdate.set(date);
if (balance.get() <= 0) {
throw new IllegalArgumentException("Not enough money");
}
});
}
Se queremos obter o saldo atual para a conta especificada, precisamos obter o valor do campo de saldo, mas ele também precisa ser chamado com a semântica atômica:
public Integer getBalance() {
return balance.atomicGet();
}
===* 5. Testando a conta *
Vamos testar nossa lógica Account. Primeiro, queremos diminuir o saldo da conta pelo valor especificado simplesmente:
@Test
public void givenAccount_whenDecrement_thenShouldReturnProperValue() {
Account a = new Account(10);
a.adjustBy(-5);
assertThat(a.getBalance()).isEqualTo(5);
}
Em seguida, digamos que nos retiramos da conta, tornando o saldo negativo. Essa ação deve gerar uma exceção e deixar a conta intacta, porque a ação foi executada em uma transação e não foi confirmada:
@Test(expected = IllegalArgumentException.class)
public void givenAccount_whenDecrementTooMuch_thenShouldThrow() {
//given
Account a = new Account(10);
//when
a.adjustBy(-11);
}
Vamos agora testar um problema de simultaneidade que pode surgir quando dois encadeamentos desejam diminuir um saldo ao mesmo tempo.
Se um segmento quiser diminuí-lo em 5 e o segundo em 6, uma dessas duas ações falhará porque o saldo atual da conta fornecida é igual a 10.
Vamos enviar dois threads para o ExecutorService e usar o CountDownLatch para iniciá-los ao mesmo tempo:
ExecutorService ex = Executors.newFixedThreadPool(2);
Account a = new Account(10);
CountDownLatch countDownLatch = new CountDownLatch(1);
AtomicBoolean exceptionThrown = new AtomicBoolean(false);
ex.submit(() -> {
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
try {
a.adjustBy(-6);
} catch (IllegalArgumentException e) {
exceptionThrown.set(true);
}
});
ex.submit(() -> {
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
try {
a.adjustBy(-5);
} catch (IllegalArgumentException e) {
exceptionThrown.set(true);
}
});
Depois de observar as duas ações ao mesmo tempo, uma delas lançará uma exceção:
countDownLatch.countDown();
ex.awaitTermination(1, TimeUnit.SECONDS);
ex.shutdown();
assertTrue(exceptionThrown.get());
===* 6. Transferindo de uma conta para outra *
Digamos que queremos transferir dinheiro de uma conta para outra. Podemos implementar o método transferTo () _ na classe _Account passando a outra Account para a qual queremos transferir a quantia especificada:
public void transferTo(Account other, int amount) {
StmUtils.atomic(() -> {
long date = System.currentTimeMillis();
adjustBy(-amount, date);
other.adjustBy(amount, date);
});
}
Toda a lógica é executada dentro de uma transação. Isso garantirá que, quando desejarmos transferir um valor superior ao saldo da conta fornecida, ambas as contas permanecerão intactas, pois a transação não será confirmada.
Vamos testar a lógica de transferência:
Account a = new Account(10);
Account b = new Account(10);
a.transferTo(b, 5);
assertThat(a.getBalance()).isEqualTo(5);
assertThat(b.getBalance()).isEqualTo(15);
Simplesmente criamos duas contas, transferimos o dinheiro de uma para a outra e tudo funciona como esperado. Em seguida, digamos que queremos transferir mais dinheiro do que o disponível na conta. A chamada _transferTo () _ lançará a _IllegalArgumentException, _ e as alterações não serão confirmadas:
try {
a.transferTo(b, 20);
} catch (IllegalArgumentException e) {
System.out.println("failed to transfer money");
}
assertThat(a.getBalance()).isEqualTo(5);
assertThat(b.getBalance()).isEqualTo(15);
Observe que o saldo das contas a e b é o mesmo de antes da chamada para o método _transferTo () _.
===* 7. STM é seguro para deadlock *
Quando estamos usando o mecanismo de sincronização Java padrão, nossa lógica pode estar sujeita a conflitos, sem nenhuma maneira de se recuperar deles.
O impasse pode ocorrer quando queremos transferir o dinheiro da conta a para a conta b. Na implementação Java padrão, um encadeamento precisa bloquear a conta a e depois a conta b. Digamos que, enquanto isso, o outro segmento deseje transferir o dinheiro da conta b para a conta a. O outro encadeamento bloqueia a conta b aguardando o desbloqueio de uma conta a.
Infelizmente, o bloqueio para uma conta a é mantido pelo primeiro segmento e o bloqueio para a conta b é mantido pelo segundo segmento. Tal situação fará com que nosso programa seja bloqueado indefinidamente.
Felizmente, ao implementar a lógica _transferTo () _ usando o STM, não precisamos nos preocupar com deadlocks, pois o STM é Deadlock Safe. Vamos testar isso usando nosso método _transferTo () _.
Digamos que temos dois threads. O primeiro segmento deseja transferir algum dinheiro da conta a para a conta b, e o segundo segmento deseja transferir algum dinheiro da conta b para a conta a. Precisamos criar duas contas e iniciar dois threads que executarão o método _transferTo () _ ao mesmo tempo:
ExecutorService ex = Executors.newFixedThreadPool(2);
Account a = new Account(10);
Account b = new Account(10);
CountDownLatch countDownLatch = new CountDownLatch(1);
ex.submit(() -> {
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
a.transferTo(b, 10);
});
ex.submit(() -> {
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
b.transferTo(a, 1);
});
Após o início do processamento, ambas as contas terão o campo de saldo adequado:
countDownLatch.countDown();
ex.awaitTermination(1, TimeUnit.SECONDS);
ex.shutdown();
assertThat(a.getBalance()).isEqualTo(1);
assertThat(b.getBalance()).isEqualTo(19);
===* 8. Conclusão*
Neste tutorial, vimos a biblioteca Multiverse e como podemos usá-la para criar uma lógica segura de thread e sem bloqueios, utilizando conceitos na Memória Transacional do Software.
Testamos o comportamento da lógica implementada e vimos que a lógica que usa o STM é livre de impasses.
A implementação de todos esses exemplos e trechos de código pode ser encontrada no GitHub project - este é um projeto do Maven, portanto, deve ser fácil importar e corra como é.