摘要:如今高要求的分散式系統的建造者遇到了不能完全由傳統的面向物件程式設計(OOP)模型解決的挑戰,但這可以從Actor模型中獲益。
為什麼現代系統需要一個新的程式設計模型?Actor模型作為一種高效能網路中的並行處理方式由Carl Hewitt幾十年前提出-高效能網路環境在當時還不可用。如今,硬體和基礎設施的能力已經趕上並超越了Hewitt的願景。因此,高要求的分散式系統的建造者遇到了不能完全由傳統的面向物件程式設計(OOP)模型解決的挑戰,但這可以從Actor模型中獲益。
封裝的挑戰 現代計算機體系結構中共享記憶體的錯覺 一個呼叫棧的錯覺封裝的挑戰
OOP的一個核心支柱是封裝。封裝表明一個物件的內部狀態不能直接從外部訪問;它只可以透過呼叫一組輔助的方法修改。物件負責暴露保護它所封裝資料的不變性的安全操作。例如,在一個有序二叉樹上的操作不允許違反樹的有序性。呼叫者希望保持有序性,當查詢樹上一條特定的資料時,它們需要能夠依賴這個約束。
當分析OOP執行時的行為時,我們有時候畫出一個訊息序列圖展示方法呼叫的互動過程。例如:
不幸的是,上面的圖表沒能精確表示執行過程中物件的生命線。實際上,一個執行緒執行所有的呼叫,所有物件的不變體約束出現在同一個方法被呼叫的執行緒中。更新執行緒執行圖,它看起來是這樣:
當試圖對多執行緒行為建模時,上面闡述的重要性變得明顯了。突然,我們畫出的簡潔的圖表變得不夠充分了。我們可以嘗試解釋多執行緒訪問同一物件:
有一個執行部分,兩個執行緒呼叫同一個方法。不幸的是,物件的封裝模型不能保證執行這部分時會發生什麼。兩個執行緒之間沒有某種協調的話,兩個呼叫指令將以不能保證不變體性質的任意方式相互交錯。現在,想象一下這個由多個執行緒存在而變得複雜的問題。
解決這個問題的常見方法是給這些方法加一個鎖。儘管這保證了在給定的時間內最多一個執行緒將執行該方法,但是這是一個代價高昂的策略:
鎖嚴重限制了併發,鎖在現代CPU體系結構中的代價很高,要求作業系統承擔掛起執行緒並隨後恢復它的重負。 呼叫者執行緒被阻塞,因此它不能做其他有意義的工作。在桌面應用中這是不能接受的,我們希望使應用程式的使用者介面(UI)即使在一個很長的後臺作業正在執行的時候也是可響應的。在後臺,阻塞是完全浪費的。或許有人想到這可以透過開啟一個新執行緒彌補,但執行緒也是一個代價高昂的抽象。 鎖引入了一個新的威脅:死鎖這些事實導致一個無法取勝的局面:
沒有足夠的鎖,狀態會被破壞 有足夠的鎖,效能受損並很容易導致死鎖另外,鎖只有在本地有用。當涉及跨機器協調時,唯一可選的是分散式鎖。不幸的是,分散式鎖比本地鎖低效幾個數量級,並且限制了伸縮性。分散式鎖協議需要在網路中跨機器的多輪通訊,因此延遲飛漲。
在面嚮物件語言中,我們通常很少考慮線路或線性執行路徑。我們經常把系統想象成一個物件例項的網路,這些例項物件響應方法呼叫、修改自身內部狀態、然後透過方法呼叫相互通訊以驅動整個應用狀態向前:
然而,在一個多執行緒的分散式環境中,實際發生的是執行緒沿著方法呼叫貫穿這個物件例項網路。因此,執行緒是真正的執行驅動者:
【總結】 物件只能在單執行緒訪問時保證封裝(不變體的保護),多執行緒執行幾乎總會導致破壞物件內部狀態。每個不變體可以被處於同一程式碼段相互競爭的兩個執行緒違反。 雖然鎖似乎是對維護多執行緒時的封裝很自然的補救,實際上,在任何現實應用中鎖很低效並很容易導致死鎖。 鎖在本地有用,但試圖使鎖成為分散式的,可以提供有限潛力的擴充套件。 現代計算機體系結構中共享記憶體的錯覺80-90年代的程式設計模型定義:寫入一個變數意味著直接寫到記憶體位置 (這在一定程上混淆了局部變數可能僅存在於暫存器)。在現代體系架構中,如果我們簡化一下,CPUs會寫到cache行而不是直接寫入記憶體。大多數caches是CPU區域性私有的,也就是,一個核寫入變數不會被其他核看到。為了使區域性改變對其他核可見,因此對於另一個執行緒,cache行需要被傳送到其他核的cache。
在JVM中,我們必須透過使用volatile或Atomic顯式地指示執行緒間共享的記憶體位置。否則,我們只能在鎖定的部分訪問這些記憶體。為什麼我們不將所有變數標記為volatile?因為跨核傳送cache行是一個代價非常高昂的操作!這樣做會隱式地停止涉及做額外工作的核,並導致快取一致性協議的瓶頸。(CPUs用於主存和其他CPUs之間傳輸cache行的協議)。結果便是降低數量級的執行速度。
即使對於瞭解這個情況的開發者,搞清楚哪個記憶體位置應該被標記為volatile或者使用哪一種原子結構是一門黑暗的藝術。
【總結】 沒有真正的共享記憶體了,CPU核就像網路中的計算機一樣,將資料塊(cache行)顯式地傳送給彼此。CPU之間的通訊和網路中計算機之間通訊的相同之處比許多人意識到的要多。傳送訊息是如今跨CPUs或網路中計算機的標準。 相對於透過標記為共享或使用原子資料結構的變數來隱藏訊息傳遞的層面,一個更規範和有原則的方法是儲存狀態到一個併發實體本地並透過訊息顯式地在併發實體間傳送資料或事件。 一個呼叫棧的錯覺今天,我們常常將呼叫棧視為理所當然。但是,呼叫棧是在一個併發程式不那麼重要的時代發明的,因為多CPU系統那時不常見。呼叫棧沒有跨越執行緒,因而沒有對非同步呼叫鏈建模。
當一個執行緒意圖委派一個任務給後臺的時候會出現問題。實際上,這意味著委託給另一個執行緒。這不是一個簡單的方法、函式呼叫,因為呼叫嚴格上屬於執行緒內部。通常,呼叫者(caller)執行緒將一個物件放入與一個工作執行緒(callee)共享的記憶體位置,反過來,這個工作執行緒(callee)在某個迴圈事件中獲取這個物件。這使得呼叫者(caller)執行緒可以向前執行和執行其他任務。
第一個問題是:呼叫者(caller)執行緒如何被通知任務完成了?但是當一個任務失敗且帶有異常的時候一個更嚴重問題出現了。異常應該傳播到哪裡?異常將被傳播到工作者(worker)執行緒的異常處理器而完全忽略誰是真正的呼叫者(caller):
這是一個嚴重的問題。工作者(worker)執行緒如何處理這種情況?它可能無法解決這個問題,因為它通常不知道失敗任務的目的。呼叫者(caller)執行緒需要以某種方式被通知,但是沒有呼叫棧去返回一個異常。失敗通知只能透過邊通道完成,例如,將一個錯誤程式碼放在呼叫者(caller)執行緒原本期待結果準備好的地方。如果這個通知不到位,呼叫者(caller)執行緒不會被通知任務失敗和丟失!這和網路系統的工作方式驚人地相似-網路系統中的訊息和請求可以丟失或失敗而沒有任何通知。
在任務出錯和一個工作者(worker)執行緒遇到一個bug並不可恢復的時候,這個糟糕的情況會變得更糟。例如,一個由bug引起的內部異常向上傳遞到工作者(worker)執行緒的根部並使該執行緒關閉。這立即產生一個疑問,誰應該重啟由該執行緒持有的這一服務的正常操作,以及怎樣將它恢復到一個已知的良好狀態?乍一看,這似乎很容易,但是我們突然遇到一個新的、意外的現象:執行緒正在執行的實際任務已經不在任務被取走得共享記憶體位置了 (通常是一個佇列)。事實上,由於異常到達頂部,展開所有的呼叫棧,任務狀態完全丟失了!我們已經丟失了一條訊息,儘管這是本地的通訊,沒有涉及到網路 (訊息丟失是可期望的)。
【總結】 為了在當下系統實現有意義的併發和效能,執行緒必須以一種高效的、無阻塞的方式相互委派任務。有了這種任務委派併發方式(網路/分散式計算更是如此),基於棧呼叫的error處理失效了,新的、顯式的error訊號機制需要被引入。失敗成為領域模型的一部分。 任務委派的併發系統需要處理服務故障並且有原則性的方法恢復它們。這種服務的客戶端需要知道任務/訊息會在重啟中丟失。即使不丟失,一個響應或許會由於佇列 (一個很長的佇列) 中先前的任務而發生任意的延遲,由垃圾回收造成的延遲等等。在這些情況下,併發系統應該以超時的形式對待響應截止時間,就像網路/分散式系統一樣。本文翻譯自https://doc.akka.io/docs/akka/current/guide/actors-motivation.html