AKKA示例教程

寫併發程序很難。程序員不得不處理線程、鎖和競態條件等等,這個過程很容易出錯,而且會導致程序代碼難以閱讀、測試和維護。

所以,很多人不傾向於使用多線程編程。取而代之的是,他們使用單線程進程(譯者注:只含有一個線程的進程),依賴外部服務(如數據庫、隊列等)處理所需的併發或異步操作。雖然這種方法在有些情況下是可行的,但還有很多其他情況不能奏效。很多實時系統——例如交易或銀行業務應用,或實時遊戲——等待一個單線程進程完成就太奢侈了(他們需要立即應答!)。其他的一些對於計算或資源要求非常高的系統,如果在程序中不引入並行機制就會耗時很久(有些情況下可以達到幾個小時或數天)。

常用的一種單線程方法(例如,在 Node.js裏廣泛應用)是使用基於事件的、非阻塞模式(Event-Based, NON-Blocking Paradigm,其中Paradigm也有譯作成例)。雖然這種方法可以避免上下文切換、鎖和阻塞,的確能提高性能,但還是不能解決併發使用多個處理器(需要啓動和協調多個獨立的處理器)的問題。

那麼,這是不是意味着爲了構建一個併發程序,除了深入到線程、鎖和競態條件之外沒有別的選擇呢?

感謝Akka框架,它爲我們提供了一種選擇。本教程介紹了Akka的示例,並仔細研究它如何幫助並簡化分佈式併發應用的實現。

Akka框架是什麼

這篇文章介紹了Akka並仔細研究它如何幫助並簡化分佈式併發應用的實現。 

Akka是JVM(JAVA虛擬機,下同)平臺上構建高併發、分佈式和容錯應用的工具包和運行時。Akka用 Scala語言寫成,同時提供了Scala和JAVA的開發接口。

Akka處理併發的方法基於 Actor(沒有慣用譯法,文中使用原詞)模型。在基於Actor的系統裏,所有的事物都是Actor,就好像在面向對象設計裏面所有的事物都是對象一樣。但是有一個重要區別——特別是和我們的討論相關的——那就是Actor模型是作爲一個併發模型設計和架構的,而面向對象模式則不是。更具體一點,在Scala的Actor系統裏,Actor互相交互並共享信息但並不對交互順序作出預設。Actor之間共享信息和發起任務的機制是消息傳遞。

創建和調度線程、接收和分發消息以及處理競態條件和同步的所有複雜性,都委託給框架,框架的處理對應用來說是透明的。

Akka在多個Actor和下面的系統之間建立了一個層次(Layer),這樣一來,Actor只需要處理消息就可以了。創建和調度線程、接收和分發消息以及處理競態條件和同步的所有複雜性,都委託給框架,框架的處理對應用來說是透明的。

Actor嚴格遵守 響應式聲明。響應式應用的目標是通過滿足以下一個或多個條件來代替傳統的多線程應用:

  • 事件驅動。使用Actor,代碼可以異步處理請求並用獨佔的方式執行非阻塞操作。
  • 可伸縮性。在Akka裏,不修改代碼就增加節點是可能的,感謝消息傳遞和本地透明性(Location Transparency)。
  • 高彈性。任何應用都會碰到錯誤並在某個時間點失敗。Akka的“監管”(容錯)策略爲實現自愈系統提供了便利。
  • 響應式。今天的高性能和快速響應應用需要對用戶快速反饋,因此對於事件的響應需要非常及時。Akka的非阻塞、基於消息的策略可以幫助達成這個目標。

Akka中的Actor是什麼

Actor本質上就是接收消息並採取行動處理消息的對象。它從消息源中解耦出來,只負責正確識別接收到的消息類型,並採取相應的行動。

收到一條消息之後,一個Actor可能會採取以下一個或多個行動:

  • 執行一些本身的操作(例如進行計算、持久化數據、調用外部的Web服務等)
  • 把消息或衍生消息轉發給另外一個Actor
  • 實例化一個新的Actor並把消息轉發給它

或者,如果這個Actor認爲合適的話,可能會完全忽略這條消息(也就是說,它可能選擇不響應)。

爲了實現一個Actor,需要繼承Akka.Actor.Actor這個Trait(一般譯爲“特徵”,譯法有一定爭議,文中保留原詞)並實現Receive方法。當一個消息發送給Actor時,它的Receive方法會被(Akka)調用。典型的實現包括使用模式匹配(Pattern Matching)來識別消息類型並作出響應,參見下面的Akka示例:

  1. import akka.actor.Actor  
  2. import akka.actor.Props  
  3. import akka.event.Logging  
  4. class MyActor extends Actor {  
  5.   def receive = {  
  6.     case value: String => doSomething(value)  
  7.     case _ => println("received unknown message")  
  8.   }  
  9. }  

模式匹配是一種相對優雅的處理消息的技術,相比基於回調的實現,更傾向於產生“更整潔”以及更容易瀏覽的代碼。例如,考慮一個簡化版的HTTP請求/響應實現。

首先,我們使用JavaScript中基於回調的方式實現:

  1. route(url, function(request){  
  2.   var query = buildQuery(request);  
  3.   dbCall(query, function(dbResponse){  
  4.     var wsRequest = buildWebServiceRequest(dbResponse);  
  5.     wsCall(wsRequest, function(wsResponse) {  
  6.       sendReply(wsResponse);  
  7.     });  
  8.   });  
  9. });  
現在,我們把它和基於模式匹配的實現做個比較: 

  1. msg match {  
  2.   case HttpRequest(request) => {  
  3.     val query = buildQuery(request)  
  4.     dbCall(query)  
  5.   }  
  6.   case DbResponse(dbResponse) => {  
  7.     var wsRequest = buildWebServiceRequest(dbResponse);  
  8.     wsCall(dbResponse)  
  9.   }  
  10.   case WsResponse(wsResponse) => sendReply(wsResponse)  
  11. }  
雖然基於回調的JavaScript代碼更緊湊,但確實更難以閱讀和瀏覽。相比而言,基於模式匹配的代碼對於需要考慮哪些情況、每種情況都是怎麼處理的寫法更加清晰。 

Actor系統

把一個複雜的問題不斷分解成更小規模的子問題通常是一種可靠的解決問題的技術。這個方法對於計算機科學特別有效(和 單一職責原則一致),因爲這樣容易產生整潔的、模塊化的代碼,產生的冗餘很少甚至沒有,而且維護起來相對容易。

在基於Actor的設計裏,使用這種技術有助於把Actor的邏輯組織變成一個層級結構,也就是所謂的Actor系統。Actor系統提供了一個基礎框架,通過這個系統Actor之間可以進行交互。

              Actor系統 

在Akka裏面,和Actor通信的唯一方式就是通過ActorRefActorRef代表Actor的一個引用,可以阻止其他對象直接訪問或操作這個Actor的內部信息和狀態。消息可以通過一個ActorRef以下面的語法協議中的一種發送到一個Actor: 
-!(“告知”) —— 發送消息並立即返回 
-?(“請求”) —— 發送消息並返回一個Future對象,代表一個可能的應答 

每個Actor都有一個收件箱,用來接收發送過來的消息。收件箱有多種實現方式可以選擇,缺省的實現是先進先出(FIFO)隊列。

在處理多條消息時,一個Actor包含多個實例變量來保持狀態。Akka確保Actor的每個實例都運行在自己的輕量級線程裏,並保證每次只處理一條消息。這樣一來,開發者不必擔心同步或競態條件,而每個Actor的狀態都可以被可靠地保持。

Akka的Actor API中提供了每個Actor執行任務所需要的有用信息:

  • sender:當前處理消息的發送者的一個ActorRef引用
  • context:Actor運行上下文相關的信息和方法(例如,包括實例化一個新Actor的方法ActorOf
  • supervisionStrategy:定義用來從錯誤中恢復的策略
  • self:Actor本身的ActorRef引用
Akka確保Actor的每個實例都運行在自己的輕量級線程裏,並保證每次只處理一條消息。這樣一來,開發者不必擔心同步或競態條件,而每個Actor的狀態都可以被可靠地保持。 

爲了把這些教程組織起來,讓我們來考慮一個簡單的例子:統計一個文本文件中單詞的數量。

爲了達到演示Akka示例的目的,我們把這個問題分解爲兩個子任務;即(1)統計每行單詞數量的“孩子”任務和(2)彙總這些單行單詞數量、得到文件裏單詞總數的“父親”任務。

父Actor會從文件中裝載每一行,然後委託一個子Actor來計算某一行的單詞數量。當子Actor完成之後,它會把結果用消息發回給父Actor。父Actor會收到(每一行的)單詞數量的消息並維持一個整個文件單詞總數的計數器,這個計數器會在完成後返回給調用者。

(注意以下提供的Akka教程的例子只是爲了教學目的,所以沒有顧及所有的邊界條件、性能優化等。同時,完整可編譯版本的代碼示例可以在這個GIST中找到)

讓我們首先看一個子類StringCounterActor的示例實現: 

  1. case class ProcessStringMsg(string: String)  
  2. case class StringProcessedMsg(words: Integer)  
  3. class StringCounterActor extends Actor {  
  4.   def receive = {  
  5.     case ProcessStringMsg(string) => {  
  6.       val wordsInLine = string.split(" ").length  
  7.       sender ! StringProcessedMsg(wordsInLine)  
  8.     }  
  9.     case _ => println("Error: message not recognized")  
  10.   }  
  11. }  

這個Actor有一個非常簡單的任務:接收ProcessStringMsg消息(包含一行文本),計算這行文本中單詞的數量,並把結果通過一個StringProcessedMsg消息返回給發送者。請注意我們已經實現了我們的類,使用(“告知”)方法發出StringProcessedMsg消息(發出消息並立即返回)。

好了,現在我們來關注父WordCounterActor類:

  1.  case class StartProcessFileMsg()  
  2.   
  3.  class WordCounterActor(filename: String) extends Actor {  
  4.   
  5.    private var running = false  
  6.    private var totalLines = 0  
  7.    private var linesProcessed = 0  
  8.    private var result = 0  
  9.    private var fileSender: Option[ActorRef] = None  
  10.   
  11.   def receive = {  
  12.     case StartProcessFileMsg() => {  
  13.       if (running) {  
  14.         // println just used for example purposes;  
  15.         // Akka logger should be used instead  
  16.         println("Warning: duplicate start message received")  
  17.       } else {  
  18.         running = true  
  19.         fileSender = Some(sender) // save reference to process invoker  
  20.         import scala.io.Source._  
  21.         fromFile(filename).getLines.foreach { line =>  
  22.           context.actorOf(Props[StringCounterActor]) ! ProcessStringMsg(line)  
  23.           totalLines += 1  
  24.         }  
  25.       }  
  26.     }  
  27.     case StringProcessedMsg(words) => {  
  28.       result += words  
  29.       linesProcessed += 1  
  30.       if (linesProcessed == totalLines) {  
  31.         fileSender.map(_ ! result)  // provide result to process invoker  
  32.       }  
  33.     }  
  34.     case _ => println("message not recognized!")  
  35.   }  
  36. }  

這裏面有很多細節,我們來逐一考察(注意討論中所引用的行號基於以上代碼示例)。

首先,請注意要處理的文件名被傳給了WordCounterActor的構造方法(第3行)。這意味着這個Actor只會用來處理一個單獨的文件。這樣通過避免重置狀態變量(runningtotalLineslinesProcessedresult)也簡化了開發者的編碼工作,因爲這個實例只使用一次(也就是說處理一個單獨的文件),然後就丟棄了。

接下來,我們看到WordCounterActor處理了兩種類型的消息:

  • StartProcessFileMsg(第12行)
    • 從最初啓動WordCounterActor的外部Actor接收到的消息
    • 收到這個消息之後,WordCounterActor首先檢查它收到的是不是一個重複的請求
    • 如果這個請求是重複的,那麼WordCounterActor生成一個警告,然後就不做別的事了(第16行)
    • 如果這不是一個重複的請求:
      • WordCounterActorFileSender實例變量(注意這是一個Option[ActorRef]而不是一個Option[Actor])中保存發送者的一個引用。當處理最終的StringProcessedMsg(從一個StringCounterActor子類中接收,如下文所述)時,爲了以後的訪問和響應,這個ActorRef是必需的。
      • 然後WordCounterActor讀取文件,當文件中每行都裝載之後,就會創建一個StringCounterActor,需要處理的包含行文本的消息就會傳遞給它(第21-24行)。
  • StringProcessedMsg(第27行)
    • 當處理完成分配給它的行之後,從StringCounterActor處接收到的消息
    • 收到此消息之後,WordCounterActor會把文件的行計數器增加,如果所有的行都處理完畢(也就是說,當totalLineslinesProcessed相等),它會把最終結果發給原來的FileSender(第28-31行)。

再次需要注意的是,在Akka裏,Actor之間通信的唯一機制就是消息傳遞。消息是Actor之間唯一共享的東西,而且因爲多個Actor可能會併發訪問同樣的消息,所以爲了避免競態條件和不可預期的行爲,消息的不可變性非常重要。

因爲Case Class默認是不可變的並且可以和模式匹配無縫集成,所以用Case Class的形式來傳遞消息是很常見的。(Scala中的Case Class就是正常的類,唯一不同的是通過模式匹配提供了可以遞歸分解的機制)。

讓我們通過運行整個應用的示例代碼來結束這個例子。

  1. object Sample extends App {  
  2.   import akka.util.Timeout  
  3.   import scala.concurrent.duration._  
  4.   import akka.pattern.ask  
  5.   import akka.dispatch.ExecutionContexts._  
  6.   implicit val ec = global  
  7.   override def main(args: Array[String]) {  
  8.     val system = ActorSystem("System")  
  9.     val actor = system.actorOf(Props(new WordCounterActor(args(0))))  
  10.     implicit val timeout = Timeout(25 seconds)  
  11.     val future = actor ? StartProcessFileMsg()  
  12.     future.map { result =>  
  13.       println("Total number of words " + result)  
  14.       system.shutdown  
  15.     }  
  16.   }  
  17. }  
請注意這裏的?方法是怎樣發送一條消息的。用這種方法,調用者可以使用返回的 Future對象,當完成之後可以打印出最後結果並最終通過停掉Actor系統退出程序。 

Akka的容錯和監管者策略

在Actor系統裏,每個Actor都是其子孫的監管者。如果Actor處理消息時失敗,它就會暫停自己及其子孫併發送一個消息給它的監管者,通常是以異常的形式。

在Akka裏面,監管者策略是定義你的系統容錯行爲的主要並且直接的機制。

在Akka裏面,一個監管者對於從子孫傳遞上來的異常的響應和處理方式稱作監管者策略。 監管者策略是定義你的系統容錯行爲的主要並且直接的機制。

當一條消息指示有一個錯誤到達了一個監管者,它會採取如下行動之一:

  • 恢復孩子(及其子孫),保持內部狀態。 當孩子的狀態沒有被錯誤破壞,還可以繼續正常工作的時候,可以使用這種策略。
  • 重啓孩子(及其子孫),清除內部狀態。 這種策略應用的場景和第一種正好相反。如果孩子的狀態已經被錯誤破壞,在它可以被用到Future之前有必須要重置其內部狀態。
  • 永久地停掉孩子(及其子孫)。 這種策略可以用在下面的場景中:錯誤條件不能被修正,但是並不影響後面執行的操作,這些操作可以在失敗的孩子不存在的情況下完成。
  • 停掉自己並向上傳播錯誤。 適用場景:當監管者不知道如何處理錯誤,就把錯誤傳遞給自己的監管者。

而且,一個Actor可以決定是否把行動應用在失敗的子孫上抑或是應用到它的兄弟上。有兩種預定義的策略:

  • OneForOneStrategy:只把指定行動應用到失敗的孩子上
  • AllForOneStrategy:把指定行動應用到所有子孫上

下面是一個使用OneForOneStrategy的簡單例子:

  1. import akka.actor.OneForOneStrategy  
  2. import akka.actor.SupervisorStrategy._  
  3. import scala.concurrent.duration._  
  4. override val supervisorStrategy =  
  5.  OneForOneStrategy() {  
  6.    case _: ArithmeticException      => Resume  
  7.    case _: NullPointerException     => Restart  
  8.    case _: IllegalArgumentException => Stop  
  9.    case _: Exception                => Escalate  
  10.  }  

如果沒有指定策略,那麼就使用如下默認的策略:

  • 如果在初始化Actor時出錯,或者Actor被結束(Killed),那麼Actor就會停止(Stopped)
  • 如果有任何類型的異常出現,Actor就會重啓

Akka提供的默認策略的實現如下:

  1. final val defaultStrategy: SupervisorStrategy = {  
  2.   def defaultDecider: Decider = {  
  3.     case _: ActorInitializationException ⇒ Stop  
  4.     case _: ActorKilledException         ⇒ Stop  
  5.     case _: Exception                    ⇒ Restart  
  6.   }  
  7.   OneForOneStrategy()(defaultDecider)  
  8. }  
Akka也考慮到對 定製化監管者策略的實現,但正如Akka文檔也提出了警告,這麼做要小心,因爲錯誤的實現會產生諸如Actor系統被阻塞的問題(也就是說,其中的多個Actor被永久掛起了)。 

本地透明性

Akka架構支持 本地透明性,使得Actor完全不知道他們接受的消息是從哪裏發出來的。消息的發送者可能駐留在同一個JVM,也有可能是存在於其他的JVM(或者運行在同一個節點,或者運行在不同的節點)。Akka處理這些情況對於Actor(也即對於開發者)來說是完全透明的。唯一需要說明的是跨越節點的消息必須要被序列化。

Akka架構支持本地透明性,使得Actor完全不知道他們接受的消息是從哪裏發出來的。

Actor系統設計的初衷,就是不需要任何專門的代碼就可以運行在分佈式環境中。Akka只需要一個配置文件(Application.Conf),用以說明發送消息到哪些節點。下面是配置文件的一個例子:

  1. akka {  
  2.   actor {  
  3.     provider = "akka.remote.RemoteActorRefProvider"  
  4.   }  
  5.   remote {  
  6.     transport = "akka.remote.netty.NettyRemoteTransport"  
  7.     netty {  
  8.       hostname = "127.0.0.1"  
  9.       port = 2552  
  10.     }  
  11.   }  
  12. }  

最後的一些提示

我們已經瞭解了Akka框架幫助完成併發和高性能的方法。然而,正如這篇教程指出的,爲了充分發揮Akka的能力,在設計和實現系統時,有些要點值得考慮:

  • 我們應盡最大可能爲每個Actor都分配最小的任務(如上面討論的,遵守單一職責原則)
  • Actor應該異步處理事件(也就是處理消息),不應該阻塞,否則就會發生上下文切換,影響性能。具體來說,最好是在一個Future對象裏執行阻塞操作(例如IO),這樣就不會阻塞Actor,如:
  1. case evt => blockingCall() // BAD  
  2. case evt => Future {  
  3.     blockingCall()           // GOOD  
  4. }  
  • 要確認你的消息都是不可變的,因爲互相傳遞消息的Actor都在它們自己的線程裏併發運行。可變的消息很有可能導致不可預期的行爲。
  • 由於在節點之間發送的消息必須是可序列化的,所以必須要記住消息體越大,序列化、發送和反序列化所花費的時間就越多,這也會降低性能。

結論

Akka用Scala語言寫成,簡化併爲開發高併發、分佈式和容錯式應用提供了便利,對開發者隱藏了很大程度的複雜性。把Akka用好肯定需要了解比這個教程更多的內容,但是希望這裏的介紹和示例能夠引起你的注意並繼續瞭解Akka。

Amazon、VMWare和CSC只是現在積極使用Akka的一部分領軍企業。可以訪問 Akka的官方網站學到更多的知識,並多花點時間研究Akka是否適合你的項目。

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