軟體事務記憶體導論(一)前言
宣告:本文是《Java虛擬機器併發程式設計》的第六章,感謝華章出版社授權併發程式設計網站釋出此文,禁止以任何形式轉載此文。
請回憶一下你最近完成那個需要對共享可變變數進行同步的專案。在那個專案中,你肯定無法身心愉悅地享受出色地完成工作所帶來的樂趣,而是會陷入無盡的質疑之中並抓狂地挨個確認是否在所有需要的地方都作了適當的同步。在過去所經歷過的程式設計工作中,我已經遇到過好幾次這樣令人神經衰弱的情況了,而其中絕大部分原因都是由於我們在用Java程式設計的時候沒有遵循正確原則和方法來處理共享可變狀態。如果我們在某個該同步的地方忘了進行同步,那些不可預知的、潛在的災難性的結果就將在不遠處等待著我們。但是人無完人,遺忘是我們的天性。所以我們應該充分利用工具來彌補我們自身的不足,同時也可以讓工具幫助我們實現我們充滿創意的大腦所追求的那些偉大的目標,而不是讓錯誤一次次地打擊我們的信心。為了能夠得到可控的行為和結果,我們需要再次把目光投向JDK。
在本章中,我們將會通過使用Clojure中十分流行的軟體事務記憶體(STM)模型來學習如何執行緒安全地處理共享可變性(shared mutability)。在需要的時候,我們可能會在示例專案中混入Clojure的程式碼。但是我們並非強迫你也要使用Clojure,因為隨著Multiverse和Akka這些優秀工具的出現,我們也可以在Java中直接使用STM了。在本章中,我們會先來看看STM在Clojure裡是什麼樣子,然後再學習如何用Java和Scala對事務記憶體進行程式設計。這種程式設計模型非常適用於那些讀多寫少的程式——它簡單易用並能提供可預測的結果。
1.1 同步與併發水火不容
同步操作本身就存在一些很基本的缺陷。
如果我們沒能處理好或乾脆就忘了進行同步,則某個執行緒所做的更改可能無法被其他執行緒及時感知。此外,為了同時保證可見性並避免競爭條件,我們還需要通過一些很麻煩的手段來進行同步操作。
不幸的是,當我們執行同步操作的時候,同時也強制了其他競爭相同資源的執行緒只能等待。由於同步的粒度對併發度是有很大影響的,所以將同步控制的工作完全交由程式設計師來完將成會大大降低程式整體效率並增加錯誤發生的機率。
同步操作還可能引發很多活躍度方面的問題。由於某個執行緒可能吃著碗裡的看著鍋裡的,所以很容易造成程式死鎖。此外,同步還很容易造成活鎖(livelock)問題,即執行緒可能會在申請某一把鎖的時候不斷遭遇失敗。
當然,我們可以嘗試使用細粒度的鎖來提高程式併發度。雖然一般來說這個主意還不錯,但是其中最大的風險是程式設計師可能沒在合適的層級進行同步動作,因為這太依賴於程式設計師的素質和責任心了。更糟的是,同步出問題的時候我們還收不到任何提示。此外,因為需要互斥訪問的執行緒加了鎖之後還是會阻塞其他執行緒的訪問請求,所以細粒度的鎖只是把執行緒等待的位置換了個地方而已。
熟練掌握JDK併發工具包的Java程式設計師在大城市裡一般都混的不錯。而且由於這麼長時間以來,我們在處理可變狀態的程式設計方面都沒能找到一個比同步更合適的替代產品,所以導致了我們在這方面的預期一直在不斷下降。但是新的程式設計模型終於還是到來了!
1.2 物件模型的缺陷
作為一個Java程式設計師,我們對面向物件的程式設計(OOP)自然都是爛熟於胸的,但語言也極大地影響了我們構建面向物件應用程式的方式。現在的OOP已經和Alan Kay當初創造這個詞時候的初衷大不相同了。他的主要思想是採用訊息傳遞並消滅所有狀態資料(他認為,系統是由一些類似於生物細胞那樣的物件構成的,這些物件通過訊息傳遞進行通訊,且無需持有任何狀態)——見附錄2中《面向物件程式設計的意義》一書。隨著這一技術的演進,面向物件的語言開始朝著通過抽象資料型別(ADTs)來實現資料隱藏(data hiding)的方向發展,並將資料和處理過程繫結或將狀態與行為組合在一起。這在很大程度上引領我們走向封裝和不斷變化的狀態。在這個過程中,我們最終還是把狀態與實體(identity)進行了融合,即把物件例項與其資料整合在一起。
對於Java程式設計師來說,實體與狀態的融合是在潛移默化間悄悄完成的。當我們順著指標或引用找到某個例項的時候,實際上是登入到了持有其狀態的一塊記憶體上,於是在那個位置上操縱資料也就成了自然而然的事了。該位置即代表了物件例項及其所包含的資料。將實體與狀態進行合併最初看起來是非常簡單且易於理解的,但從併發的角度來看,這種做法其實有很多嚴重的不良後果。例如,如果我們需要實現一個列印銀行賬戶詳情(資金數量、當前餘額、交易資訊、最小余額等等)的程式,我們就會碰到很多併發相關的問題。你會發現手頭待處理的引用其實是一個隨時都可能發生變化的狀態的代理。所以當我們檢視賬戶資訊的時候,就需要通過加鎖來阻止其他執行緒對賬戶內容進行修改,而這也必將導致併發度的大幅下降。但問題並不是從加鎖的那一刻才開始出現的,而是在我們把賬戶的實體與其狀態合併的時候就已經存在了。
我們曾經被告知說面向物件的程式設計是對真實世界的建模。但悲催的是,真實世界與OO正規化所試圖構建的模型實際是大相徑庭的。因為在真實的世界中,狀態是不變的,而實體卻是不斷變化的。接下來我們將討論這種說法為何是正確的。
1.3 將實體與狀態分離
你能快速告訴我Google的股價現在是多少嗎?我們當然可以說從證券市場開市的那一刻起股價就是在不斷變化的,但這只不過是一種文字遊戲罷了。舉一個簡單的例子,2010年12月10日Google的收盤價是592美元,並且這個數字已經被載入史冊、是不會再改變了。而我們所要查詢的只是Google股價當時的一個快照。當然,Google今天的股價和那天已經完全不同了。而如果過幾分鐘之後再來檢視Google的股價(假設證券市場是開市的),我們就會看到一個不一樣的值,但之前的那個值其實並沒有改變。從現在開始,我們得改變一下我們對物件的認識,而這也將同時改變我們使用物件的方式。後面我們會看到,把物件的實體與其不可變狀態值進行分離的做法將如何幫助我們實現鎖無關(lock-free)程式設計、提高併發度、同時最大程度地降低競爭。
將實體與狀態分離絕對是一個天才的構想,這是Rich Hickey在其實現Clojure的STM模型過程中所採用的一個非常關鍵的步驟,詳情請見附錄2中的“值與變化—Clojure處理實體和狀態的方法”。假定我們的Google股票物件由兩部分組成:第一部分用於表示該股的實體,其中包含一個指向第二部分的指標。第二部分則包含了該股最新股價,其中儲存股價的變數即為不可變狀態,如圖 6‑1所示。
圖 6‑1 將可變實體部分與不可變狀態值進行分離 |
一旦接收到一個新的股價資訊,我們就可以在不更改任何已存在事務的情況下將其放入歷史價格指數中。由於舊的股價是不可變的,所以我們可以將其共享出去供所有執行緒訪問。正如我們在3.6節中所討論的那樣,如果我們在這裡採用持久化資料結構的話,則Google股票物件就可以多快好省地對外提供資料讀取服務。而一旦有新的資料準備就緒之後,我們可以快速更改實體中的指標,以使其指向儲存新股價的欄位。
實體與狀態分離的做法對於併發來說也是一大福音。因為採用了這種方法之後,我們就可以不用阻塞任何查詢股價的請求了。由於狀態是不會變的,所以我們可以欣然將其指標傳遞給發出查詢請求的執行緒。所有在我們更新實體(內部的指標——譯者注)之後到達的查詢請求都可以看到更新後的股價。我們知道,非阻塞的讀操作即意味著更高的併發度,所以我們只需要確保每個執行緒都能獲得一致的檢視即可。而這其中最棒的是,我們其實什麼也不用做,STM已經幫我們都搞定了。相信你已經迫不及待想要了解更多關於STM的知識了吧?下面就讓我們一起來學習這方面的內容。
(未完待續)