軟體事務記憶體導論(五)建立巢狀事務
宣告:本文是《Java虛擬機器併發程式設計》的第六章,感謝華章出版社授權併發程式設計網站釋出此文,禁止以任何形式轉載此文。
1.1 建立巢狀事務
在之前的示例中,每個用到事務的方法都是各自在其內部單獨建立事務,並且事務所涉及的變動也都是各自獨立提交的。但如果我們想要將多個方法裡的事務調整成一個統一的原子操作的時候,上述做法就無能為力了,所以我們需要使用巢狀事務來實現這一目標。
通過使用巢狀事務,所有被主控函式呼叫的那些函式所建立的事務都會預設被整合到主控函式的事務中。除此之外,Akka/Multiverse還提供了很多其他配置選項,如新隔離事務(new isolated transactions)等。總之,使用了巢狀事務之後,只有位於最外層的主控函式事務提交時,其內部所做的變更才會被提交。在具體使用時,為了保證所有巢狀事務能夠作為一個整體成功完成,我們需要保證所有函式都必須在一個可配置的超時範圍內做完。
我們在4.6節中通過加鎖方式實現的AccountService的transfer()函式將會受益於巢狀事務。因為這個版本的transfer()函式需要按自然順序對所有賬戶排序並顯式地對鎖進行管理。STM將為我們消除所有這些負擔。下面我們會首先在Java中用巢狀事務重新實現這一示例,然後再來看一下該示例在Scala中是如何實現的。
在Java中使用巢狀事務
現在讓我們開始對Account類進行事務化的改造吧。首先我們需要把儲存賬戶餘額的變數balance改成託管引用,下面我們就來定義這個欄位以及該欄位的getter函式。
public class Account { final private Ref<Integer> balance = new Ref<Integer>(); public Account(int initialBalance) { balance.swap(initialBalance); } public int getBalance() { return balance.get(); }
在建構函式中,我們用Ref的swap()函式將給定的數量設定成balance的初始值。由於swap()函式執行在自己獨立的事務中,所以我們就無需再建立額外的事務了(同時我們假設呼叫者也不會為這個操作建立額外的事務)。getBalance()函式的情況與之類似,就不再贅述了。
由於deposit()函式需要對balance進行先讀後寫的操作,所以該函式內的所有操作需要整體封裝到一個事務裡執行。下面的程式碼為我們展示瞭如何將這兩個操作封裝到一個獨立事務中的方法。
public void deposit(final int amount) { new Atomic<Boolean>() { public Boolean atomically() { System.out.println("Deposit " + amount); if (amount > 0) { balance.swap(balance.get() + amount); return true; } throw new AccountOperationFailedException(); } }.
基於同樣的理由,我們需要把withdraw()函式裡的所有操作也封裝到一個獨立的事務中。
public void withdraw(final int amount) { new Atomic<Boolean>() { public Boolean atomically() { int currentBalance = balance.get(); 112 • Chapter 6. Introduction to Software Transactional Memory if (amount > 0 && currentBalance >= amount) { balance.swap(currentBalance - amount); return true; } throw new AccountOperationFailedException(); } }.execute(); } }
如果執行過程中有異常丟擲,則事務將會強制失敗。所以當賬戶內餘額不足或存款/取款操作輸入了非法引數時,我們就可以利用這一點來表示操作失敗。相當簡單,是吧?從此我們就可以不用再擔心同步、加鎖、死鎖等令人煩惱的問題了。
現在到了該瀏覽一下執行轉賬操作的AccountService類的時候了,讓我們首先來看一下其中的transfer()函式(校注:java中應該叫方法)
public class AccountService { public void transfer( final Account from, final Account to, final int amount) { new Atomic<Boolean>() { public Boolean atomically() { System.out.println("Attempting transfer..."); to.deposit(amount); System.out.println("Simulating a delay in transfer..."); try { Thread.sleep(5000); } catch(Exception ex) {} System.out.println("Uncommitted balance after deposit $" + to.getBalance()); from.withdraw(amount); return true; } }.execute(); }
在這個示例中,我們會將多個事務置於相互衝突的環境中,以此來演示巢狀事務的行為並幫助你加深對巢狀事務的理解。Transfer()函式中的所有操作都是在同一個事務中完成的。作為轉賬過程的一部分,我們首先將錢存到目標賬戶中。緊接著,在經過一個為引入事務衝突而專門設定的延時之後,我們將錢從源賬戶中划走。我們希望當且僅當從源帳戶劃款成功之後,向目標賬戶存款的操作才能夠成功,這也是我們這個事務所要完成的目標。
我們可以通過列印balance的值來觀察轉賬操作是否成功。如果有一個方便的函式來呼叫transfer()函式,處理下異常,並在最後列印一下balance的值就更好了,下面就讓我們動手寫一個吧:
public static void transferAndPrintBalance( final Account from, final Account to, final int amount) { boolean result = true; try { new AccountService().transfer(from, to, amount); } catch(AccountOperationFailedException ex) { result = false; } System.out.println("Result of transfer is " + (result ? "Pass" : "Fail")); System.out.println("From account has $" + from.getBalance()); System.out.println("To account has $" + to.getBalance()); }
最後我們還需要一個main()函式來讓整個示例運轉起來。
public static void main(final String[] args) throws Exception { final Account account1 = new Account(2000); final Account account2 = new Account(100); final ExecutorService service = Executors.newSingleThreadExecutor(); service.submit(new Runnable() { public void run() { try { Thread.sleep(1000); } catch(Exception ex) {} account2.deposit(20); } }); service.shutdown(); transferAndPrintBalance(account1, account2, 500); System.out.println("Making large transfer..."); transferAndPrintBalance(account1, account2, 5000); } }
在main函式中,我們建立了兩個賬戶,並在一個單獨的執行緒中從第二個賬戶裡取走$20。與此同時,我們還啟動了一個在賬戶之間轉賬的事務。由於這些操作都會影響到公共例項(即兩個賬戶——譯者注),所以這種做法將導致兩個事務(存$20的事務和轉賬$500的事務——譯者注)產生衝突。於是只有一個事務能夠順利完成,而另一個將會重做。最後,我們會啟動一個超出源賬戶餘額的轉賬操作,以此來演示存款和取款這兩個相互關聯的事務通過巢狀事務的方式在轉賬過程中實現了原子性的操作。下面讓我們通過輸出結果來觀察事務的行為:
Attempting transfer... Deposit 500 Attempting transfer... Deposit 500 Simulating a delay in transfer... Deposit 20 Uncommitted balance after deposit $600 Attempting transfer... Deposit 500 Simulating a delay in transfer... Uncommitted balance after deposit $620 Result of transfer is Pass From account has $1500 To account has $620 Making large transfer... Attempting transfer... Deposit 5000 Simulating a delay in transfer... Uncommitted balance after deposit $5620 Result of transfer is Fail From account has $1500 To account has $620
輸出結果起始處的重試操作讓人看起來有些摸不著頭腦。這個非預期的重試是由Multiverse對於單個物件上的只讀事務的預設優化造成的。雖然有兩種方法可以重新配置這一行為,但修改了之後可能會對效能造成影響。請參閱Akka/Multiverse文件來進一步瞭解變更這一配置所造成的影響。
在本例中,向帳戶2存$20的操作會先完成。而與此同時,從賬戶1向賬戶2的轉賬事務則處於模擬的延遲當中。當轉賬事務重新恢復執行並察覺到其涉及的物件發生了變化時,該事務將悄悄地回滾並重做。如果事務在執行過程中一直出現內部資料有變化的情況,則該事務會不斷重做直至成功或超時退出為止。本例中的轉賬事務是最終成功了的,帳戶餘額的變化充分地反映了這一結果——賬戶1轉出了$500,而賬戶2則從併發的存款和轉賬操作中總共獲取了$520。
本例的最後一個操作是從賬戶1向賬戶2轉$5000。在這個事務中,存款操作順利完成了,但事務能否最終成功還是要看取款操作的結果。不出所料,取款動作由於賬戶餘額不足而失敗並拋了異常。隨後,之前的存款動作被回滾,系統最終保證了賬戶餘額資料不受事務失敗的影響。
再次宣告,在事務中列印資訊和插入延時都不是好習慣,我在本例中這樣用是為了使你能夠更好地觀察事務的執行順序和重做行為,在實際工作中請最好不要在事務程式碼裡列印訊息或打日誌。請記住,事務是不應該有任何副作用的。如果事務中確實需要包含有副作用的操作,我們可以將這些程式碼放到後面將會提到的後置提交(post-commit)handler裡面去。
我可以拍胸脯向你保證,使用事務絕對可以替你分擔大部分併發程式設計方面的煩惱。下面就讓我們通過一組對比來看看事務到底效用幾何。讓我們回顧一下4.6節中我們用加鎖方式實現的轉賬函式transfer(),為方便起見我將程式碼列在下面:
public boolean transfer( final Account from, final Account to, final int amount) throws LockException, InterruptedException { final Account[] accounts = new Account[] {from, to}; Arrays.sort(accounts); if(accounts[0].monitor.tryLock(1, TimeUnit.SECONDS)) { try { if (accounts[1].monitor.tryLock(1, TimeUnit.SECONDS)) { try { if(from.withdraw(amount)) { to.deposit(amount); return true; } else { return false; } } finally { accounts[1].monitor.unlock(); } } } finally { accounts[0].monitor.unlock(); } } throw new LockException("Unable to acquire locks on the accounts"); }
你可以將上述程式碼與去掉了延時和log輸出的事務版本進行比較:
public void transfer( final Account from, final Account to, final int amount) { new Atomic<Boolean>() { public Boolean atomically() { to.deposit(amount); from.withdraw(amount); return true; } }.execute(); }
舊版本的程式碼既要考慮加鎖的問題又要顧及加鎖的順序,所以很容易出錯。程式碼越多越容易出問題,這是顯而易見的道理。在新版本中,我們顯著地降低了程式碼量和複雜度。這讓我想起了C.A.R.Hoare的名言:“這世界上有兩種構建軟體設計的方法。一種方法是使其足夠簡單以至於不存在明顯的缺陷。而另一種方法是使其足夠複雜以至於無法看出有什麼毛病” 。只有讓程式碼更少、結構更簡單,我們才能將更多的時間投入到程式邏輯的設計開發中去。
在Scala中使用巢狀事務
從上例中我們可以看到,使用了巢狀事務的Java版轉賬函式是非常簡潔的。然而,雖然事務的使用讓我們得以去除Java中那些用於同步的冗餘程式碼,但還是會有一些由於Java語法需要而存在的一些額外程式碼。正如我們下面所看到的那樣,Scala的優雅和強大的表達能力使其在程式碼清晰簡潔方面更勝一籌。下面就是Scala版的Account類:
class Account(val initialBalance : Int) { val balance = Ref(initialBalance) def getBalance() = balance.get() def deposit(amount : Int) = { atomic { println("Deposit " + amount) if(amount > 0) balance.swap(balance.get() + amount) else throw new AccountOperationFailedException() } } def withdraw(amount : Int) = { atomic { val currentBalance = balance.get() if(amount > 0 && currentBalance >= amount) balance.swap(currentBalance - amount) else throw new AccountOperationFailedException() } } }
Scala版本的Account是邏輯直接從Java版本翻譯過來的、但程式碼風格又帶有Scala和Akka簡潔優雅特徵的一種實現。在Scala版本的AccountService中我們也可以看到同樣的優點
object AccountService { def transfer(from : Account, to : Account, amount : Int) = { atomic { println("Attempting transfer...") to.deposit(amount) println("Simulating a delay in transfer...") Thread.sleep(5000) println("Uncommitted balance after deposit $" + to.getBalance()) from.withdraw(amount) } } def transferAndPrintBalance( from : Account, to : Account, amount : Int) = { var result = "Pass" try { AccountService.transfer(from, to, amount) } catch { case ex => result = "Fail" } println("Result of transfer is " + result) println("From account has $" + from.getBalance()) println("To account has $" + to.getBalance()) } def main(args : Array[String]) = { val account1 = new Account(2000) val account2 = new Account(100) actor { Thread.sleep(1000) account2.deposit(20) } transferAndPrintBalance(account1, account2, 500) println("Making large transfer...") transferAndPrintBalance(account1, account2, 5000) } }
與Java版本一樣,Scala版本的AccountService同樣會將事務置於相互衝突的環境之下。所以毫無懸念,其輸出結果也與Java版本完全相同:
Attempting transfer... Deposit 500 Attempting transfer... Deposit 500 Simulating a delay in transfer... 118 • Chapter 6. Introduction to Software Transactional Memory Deposit 20 Uncommitted balance after deposit $600 Attempting transfer... Deposit 500 Simulating a delay in transfer... Uncommitted balance after deposit $620 Result of transfer is Pass From account has $1500 To account has $620 Making large transfer... Attempting transfer... Deposit 5000 Simulating a delay in transfer... Uncommitted balance after deposit $5620 Result of transfer is Fail From account has $1500 To account has $620
前面我們已經比較過用Java實現的加鎖同步版本和巢狀事務版本(如下所示)的轉賬函式
public void transfer( final Account from, final Account to, final int amount) { new Atomic<Boolean>() { public Boolean atomically() { to.deposit(amount); from.withdraw(amount); return true; } }.execute(); }
現在讓我們將之與Scala版本進行一下比較:
def transfer(from : Account, to : Account, amount : Int) = { atomic { to.deposit(amount) from.withdraw(amount) } }
從上面的對比中我們可以清晰地看到,Scala版本的程式碼除了核心邏輯之外沒有任何冗餘。這又讓我想起了Alan Perlis的名言:“如果用某種程式語言寫程式碼時還需要注意一些與核心邏輯無關的東西,那麼這個語言就是低階語言。”
截至目前,我們已經學習瞭如何用Akka建立事務以及如何組合巢狀事務,但我們才剛上路呢。下面我們將一起了解一下在Akka中如何對事務進行配置。