14.1 併發、並行和協程

14.1.1 什麼是協程

一個應用程序是運行在機器上的一個進程;進程是一個運行在自己內存地址空間裏的獨立執行體。一個進程由一個或多個操作系統線程組成,這些線程其實是共享同一個內存地址空間的一起工作的執行體。幾乎所有'正式'的程序都是多線程的,以便讓用戶或計算機不必等待,或者能夠同時服務多個請求(如 Web 服務器),或增加性能和吞吐量(例如,通過對不同的數據集並行執行代碼)。一個併發程序可以在一個處理器或者內核上使用多個線程來執行任務,但是隻有同一個程序在某個時間點同時運行在多核或者多處理器上纔是真正的並行。

並行是一種通過使用多處理器以提高速度的能力。所以併發程序可以是並行的,也可以不是。

公認的,使用多線程的應用難以做到準確,最主要的問題是內存中的數據共享,它們會被多線程以無法預知的方式進行操作,導致一些無法重現或者隨機的結果(稱作 競態)。

不要使用全局變量或者共享內存,它們會給你的代碼在併發運算的時候帶來危險。

解決之道在於同步不同的線程,對數據加鎖,這樣同時就只有一個線程可以變更數據。在 Go 的標準庫 sync 中有一些工具用來在低級別的代碼中實現加鎖;我們在第 9.3 節中討論過這個問題。不過過去的軟件開發經驗告訴我們這會帶來更高的複雜度,更容易使代碼出錯以及更低的性能,所以這個經典的方法明顯不再適合現代多核/多處理器編程:thread-per-connection 模型不夠有效。

Go 更傾向於其他的方式,在諸多比較合適的範式中,有個被稱作 Communicating Sequential Processes(順序通信處理)(CSP, C. Hoare 發明的)還有一個叫做 message passing-model(消息傳遞)(已經運用在了其他語言中,比如 Erlang)。

在 Go 中,應用程序併發處理的部分被稱作 goroutines(協程),它可以進行更有效的併發運算。在協程和操作系統線程之間並無一對一的關係:協程是根據一個或多個線程的可用性,映射(多路複用,執行於)在他們之上的;協程調度器在 Go 運行時很好的完成了這個工作。

協程工作在相同的地址空間中,所以共享內存的方式一定是同步的;這個可以使用 sync 包來實現(參見第 9.3 節),不過我們很不鼓勵這樣做:Go 使用 channels 來同步協程(可以參見第 14.2 節等章節)

當系統調用(比如等待 I/O)阻塞協程時,其他協程會繼續在其他線程上工作。協程的設計隱藏了許多線程創建和管理方面的複雜工作。

協程是輕量的,比線程更輕。它們痕跡非常不明顯(使用少量的內存和資源):使用 4K 的棧內存就可以在堆中創建它們。因爲創建非常廉價,必要的時候可以輕鬆創建並運行大量的協程(在同一個地址空間中 100,000 個連續的協程)。並且它們對棧進行了分割,從而動態的增加(或縮減)內存的使用;棧的管理是自動的,但不是由垃圾回收器管理的,而是在協程退出後自動釋放。

協程可以運行在多個操作系統線程之間,也可以運行在線程之內,讓你可以很小的內存佔用就可以處理大量的任務。由於操作系統線程上的協程時間片,你可以使用少量的操作系統線程就能擁有任意多個提供服務的協程,而且 Go 運行時可以聰明的意識到哪些協程被阻塞了,暫時擱置它們並處理其他協程。

存在兩種併發方式:確定性的(明確定義排序)和非確定性的(加鎖/互斥從而未定義排序)。Go 的協程和通道理所當然的支持確定性的併發方式(例如通道具有一個 sender 和一個 receiver)。我們會在第 14.7 節中使用一個常見的算法問題(工人問題)來對比兩種處理方式。

協程是通過使用關鍵字 go 調用(執行)一個函數或者方法來實現的(也可以是匿名或者 lambda 函數)。這樣會在當前的計算過程中開始一個同時進行的函數,在相同的地址空間中並且分配了獨立的棧,比如:go sum(bigArray),在後臺計算總和。

協程的棧會根據需要進行伸縮,不出現棧溢出;開發者不需要關心棧的大小。當協程結束的時候,它會靜默退出:用來啓動這個協程的函數不會得到任何的返回值。

任何 Go 程序都必須有的 main() 函數也可以看做是一個協程,儘管它並沒有通過 go 來啓動。協程可以在程序初始化的過程中運行(在 init() 函數中)。

在一個協程中,比如它需要進行非常密集的運算,你可以在運算循環中週期的使用 runtime.Gosched():這會讓出處理器,允許運行其他協程;它並不會使當前協程掛起,所以它會自動恢復執行。使用 Gosched() 可以使計算均勻分佈,使通信不至於遲遲得不到響應。

14.1.2 併發和並行的差異

Go 的併發原語提供了良好的併發設計基礎:表達程序結構以便表示獨立地執行的動作;所以Go的的重點不在於並行的首要位置:併發程序可能是並行的,也可能不是。並行是一種通過使用多處理器以提高速度的能力。但往往是,一個設計良好的併發程序在並行方面的表現也非常出色。

在當前的運行時(2012 年一月)實現中,Go 默認沒有並行指令,只有一個獨立的核心或處理器被專門用於 Go 程序,不論它啓動了多少個協程;所以這些協程是併發運行的,但他們不是並行運行的:同一時間只有一個協程會處在運行狀態。

這個情況在以後可能會發生改變,不過屆時,爲了使你的程序可以使用多個核心運行,這時協程就真正的是並行運行了,你必須使用 GOMAXPROCS 變量。

這會告訴運行時有多少個協程同時執行。

並且只有 gc 編譯器真正實現了協程,適當的把協程映射到操作系統線程。使用 gccgo 編譯器,會爲每一個協程創建操作系統線程。

14.1.3 使用 GOMAXPROCS

在 gc 編譯器下(6g 或者 8g)你必須設置 GOMAXPROCS 爲一個大於默認值 1 的數值來允許運行時支持使用多於 1 個的操作系統線程,所有的協程都會共享同一個線程除非將 GOMAXPROCS 設置爲一個大於 1 的數。當 GOMAXPROCS 大於 1 時,會有一個線程池管理許多的線程。通過 gccgo 編譯器 GOMAXPROCS 有效的與運行中的協程數量相等。假設 n 是機器上處理器或者核心的數量。如果你設置環境變量 GOMAXPROCS>=n,或者執行 runtime.GOMAXPROCS(n),接下來協程會被分割(分散)到 n 個處理器上。更多的處理器並不意味着性能的線性提升。有這樣一個經驗法則,對於 n 個核心的情況設置 GOMAXPROCS 爲 n-1 以獲得最佳性能,也同樣需要遵守這條規則:協程的數量 > 1 + GOMAXPROCS > 1。

所以如果在某一時間只有一個協程在執行,不要設置 GOMAXPROCS!

還有一些通過實驗觀察到的現象:在一臺 1 顆 CPU 的筆記本電腦上,增加 GOMAXPROCS 到 9 會帶來性能提升。在一臺 32 核的機器上,設置 GOMAXPROCS=8 會達到最好的性能,在測試環境中,更高的數值無法提升性能。如果設置一個很大的 GOMAXPROCS 只會帶來輕微的性能下降;設置 GOMAXPROCS=100,使用 top 命令和 H 選項查看到只有 7 個活動的線程。

增加 GOMAXPROCS 的數值對程序進行併發計算是有好處的;

請看 goroutine_select2.go

總結:GOMAXPROCS 等同於(併發的)線程數量,在一臺核心數多於1個的機器上,會盡可能有等同於核心數的線程在並行運行。

14.1.4 如何用命令行指定使用的核心數量

使用 flags 包,如下:

var numCores = flag.Int("n", 2, "number of CPU cores to use")

in main()
flag.Parse()
runtime.GOMAXPROCS(*numCores)

協程可以通過調用runtime.Goexit()來停止,儘管這樣做幾乎沒有必要。

示例 14.1-goroutine1.go 介紹了概念:

package main

import (
    "fmt"
    "time"
)

func main() {
    fmt.Println("In main()")
    go longWait()
    go shortWait()
    fmt.Println("About to sleep in main()")
    // sleep works with a Duration in nanoseconds (ns) !
    time.Sleep(10 * 1e9)
    fmt.Println("At the end of main()")
}

func longWait() {
    fmt.Println("Beginning longWait()")
    time.Sleep(5 * 1e9) // sleep for 5 seconds
    fmt.Println("End of longWait()")
}

func shortWait() {
    fmt.Println("Beginning shortWait()")
    time.Sleep(2 * 1e9) // sleep for 2 seconds
    fmt.Println("End of shortWait()")
}

輸出:

In main()
About to sleep in main()
Beginning longWait()
Beginning shortWait()
End of shortWait()
End of longWait()
At the end of main() // after 10s

main()longWait()shortWait() 三個函數作爲獨立的處理單元按順序啓動,然後開始並行運行。每一個函數都在運行的開始和結束階段輸出了消息。爲了模擬他們運算的時間消耗,我們使用了 time 包中的 Sleep 函數。Sleep() 可以按照指定的時間來暫停函數或協程的執行,這裏使用了納秒(ns,符號 1e9 表示 1 乘 10 的 9 次方,e=指數)。

他們按照我們期望的順序打印出了消息,幾乎都一樣,可是我們明白這是模擬出來的,以並行的方式。我們讓 main() 函數暫停 10 秒從而確定它會在另外兩個協程之後結束。如果不這樣(如果我們讓 main() 函數停止 4 秒),main() 會提前結束,longWait() 則無法完成。如果我們不在 main() 中等待,協程會隨着程序的結束而消亡。

main() 函數返回的時候,程序退出:它不會等待任何其他非 main 協程的結束。這就是爲什麼在服務器程序中,每一個請求都會啓動一個協程來處理,server() 函數必須保持運行狀態。通常使用一個無限循環來達到這樣的目的。

另外,協程是獨立的處理單元,一旦陸續啓動一些協程,你無法確定他們是什麼時候真正開始執行的。你的代碼邏輯必須獨立於協程調用的順序。

爲了對比使用一個線程,連續調用的情況,移除 go 關鍵字,重新運行程序。

現在輸出:

In main()
Beginning longWait()
End of longWait()
Beginning shortWait()
End of shortWait()
About to sleep in main()
At the end of main() // after 17 s

協程更有用的一個例子應該是在一個非常長的數組中查找一個元素。

將數組分割爲若干個不重複的切片,然後給每一個切片啓動一個協程進行查找計算。這樣許多並行的協程可以用來進行查找任務,整體的查找時間會縮短(除以協程的數量)。

14.1.5 Go 協程(goroutines)和協程(coroutines)

(譯者注:標題中的“Go協程(goroutines)” 即是 14 章講的協程指的是 Go 語言中的協程。而“協程(coroutines)”指的是其他語言中的協程概念,僅在本節出現。)

在其他語言中,比如 C#,Lua 或者 Python 都有協程的概念。這個名字表明它和 Go協程有些相似,不過有兩點不同:

  • Go 協程意味着並行(或者可以以並行的方式部署),協程一般來說不是這樣的
  • Go 協程通過通道來通信;協程通過讓出和恢復操作來通信

Go 協程比協程更強大,也很容易從協程的邏輯複用到 Go 協程。

鏈接

results matching ""

    No results matching ""