Golang - 排程剖析【第三部分】
簡介
首先,在我平時遇到問題的時候,特別是如果它是一個新問題,我一開始並不會考慮使用併發的設計去解決它。我會先實現順序執行的邏輯,並確保它能正常工作。然後在可讀性和技術關鍵點都 Review 之後,我才會開始思考併發執行的實用性和可行性。有的時候,併發執行是一個很好的選擇,有時則不一定。
在本系列的第一部分中,我解釋了系統排程 的機制和語義,如果你打算編寫多執行緒程式碼,我認為這些機制和語義對於實現正確的邏輯是很重要的。在第二部分中,我解釋了Go 排程 的語義,我認為它能幫助你理解如何在 Go 中編寫高質量的併發程式。在這篇文章中,我會把系統排程 和Go 排程 的機制和語義結合在一起,以便更深入地理解什麼才是併發以及它的本質。
什麼是併發
併發意味著亂序
執行。拿一組原來是順序執行的指令,而後找到一種方法,使這些指令亂序執行,但仍然產生相同的結果。
那麼,順序執行還是亂序執行?根本在於,針對我們目前考慮的問題,使用併發必須是有收益的!確切來說,是併發帶來的效能提升要大於它帶來的複雜性成本。當然有些場景,程式碼邏輯就已經約束了我們不能執行亂序,這樣使用併發也就沒有了意義。
併發與並行
理解併發
與並行
的不同也非常重要。並行
意味著同時執行兩個或更多指令,簡單來說,只有多個CPU核心之間才叫並行
。在 Go 中,至少要有兩個作業系統硬體執行緒並至少有兩個 Goroutine 時才能實現並行,每個 Goroutine 在一個單獨的系統執行緒上執行指令。
如圖:
我們看到有兩個邏輯處理器P
,每個邏輯處理器都掛載在一個系統執行緒M
上,而每個M
適配到計算機上的一個CPU處理器Core
。
其中,有兩個 GoroutineG1
和G2
在並行
執行,因為它們同時在各自的系統硬體執行緒上執行指令。
再看,在每一個邏輯處理器中,都有三個 GoroutineG2 G3 G5
或G1 G4 G6
輪流共享各自的系統執行緒。看起來就像這三個 Goroutine 在同時執行著,沒有特定順序地執行它們的指令,並在系統執行緒上共享時間。
那麼這就會發生競爭 ,有時候如果只在一個物理核心上實現併發則實際上會降低吞吐量。還有有意思的是,有時候即便利用上了並行的併發,也不會給你帶來想象中更大的效能提升。
工作負載
我們怎麼判斷在什麼時候併發會更有意義呢?我們就從瞭解當前執行邏輯的工作負載型別開始。在考慮併發時,有兩種型別的工作負載是很重要的。
兩種型別
CPU-Bound:這是一種不會導致 Goroutine 主動切換上下文到等待狀態的型別。它會一直不停地進行計算。比如說,計算 π 到第 N 位的 Goroutine 就是 CPU-Bound 的。
IO-Bound:與上面相反,這種型別會導致 Goroutine 自然地進入到等待狀態。它包括請求通過網路訪問資源,或使用系統呼叫進入作業系統,或等待事件的發生。比如說,需要讀取檔案的 Goroutine 就是 IO-Bound。我把同步事件(互斥,原子),會導致 Goroutine 等待的情況也包含在此類。
在CPU-Bound
中,我們需要利用並行。因為單個系統執行緒處理多個 Goroutine 的效率不高。而使用比系統執行緒更多的 Goroutine 也會拖慢執行速度,因為在系統執行緒上切換 Goroutine 是有時間成本的。上下文切換會導致發生STW(Stop The World)
,意思是在切換期間當前工作指令都不會被執行。
在IO-Bound
中,並行則不是必須的了。單個系統執行緒可以高效地處理多個 Goroutine,是因為Goroutine 在執行這類指令時會自然地進入和退出等待狀態。使用比系統執行緒更多的 Goroutine 可以加快執行速度,因為此時在系統執行緒上切換 Goroutine 的延遲成本並不會產生STW
事件。進入到IO阻塞時,CPU就閒下來了,那麼我們可以使不同的 Goroutine 有效地複用相同的執行緒,不讓系統執行緒閒置。
我們如何評估一個系統執行緒匹配多少 Gorountine 是最合適的呢?如果 Goroutine 少了,則會無法充分利用硬體;如果 Goroutine 多了,則會導致上下文切換延遲。這是一個值得考慮的問題,但此時暫不深究。
現在,更重要的是要通過仔細推敲程式碼來幫助我們準確識別什麼情況需要併發,什麼情況不能用併發,以及是否需要並行。
加法
我們不需要複雜的程式碼來展示和理解這些語義。先來看看下面這個名為add
的函式:
1 func add(numbers []int) int { 2var v int 3for _, n := range numbers { 4v += n 5} 6return v 7 }
在第 1 行,聲明瞭一個名為add
的函式,它接收一個整型切片並返回切片中所有元素的和。它從第 2 行開始,聲明瞭一個v
變數來儲存總和。然後第 3 行,線性地遍歷切片,並且每個數字被加到v
中。最後在第 6 行,函式將最終的總和返回給呼叫者。
問題:add
函式是否適合併發執行?從大體上來說答案是適合的。可以將輸入切片分解,然後同時處理它們。最後將每個小切片的執行結果相加,就可以得到和順序執行相同的最終結果。
與此同時,引申出另外一個問題:應該分成多少個小切片來處理是效能最佳的呢?要回答此問題,我們必須知道它的工作負載型別。
add
函式正在執行CPU-Bound
工作負載,因為實現演算法正在執行純數學運算,並且它不會導致 Goroutine 進入等待狀態。這意味著每個系統執行緒使用一個 Goroutine 就可以獲得不錯的吞吐量。
併發版本
下面來看一下併發版本如何實現,宣告一個addConcurrent
函式。程式碼量相比順序版本增加了很多。
1 func addConcurrent(goroutines int, numbers []int) int { 2var v int64 3totalNumbers := len(numbers) 4lastGoroutine := goroutines - 1 5stride := totalNumbers / goroutines 6 7var wg sync.WaitGroup 8wg.Add(goroutines) 9 10for g := 0; g < goroutines; g++ { 11go func(g int) { 12start := g * stride 13end := start + stride 14if g == lastGoroutine { 15end = totalNumbers 16} 17 18var lv int 19for _, n := range numbers[start:end] { 20lv += n 21} 22 23atomic.AddInt64(&v, int64(lv)) 24wg.Done() 25}(g) 26} 27 28wg.Wait() 29 30return int(v) 31 }
第 5 行:計算每個 Goroutine 的子切片大小。使用輸入切片總數除以 Goroutine 的數量得到。
第 10 行:建立一定數量的 Goroutine 執行子任務
第 14-16 行:子切片剩下的所有元素都放到最後一個 Goroutine 執行,可能比前幾個 Goroutine 處理的資料要多。
第 23 行:將子結果追加到最終結果中。
然而,併發版本肯定比順序版本更復雜,但和增加的複雜性相比,效能有提升嗎?值得這麼做嗎?讓我們用事實來說話,下面執行基準測試。
基準測試
下面的基準測試,我使用了1000萬個數字的切片,並關閉了GC。分別有順序版本add
函式和併發版本addConcurrent
函式。
func BenchmarkSequential(b *testing.B) { for i := 0; i < b.N; i++ { add(numbers) } } func BenchmarkConcurrent(b *testing.B) { for i := 0; i < b.N; i++ { addConcurrent(runtime.NumCPU(), numbers) } }
無並行
以下是所有 Goroutine 只有一個硬體執行緒可用的結果。順序版本使用1 Goroutine
,併發版本在我的機器上使用runtime.NumCPU
或8 Goroutines
。在這種情況下,併發版本實際正跑在沒有並行的機制上。
10 Million Numbers using 8 goroutines with 1 core 2.9 GHz Intel 4 Core i7 Concurrency WITHOUT Parallelism ----------------------------------------------------------------------------- $ GOGC=off go test -cpu 1 -run none -bench . -benchtime 3s goos: darwin goarch: amd64 pkg: github.com/ardanlabs/gotraining/topics/go/testing/benchmarks/cpu-bound BenchmarkSequential10005720764 ns/op : ~10% Faster BenchmarkConcurrent10006387344 ns/op BenchmarkSequentialAgain10005614666 ns/op : ~13% Faster BenchmarkConcurrentAgain10006482612 ns/op
結果表明:當只有一個系統執行緒可用於所有 Goroutine 時,順序版本比並發快約10%到13%。這和我們之前的理論預期相符,主要就是因為併發版本在單核上的上下文切換和 Goroutine 管理排程的開銷。
有並行
以下是每個 Goroutine 都有單獨可用的系統執行緒的結果。順序版本使用1 Goroutine
,併發版本在我的機器上使用runtime.NumCPU
或8 Goroutines
。在這種情況下,併發版本利用上了並行機制。
10 Million Numbers using 8 goroutines with 8 cores 2.9 GHz Intel 4 Core i7 Concurrency WITH Parallelism ----------------------------------------------------------------------------- $ GOGC=off go test -cpu 8 -run none -bench . -benchtime 3s goos: darwin goarch: amd64 pkg: github.com/ardanlabs/gotraining/topics/go/testing/benchmarks/cpu-bound BenchmarkSequential-810005910799 ns/op BenchmarkConcurrent-820003362643 ns/op : ~43% Faster BenchmarkSequentialAgain-810005933444 ns/op BenchmarkConcurrentAgain-820003477253 ns/op : ~41% Faster
結果表明:當為每個 Goroutine 提供單獨的系統執行緒時,併發版本比順序版本快大約41%到43%。這才也和預期一致,所有 Goroutine 現都在並行執行著,意味著他們真的在同時執行。
排序
另外,我們也要知道並非所有的CPU-Bound 都適合併發。當切分輸入或合併結果的代價非常高時,就不太合適。下面展示一個氣泡排序演算法來說明此場景。
順序版本
01 package main 02 03 import "fmt" 04 05 func bubbleSort(numbers []int) { 06n := len(numbers) 07for i := 0; i < n; i++ { 08if !sweep(numbers, i) { 09return 10} 11} 12 } 13 14 func sweep(numbers []int, currentPass int) bool { 15var idx int 16idxNext := idx + 1 17n := len(numbers) 18var swap bool 19 20for idxNext < (n - currentPass) { 21a := numbers[idx] 22b := numbers[idxNext] 23if a > b { 24numbers[idx] = b 25numbers[idxNext] = a 26swap = true 27} 28idx++ 29idxNext = idx + 1 30} 31return swap 32 } 33 34 func main() { 35org := []int{1, 3, 2, 4, 8, 6, 7, 2, 3, 0} 36fmt.Println(org) 37 38bubbleSort(org) 39fmt.Println(org) 40 }
這種排序演算法會掃描每次在交換值時傳遞的切片。在對所有內容進行排序之前,可能需要多次遍歷切片。
那麼問題:bubbleSort
函式是否適用併發?我相信答案是否定的。原始切片可以分解為較小的,並且可以同時對它們排序。但是!在併發執行完之後,沒有一個有效的手段將子結果的切片排序合併。下面我們來看併發版本是如何實現的。
併發版本
01 func bubbleSortConcurrent(goroutines int, numbers []int) { 02totalNumbers := len(numbers) 03lastGoroutine := goroutines - 1 04stride := totalNumbers / goroutines 05 06var wg sync.WaitGroup 07wg.Add(goroutines) 08 09for g := 0; g < goroutines; g++ { 10go func(g int) { 11start := g * stride 12end := start + stride 13if g == lastGoroutine { 14end = totalNumbers 15} 16 17bubbleSort(numbers[start:end]) 18wg.Done() 19}(g) 20} 21 22wg.Wait() 23 24// Ugh, we have to sort the entire list again. 25bubbleSort(numbers) 26 }
bubbleSortConcurrent
它使用多個 Goroutine 同時對輸入的一部分進行排序。我們直接來看結果:
Before: 25 51 15 57 87 10 10 85 90 32 98 53 91 82 84 97 67 37 71 94 262 81 79 66 70 93 86 19 81 52 75 85 10 87 49 After: 10 10 15 25 32 51 53 57 85 87 90 98 2 26 37 67 71 79 81 82 84 91 94 97 10 19 49 52 66 70 75 81 85 86 87 93
由於氣泡排序的本質是依次掃描,第 25 行對bubbleSort
的呼叫將掩蓋使用併發解決問題帶來的潛在收益。結論是:在氣泡排序中,使用併發不會帶來效能提升。
讀取檔案
前面已經舉了兩個CPU-Bound 的例子,下面我們來看IO-Bound 。
順序版本
01 func find(topic string, docs []string) int { 02var found int 03for _, doc := range docs { 04items, err := read(doc) 05if err != nil { 06continue 07} 08for _, item := range items { 09if strings.Contains(item.Description, topic) { 10found++ 11} 12} 13} 14return found 15 }
第 2 行:聲明瞭一個名為found
的變數,用於儲存在給定文件中找到指定主題的次數。
第 3-4 行:迭代文件,並使用read
函式讀取每個文件。
第 8-11 行:使用strings.Contains
函式檢查文件中是否包含指定主題。如果包含,則found
加1。
然後來看一下read
是如何實現的。
01 func read(doc string) ([]item, error) { 02time.Sleep(time.Millisecond) // 模擬阻塞的讀 03var d document 04if err := xml.Unmarshal([]byte(file), &d); err != nil { 05return nil, err 06} 07return d.Channel.Items, nil 08 }
此功能以time.Sleep
開始,持續1毫秒。此呼叫用於模擬在我們執行實際系統呼叫以從磁碟讀取文件時可能產生的延遲。這種延遲的一致性對於準確測量find
順序版本和併發版本的效能差距非常重要。
然後在第 03-07 行,將儲存在全域性變數檔案中的模擬xml
文件反序列化為struct
值。最後,將Items
返回給呼叫者。
併發版本
01 func findConcurrent(goroutines int, topic string, docs []string) int { 02var found int64 03 04ch := make(chan string, len(docs)) 05for _, doc := range docs { 06ch <- doc 07} 08close(ch) 09 10var wg sync.WaitGroup 11wg.Add(goroutines) 12 13for g := 0; g < goroutines; g++ { 14go func() { 15var lFound int64 16for doc := range ch { 17items, err := read(doc) 18if err != nil { 19continue 20} 21for _, item := range items { 22if strings.Contains(item.Description, topic) { 23lFound++ 24} 25} 26} 27atomic.AddInt64(&found, lFound) 28wg.Done() 29}() 30} 31 32wg.Wait() 33 34return int(found) 35 }
第 4-7 行:建立一個channel
並寫入所有要處理的文件。
第 8 行:關閉這個channel
,這樣當讀取完所有文件後就會直接退出迴圈。
第 16-26 行:每個 Goroutine 都從同一個channel
接收文件,read
並strings.Contains
邏輯和順序的版本一致。
第 27 行:將各個 Goroutine 計數加在一起作為最終計數。
基準測試
同樣的,我們再次執行基準測試來驗證我們的結論。
func BenchmarkSequential(b *testing.B) { for i := 0; i < b.N; i++ { find("test", docs) } } func BenchmarkConcurrent(b *testing.B) { for i := 0; i < b.N; i++ { findConcurrent(runtime.NumCPU(), "test", docs) } }
無並行
10 Thousand Documents using 8 goroutines with 1 core 2.9 GHz Intel 4 Core i7 Concurrency WITHOUT Parallelism ----------------------------------------------------------------------------- $ GOGC=off go test -cpu 1 -run none -bench . -benchtime 3s goos: darwin goarch: amd64 pkg: github.com/ardanlabs/gotraining/topics/go/testing/benchmarks/io-bound BenchmarkSequential31483458120 ns/op BenchmarkConcurrent20188941855 ns/op : ~87% Faster BenchmarkSequentialAgain21502682536 ns/op BenchmarkConcurrentAgain20184037843 ns/op : ~88% Faster
當只有一個系統執行緒時,併發版本比順序版本快大約87%到88%。與預期一致,因為所有 Goroutine 都有效地共享單個系統執行緒。
有並行
10 Thousand Documents using 8 goroutines with 8 core 2.9 GHz Intel 4 Core i7 Concurrency WITH Parallelism ----------------------------------------------------------------------------- $ GOGC=off go test -run none -bench . -benchtime 3s goos: darwin goarch: amd64 pkg: github.com/ardanlabs/gotraining/topics/go/testing/benchmarks/io-bound BenchmarkSequential-831490947198 ns/op BenchmarkConcurrent-820187382200 ns/op : ~88% Faster BenchmarkSequentialAgain-831416126029 ns/op BenchmarkConcurrentAgain-820185965460 ns/op : ~87% Faster
有意思的來了,使用額外的系統執行緒提供並行能力,實際程式碼效能卻沒有提升。也印證了開頭的說法。
結語
我們可以清楚地看到,使用IO-Bound 並不需要並行來獲得性能上的巨大提升。這與我們在CPU-Bound 中看到的結果相反。當涉及像氣泡排序這樣的演算法時,併發的使用會增加複雜性而沒有任何實際的效能優勢。
所以,我們在考慮解決方案時,首先要確定它是否適合併發,而不是盲目認為使用更多的 Goroutine 就一定會提升效能。