1. 程式人生 > 實用技巧 >為什麼現代系統需要一個新的程式設計模型

為什麼現代系統需要一個新的程式設計模型

akka版本2.6.9
版權宣告:本文為博主原創文章,未經博主允許不得轉載。

 actor模型是Carl Hewitt在幾十年前提出的,作為在高效能網路中並行處理的一種方法(當時還沒有這種環境)。今天,硬體和基礎設施的能力已經趕上並超過了Hewitt的願景。因此,構建具有複雜需求的分散式系統會遇到一些挑戰,這些挑戰無法用傳統的面向物件程式設計(OOP)模型完全解決,但可以從actor模型獲的較大的啟發。
 今天,actor模型不僅被認為是一種高效的解決方案,而且已經在世界上一些最複雜的應用程式的生產環境中得到了證明。為了突出actor模型所解決的問題,本主要討論了以下傳統程式設計假設與現代多執行緒、多cpu架構現實之間的不匹配:

  1. 封裝的挑戰
  2. 現代計算機結構中共享記憶體的錯覺
  3. 呼叫堆疊的假象

一. 封裝的挑戰
 OOP的核心支柱是封裝。封裝說明物件的內部資料不能直接從外部訪問;只能通過呼叫一組經過裝飾的方法來修改它。物件負責公開保護其封裝資料的不變特性的安全操作。
 例如,對有序二叉樹實現的操作必須不允許違反樹排序不變性。呼叫者希望排序是完整的,並且在查詢樹中特定的資料片段時,他們需要能夠依賴這個約束。
 在分析OOP執行時行為時,我們有時會畫一個訊息序列圖來顯示方法呼叫的互動。例如:
在這裡插入圖片描述
 不幸的是,上面的圖表沒有準確地表示例項在執行期間的生命線。實際上,執行緒執行所有這些呼叫,不變數的實施發生在呼叫方法的同一個執行緒上。用執行執行緒更新圖表,它看起來是這樣的:

在這裡插入圖片描述
 當您試圖對多執行緒所發生的事情進行建模時,這種澄清的重要性就變得很清楚了。顯然,我們畫得很整齊的圖表變得不夠用了。我們可以試著舉例說明多執行緒訪問同一個例項:
在這裡插入圖片描述
 在執行的某一段中,兩個執行緒進入同一個方法。不幸的是,物件的封裝模型不能保證該部分中發生的任何事情。這兩個呼叫的指令可以以任意的方式交錯,如果兩個執行緒之間沒有某種型別的協調,就無法保持不變數的完整性。現在,想象一下這個問題由於許多執行緒的存在而變得複雜。

 解決這個問題的常見方法是新增一個鎖在這些方法。雖然這確保了在任何給定的時間最多有一個執行緒進入方法,但這是一個非常昂貴的策略:

  • 鎖嚴重限制併發性,在現代CPU體系結構上,鎖的開銷非常大,需要從作業系統中進行大量工作,以掛起執行緒並在稍後恢復它。
  • 呼叫者執行緒當前被阻塞,因此它不能做任何其他有意義的工作。即使在桌面應用程式中,這也是不可接受的,我們希望應用程式面向使用者的部分(其UI)能夠響應,即使在執行較長的後臺作業時也是如此。在後端,阻塞完全是浪費。有人可能認為這可以通過啟動新執行緒來補償,但執行緒也是一個代價高昂的抽象。
  • 鎖引入了一個新的威脅:死鎖。

 鎖的使用雖然解決了部分問題,但這始終不是一個雙贏的局面:

  • 沒有足夠的鎖,狀態就會被破壞。
  • 如果有很多鎖,效能會受到影響,很容易導致死鎖。

 此外,鎖只在本地工作得很好。當涉及到跨多臺機器進行協調時,唯一的替代方案是分散式鎖。不幸的是,分散式鎖的效率比本地鎖低幾個量級,並且通常會對向外擴充套件施加硬性限制。分散式鎖協議需要在網路上跨多臺機器進行多次通訊往返,因此延遲非常高。
 在面嚮物件語言中,我們通常很少考慮執行緒或線性執行路徑。我們經常想象一個系統作為網路應對方法呼叫的物件例項,修改他們的內部狀態,然後通過方法呼叫相互交流推動整個應用程式狀態:
在這裡插入圖片描述
 然而,在一個多執行緒分散式環境中,實際發生的是,執行緒“穿越”這個網路物件例項的方法呼叫。因此,執行緒是真正驅動執行:
在這裡插入圖片描述
總而言之:

  • 物件只能保證封裝(不變數的保護)在面對單執行緒訪問時,多執行緒執行幾乎總是導致損壞內部狀態。在同一個程式碼段中有兩個競爭執行緒可能會違反每個不變式。
  • 雖然鎖似乎是支援多執行緒封裝的自然補救方法,但實際上,在任何實際規模的應用程式中,它們都是低效的,而且很容易導致死鎖。
  • 鎖在本地工作,試圖讓它們分散式存在,但提供有限的擴充套件潛力。

二. 現代計算機結構中共享記憶體的錯覺
 80 -90年代的程式設計模型認為,寫入變數意味著直接寫入記憶體位置(這在某種程度上混淆了局部變數可能只存在於暫存器中的這一事實)。在現代的架構中——如果我們稍微簡化一下的話——cpu會直接寫入快取線,而不是直接寫入記憶體。大多數快取都是在CPU核心的本地進行的,也就是說,一個核的寫入對另一個核是不可見的。為了使本地更改對另一個核心可見,從而對另一個執行緒可見,需要將快取線傳送到另一個核心的快取。
 在JVM上,我們必須通過使用易失性標記或原子包裝器顯式地表示要跨執行緒共享的記憶體位置。否則,我們只能在一個鎖著的部分訪問它們。為什麼我們不把所有變數都標記為volatile呢?因為跨核心傳送快取線是一項非常昂貴的操作!這樣做會隱式地使所涉及的核心停止執行額外的工作,並導致快取一致性協議(cpu用於在主存和其他cpu之間傳輸快取線的協議)上出現瓶頸。其結果是經濟放緩的程度。
 即使開發人員知道這種情況,找出哪些記憶體位置應該標記為volatile,或使用哪些原子結構是一種黑暗的藝術。

總而言之:

  • 不再存在真正的共享記憶體,CPU核心之間顯式地傳遞資料塊(快取線),就像網路上的計算機那樣。cpu間通訊和網路通訊的共同點比許多人意識到的要多。現在,通過cpu或網路計算機傳遞訊息是一種常態。
  • 與通過標記為shared或使用原子資料結構的變數隱藏訊息傳遞方面不同,更有紀律和原則的方法是將狀態保持為併發實體的本地狀態,並通過訊息顯式地在併發實體之間傳播資料或事件。

三. 用堆疊的假象
 今天,我們經常認為呼叫堆疊是理所當然的。但是,它們是在併發程式設計不那麼重要的時代發明的,因為多cpu系統還不常見。呼叫堆疊不跨執行緒,因此不建模非同步呼叫鏈。
 當執行緒打算將任務委託給“後臺”時,問題就出現了。實際上,這實際上意味著委託給另一個執行緒。這不能是一個簡單的方法/函式呼叫,因為呼叫嚴格地是執行緒本地的。通常發生的情況是,“呼叫者”將一個物件放入一個由工作執行緒(“被呼叫者”)共享的記憶體位置,而工作執行緒依次在某個事件迴圈中獲取該物件。這允許“呼叫者”執行緒繼續執行其他任務。
 第一個問題是,如何通知“呼叫者”任務已經完成?但是,當任務因異常而失敗時,會出現更嚴重的問題。異常傳播到哪裡?它將傳播到工作執行緒的異常處理程式,完全忽略實際的“呼叫者”是誰:
在這裡插入圖片描述
 這是一個嚴重的問題。工作執行緒如何處理這種情況?它可能無法解決這個問題,因為它通常忽略了失敗任務的目的。需要以某種方式通知“呼叫者”執行緒,但是沒有呼叫堆疊需要異常展開。失敗通知只能通過側通道來完成,例如,在“呼叫者”執行緒準備好之後,將錯誤程式碼放在它希望得到結果的地方。如果沒有此通知,則永遠不會通知“呼叫者”失敗,任務將丟失!這與網路系統的工作方式驚人地相似,在網路系統中,訊息/請求可能在沒有任何通知的情況下丟失/失敗。
 當事情真正出錯,由執行緒支援的工作程式遇到bug並以不可恢復的情況結束時,這種糟糕的情況會變得更糟。例如,由錯誤引起的內部異常會氣泡到執行緒的根,並導致執行緒關閉。這立即提出了問題,誰應該重新啟動執行緒承載的服務的正常操作,以及如何將其恢復到已知的良好狀態?乍一看,這似乎是可管理的,但我們突然面臨一個新的、意想不到的現象:執行緒當前正在處理的實際任務不再位於從其中獲取任務的共享記憶體位置(通常是一個佇列)。實際上,由於異常到達頂部,展開所有呼叫堆疊,任務狀態完全丟失!我們已經丟失了一條訊息,儘管這是與網路無關的本地通訊(訊息丟失是意料之中的)。

總而言之:

  • 要在當前系統上實現任何有意義的併發性和效能,執行緒必須在不阻塞的情況下在彼此之間有效地委託任務。對於這種任務委託併發(在網路/分散式計算中更是如此),基於呼叫堆疊的錯誤處理就會失效,需要引入新的顯式錯誤訊號機制。失敗成為域模型的一部分。
  • 具有工作委託的併發系統需要處理服務故障,並有原則性的方法從故障中恢復。這些服務的客戶端需要知道,在重新啟動期間,任務/訊息可能會丟失。即使沒有發生丟失,響應也可能會因為之前排隊的任務(一個長佇列)、垃圾收集引起的延遲等原因而任意延遲。面對這些問題,併發系統應該像網路/分散式系統一樣,以超時的形式來處理響應的最後期限。