Memória transacional de software em Java usando o Multiverse

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 é.