爲何現代系統須要一個新的編程模型?

摘要:現在高要求的分佈式系統的建造者遇到了不能徹底由傳統的面向對象編程(OOP)模型解決的挑戰,但這能夠從Actor模型中獲益。

爲何現代系統須要一個新的編程模型?

Actor模型做爲一種高性能網絡中的並行處理方式由Carl Hewitt幾十年前提出-高性能網絡環境在當時還不可用。現在,硬件和基礎設施的能力已經遇上並超越了Hewitt的願景。所以,高要求的分佈式系統的建造者遇到了不能徹底由傳統的面向對象編程(OOP)模型解決的挑戰,但這能夠從Actor模型中獲益。

今天,Actor模型不只被認爲是高效的解決方案——這已經被世界上要求最高的應用所檢驗。爲了突出Actor模型解決的問題,這個主題討論如下傳統編程的假設與現代多線程、多CPU體系架構之間的不匹配:html

  • 封裝的挑戰
  • 現代計算機體系結構中共享內存的錯覺
  • 一個調用棧的錯覺

封裝的挑戰

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

本文分享自華爲雲社區《【Akka系列】之 爲何現代系統須要一個新的編程模型? 》,原文做者:荔子 。

 

點擊關注,第一時間瞭解華爲雲新鮮技術~

相關文章
相關標籤/搜索