Mémoire transactionnelle logicielle en Java avec multivers

1. Vue d’ensemble

Dans cet article, nous examinerons la bibliothèque Multiverse , qui nous aide à implémenter le concept de Software Transactional Memory en Java.

En utilisant des constructions en dehors de cette bibliothèque, nous pouvons créer un mécanisme de synchronisation sur l’état partagé - solution plus élégante et lisible que l’implémentation standard avec la bibliothèque principale Java.

2. Dépendance Maven

Pour commencer, nous devons ajouter https://search.maven.org/classic/#search%7Cgav%7C1%7Cg%3A%22org.multiverse%22%20AND%20a%3A%22multiverse-core%22 .[ multiverse-core ]librairie dans notre pom:

<dependency>
    <groupId>org.multiverse</groupId>
    <artifactId>multiverse-core</artifactId>
    <version>0.7.0</version>
</dependency>

3. API multivers

Commençons par quelques notions de base.

La mémoire logicielle transactionnelle (STM) est un concept issu du monde de la base de données SQL - chaque opération étant exécutée dans des transactions satisfaisant les propriétés ACID (Atomicité, Cohérence, Isolation, Durabilité) . Ici, seuls l’atomicité, la cohérence et l’isolement sont satisfaits car le mécanisme fonctionne en mémoire.

  • L’interface principale de la bibliothèque Multiverse est TxnObject __ - chaque objet transactionnel doit être implémenté et la bibliothèque nous fournit un certain nombre de sous-classes spécifiques que nous pouvons utiliser.

Chaque opération devant être placée dans une section critique, accessible par un seul thread et utilisant un objet transactionnel, doit être encapsulée dans la méthode StmUtils.atomic () . Une section critique est un emplacement d’un programme qui ne peut pas être exécuté par plusieurs threads simultanément. Par conséquent, son accès doit être protégé par un mécanisme de synchronisation.

Si une action dans une transaction réussit, la transaction sera validée et le nouvel état sera accessible aux autres threads. Si une erreur survient, la transaction ne sera pas validée et par conséquent, l’état ne changera pas.

Enfin, si deux threads veulent modifier le même état dans une transaction, un seul réussira et validera ses modifications. Le prochain thread pourra effectuer son action dans sa transaction.

4. Implémentation de la logique de compte à l’aide de STM

  • Voyons maintenant un exemple ** .

Disons que nous voulons créer une logique de compte bancaire en utilisant STM fournie par la bibliothèque Multiverse . Notre objet Account aura le timestamp lastUpadate de type TxnLong et le champ balance qui stocke le solde en cours pour un compte donné et est de type TxnInteger .

TxnLong et TxnInteger sont des classes de Multiverse . Ils doivent être exécutés dans une transaction. Sinon, une exception sera levée. Nous devons utiliser StmUtils pour créer de nouvelles instances des objets transactionnels:

public class Account {
    private TxnLong lastUpdate;
    private TxnInteger balance;

    public Account(int balance) {
        this.lastUpdate = StmUtils.newTxnLong(System.currentTimeMillis());
        this.balance = StmUtils.newTxnInteger(balance);
    }
}

Ensuite, nous allons créer la méthode adjustBy () - qui augmentera le solde du montant donné. Cette action doit être exécutée dans une transaction.

Si une exception est renvoyée à l’intérieur de celle-ci, la transaction se terminera sans engager aucune modification

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");
        }
    });
}

Si nous voulons obtenir le solde actuel pour le compte donné, nous devons obtenir la valeur du champ solde, mais il doit également être appelé avec la sémantique atomique:

public Integer getBalance() {
    return balance.atomicGet();
}

5. Test du compte

Testons notre logique de compte. Premièrement, nous voulons décrémenter le solde du compte du montant donné simplement:

@Test
public void givenAccount__whenDecrement__thenShouldReturnProperValue() {
    Account a = new Account(10);
    a.adjustBy(-5);

    assertThat(a.getBalance()).isEqualTo(5);
}

Ensuite, disons que nous retirons du compte, ce qui rend le solde négatif. Cette action doit générer une exception et laisser le compte intact car l’action a été exécutée dans une transaction et n’a pas été validée:

@Test(expected = IllegalArgumentException.class)
public void givenAccount__whenDecrementTooMuch__thenShouldThrow() {
   //given
    Account a = new Account(10);

   //when
    a.adjustBy(-11);
}

Essayons maintenant un problème de concurrence qui peut survenir lorsque deux threads veulent décrémenter une balance en même temps.

Si un thread souhaite le décrémenter de 5 et le second de 6, l’une de ces deux actions doit échouer car le solde actuel du compte donné est égal à 10.

Nous allons soumettre deux threads au ExecutorService et utiliser le CountDownLatch pour les démarrer en même temps:

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);
    }
});

Après avoir regardé les deux actions en même temps, l’un d’eux lève une exception:

countDownLatch.countDown();
ex.awaitTermination(1, TimeUnit.SECONDS);
ex.shutdown();

assertTrue(exceptionThrown.get());

6. Transférer d’un compte à un autre

Disons que nous voulons transférer de l’argent d’un compte à l’autre.

Nous pouvons implémenter la méthode transferTo () sur la classe Account en transmettant l’autre Account auquel nous voulons transférer le montant donné:

public void transferTo(Account other, int amount) {
    StmUtils.atomic(() -> {
        long date = System.currentTimeMillis();
        adjustBy(-amount, date);
        other.adjustBy(amount, date);
    });
}

Toute la logique est exécutée dans une transaction. Cela garantira que lorsque nous voulons transférer un montant supérieur au solde du compte donné, les deux comptes seront intacts car la transaction ne sera pas validée.

Testons la logique de transfert:

Account a = new Account(10);
Account b = new Account(10);

a.transferTo(b, 5);

assertThat(a.getBalance()).isEqualTo(5);
assertThat(b.getBalance()).isEqualTo(15);

Nous créons simplement deux comptes, nous transférons l’argent de l’un à l’autre et tout fonctionne comme prévu. Ensuite, disons que nous voulons transférer plus d’argent que ce qui est disponible sur le compte. L’appel transferTo () lève l’exception IllegalArgumentException et les modifications ne seront pas validées:

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);

Notez que le solde des comptes a et b est le même qu’avant l’appel de la méthode transferTo () .

7. STM est un blocage sécurisé

Lorsque nous utilisons le mécanisme de synchronisation Java standard, notre logique peut être sujet à des blocages, sans possibilité de récupération.

Le blocage peut survenir lorsque nous souhaitons transférer l’argent du compte a au compte b . Dans l’implémentation Java standard, un thread doit verrouiller le compte a , puis le compte b . Disons que, dans l’intervalle, l’autre thread souhaite transférer l’argent du compte b au compte a . L’autre thread verrouille le compte b en attendant qu’un compte a soit déverrouillé.

Malheureusement, le verrou du compte a est maintenu par le premier thread et celui du compte b par le second. Une telle situation fera bloquer notre programme indéfiniment.

Heureusement, lors de l’implémentation de la logique transferTo () à l’aide de STM, nous n’avons pas à nous préoccuper des blocages, car ce dernier est sécurisé. Testons cela en utilisant notre méthode transferTo () .

Disons que nous avons deux threads. Le premier thread veut virer de l’argent du compte a au compte b , et le deuxième thread veut virer de l’argent du compte b au compte a . Nous devons créer deux comptes et démarrer deux threads qui exécuteront la méthode transferTo () en même temps:

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);

});

Après le démarrage du traitement, les deux comptes auront le champ de solde approprié:

countDownLatch.countDown();
ex.awaitTermination(1, TimeUnit.SECONDS);
ex.shutdown();

assertThat(a.getBalance()).isEqualTo(1);
assertThat(b.getBalance()).isEqualTo(19);

8. Conclusion

Dans ce didacticiel, nous avons examiné la bibliothèque Multiverse et la manière dont nous pouvons l’utiliser pour créer une logique sans verrou et thread-safe utilisant les concepts de la mémoire transactionnelle logicielle.

Nous avons testé le comportement de la logique mise en œuvre et constaté que la logique utilisant le STM ne comportait aucune impasse.

Vous trouverez la mise en œuvre de tous ces exemples et extraits de code dans le projet GitHub - il s’agit d’un projet Maven, il devrait donc être facile à importer et à courir comme il est.