Multiverseを使用したJavaのソフトウェアトランザクショナルメモリ
1. 概要
この記事では、マルチバースライブラリを見ていきます。これは、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(Atomicity、Consistency、Isolation、Durability)プロパティを満たすトランザクション内で実行されます。 ここでは、メカニズムがメモリ内で実行されるため、 Atomicity、Consistency、Isolationのみが満たされます。
MultiverseライブラリのメインインターフェイスはTxnObject です。各トランザクションオブジェクトはそれを実装する必要があり、ライブラリは使用できる特定のサブクラスをいくつか提供します。
クリティカルセクション内に配置する必要があり、1つのスレッドのみがアクセスでき、トランザクションオブジェクトを使用する必要がある各操作は、 StmUtils.atomic()メソッド内でラップする必要があります。 クリティカルセクションは、複数のスレッドで同時に実行できないプログラムの場所であるため、そのセクションへのアクセスは、何らかの同期メカニズムによって保護する必要があります。
トランザクション内のアクションが成功すると、トランザクションがコミットされ、他のスレッドが新しい状態にアクセスできるようになります。 エラーが発生した場合、トランザクションはコミットされないため、状態は変更されません。
最後に、2つのスレッドがトランザクション内の同じ状態を変更する場合、1つだけが成功し、その変更をコミットします。 次のスレッドは、トランザクション内でアクションを実行できるようになります。
4. STMを使用したアカウントロジックの実装
例を見てみましょう。
Multiverseライブラリが提供するSTMを使用して銀行口座ロジックを作成するとします。 アカウントオブジェクトには、TxnLongタイプのlastUpadateタイムスタンプと、特定の現在の残高を格納する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. アカウントのテスト
アカウントロジックをテストしてみましょう。 まず、アカウントの残高を指定された金額だけ単純にデクリメントします。
@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);
}
次に、2つのスレッドが同時にバランスをデクリメントしたい場合に発生する可能性のある同時実行の問題をテストしてみましょう。
1つのスレッドが5ずつ、2番目のスレッドが6ずつデクリメントしたい場合、指定されたアカウントの現在の残高は10に等しいため、これら2つのアクションの1つは失敗するはずです。
2つのスレッドを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);
}
});
両方のアクションを同時に見つめた後、そのうちの1つは例外をスローします。
countDownLatch.countDown();
ex.awaitTermination(1, TimeUnit.SECONDS);
ex.shutdown();
assertTrue(exceptionThrown.get());
6. あるアカウントから別のアカウントへの転送
ある口座から別の口座に送金したいとしましょう。 アカウントクラスにtransferTo()メソッドを実装するには、指定された金額を送金する他のアカウントを渡します。
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);
2つのアカウントを作成し、一方から他方に送金するだけで、すべてが期待どおりに機能します。 次に、アカウントで利用できるよりも多くのお金を送金したいとします。 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実装では、1つのスレッドがアカウント a をロックしてから、アカウントbをロックする必要があります。 その間に、他のスレッドがアカウントbからアカウントaに送金したいとします。 他のスレッドはアカウントbをロックし、アカウントaのロックが解除されるのを待っています。
残念ながら、アカウント a のロックは最初のスレッドによって保持され、アカウントbのロックは2番目のスレッドによって保持されます。 このような状況では、プログラムが無期限にブロックされます。
幸い、STMを使用して transferTo()ロジックを実装する場合、STMはデッドロックセーフであるため、デッドロックについて心配する必要はありません。 transferTo()メソッドを使用してテストしてみましょう。
2つのスレッドがあるとしましょう。 最初のスレッドはアカウントaからアカウントbに送金したいと考えており、2番目のスレッドはアカウントbからアカウントaに送金したいと考えています。 。 2つのアカウントを作成し、 transferTo()メソッドを同時に実行する2つのスレッドを開始する必要があります。
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. 結論
このチュートリアルでは、マルチバースライブラリと、それを使用してソフトウェアトランザクショナルメモリの概念を利用してロックフリーでスレッドセーフなロジックを作成する方法について説明しました。
実装されたロジックの動作をテストし、STMを使用するロジックにデッドロックがないことを確認しました。
これらすべての例とコードスニペットの実装は、 GitHubプロジェクトにあります。これはMavenプロジェクトであるため、そのままインポートして実行するのは簡単です。