Scala與Golang的並發實現對比

並發語言儼然是應大規模應用架構的需要而提出,有其現實所需。前後了解了Scala和Golang,深深體會到現代並發語言與舊有的Java、C++等語言在風格及理念上的巨大差異。本文主要針對Scala和Golang這兩個我喜愛的並發語言在並發特性上的不同實現,做個比較和闡述,以進一步加深理解。

一. Scala與Golang的並發實現思路

Scala語言並發設計採用Actor模型,借鑒了Erlang的Actor實現,並且在Scala 2.10之後,Scala採用的是Akka Actor模型庫。Actor模型主要特徵如下:

  1. 「一切皆是參與者」,且各個actor間是獨立的;
  2. 發送者與已發送消息間解耦,這是Actor模型顯著特點,據此實現非同步通信;
  3. actor是封裝狀態和行為的對象,通過消息交換進行相互通信,交換的消息存放在接收方的郵箱中;
  4. actor可以有父子關係,父actor可以監管子actor,子actor唯一的監管者就是父actor;
  5. 一個actor就是一個容器,它包含了狀態、行為、一個郵箱(郵箱用來接受消息)、子actor和一個監管策略;

Go語言也能夠實現傳統的共享內存的通信方式,但Go更提倡「以通信來共享內存,而非以共享內存來通信」。Go的並發通信方式借鑒CSP(Communicating Sequential Process)模型,其主要特徵如下:

  1. goroutine(協程,Go的輕量級線程)是Go的輕量級線程管理機制,用「go」啟動一個goroutine, 如果當前線程阻塞則分配一個空閑線程,如果沒有空閑線程,則新建一個線程;
  2. 通過管道(channel)來存放消息,channel在goroutine之間傳遞消息;比如通過讀取channel里的消息(通俗點說好比一個個「值」),你能夠明白某個goroutine里的任務完成以否;
  3. Go給channel做了增強,可帶緩存。

Scala與Go在並發通信模型實現上的主要差異如下:

  1. actor是非同步的,因為發送者與已發送消息間實現了解耦;而channel則是某種意義上的同步,比如channel的讀寫是有關係的,期間會依賴對方來決定是否阻塞自己;
  2. actor是一個容器,使用actorOf來創建Actor實例時,也就意味著需指定具體Actor實例,即指定哪個actor在執行任務,該actor必然要有「身份」標識,否則怎麼指定呢?!而channel通常是匿名的, 任務放進channel之後你不用關心是哪個channel在執行任務;

二. 實例說明

我們來看一個例子:對一組連續序列(1-10000)的整數值進行累加,分別觀察Scala與Go環境下單線程與多線程效率,一方面了解並發效率的提升;一方面也能夠對比Scala與Go並發實現的差異 ── 這才是本文的重點。具體要求如下:

對1 - 10000的整數進行累加,在並發條件下,我們將1 - 10000平均劃分為四部分,啟動四個線程進行並發計算,之後將四個線程的運行結果相加得出最終的累加統計值。為了更明顯地觀察到時間上的差異性,在每部分的每次計算過程中,我們添加一個3000000次的空循環:)

三. Scala實現

以下先列出Scala Akka Actor並發實現的完整示例代碼:

// Akka並發計算實例nnimport akka.actor.Actornimport akka.actor.Propsnimport akka.actor.ActorSystemnimport akka.routing.RoundRobinPooln n// 定義一個case類nsealed trait SumTraitncase class Result(value: Int) extends SumTraitn n// 計算用的Actornclass SumActor extends Actor {n val RANGE = 10000n n def calculate(start: Int, end: Int, flag : String): Int = {n var cal = 0n n for (i <- (start to end)) {n for (j <- 1 to 3000000) {}n cal += in }n n println("flag : " + flag + ".")n return caln }n n def receive = {n case value: Int =>n sender ! Result(calculate((RANGE / 4) * (value - 1) + 1, (RANGE / 4) * value, value.toString))n case _ => println("未知 in SumActor...")n }n}n n// 列印結果用的Actornclass PrintActor extends Actor {n def receive = {n case (sum: Int, startTime: Long) =>n println("總數為:" + sum + ";所花時間為:"n + (System.nanoTime() - startTime)/1000000000.0 + "秒。")n case _ => println("未知 in PrintActor...")n }n}n n// 主actor,發送計算指令給SumActor,發送列印指令給PrintActornclass MasterActor extends Actor {n var sum = 0n var count = 0n var startTime: Long = 0n n // 聲明Actor實例,nrOfInstances是pool里所啟routee(SumActor)的數量,n // 這裡用4個SumActor來同時計算,很Powerful。n val sumActor = context.actorOf(Props[SumActor]n .withRouter(RoundRobinPool(nrOfInstances = 4)), name = "sumActor")n val printActor = context.actorOf(Props[PrintActor], name = "printActor")n n def receive = {n case "calculate..." =>n startTime = System.nanoTime()n for (i <- 1 to 4) sumActor ! in case Result(value) =>n sum += valuen count += 1n if (count == 4) {n printActor ! (sum, startTime)n context.stop(self)n }n case _ => println("未知 in MasterActor...")n }n}n nobject Sum {n def main(args: Array[String]): Unit = {n var sum = 0n n val system = ActorSystem("MasterActorSystem")n val masterActor = system.actorOf(Props[MasterActor], name = "masterActor")n masterActor ! "calculate..."n n Thread.sleep(5000)n system.shutdown()n }n}n

在這裡我們設計了3個Actor實例,如下圖所示:

在這裡,我們一共定義了 三個Actor實例(actor),MasterActor、SumActor和PrintActor,其中,前者是後兩者的父親actor,如前文Scala的Actor模型特徵里提到的:「actor可以有父子關係,父actor可以監管子actor,子actor唯一的監管者就是父actor」。

我們的主程序通過向MasterActor發送「calculate...」指令,啟動整個計算過程,嗯哼,好戲開始登場了:)

注意以下代碼:

val sumActor = context.actorOf(Props[SumActor]n .withRouter(RoundRobinPool(nrOfInstances = 4)), name = "sumActor")n

這裡的設置將會在線程池裡初始化稱為「routee」的子actor(這裡是SumActor),數量為4,也就是我們需要4個SumActor實例參與並發計算。這一步很關鍵。

然後,在接受消息的模式匹配中,通過以下代碼啟動計算actor:

for (i <- 1 to 4) sumActor ! i n

在SumActor中,每個計算線程都會調用calculate方法,該方法將處理分段的整數累加,並返回分段累加值給父actor MasterActor,我們特地通過case類實現MasterActor接受消息中的一個模式匹配功能(case Result(value) =>...),可以發現,模式匹配在Scala並發功能實現中的地位非常重要,並大大提升了開發人員的開發效率。在這裡,我們獲取了4個並發過程返回的分段累加值,MasterActor會計算最終的累加值。如果4個並發過程全部完成,就調用PrintActor實例列印結果和所花時間。

在整個運算過程中,我們很容易理解發送者與已發送消息間的解耦特徵,發送者和接受者各種關心自己要處理的任務即可,比如狀態和行為處理、發送的時機與內容、接收消息的時機與內容等。當然,actor確實是一個「容器」,且「五臟俱全」:我們用類來封裝,裡面也封裝了必須的邏輯方法。

Scala Akka的並發實現,給我的感覺是設計才是關鍵,將各個actor的功能及關聯關係表述清楚,剩餘的代碼實現就非常容易,這正是Scala、Akka的魅力體現,在底層幫我們做了大量工作!

在這裡的PrintActor實際上並無太大存在意義,因為它並不實現並發功能。實現它主要是為了演示actor間的消息傳遞與控制。

再來看看單線程的計算運行模式:

...nval RANGE = 10000nvar cal = 0nval startTime = System.nanoTime()n nfor (i <- (1 to RANGE)) {n for (j <- 1 to 3000000) {}n cal += in}n nval endTime = System.nanoTime()n...n

並發與單線程兩種模式的效率在後面一塊說,暫且按下不表。

四. Go語言實現

仍然先列出Go語言實現的並發功能完整代碼:

// Go並發計算實例n npackage mainn nimport (n "fmt"n "runtime"n "strconv"n "time"n)n ntype Sum []intn nfunc (s Sum) Calculate(count, start, end int, flag string, ch chan int) {n cal := 0n n for i := start; i <= end; i++ {n for j := 1; j <= 3000000; j++ {n }n cal += in }n n s[count] = caln fmt.Println("flag :", flag, ".")n ch <- countn}n nfunc (s Sum) LetsGo() {n // runtime.NumCPU()可以獲取CPU核數,我的環境為4核,所以這裡就簡單起見直接設為4了n const NCPU = 4n const RANGE = 10000n var ch = make(chan int)n n runtime.GOMAXPROCS(NCPU)n for i := 0; i < NCPU; i++ {n go s.Calculate(i, (RANGE/NCPU)*i+1, (RANGE/NCPU)*(i+1), strconv.Itoa(i+1), ch)n }n n for i := 0; i < NCPU; i++ {n <-chn }n}n nfunc main() {n var s Sum = make([]int, 4, 4)n var sum int = 0n var startTime = time.Now()n n s.LetsGo()n n for _, v := range s {n sum += vn }n n fmt.Println("總數為:", sum, ";所花時間為:",n (time.Now().Sub(startTime)), "秒。")n}n

Go語言的實現與之前的Scala實現風格完全不一樣,其通過「go」關鍵字實現的goroutine協程工作方式,結合channel,實現並發功能。goroutine和channel是Go語言非常強大的兩個招式,簡約而不簡單。在這裡,我們的並發實現模型如下圖所示:

由上可知,Go語言的並發魔力來源於goroutine和channel。我們定義了一個Sum類型(插一句:Go語言的類型系統設計得也非常特別,這是別的主題了,:)),它有兩個方法:LetsGo()和Calculate,LetsGo()首先創建一個計數用的channel,隨後發起4個並發計算的協程。每個計算協程調用Calculate()進行分段計算(並會傳入channel),Calculate()方法的最後,在分段計算完成時,都會往channel里塞一個計數標誌:

ch <- count n

總有某個協程搶先運行到此處,那麼該協程對應的計數標誌就塞進了channel,在channel里的計數標誌未被讀取之前,其他協程在處理完分段計算的業務邏輯之後,其他協程的計數標誌是無法塞進channel里的,其他協程只能等待,因為channel在之前被塞進一個計數標誌之後,標誌一直未被讀取出來,程序阻塞了。再看看以下代碼:

for i := 0; i < NCPU; i++ {n <-chn}n

在這裡,從channel依次取出協程里塞進的計數標誌。每取出了一個標誌,則意味著該標誌對應的協程結束使命,下一個協程在判斷channel為空之後,會將它的計數標誌塞進channel。如此循環,直至channel里的計數標誌全被取出,則所有的協程都處理完畢了。另外,如果讀取的channel里沒東西了還繼續讀取它會怎樣?那麼,程序也會阻塞,直至有東西可讀。

對於channel的寫入、等待和讀取,簡單形象地用下圖描述:

這裡為了演示方便,且本例中的協程和業務邏輯也不至於會造成協程僵死或locked,因此未考慮協程永久等待的處理,如果要處理超時,可以這麼考慮:

for {n select {n case <-ch: ...n case <-time.After(3 * time.Second): ...n }n}n

select機制也是Go語言並發處理中的強大武器,由於與本主題關係不大,故不表。但可以看出,Go語言有Unix和C的深深烙印,select、channel概念就是很好的例證。

在所有的分段計算結束後,就可以計算總的累加值了:

for _, v := range s {n sum += vn}n

這段代碼從Sum類型實例中獲取分段累加值,最後計算出總的累加統計值。

Go中的channel是可以帶緩存的,在緩存未被填滿之前,都可以寫入。本例中未使用帶緩存的channel,雖然這樣做在理論上可以節省寫入channel時的等待時間,但在這裡可以忽略,大型應用中就要慎重對待了。

來看看單線程的計算運行模式:

...ncal := 0n nfor i := start; i <= end; i++ {n for j := 1; j <= 3000000; j++ {n }n cal += in}n...n

五. 對Scala與Go的感知

運行效率

先來看看運行效率。我的操作系統是Windows 8.1 64位,分別用以下命令編譯及運行Scala和Go程序並發程序:

scalac -cp libakka-actor_2.11-2.3.4.jar Sum.scalanscala Sumn ngo build Sum.gonSumn

具體運行時間如下所列:

Scala:7.189461763秒(單線程模式),3.895642655秒(併發模式)

Golang 12.987232秒(單線程模式),7.1636263秒(併發模式)

從上可知,Scala與Go語言的並發實現都比單線程實現快了45%左右,這個數據還是比較可觀的。而Golang並發卻比Scala並發慢了不少,事實的確如此嗎?我在另一台比較舊的32位操作系統機器上運行,Scala的並發足足花了近300秒,而Go語言並發差不多是20秒。因此,拿Scala和Go的並發效率來對比,應該是沒什麼意義的,其間要受到各自內部實現、類型系統、內存使用機制、併發模式、並發規模以及硬體支持等等複雜因素的影響。如果一定要對兩者進行比較,則肯定會引發口水戰。

模式上的差異

如果前面講述「Scala與Golang的並發實現思路」時,理解起來還比較抽象,但經過上面的示例說明與比較,相信感知會比較具體了:

  1. Akka的actor是解耦的、相對獨立的,定義好各個actor間如何溝通,剩下的東西就儘管交給它們處理好了,它們自會按既定方式各司其職,而且每個actor「麻雀雖小五臟俱全」,這也是其解耦性做得好的必然基礎。Go語言則獨闢蹊徑,通過「go」魔法和Unix風格的channel,以更輕量級的協程方式來處理並發,雖說是更輕量,但你仍得花點心思關注下channel的狀態,別一不小心阻塞了,特別是channel多了、複雜了,並且其中包含了業務處理所需數據、而不僅僅只是計數標誌的情況下;

  2. Akka的Actor實現是庫的形式,其也能應用於Java語言。Go語言內嵌了協程的並發實現;

  3. Akka基於JVM,實現模式是面向對象,天然講究抽象與封裝,雖然可以穿插混合應用函數式風格。而Go語言顯然體現了命令式語言的風格,在需要考慮封裝性的時候,需要開發者多著墨。

是Scala還是Go?

據說Go語言中輕量級的協程可以輕易啟動數十上百萬個,這對Scala來說當然是有壓力的。但相較而言,Go語言的普及及應用程度尚遠不及Java生態,我也希望更多的應用能夠實踐Go語言。此外,從代碼簡潔程度來看,Go語言應該會更簡潔些吧。

在你了解了Akka之後,再回過頭來看看Java與它的concurrent,就會有一種弱爆了的感覺,動不動就阻塞、同步。因此,如果是Java平台上的選擇,不要說Akka就是很重要的考量指標了。

不得不提的一點是,不同模式有其適用的業務和環境,因此,選擇Scala還是Go語言來實現功能,這必須有賴於現實業務與環境的需求──是Scala還是Go?這永遠是個問題。

六. 結束語

發實現及場景是複雜的,比如遠程調用、異常處理以及選擇恰當的併發模式等。需要不斷深入學習與實踐,才能對並發技能運用自如。希望通過本文的闡述,能夠讓你了解到一些Scala與Golang的並發實現思路。


推薦閱讀:

如何看待 Scala.js?
scala 逆變有什麼用?
Scala這門語言最早是如何被設計出來的?

TAG:Scala | Go语言 | 并发 | 编程 | 编程语言 |