Перейти к основному содержимому

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

· 7 мин. чтения

1. Обзор

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

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

2. Зависимость от Maven

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

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

3. Мультивселенный API

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

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

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

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

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

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

4. Реализация логики аккаунта с помощью STM

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

Допустим, мы хотим создать логику банковского счета, используя STM, предоставляемую библиотекой Multiverse . Наш объект Account будет иметь метку времени lastUpdate типа TxnLong и поле баланса , в котором хранится текущий баланс для данной учетной записи, и тип 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. Тестирование аккаунта

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

@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, поэтому его должно быть легко импортировать и запускать как есть.