Программная транзакционная память в Java с использованием Multiverse

1. Обзор

В этой статье мы рассмотрим библиотеку Multiverse , которая поможет нам реализовать концепцию Software Transactional Memory в Java.

Используя конструкции из этой библиотеки, мы можем создать механизм синхронизации для общего состояния, что является более элегантным и читаемым решением, чем стандартная реализация с базовой библиотекой Java.

2. Maven Dependency

Для начала нам нужно добавить multiverse-core библиотека в нашем pom:

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

3. Multiverse API

Давайте начнем с некоторых основ.

Программная транзакционная память (STM) - это концепт, перенесенный из мира баз данных SQL, где каждая операция выполняется в транзакциях, которые удовлетворяют свойствам ACID (атомарность, согласованность, изоляция, долговечность) . Здесь только атомарность, согласованность и изоляция удовлетворяются, потому что механизм работает в памяти.

  • Основным интерфейсом в библиотеке Multiverse является TxnObject ** - каждый транзакционный объект должен реализовывать его, и библиотека предоставляет нам ряд конкретных подклассов, которые мы можем использовать.

Каждая операция, которую необходимо поместить в критическую секцию, доступную только одному потоку и использующую любой транзакционный объект, должна быть заключена в метод StmUtils.atomic () . Критический раздел - это место программы, которое не может быть выполнено более чем одним потоком одновременно, поэтому доступ к нему должен быть защищен каким-то механизмом синхронизации.

Если действие в транзакции завершается успешно, транзакция будет зафиксирована, и новое состояние будет доступно для других потоков. Если возникает какая-либо ошибка, транзакция не будет зафиксирована, и, следовательно, состояние не изменится.

Наконец, если два потока хотят изменить одно и то же состояние внутри транзакции, только один из них завершится успешно и зафиксирует свои изменения. Следующий поток сможет выполнить свое действие в рамках своей транзакции.

4. Внедрение логики учетной записи с использованием STM

  • Давайте теперь посмотрим на пример ** .

Допустим, мы хотим создать логику банковского счета, используя STM, предоставляемый библиотекой Multiverse . Наш объект Account будет иметь метку времени lastUpadate , которая имеет тип TxnLong , и поле balance , в котором хранится текущий баланс для данной учетной записи, и имеет тип TxnInteger .

TxnLong и TxnInteger являются классами из Multiverse . Они должны быть выполнены в рамках транзакции. В противном случае будет выдано исключение. Нам нужно использовать StmUtils для создания новых экземпляров транзакционных объектов:

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

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

Далее мы создадим метод adjustBy () , который увеличит баланс на заданную величину. Это действие должно быть выполнено в транзакции.

Если внутри него возникнет какое-либо исключение, транзакция завершится без внесения каких-либо изменений:

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

Если мы хотим получить текущий баланс для данной учетной записи, нам нужно получить значение из поля баланса, но оно также должно вызываться с атомарной семантикой:

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

5. Тестирование аккаунта

Давайте проверим нашу логику Account . Во-первых, мы хотим уменьшить остаток со счета на данную сумму просто:

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

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

Далее, допустим, что мы снимаем со счета, делая баланс отрицательным. Это действие должно вызвать исключение и оставить учетную запись нетронутой, поскольку действие было выполнено в транзакции и не было совершено:

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

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

Теперь давайте проверим проблему параллелизма, которая может возникнуть, когда два потока хотят уменьшить баланс одновременно.

Если один поток хочет уменьшить его на 5, а второй на 6, одно из этих двух действий должно завершиться неудачей, потому что текущий баланс данного счета равен 10.

Мы собираемся отправить два потока в ExecutorService и использовать CountDownLatch , чтобы запустить их одновременно:

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

После запуска обоих действий одновременно одно из них выдаст исключение:

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

assertTrue(exceptionThrown.get());

6. Перевод с одного аккаунта на другой

Допустим, мы хотим перевести деньги с одного счета на другой.

Мы можем реализовать метод transferTo () в классе Account , передав другой Account , в который мы хотим перевести данную сумму денег:

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

Вся логика выполняется внутри транзакции. Это гарантирует, что когда мы захотим перевести сумму, превышающую остаток на данном счете, обе учетные записи останутся без изменений, поскольку транзакция не будет зафиксирована.

Давайте проверим логику переноса:

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

a.transferTo(b, 5);

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

Мы просто создаем два аккаунта, переводим деньги с одного на другой, и все работает как положено. Далее, допустим, что мы хотим перевести больше денег, чем доступно на счете. Вызов transferTo () сгенерирует исключение IllegalArgumentException, и изменения не будут зафиксированы:

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

Обратите внимание, что баланс для учетных записей a и b такой же, как и до вызова метода transferTo () .

7. STM безопасен для тупиков

Когда мы используем стандартный механизм синхронизации Java, наша логика может быть подвержена тупикам без возможности восстановления после них.

Блокировка может возникнуть, когда мы хотим перевести деньги со счета a на счет b . В стандартной реализации Java одному потоку необходимо заблокировать учетную запись a , а затем учетную запись b . Допустим, тем временем другой поток хочет перевести деньги со счета b на счет a . Другой поток блокирует учетную запись b , ожидая, что учетная запись a будет разблокирована.

К сожалению, блокировка для учетной записи a удерживается первым потоком, а блокировка для учетной записи b удерживается вторым потоком. Такая ситуация заставит нашу программу блокироваться на неопределенный срок.

К счастью, при реализации логики transferTo () с использованием STM нам не нужно беспокоиться о взаимоблокировках, поскольку STM является безопасным для блокировки. Давайте проверим это, используя наш метод transferTo () .

Допустим, у нас есть две темы. Первый поток хочет перевести деньги со счета a на счет b , а второй поток хочет перевести деньги со счета b на счет a . Нам нужно создать две учетные записи и запустить два потока, которые будут одновременно выполнять метод transferTo () :

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

});

После начала обработки оба счета будут иметь правильное поле баланса:

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

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

8. Заключение

В этом руководстве мы взглянули на библиотеку Multiverse и на то, как мы можем использовать ее для создания логики без блокировок и многопоточной обработки, используя концепции в программной транзакционной памяти.

Мы проверили поведение реализованной логики и увидели, что логика, использующая STM, не имеет тупиков.

Реализация всех этих примеров и фрагментов кода может быть найдена в проекте GitHub - это проект Maven, поэтому его легко импортировать и беги как есть.