【AKKA 官方文檔翻譯】爲什麼現代系統需要一個新的編程模型

爲什麼現代系統需要一個新的編程模型

akka版本2.5.8
版權聲明:本文爲博主原創文章,未經博主允許不得轉載。

actor模型是由Carl Hewitt在數十年前提出的,這個模型提供了一種在高性能網絡中進行並行處理的方式,然而這種環境在當時還尚不存在。現如今,硬件和基礎設置的性能已經達到並超越了Hewitt的願景。一些組織在構建具有苛刻要求的分佈式系統時經常會遇到挑戰,這些問題已經無法使用傳統OOP編程模型來完全解決。這種情況下,他們可以在actor模型中獲得幫助。

現在actor模型已經成爲了一種公認的高效解決方案,並且在一些要求最苛刻的系統中得到了證明。爲了體現actor模型的價值,本節主要討論在現代多線程、多CPU結構之上使用傳統編程手段的一些問題。

封裝的挑戰

OOP編程的核心是封裝。封裝要求對象內的數據不能被外部直接訪問,它們只能通過方法調用被改變。對象有義務對外提供安全的操作方法以維持其內部封裝數據的特性。

例如,一個有序二叉樹的操作不能改變其內部元素順序不變的性質,在做樹的查詢時,調用者會依賴這些性質進行程序的操作。
當我們分析OOP運行時的行爲時,我們可以繪製以下的信息序列圖來表現方法的調用:

這裏寫圖片描述

不幸的是,上面的圖並不能準確地表示程序生命週期的執行過程。實際上,這些程序是在單線程上執行的,並且這些OOP內部不變特性的執行發生在調用該方法的相同線程上,圖表中加入執行線程如下:

這裏寫圖片描述

當我們嘗試使用多線程進行建模時,我們會發現我們的圖表變得不那麼合適了。以下是多線程同時訪問同一個示例的圖表:

這裏寫圖片描述

有兩個線程同時調用了同一個方法。但是在這種情況下,對象的封裝模型並不能保證該調用得到預期的結果。兩個線程執行的指令可能以任意的方式進行交錯,因此在沒有正確協調兩個線程的情況下,對象的內部不變特性在運行之後是不可預知的,更不用說在多個線程同時調用下的結果了。

通常情況下,爲了解決這個問題,我們會爲這個方法添加一個鎖。鎖可以保證在任何時刻只會有一個線程進入這個方法。但是這是一種很消耗資源的策略:

1、鎖嚴重限制了線程的併發。加鎖在現代CPU架構上是一個很消耗資源的方式,需要操作系統掛起線程在之後進行恢復,這對操作系統來說是一個重擔。

2、調用線程會被阻塞,因此這個線程在這段時間內不能去做其他工作。即使在桌面應用程序中,這也是不可接受的。我們希望在後臺即使有耗時很長的程序運行情況下,用戶界面也可以響應。對後端而言,線程阻塞是一種極大的浪費,有人認爲這個情況可以通過啓動一個新的線程來進行補償,但是線程也是一個昂貴的抽象。

3、鎖會引入一個新的威脅:死鎖

這些問題導致出現了一些糾結的情形:

1、如果沒有足夠的鎖,狀態會被多線程破壞

2、如果加入了大量的鎖,程序的性能會受到很大的影響,並且很容易產生死鎖。

另外,鎖只能在本地很好地運行。當我們涉及到多臺機器協作時,唯一的辦法只能是使用分佈式鎖。不幸的是,分佈式鎖的效率要比本地鎖低幾個數量級,並且通常情況下對機器的擴展性限制很大。分佈式鎖協議需要在多臺機器之間通過網絡進行多次往返通信,因此延遲會飛速增長。

在面嚮對象語言中,我們很少會去考慮線程的執行路徑。我們經常將系統設想爲一個對象實例的網絡。它對方法調用作出反應,並修改它們的內部狀態。然後通過方法調用相互通信,從而驅動整個應用程序運行:
這裏寫圖片描述

然而,在多線程分佈式環境下,線程像下圖一樣通過方法調用來“遍歷”對象實例網絡。結果線程成爲真正的程序驅動:

這裏寫圖片描述

總結:

1、對象只能在單線程訪問時保證封裝(保護其內部不變特性),多線程執行幾乎總是導致內部狀態的破壞。對象內部的不變特性可能被兩個執行同一代碼的競爭線程改變。

2、在當前看來,鎖似乎是維護多線程先對象封裝的自然選擇。但實際上它們效率低下,並且它們很容易導致死鎖。

3、鎖工作在本地,試圖把他們擴展到分佈式系統中,但是限制了擴展能力。

在現代計算機體系結構上共享內存的錯覺

在80-90年代的編程模型的概念中,寫入一個變量意味着直接寫入內存中的一個地址(局部變量可能只存在於寄存器中)。在現代架構上,我們稍微簡化一下,CPU寫入緩存(cache line)中而不是直接寫入內存。大多數這些高速緩存是存在於CPU核心本地的,也就是說一個核寫入的數據是對其他核不可見的。爲了使內核的本地修改對其他核可見,從而可以對其他線程可見,需要將高速緩存傳送到另一個內核的高速緩存中。

在JVM上,我們必須通過使用volatile標記或者Atomic包來明確指出這些內存地址是要被線程間共享的。否則,我們只能使用鎖在臨界區訪問它們。這麼看來爲什麼我們不把所有的變量都聲明爲volatile變量呢?因爲在不同的核之間傳遞緩存是非常昂貴的操作,這會潛在地拖慢所涉及到的核心的運行,並導致高速緩存一致性協議的瓶頸(CPU使用這個協議在主存儲器和其他CPU間傳遞高速緩存行),最終導致的運行效率劇烈下降。

即使開發人員意識到這種情況,在何處使用volatile或者何處使用atomic結構依舊是個難題。

總結:

1、再也沒有真正的共享內存了。CPU內核就像網絡上的計算機一樣,將數據塊(緩存行)顯式地傳遞給對方。CPU間的通信和網絡通信有很多共同之處。它們通過標準來傳遞消息。

2、更好的方法是在線程本地保持狀態,並且使用可併發的對象發送信息的方式向其他線程顯式地傳遞數據或事件。用這種方式替代一些volatile標記和atomic結構是更好的選擇。

調用堆棧的錯覺

今天,我們經常使用堆棧結構,但是它們是在一個併發編程並不重要的時代發明的,因爲在那時候多CPU系統並不常見。堆棧不會被跨線程調用,因此不需要模擬異步調用鏈。

當線程打算將任務委託給一個“後臺”時出現了問題。實際上,這意味着任務委託給了另一個線程。這種委託和方法調用是不同的,因爲方法調用是嚴格在同一個線程本地進行的。我們經常看到調用者將一個對象放入一個和工作線程共享的內存位置,然後工作線程在某個事件循環中進行拾取。這允許調用者線程不被阻塞繼續進行內其他的任務。

但是我們要如何通知調用者該任務已經完成了?並且,如果一個任務失敗並且產生了一個異常,則會出現更嚴重的問題。異常會傳播到什麼地方?它將傳播到工作線程的異常處理程序裏,完全忽略調用者是誰:

這裏寫圖片描述

這是一個嚴重的問題,工作先稱該如何處理這種情況?因爲它通常不知道這個失敗的任務是爲了做什麼。這時需要通過某種方式通知調用者,但是沒有調用堆棧來解除異常。失敗通知只能被另外的通道處理,例如把錯誤碼放在調用者取結果的地方。如果這個結果沒有放到位,調用者就永遠得不到任務失敗通知,從而導致任務丟失。這種情況與聯網系統的工作方式非常相似,即消息/請求可能在沒有任何通知的情況下丟失/失敗。

當真正的錯誤發生或者運行在線程上的工作者由於bug導致不可恢復的失敗時,情況會變得更糟。舉例來說,當一個bug引起一個內部的異常,並且異常冒泡到線程根部導致線程關閉。問題來了,應該由誰來重啓這個線程所運行的服務?怎樣恢復它到一個正確的狀態?這看起來似乎是可以解決的,但是還有另一個問題,線程失敗的時候正在運行的這個任務、其運行狀態已經完全丟失了,我們丟失了一個信息,即使這是一個本地通通信,沒有涉及到網絡。

總結:

1、爲了在當今系統上實現好的併發,線程需要使用高效的方式來給其他線程委託工作,並且不被阻塞。在這種使用任務委託方式進行併發(在網絡化/分佈式計算中更是如此)調用情況下,基於堆棧的錯誤處理將會崩潰,一個新的明確的錯誤信號機制需要被引入。失敗需要成爲領域模型的一部分。

2、工作委託下的併發系統需要處理服務故障,並且有方法去恢復它們。這些服務的客戶端需要知道在重新啓動期間,任務/消息可能會丟失。即使沒有丟失,響應也可能會被之前很長的任務隊列、GC等延遲。面對這些情況,併發系統需要像網絡/分佈式系統一樣加入超時機制來處理響應的期限。

接下來,讓我們一起看看actor模型是如何應對這些挑戰的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章