Akka 系列(六):Actor 解決了什麼問題?

ScalaCool發表於2017-07-10

本文由 GodPan 發表在 ScalaCool 團隊部落格。

這段時間由於忙畢業前前後後的事情,拖更了很久,表示非常抱歉,迴歸後的第一篇文章主要是看到了Akka最新文件中寫的 What problems does the actor model solve? ,閱讀完後覺得還是蠻不錯,能簡潔清晰的闡述目前併發領域遇到的問題,併為何利用Actor模型可以解決這些問題,本文主要是利用自己的理解將這篇文章進行翻譯,有不足之處還請指出。

Actor解決了什麼問題?

Akka 使用 Actor 模型來克服傳統物件導向程式設計模型的侷限性,並應對高併發分散式系統所帶來的挑戰。 充分理解 Actor 模型是必需的,它有助於我們認識到傳統的程式設計方法在併發和分散式計算的領域上的不足之處。

封裝的弊端

物件導向程式設計(OOP)是一種廣泛採用的,熟悉的程式設計模型,它的一個核心理念就是封裝,並規定物件封裝的內部資料不能從外部直接訪問,只允許相關的屬性方法進行資料操作,比如我們熟悉的Javabean中的getX,setX等方法,物件為封裝的內部資料提供安全的資料操作。

舉個例子,有序二叉樹必須保證樹節點資料的分佈規則,若你想利用有序二叉樹進行查詢相關資料,就必須要依賴這個約束。

當我們在分析物件導向程式設計在執行時的行為時,我們可能會繪製一個訊息序列圖,用來顯示方法呼叫時的互動,如下圖所示:

seq chart
seq chart

但上述圖表並不能準確地表示例項在執行過程中的生命線。實際上,一個執行緒執行所有這些呼叫,並且變數的操作也在呼叫該方法的同一執行緒上。為剛才的序列圖加上執行執行緒,看起來像這樣:

seq chart thread
seq chart thread

但當在面對多執行緒的情況下,會發現此前的圖越來越混亂和變得不清晰,現在我們模擬多個執行緒訪問同一個示例:

seq chart multi thread
seq chart multi thread

在上面的這種情況中,兩個執行緒呼叫同一個方法,但別呼叫的物件並不能保證其封裝的資料發生了什麼,兩個呼叫的方法指令可以任意方式的交織,無法保證共享變數的一致性,現在,想象一下在更多執行緒下這個問題會更加嚴重。

解決這個問題最通常的方法就是在該方法上加鎖。通過加鎖可以保證同一時刻只有一個執行緒能進入該方法,但這是一個代價非常昂貴的方法:

  • 鎖非常嚴重的限制併發,它在現在的CPU架構上代價是非常大的,它需要作業系統暫停和重啟執行緒。

  • 呼叫者的執行緒會被阻塞,以致於它不能去做其他有意義的任務,舉個例子我們希望桌面程式在後臺執行的時候,操作UI介面也能得到響應。在後臺,,執行緒阻塞完全是浪費的,有人可能會說可以通過啟動新執行緒進行補償,但執行緒也是一種非常昂貴的資源。

  • 使用鎖會導致一個新的問題:死鎖。

這些現實存在的問題讓我們只能兩者選一:

  • 不使用鎖,但會導致狀態混亂。

  • 使用大量的鎖,但是會降低效能並很容易導致死鎖。

另外,鎖只能在本地更好的利用,當我們的程式部署在不同的機器上時,我們只能選擇使用分散式鎖,但不幸的是,分散式鎖的效率可能比本地鎖低好幾個量級,對後續的擴充套件也會有很大的限制,分散式鎖的協議要求多臺機器在網路上進行相互通訊,因此延遲可能會變得非常高。

在面嚮物件語言中,我們很少會去考慮執行緒或者它的執行路徑,我們通常將系統想象成許多例項物件連線成的網路,通過方法呼叫,修改例項物件內部的狀態,然後通過例項物件之前的方法呼叫驅動整個程式進行互動:


object graph
object graph

然後,在多執行緒分散式環境中,實際上執行緒是通過方法呼叫遍歷這個物件例項網路。因此,執行緒是方法呼叫驅動執行的:


object graph snakes
object graph snakes

總結:

  • 物件只能保證在單一執行緒中封裝資料的正確性,在多執行緒環境下可能會導致狀態混亂,在同一個程式碼段,兩個競爭的執行緒可能導致變數的不一致。

  • 使用鎖看起來可以在多執行緒環境下保證封裝資料的正確性,但實際上它在程式真是執行時是低效的並且很容易導致死鎖。

  • 鎖在單機工作可能還不錯,但是在分散式的環境表現的很不理想,擴充套件性很差。

共享記憶體在現代計算機架構上的弊端

在80-90年代的程式設計模型概念中,寫一個變數相當於直接把它寫入記憶體,但是在現代的計算機架構中,我們做了一些改變,寫入相應的快取中而不是直接寫入記憶體,大多數快取都是CPU核心的本地快取,但是由一個CPU寫入的快取對其他CPU是不可見的。為了讓本地快取的變化對其他CPU或者執行緒可見的話,快取必須進行互動。

在JVM上,我們必須使用volatile標識或者Atomic包裝類來保證記憶體對跨執行緒的共享,否則,我們只能用鎖來保證共享記憶體的正確性。那麼我們為什麼不在所有的變數上都加volatile標識呢?因為在快取間互動資訊是一個代價非常昂貴的操作,而且這個操作會隱式的阻止CPU核心不能去做其他的工作,並且會導致快取一致性協議(快取一致性協議是指CPU用於在主記憶體和其他CPU之間傳輸快取)的瓶頸。

即使開發者認識到這些問題,弄清楚哪些記憶體位置需要使用volatile標識或者Atomic包裝類,但這並非是一種很好的解決方案,可能到程式後期,你都不清楚自己做了什麼。

總結:

  • 沒有真正的共享記憶體了,CPU核心就像網路上的計算機一樣,將資料塊(快取記憶體行)明確地傳遞給彼此。CPU間的通訊和網路通訊有更多的共同點。 現在通過CPU或網路計算機傳遞訊息是標準的。

  • 使用共享記憶體標識或者Atomic資料結構來代替隱藏訊息傳遞,其實有一種更加規範的方法就是將共享狀態儲存在併發實體內,並明確併發實體間通過訊息來傳遞事件和資料。

呼叫堆疊的弊端

今天,我們還經常呼叫堆疊來進行任務執行,但是它是在併發並不那麼重要的時代發明的,因為當時多核的CPU系統並不常見。呼叫堆疊不能跨執行緒,所以不能進行非同步呼叫。

執行緒在將任務委託後臺執行會出現一個問題,實際中,是將任務委託給另一個執行緒執行,這不是簡單的方法呼叫,而是有本地的執行緒直接呼叫執行,通常來說,一個呼叫者執行緒將任務新增到一個記憶體位置中,具體的工作執行緒可以不斷的從中選取任務進行執行,這樣的話,呼叫者執行緒不必阻塞可以去做一些其他的任務了。

但是這裡有幾個問題,第一個就是呼叫者如何受到任務完成的通知?還有一個更重要的問題是當任務發生異常出現錯誤後,異常會被誰處理?異常將會被具體執行任務的工作執行緒所處理並不會關心是哪個呼叫者呼叫的任務:


exception prop
exception prop

這是一個很嚴重的問題,具體執行任務的執行緒是怎麼處理這種狀況的?具體執行任務去處理這個問題並不是一個好的方案,因為它並不清楚該任務執行的真正目的,而且呼叫者應該被通知發生了什麼,但是實際上並沒有這樣的結構去解決這個問題。假如並不能正確的通知,呼叫者執行緒將不會的到任何錯誤的資訊甚至任務都會丟失。這就好比在網路上你的請求失敗或者訊息丟失卻得不到任何的通知。

在某些情況,這個問題可能會變得更糟糕,工作執行緒發生了錯誤但是其自身卻無法恢復。比如一個由bug引起的內部錯誤導致了執行緒的關閉,那麼會導致一個問題,到底應該由誰來重啟執行緒並且儲存執行緒之前的狀態呢?表面上看,這個問題是可以解決的,但又會有一個新的意外可能發生,當工作執行緒正在執行任務的時候,它便不能共享任務佇列,而事實上,當一個異常發生後,並逐級上傳,最終可能導致整個任務佇列的狀態全部丟失。所以說即使我們在本地互動也可能存在訊息丟失的情況。

總結:

  • 實現任何一個高併發且高效效能的系統,執行緒必須將任務有效率的委託給別的執行緒執行以至不會阻塞,這種任務委託的併發方式在分散式的環境也適用,但是需要引入錯誤處理和失敗通知等機制。失敗成為這種領域模型的一部分。

  • 併發系統適用任務委託機制需要去處理服務故障也就意味需要在發生故障後去恢復服務,但實際情況下,重啟服務可能會丟失訊息,即使沒有發生這種情況,呼叫者得到的迴應也可能因為佇列等待,垃圾回收等影響而延遲,所以,在真正的環境中,我們需要設定請求回覆的超時時間,就像在網路系統亦或者分散式系統。

為什麼在高併發,分散式系統需要Actor模型?

綜上所述,通常的程式設計模型並不適用現代的高併發分散式系統,幸運的是,我們可以不必拋棄我們瞭解的知識,另外,Actor用很好的方式幫我們克服了這些問題,它讓我們以一種更好的模型去實現我們的系統。

我們重點需求的是以下幾個方面:

  • 使用封裝,但是不使用鎖。

  • 構建一種實體能夠處理訊息,更改狀態,傳送訊息用來推動整個程式執行。

  • 不必擔心程式執行與真實環境的不匹配。

Actor模型能幫我們實現這些目標,以下是具體描述。

使用訊息機制避免使用鎖以防止阻塞

不同於方法呼叫,Actor模型使用訊息進行互動。傳送訊息的方式不會將傳送訊息方的執行執行緒轉換為具體的任務執行執行緒。Actor可以不斷的傳送和接收訊息但不會阻塞。因此它可以做更多的工作,比如傳送訊息和接收訊息。

在面對物件程式設計上,直到一個方法返回後,才會釋放對呼叫者執行緒的控制。在這這一方面上,Actor模型跟面對物件模型類似,它對訊息做出處理,並在訊息處理完成後返回執行。我們可以模擬這種執行模式:


actor graph
actor graph

但是這種方式與方法呼叫方式最大的區別就是沒有返回值。通過傳送訊息,Actor將任務委託給另一Actor執行。就想我們之前說的堆疊呼叫一樣,加入你需要一個返回值,那麼傳送Actor需要阻塞或者與具體執行任務的Actor在同一個執行緒中。另外,接收Actor以訊息的方式返回結果。

第二個關鍵的變化是繼續保持封裝。Actor對訊息處理就就跟呼叫方法一樣,但是不同的是,Actor在多執行緒的情況下能保證自身內部的狀態和變數不會被破壞,Actor的執行獨立於傳送訊息的Actor,並且同一個Actor在同一個時刻只處理一個訊息。每個Actor有序的處理接收的訊息,所以一個Actor系統中多個Actor可以併發的處理自己的訊息,充分的利用多核CPU。因為一個Actor同一時刻最多處理一個訊息,所以它不需要同步機制保障變數的一致性。所以說它並不需要鎖:


serialized timeline invariants
serialized timeline invariants

總而言之,Actor執行的時候會發生以下行為:

1.Actor將訊息加入到訊息佇列的尾部。
2.假如一個Actor並未被排程執行,則將其標記為可執行。
3.一個(對外部不可見)排程器對Actor的執行進行排程。
4.Actor從訊息佇列頭部選擇一個訊息進行處理。
5.Actor在處理過程中修改自身的狀態,併傳送訊息給其他的Actor。
6.Actor

為了實現這些行為,Actor必須有以下特性:

  • 郵箱(作為一個訊息佇列)
  • 行為(作為Actor的內部狀態,處理訊息邏輯)
  • 訊息(請求Actor的資料,可看成方法呼叫時的引數資料)
  • 執行環境(比如執行緒池,排程器,訊息分發機制等)
  • 位置資訊(用於後續可能會發生的行為)

訊息會被新增到Actor的信箱中,Actor的行為可以看成Actor是如何對訊息做出迴應的(比如傳送更多訊息或者修改自身狀態)。執行環境提供一組執行緒池,用於執行Actor的這些行為操作。

Actor是一個非常簡單的模型而且它可以解決先前提到的問題:

  • 繼續使用封裝,但通過訊號機制保障不需傳遞執行(方法呼叫需要傳遞執行執行緒,但傳送訊息不需要)。

  • 不需要任何的鎖,修改Actor內部的狀態只能通過訊息,Actor是序列處理訊息,可以保障內部狀態和變數的正確性。

  • 因為不會再任何地方使用鎖,所以傳送者不會被阻塞,成千上萬個Actor可以被合理的分配在幾十個執行緒上執行,充分利用了現代CPU的潛力。任務委託這個模式在Actor上非常適用。

  • Actor的狀態是本地的,不可共享的,變化和資料只能通過訊息傳遞。

Actor優雅的處理錯誤

Actor不再使用共享的堆疊呼叫,所以它要以不同的方式去處理錯誤。這裡有兩種錯誤需要考慮:

  • 第一種情況是當任務委託後再目標Actor上由於任務本身錯誤而失敗了(典型的如驗證錯誤,比如不存在的使用者ID)。在這個情況下,Actor服務本身是正確的,只是相應的任務出錯了。服務Actor應該想傳送Actor傳送訊息,已告知錯誤情況。這裡沒什麼特殊的,錯誤作為Actor模型的一部分,也可以當做訊息。

  • 第二種情況是當服務本身遇到內部故障時。Akka強制所有Actor被組織成一個樹狀的層次結構,即建立另一個Actor的Actor成為該新Actor的分級。 這與作業系統將流程組合到樹中非常相似。就像程式一樣,當一個Actor失敗時,它的父actor被通知,並對失敗做出反應。此外,如果父actor停止,其所有子Actor也被遞迴停止。這中形式被稱為監督,它是Akka的核心:


actor tree supervision
actor tree supervision

監管者可以根據被監管者(子Actor)的失敗的錯誤型別來執行不同的策略,比如重啟該Actor或者停止該Actor讓其它Actor代替執行任務。一個Actor不會無緣無故的死亡(除非出現死迴圈之類的情況),而是失敗,並可以將失敗傳遞給它的監管者讓其做出相應的故障處理策略,當然也可能會被停止(若被停止,也會接收到相應的訊息指令)。一個Actor總有監管者就是它的父級Actor。Actor重新啟動是不可見的,協作Actor可以幫其代發訊息直到目標Actor重啟成功。

相關文章