1. 程式人生 > >軟體事務記憶體導論(二)軟體事務記憶體

軟體事務記憶體導論(二)軟體事務記憶體

宣告:本文是《Java虛擬機器併發程式設計》的第六章,感謝華章出版社授權併發程式設計網站釋出此文,禁止以任何形式轉載此文。

1.1    軟體事務記憶體

將實體與狀態分離的做法有助於STM(軟體事務記憶體)解決與同步相關的兩大主要問題:跨越記憶體柵欄和避免競爭條件。讓我們先來看一下在Clojure上下文中的STM是什麼樣子,然後再在Java裡面使用它。

通過將對記憶體的訪問封裝在事務(transactions)中,Clojure消除了記憶體同步過程中我們易犯的那些錯誤(見《Programming Clojure》[Hal09]和《The Joy of Clojure》[FH11])。Clojure會敏銳地觀察和協調執行緒的所有活動。如果沒有任何衝突——例如,每個執行緒都在存取不同賬戶——則整個過程中就不會涉及到任何鎖,於是也就不會有延遲,並最終達到最大的併發度。當有兩個執行緒試圖訪問相同資料時,事務管理器就會介入解決衝突,而我們的程式碼也就無需涉及任何顯式加鎖的操作。下面讓我們一起研究一下這套系統是如何運作的。

在設計上,值(values)是不可變的,而實體(identities)也僅在Clojure的事務中是可變的。在Clojure中,壓根就沒有改變狀態的方法,也沒有任何相關的程式設計工具可用。而如果出現任何試圖在事務之外改變實體的動作時,系統就會丟擲illegalStateException異常。換句話說,一旦與事務進行了繫結,在沒有衝突時,所有變更都是即時生效的;而一旦發生衝突,Clojure將會自動將事務回滾並重試。我們程式設計師的主要職責是保證事務中的程式碼都是冪等的——這是我們在函數語言程式設計中避免副作用的常用手段,而這種手段在Clojure的程式設計模型中也同樣適用。

是時候該看一個Clojure STM的例子了。我們可以用ref在Clojure中建立多個可變的實體,其中每個ref都提供了對於其表示的不可變狀態的實體的可協調同步變更

[1]。下面就讓我們建立一個ref並嘗試改變它。

(def balance(ref 0))

(println "Balance is "@balance)

(ref-set balance 100)

(println "Balance is now" @balance)

在上面的程式碼中,我們定義了一個名為balance的變數並用ref將其標記為可變的,此時balance就表示了一個帶有不可變數值0的可變實體。然後我們將該實體的當前值列印了出來。接著我們會通過ref-set命令來嘗試修改balance的值。如果該操作成功,我們就可以通過最後一條列印語句看到balance的新值。下面就讓我們來看看這段程式碼的執行結果。我是用clj mutable.clj來執行這個指令碼的,關於如何在你的系統上安裝和執行Clojure指令碼請參閱Clojure的文件。

“Clojure還提供了一些我在本書中未能覆蓋的其他併發模型,如Agents,Atoms和Vars。更多資訊請參閱Clojure的官方文件和相關書籍。”

Balance is 0
Ecception in thread "main"
   java.lang.IllegalStateException : No transaction running(mutate.clj:0)
...

從結果來看,由於控制檯正常打印出了實體balance的值0,因此前兩行程式碼應該是沒問題的。由於我們在事務之外更改了變數的值,以至於惹惱了Clojure之神,所以在執行到第三行的時候系統拋了IlligalStateException異常。當我們在沒進行同步就更改共享可變變數的時候,與Java裡那些人神共憤的行為相比,Clojure丟擲的這種清晰明確的失敗簡直是程式設計師的福音。這對於程式開發來說是一個相當大的改進,因為我們寧願程式碼要麼正確要麼明確地丟擲錯誤,而不是默默地產生一些不可預測的結果。問題進行到這裡,原本我還想邀你慶祝一下我們獲得了這麼好的特性呢,不過看你的樣子可能更希望先修復一下Clojure STM所丟擲的那個錯誤,所以讓我們繼續往下看。

在Clojure中建立一個事務是很容易的,只需要用一個dosync呼叫把程式碼段包裝起來就行了。在結構上,這種做法十分類似於Java中用synchronized修飾程式碼段的做法,但二者其實還是有不少區別的:

  • 如果我們忘記了為需要同步的程式碼段加上dosync的話,系統會對此予以清晰的警告
  • dosync並沒有建立任何互斥鎖,而是通過將程式碼段用一個事務包裝起來的方式與其他執行緒進行公平競爭
  • 由於並未使用任何顯式的鎖,所以我們可以無須擔心加鎖順序並享受這種不會死鎖的併發所帶來的好處
  • STM提供了一套簡單的執行時事務組合鎖,所以我們無需在程式設計期間預先考慮諸如“誰用什麼順序鎖住了什麼”這樣的問題
  • 不使用顯式的鎖就意味著程式設計師無須再使用保守的互斥程式碼塊了(即synchronized程式碼塊——譯者注)。於是程式整體的併發度可以得到最大程度的開發,並且其效果只受程式行為和資料訪問方式的影響。

當一個事務執行起來之後,如果沒有與之衝突的執行緒/事務,則該事務一定可以完成,並且其所做的更改可以被寫到記憶體中。然而,一旦發現之前啟動的其他事務可能會干擾到本事務執行的時候,Clojure就會自動將之前所做的變更進行回滾並重做本事務。在對程式碼經過下面的修正之後,我們就可以成功地更改balance實體的值了。

(def balance(ref 0))

(println "Balance is "@balance)

(dosync
   (ref-set balance 100))

(println "Balance is now"@balance)

這段程式碼較之上一版本唯一的變化就是將ref-set用dosync包裹起來。當然,dosync的作用域並不僅僅侷限於單條語句,而是可以覆蓋整個程式碼塊或同一事務中的多個表示式。下面讓我們執行這段程式碼並觀察其結果。

Balance is 0
Balance is now 100

我們知道,balance的狀態值(value)“0”是不可變的,而balance本身則是一個可變實體。在dosync作用域內所形成的這個事務中,我們先是建立了一個值100的常量,然後修改balance令其指向這個常量(我們要逐步習慣接受常量的概念)。但此時舊的常量0依然還在,balance也正指向該常量,所以我們需要在新的常量(100)建立完成之後,立即告知Clojure修改balance內部的指標,使其指向該常量。如果後面不再有任何引用指向舊常量“0”,則垃圾回收器會負責將其回收掉。

  • Clojure提供了3種改變可變實體的方法,並且所有這三種方法都只能被封裝在事務中才能使用:
  • ref-set命令可以設定實體的值並在操作完成後返回該值。
  • alter命令能夠將實體在事務中的值設定成某特定函式的返回值,並在操作完成後返回該值。使用該命令是改變一個可變實體值的最佳方法。

commute命令與alter命令功能類似,二者的主要區別是commute會將提交點(commit point)與事務中所發生的變化分離開來。該命令的主要功能也是將實體在事務中的值設定成某特定函式的返回值,並在操作完成後返回該值。當代碼執行至提交點的時候,只有最後一個對實體的變更操作才能最終生效,而中間值都將被忽略。

commute是一個非常好用的命令,尤其是我們遇到類似“誰留到最後誰是贏家”這類應用的時候,其併發度要大大高於alter命令。但在除此之外的大多數情況下,alter都是要比commute更合適的。

除了ref命令簇之外,Clojure還提供了能夠同步地更改資料的atom命令簇。與ref命令簇所不同的是,由atom命令簇所作出的變更是不接受調整的,並且同一事務下用atom所做的變更也不能和其他變更組合在一起。究其本質,是因為atom命令簇其實並不屬於事務內的操作(我們可以將每個atom變更看作是一個獨立的事務)。為了清晰起見,離散的變更最好用atom命令簇,而對於多個需要組合或協調的變更則最好使用ref命令簇。

1.2    STM中的事務

相信你之前一定在資料庫中使用過事務,所以對原子性、一致性、隔離性和永續性這些事務的基本屬性應該非常熟悉了。Clojure的STM對事務的支援與資料庫有所不同,由於STM中的資料是全都放在記憶體而不是資料庫或檔案系統裡的,所以STM只提供了事務的前三個屬性,而缺少了對永續性的支援。

原子性:STM事務是原子的。即我們在一個事務中所做的變更要麼對所有其他外部事務可見,要麼完全不可見。更具體一些,就是一個事務中所有ref的變更要麼都生效,要麼都不生效。

一致性:是指事務應該要麼執行完成並令外界看到其造成的變化,要麼執行失敗並使所有相關資料都保持原狀。如果有多個事務同時執行,那麼從這些事務之外的角度來進行觀察,我們可以看到它們所造成的變化始終是一個接著一個發生的,中間不會有任何交叉。例如,在(對同一個賬戶——譯者注)兩個獨立且併發的存款和取款事務完成之後,賬戶餘額應該是兩個操作所產生的累加效果(取錢是對賬戶加上一個負數——譯者注)。

隔離性:本事務無法看到其他事務的區域性變更結果,即事務所造成的變更只能在其成功完成後才對外可見。

我們可以看到,這些屬性都是側重於資料的完整性和可見性的。其中,隔離性並不意味著事務之間就不能進行協調了。相反地,STM會密切監控所有事務的進展情況並努力使所有事務都能跑完(除非遇到由應用程式產生的異常)。

Clojure的STM採用了與資料庫相似的多版本併發控制技術(MVCC),其併發控制也和資料庫中的樂觀鎖(optimistic locking)很像。當我們啟動一個事務的時候,STM會記錄一下時間戳,並將事務中將會用到所有ref都拷貝一份。由於狀態是不可變的,所以對於ref的拷貝是多快好省的。當對某個不可變狀態進行“變更”的時候,我們其實並沒有改變它的值(value),而是為其建立了一個含有新值的拷貝。該拷貝是本事務的一個內部狀態,並且由於我們使用了持久化的資料結構(見3.6節),這一步也是多快好省的。而如果STM識別出我們操作過的ref已經被別的事務改了的話,它就會中止並重做本事務。當事務成功完成時,所有的變更都會被寫入記憶體,而時間戳也將被更新(見圖 6‑1)。

1.3    用STM實現併發

用事務來實現併發自然是極好的,但如果兩個事務都試圖更改同一實體的話會是什麼狀況呢?放心,我不會讓你等很久的,本節我們將研究幾個關於這方面問題的例子。

在進入例項研究之前我有句話要提醒你:由於事務可能被重複執行多次,所以在寫程式碼的時候請務必確保事務是冪等的並且沒有任何副作用。這意味著在事務中控制檯不能有任何輸出、不能打日誌、不能發郵件、也不能做任何不可逆操作。如果違背了上述任何一點,我們只能後果自負。一般而言,我們最好是把這些有副作用的操作收攏起來,在事務完成之後再統一執行它們。

在示例程式碼中,我並沒有完全遵循上述警告,所以你將會在程式碼中看到列印語句。但這純粹只是為了演示的需要才加進去的,請千萬不要在辦公室這麼寫程式碼!

通過之前的例子,我們已經知道了如何在一個事務中更改balance的值。現在讓我們寫一個多事務競爭更改balance的程式:

(defn deposit [balance amout])
  (dosync
    (println "Ready to deposit..." amout)
    (let [current-balance @balance]
      (println "simulating delay in deposit...")
      (. Thread sleep 2000)
      (alter balance + amout)
      (println "done with deposit of" amount))))

(defn withdraw [balance amount])
  (dosync
   (println "Ready to withdraw..." amout)
   (let [current-balance @balance]
    (println "simulating delay in withdraw...")
      (. Thread sleep 2000)
      (alter balance - amout)
      (println "done with withdraw of" amount))))

(def balance(ref 100))

(println "Balancel is" @balancel)

(future (deposit balance1 20))
(future (withdraw balance1 10))

(. Thread sleep 10000)

(println "Balancel now is" @balance1)

本例中我們建立了兩個事務,分別用於存款和取款。在deposit()函式中,我們先把balance複製了一份,然後插入一個2秒的延時以便模擬事務衝突的環境。在延時結束之後,我們將當前餘額與待存入錢數(amount的值)進行累加從而得到存入後的餘額。withdraw()函式的實現與deposit()十分類似,唯一區別就是需要用當前餘額減去取款金額。這兩個方法之後的程式碼都是用於測試執行結果用的:我們首先將變數balance1的初始值設為100,然後用future()函式分別啟動兩個獨立的執行緒來執行上述兩個函式。下面讓我們觀察一下程式的輸出結果:

Balance1 is 100
Ready to deposit... 20
simulating delay in deposit...
Ready to withdraw...10
simulating delay in withdraw...
done with withdraw of 20
Ready to withdraw...10
simulating delay in withdraw...
done with withdraw of 10
Balancel now is 110

deposit()函式和withdraw()函式都持有一個balance的本地拷貝。當模擬延時結束之後,deposit()事務也隨即很快執行完畢,但withdraw()函式就沒那麼幸運了。因為withdraw()函式從延時中返回之後發現balance的值已經發生了變化,這意味著其本地拷貝已經失效,所以後面的動作再進行下去也沒意義了,於是Clojure的STM迅速終止並重做了該事務。事務程式碼中的列印語句為我們理解STM的運作機制提供了很大的幫助,如果沒有那些列印語句,我們就不會注意到這些細節。除了STM運作細節之外,本例中我們最值得注意的地方是在兩個事務競爭執行的環境下,balance保持了一致性,其值正確地反映了存取款動作對其造成的影響。

本例中,我們通過讀取餘額並延遲後續操作執行的方式故意將兩個事務置於衝突環境下。但如果把let語句都去掉,我們就會發現兩個事務都能在保持一致性的前提下無重做地完成。這一現象向我們充分展示了STM既能保持一致性又可以提供最大程度併發性的能力。

通過上面的例子我們已經知道如何更改一個簡單變數,但如果我們有一堆變數時該怎麼辦呢?Clojure裡面的list是不可變的,但我們可以通過一個能改變其實體指向的可變引用來模擬List變更的行為。在這種方式下,我們只是簡單地改變了list的檢視,list本身則並沒有改變。下面就讓我們通過一個例子來看一下具體如何運作。我的家庭夢想表單裡原本只有一個iPad,現在我想往裡面再新增一個新的MacBook Pro(MBP)和一個我兒子想要的新自行車進去,於是我就通過兩個執行緒分別把這兩個心願新增到表單裡。下面就是相關的實現程式碼:

(defn add-item [withlist item])
  (dosyn (alert wishlist conj item))

(def fammily-wishlist (ref '("ipad")))
(def original-wishlist @family-wishlist)

(print "Original wish list is " original-wishlist)

(futrue (addItem family-wishlist "MBP"))
(futrue (addItem family-wishlist "Bike"))

(. Thread sleep 1000)

(print "Original wish list is" original-wishlist)
(print "Update wish list is" @family-wishlist)

addItem()函式的功能是將給定心願項新增到心願單裡,其中alter方法的功能是用給定的函式(在本例中特指conj()函式)來更改事務內的ref變數。而conj()函式則可以返回一個新的集合,該集合是待加入項與原集合的並集。隨後我們在兩個不同的執行緒中呼叫了addItem()函式,並在最後將結果打印出來。下面就是程式碼的執行結果:

Original wish list is (iPad)
Original wish list is (iPad)
Update wish list is (Bike MBP iPad)

原始的心願單是不可變的,所以從程式碼結尾的輸出來看它仍然保持不變。當我們向心願單新增新專案時,本來是應該將原始心願單資料複製一份來用的。但是由於採用了持久化的資料結構,我們就可以通過共享心願項的方式兼顧記憶體使用與效能,其實現方式如圖 6‑2所示。

 

圖 6‑2 向不可變的心願單中“新增”心願項

心願單的狀態與originalWishList引用都是不可變的,而familyWishList則是一個可變的引用。所有新增心願項的請求都是在各自獨立的事物中執行的。第一個完成新增的事務更改了familyWishList,使其指向新的心願單(如圖 6‑2中(2)所示)。與此同時,由於心願單本身是不可變的,所以新的心願單可以與原始的心願單共享“iPad”這一項。當第二個新增心願的事務完成時,新的心願單同樣可以與之前的心願單共享前兩個心願項(如圖 6‑2中(3)所示)。

處理寫偏斜異常(write sskew anomaly

前面我們已經學習了STM是如何處理事務間寫衝突的,但有些時候衝突並非如此顯而易見。假設我們在某銀行有一個支票賬戶和一個儲蓄賬戶,且銀行規定兩個賬戶的最小總餘額不得低於$1000,而此時兩個賬戶的餘額分別為$500和$600。根據銀行的規定可知,我們現在只能從其中一個賬戶中取$100。如果我們按順序分別從兩個賬戶取$100,那麼第一個請求會成功而第二個請求則會失敗。如果兩個取款請求是併發執行的,那麼由於所謂寫偏斜異常的存在,兩個事務都能夠順利完成--兩個取款事務看到的總餘額都超過了$1000,同時二者更改的又是不同的變數,所以根本不存在寫衝突的問題。其造成的結果是,賬戶總餘額最終為$900,低於銀行設定的最低限額。下面讓我們用程式碼構建出這種異常,然後再研究如何解決這個問題。

(def  checking-balance (ref 500))

(def  savings-balance (ref 600))

(defn withdraw-account [from balance constraining-balance amout]

  (dosyn

  (let [total-balamce([email protected] @constrainning - balance)]

    (. Thread sleep 1000)

    (if (>=(- total -balance  amount ) 1000)

     (alert  from-balance -amout )

     (println "Sorry,can,t withdraw due to constraint violation")))))

(println "checking-balance is" @checking-balance)
(println "savings-balance is" @savings-balance)
(println "Total balance is" ([email protected] @savings-balance))

(future (Withdraw-account checking-balance savings-balance 100))
(future (Withdraw-account saving-balance checking-balance 100))

(. Thread sleep 1000)

(println "checking-balance is" @checking-balance)
(println "savings-balance is" @savings-balance)
(println "Total balance is" ([email protected] @savings-balance))

程式碼的前兩行是對賬戶餘額進行賦初始值。在withdraw-account()函式中,我們會讀取兩個賬戶的餘額並將二者相加得到總餘額(total-balance)。為了製造事務衝突的環境,我們在計算餘額之後會人為地插入一個延時。隨後,只要在減去取款金額之後兩個賬戶的總餘額不低於銀行的最小限額的話,我們就會更新from-balance所代表的那個賬戶的餘額。在餘下的程式碼裡,我們併發地執行了兩個取款的事務,其中第一個事務從支票賬戶中取了$100而第二個事務則從儲蓄賬戶中取了$100。正如我們在程式的輸出結果中所看到的那樣,由於兩個事務是分別獨立執行且二者沒有任何交集,所以它們無法意識到彼此已經陷入到寫偏斜並使最終結果違反了銀行的規定。

checking-balance is 500
savings-balance is 600
Total balance is 1100
checking-balance is 400
savings-balance is 500
Total balance is 900

在Clojure中,我們可以通過ensure()函式很容易地避免寫偏斜問題。通過該方法,我們可以告訴事務要睜大眼睛盯著某個本事務只讀不改的變數。這樣一來,STM就可以確保只有在我們盯著的這個變數沒有在本事務外被修改的情況下,本事務的寫操作才能被提交;或者一旦盯著的那個變數有改變,則STM要重做本事務。

根據上述思路,讓我們修改一下withdraw-account()函式:

(defn withdraw-account [from-balance constrainting-balance amount]
  (dosync
    (let [total-balace (+ @from-balance (ensure constrainting-balance ))]
    (. Thread sleep 1000)
    (if (>= (-total-balace -amount))
    (println "Sorry,can't withdraw due to constraint violation")))))

在程式碼第3行,我們呼叫ensure()函式來監控constraining-balance這個本事務只讀不改的變數的值。事實上,STM在這行程式碼中對constraining-balance變數加了一個讀鎖,其目的是為了阻止其他事務獲得該變數的寫鎖。在本事務臨近結束的時候,STM會在執行提交動作之前釋放所有的讀鎖,這樣就可以在併發度增加的時候避免死鎖的發生。

正如我們在下面輸出結果中所看到的那樣,即使我們像剛才一樣併發執行兩個取款事務,但由於我們在withdraw-account()函式裡呼叫了ensure(),所以銀行對兩個帳戶的最小總餘額限制仍然能夠得以保持。

checking-balance is 500
savings-balance is 600
Total balance is 1100
checking-balance is 400
savings-balance is 500
Total balance is 1000
Sorry,can't withdraw due to constraint violation

STM的顯式鎖無關執行模型是相當強大的。如果事務間沒有衝突,那就不會有任何阻塞發生。而一旦存在衝突,則至少有一個事務可以無障礙地執行下去,而其他競爭者則需要重做。對於需要重做的事務,Clojure設定了一個最大重做次數,並能夠保證兩個執行緒不會因為重做節奏相同而導致反覆衝突重做。當我們的業務模型是讀多、寫衝突非常少的情況時,STM的執行模型就更能體現其優勢。例如,該模型非常適合於傳統的網路應用——通常情況是,多個使用者併發地更新他們各自的資料,使用者之間的共享狀態衝突非常少,而這些少量的寫衝突則可以通過STM來輕鬆處理掉。

由於能夠解決如此多令人頭痛的併發問題,Clojure的STM可以稱得上是併發程式設計世界裡的阿司匹林了。如果忘了建立事務,我們就會被系統嚴厲地斥責(指拋異常——譯者注)。而與之相反的是,只需簡單地把dosync放到正確的位置上,系統就能夠回饋給我們多執行緒環境下的高併發和一致性。如此低的使用門檻,簡潔、明確以及可預測的行為都使得Clojure STM成為我們開發併發應用時一個非常值得認真考慮的選擇。

[1] Clojure還提供了一些我在本書中未能覆蓋的其他併發模型,如Agents,Atoms和Vars。更多資訊請參閱Clojure的官方文件和相關書籍。


方 騰飛

花名清英,併發網(ifeve.com)創始人,暢銷書《Java併發程式設計的藝術》作者,螞蟻金服技術專家。目前工作於支付寶微貸事業部,關注網際網路金融,併發程式設計和敏捷實踐。微信公眾號aliqinying。