Memoria transazionale software in Java utilizzando Multiverse

1. Panoramica

In questo articolo, esamineremo la libreria Multiverse , che ci aiuta a implementare il concetto di Software Transactional Memory in Java.

Utilizzando i costrutti di questa libreria, possiamo creare un meccanismo di sincronizzazione sullo stato condiviso, che è una soluzione più elegante e leggibile rispetto all'implementazione standard con la libreria principale Java.

2. Dipendenza da Maven

Per iniziare avremo bisogno di aggiungere la libreria multiverse-core nel nostro pom:

 org.multiverse multiverse-core 0.7.0 

3. API Multiverse

Cominciamo con alcune delle basi.

Software Transactional Memory (STM) è un concetto portato dal mondo del database SQL, in cui ogni operazione viene eseguita all'interno di transazioni che soddisfano le proprietà ACID (Atomicity, Consistency, Isolation, Durability) . Qui, solo Atomicity, Consistency e Isolation sono soddisfatti perché il meccanismo viene eseguito in memoria.

L'interfaccia principale nella libreria Multiverse è TxnObject : ogni oggetto transazionale deve implementarlo e la libreria ci fornisce un numero di sottoclassi specifiche che possiamo utilizzare.

Ogni operazione che deve essere inserita in una sezione critica, accessibile da un solo thread e utilizzando qualsiasi oggetto transazionale, deve essere inserita nel metodo StmUtils.atomic () . Una sezione critica è un luogo di un programma che non può essere eseguito da più di un thread contemporaneamente, quindi l'accesso dovrebbe essere protetto da un meccanismo di sincronizzazione.

Se un'azione all'interno di una transazione ha esito positivo, verrà eseguito il commit della transazione e il nuovo stato sarà accessibile ad altri thread. Se si verifica un errore, la transazione non verrà salvata e quindi lo stato non cambierà.

Infine, se due thread vogliono modificare lo stesso stato all'interno di una transazione, solo uno avrà successo e eseguirà il commit delle sue modifiche. Il thread successivo sarà in grado di eseguire la sua azione all'interno della sua transazione.

4. Implementazione della logica dell'account tramite STM

Vediamo ora un esempio .

Supponiamo di voler creare una logica di conto bancario utilizzando STM fornito dalla libreria Multiverse . Il nostro oggetto Account avrà il timestamp lastUpadate che è di tipo TxnLong e il campo balance che memorizza il saldo corrente per un dato account ed è di tipo TxnInteger .

Il TxnLong e TxnInteger sono classi del Multiverso . Devono essere eseguiti all'interno di una transazione. In caso contrario, verrà generata un'eccezione. Dobbiamo usare StmUtils per creare nuove istanze degli oggetti transazionali:

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

Successivamente, creeremo il metodo AdjustBy () , che incrementerà il saldo dell'importo specificato. Quell'azione deve essere eseguita all'interno di una transazione.

Se viene generata un'eccezione al suo interno, la transazione terminerà senza eseguire alcuna modifica:

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 vogliamo ottenere il saldo corrente per il dato account, dobbiamo ottenere il valore dal campo balance, ma deve anche essere richiamato con la semantica atomica:

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

5. Verifica dell'account

Testiamo la nostra logica dell'account . Innanzitutto, vogliamo decrementare il saldo dal conto dell'importo dato semplicemente:

@Test public void givenAccount_whenDecrement_thenShouldReturnProperValue() { Account a = new Account(10); a.adjustBy(-5); assertThat(a.getBalance()).isEqualTo(5); }

Quindi, diciamo che ritiriamo dal conto rendendo il saldo negativo. Quell'azione dovrebbe generare un'eccezione e lasciare intatto l'account perché l'azione è stata eseguita all'interno di una transazione e non è stata confermata:

@Test(expected = IllegalArgumentException.class) public void givenAccount_whenDecrementTooMuch_thenShouldThrow() { // given Account a = new Account(10); // when a.adjustBy(-11); } 

Proviamo ora a testare un problema di concorrenza che può sorgere quando due thread vogliono diminuire un equilibrio contemporaneamente.

Se un thread vuole diminuirlo di 5 e il secondo di 6, una di queste due azioni dovrebbe fallire perché il saldo corrente del conto dato è uguale a 10.

Invieremo due thread a ExecutorService e utilizzeremo CountDownLatch per avviarli allo stesso 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); } });

Dopo aver fissato entrambe le azioni contemporaneamente, una di esse genererà un'eccezione:

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

6. Trasferimento da un account a un altro

Diciamo che vogliamo trasferire denaro da un conto all'altro. Possiamo implementare il metodo transferTo () sulla classe Account passando l'altro Account a cui vogliamo trasferire la data quantità di denaro:

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

Tutta la logica viene eseguita all'interno di una transazione. Ciò garantirà che quando vogliamo trasferire un importo superiore al saldo del conto dato, entrambi i conti saranno intatti perché la transazione non verrà impegnata.

Proviamo la logica di trasferimento:

Account a = new Account(10); Account b = new Account(10); a.transferTo(b, 5); assertThat(a.getBalance()).isEqualTo(5); assertThat(b.getBalance()).isEqualTo(15);

Creiamo semplicemente due account, trasferiamo il denaro da uno all'altro e tutto funziona come previsto. Quindi, supponiamo di voler trasferire più denaro di quello disponibile sul conto. La chiamata transferTo () genererà IllegalArgumentException e le modifiche non verranno salvate :

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

Nota che il saldo per entrambi i conti a e b è lo stesso di prima della chiamata al metodo transferTo () .

7. STM è deadlock safe

Quando utilizziamo il meccanismo di sincronizzazione Java standard, la nostra logica può essere soggetta a deadlock, senza alcun modo per ripristinarli.

Il deadlock può verificarsi quando si desidera trasferire il denaro dal conto a al conto b . Nell'implementazione Java standard, un thread deve bloccare l'account a , quindi l'account b . Diciamo che, nel frattempo, l'altro thread vuole trasferire i soldi dal conto b al conto a . Le altre serrature filo rappresentano b in attesa di un account di essere sbloccato.

Sfortunatamente, il blocco per un account a è mantenuto dal primo thread e il blocco per l'account b è mantenuto dal secondo thread. Tale situazione causerà il blocco del nostro programma a tempo indeterminato.

Fortunatamente, quando si implementa la logica transferTo () utilizzando STM, non è necessario preoccuparsi dei deadlock poiché STM è Deadlock Safe. Proviamolo usando il nostro metodo transferTo () .

Diciamo che abbiamo due thread. Il primo thread vuole trasferire del denaro dal conto a al conto b , e il secondo thread vuole trasferire del denaro dal conto b al conto a . Dobbiamo creare due account e avviare due thread che eseguiranno il metodo transferTo () nello stesso 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); });

Dopo aver avviato l'elaborazione, entrambi gli account avranno il campo saldo corretto:

countDownLatch.countDown(); ex.awaitTermination(1, TimeUnit.SECONDS); ex.shutdown(); assertThat(a.getBalance()).isEqualTo(1); assertThat(b.getBalance()).isEqualTo(19);

8. Conclusione

In questo tutorial, abbiamo esaminato la libreria Multiverse e come possiamo usarla per creare logica priva di lock e thread-safe utilizzando i concetti della Software Transactional Memory.

Abbiamo testato il comportamento della logica implementata e abbiamo visto che la logica che utilizza STM è priva di deadlock.

L'implementazione di tutti questi esempi e frammenti di codice può essere trovata nel progetto GitHub: questo è un progetto Maven, quindi dovrebbe essere facile da importare ed eseguire così com'è.