前言html
這個是用來說述go調度器機制和特性的第三部分. 這個主要關注併發. git
博客三部分的順序:github
1) go調度: 第一部分-操做系統調度算法
2) go調度: 第二部分-go調度器網絡
3) go調度: 第三部分-併發多線程
介紹併發
當我在解決一個問題, 尤爲是一個新問題的時候, 開始階段, 我不會考慮併發是否是有用. 我尋找一個順序化解決方案, 而且確保這個方案有效. 而後, 我進行評估, 來看看併發是否合適. 有些狀況下, 併發是很合適的, 而有些狀況下則未必.ide
在第一部分,我講了操做系統的調度器的特性, 若是你想寫多線程應用, 這個是頗有必要的. 在第二部分, 我講了go的調度器特性, 我認爲, 這個對使用go語言寫多線程是頗有意義的. 在這篇中, 我將結合操做系統調度和go程調度, 來提供一個對於使用go語言寫多線程的更加深刻的理解.函數
這篇博客的目的是:性能
(1) 辨別你代碼應用的場景是否適合使用併發.
(2) 展現如何根據應用場景改變併發的使用.
什麼是併發
併發意味着亂序執行. 一系列指令, 能夠被順序執行, 也能夠在知足限制的狀況下亂序執行, 可是仍是能夠產生相同的結果. 對於你眼前的這個問題, 亂序執行必須能夠展現出明顯的價值, 也就是說, 併發能夠明顯的提升性能, 同時, 沒有代碼複雜度的增長能夠容忍. 根據你的問題, 亂序執行有時候是不可行的.
有一個重要的點須要注意, 併發不等於並行. 並行意味着同一時間執行多條指令. 這個與併發的概念並不相同. 只有在有至少2個操做系統線程(運行在兩個硬件線程之上), 而且有至少2個go程的狀況下, 每一個操做系統/硬件線程運行一組指令的狀況下, 並行纔會發生.
圖1
在圖1中, 你看到兩個邏輯處理器(P)運行在兩個不一樣的操做系統線程(M)上, 這兩個M對應着不一樣的硬件線程. 這種狀況下, 兩個go程(G1和G2)處於並行狀態. 在每一個邏輯處理器上, go程輪流分享操做系統線程. 全部的這些go程都併發地執行, 這些go程分享操做系統的運行時間, 以一種不肯定的順序運行.
有一點須要注意的是, 有時候沒有並行執行的併發會下降程序的性能. 另外, 有時候程序並行執行, 可是並不會明顯提高你的程序的性能.
負載
咱們如何知道併發是否是有意義呢? 理解你的問題的負載類型, 是一個好的入手點. 在考慮使用併發時, 你須要區分以下兩類負載:
(1) CPU密集型: 這類問題, 主要用來作計算, 不會讓go程常常在等待和運行狀態之間切換. 計算Pi的第Nth小數屬於這類負載.
(2) IO密集型. 這類負載須要go程常常在等待和運行狀態之間切換. 這類工做包括網絡請求資源, 操做系統調用, 等待事件發生. 讀取文件, 使用同步事件(mutexes, atomic)屬於這類負載.
對於CPU密集型負載, 你須要使用並行來提升性能. 一個單一的操做系統/硬件線程處理多個go程, 比處理單個go程性能更差, 由於要進行等待和運行狀態的切換. 因此, 在這種狀況下, go程數超過操做系統/硬件線程數, 會下降性能, 而不是提升性能.
對於IO密集型負載, 你能夠經過併發(能夠不適用並行)來提升性能. 一個操做系統/硬件線程能夠高效地處理不少個go程, 由於go調度器很擅長處理等待和運行狀態的切換. go程數超過操做系統/硬件線程數, 能夠加快負載的執行, 由於這種狀況下, 能夠減小操做系統/硬件線程的空載時間.
咱們如何知道每一個硬件線程對應多少個go程比較合適? go程不多意味着更多的空載時間. 線程太多, 用於上下文切換的時間就會花費不少.
下面, 咱們看一些代碼, 學習在什麼狀況下, 能夠利用併發, 何時能夠利用並行.
加法運算(Adding Numbers)
咱們不須要複雜的代碼來理解這種語境. 看下面這個將多個數字加在一塊兒的函數.
36 func add(numbers []int) int { 37 var v int 38 for _, n := range numbers { 39 v += n 40 } 41 return v 42 }
問題: add函數是一個適合併發的負載嗎? 我相信是的. 這些整數能夠拆分程更小的幾組整數, 而後每組整數併發計算. 當這些組整數都相加完成後, 而後將這些整數相加的結果進行相加, 就能夠獲得最終的結果.
然而, 如今有另一個問題, 咱們須要拆成多少個小的組, 而後讓他們併發執行, 從而提供最好的性能? 爲了回答這個問題, 咱們須要知道add的負載類型. add函數是CPU密集型的負載, 由於這個函數只進行數學運算, 而不會使go程進入等待狀態. 這種狀況下, 每一個go程對應一個操做系統/硬件線程是合理的.
Listing 2是個人add函數的併發版本.
Listing 2
44 func addConcurrent(goroutines int, numbers []int) int { 45 var v int64 46 totalNumbers := len(numbers) 47 lastGoroutine := goroutines - 1 48 stride := totalNumbers / goroutines 49 50 var wg sync.WaitGroup 51 wg.Add(goroutines) 52 53 for g := 0; g < goroutines; g++ { 54 go func(g int) { 55 start := g * stride 56 end := start + stride 57 if g == lastGoroutine { 58 end = totalNumbers 59 } 60 61 var lv int 62 for _, n := range numbers[start:end] { 63 lv += n 64 } 65 66 atomic.AddInt64(&v, int64(lv)) 67 wg.Done() 68 }(g) 69 } 70 71 wg.Wait() 72 73 return int(v) 74 }
併發版本明顯比順序運行版本複雜, 那麼增長的這個複雜性值得嗎? 最好地回答這個問題的方法是經過基準測試(benchmark). 對於這些基準測試, 我將垃圾收集器關閉, 而後將一千萬個數字相加. 下面測試分別使用了順序版本的add函數, 和併發版本的addConcurrent函數.
Listing 3
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) } }
Listing 4
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 BenchmarkSequential 1000 5720764 ns/op : ~10% Faster BenchmarkConcurrent 1000 6387344 ns/op BenchmarkSequentialAgain 1000 5614666 ns/op : ~13% Faster BenchmarkConcurrentAgain 1000 6482612 ns/op
注意: 在本機運行基準測試不是一件簡單的事. 有不少會致使基準測試不許確的因素, 所以, 你須要確保你的機器儘量的空閒, 而且多運行幾回測試.
listing 4的基準測試顯示, 當只有一個硬件線程時, 順序版本比並發版本快大約10%到13%. 由於併發版本有在同一個操做系統線程上的go程的上下文切換, 這種狀況是能夠預料到的.
Listing 5
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-8 1000 5910799 ns/op BenchmarkConcurrent-8 2000 3362643 ns/op : ~43% Faster BenchmarkSequentialAgain-8 1000 5933444 ns/op BenchmarkConcurrentAgain-8 2000 3477253 ns/op : ~41% Faster
在上面的基準測試中, 併發版本快了大約41%到43%, 由於每一個go程能夠運行在不一樣的操做系統/硬件線程.
排序
理解不是全部的CPU密集型負載都適合併發是很重要的. 尤爲是在將任務拆解, 以及任務聚合都很複雜的時候. 排序算法中的冒泡排序就是其中的一個例子. 咱們來看看go語言中實現的冒泡排序.
Listing 6
01 package main 02 03 import "fmt" 04 05 func bubbleSort(numbers []int) { 06 n := len(numbers) 07 for i := 0; i < n; i++ { 08 if !sweep(numbers, i) { 09 return 10 } 11 } 12 } 13 14 func sweep(numbers []int, currentPass int) bool { 15 var idx int 16 idxNext := idx + 1 17 n := len(numbers) 18 var swap bool 19 20 for idxNext < (n - currentPass) { 21 a := numbers[idx] 22 b := numbers[idxNext] 23 if a > b { 24 numbers[idx] = b 25 numbers[idxNext] = a 26 swap = true 27 } 28 idx++ 29 idxNext = idx + 1 30 } 31 return swap 32 } 33 34 func main() { 35 org := []int{1, 3, 2, 4, 8, 6, 7, 2, 3, 0} 36 fmt.Println(org) 37 38 bubbleSort(org) 39 fmt.Println(org) 40 }
問題: bubbleSort函數適合改爲併發執行嗎? 我相信不合適. 這些整數能夠拆分紅小的隊列, 而後這些隊列併發排序. 然而, 這些小的已排序的隊列, 沒有好的辦法, 將它們排序在一塊兒. 下面是冒泡排序的併發版本.
Listing 8
01 func bubbleSortConcurrent(goroutines int, numbers []int) { 02 totalNumbers := len(numbers) 03 lastGoroutine := goroutines - 1 04 stride := totalNumbers / goroutines 05 06 var wg sync.WaitGroup 07 wg.Add(goroutines) 08 09 for g := 0; g < goroutines; g++ { 10 go func(g int) { 11 start := g * stride 12 end := start + stride 13 if g == lastGoroutine { 14 end = totalNumbers 15 } 16 17 bubbleSort(numbers[start:end]) 18 wg.Done() 19 }(g) 20 } 21 22 wg.Wait() 23 24 // Ugh, we have to sort the entire list again. 25 bubbleSort(numbers) 26 }
在Listing 8中, bubbleSortConcurrent函數做爲bubbleSort函數的併發版本.
Listing 9
Before: 25 51 15 57 87 10 10 85 90 32 98 53 91 82 84 97 67 37 71 94 26 2 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
bubbleSortConcurrent的25行調用了bubbleSort, 這裏抵消了併發可能實現的提高. 對於冒泡排序, 併發不能實現性能提高.
讀文件
舉了兩個CPU密集型負載的例子, 下面, 咱們看一下IO密集型負載的例子. 咱們看一下讀取文件, 而後進行文本搜索的例子.
順序操做版本的函數名叫作find.
Listing 10
42 func find(topic string, docs []string) int { 43 var found int 44 for _, doc := range docs { 45 items, err := read(doc) 46 if err != nil { 47 continue 48 } 49 for _, item := range items { 50 if strings.Contains(item.Description, topic) { 51 found++ 52 } 53 } 54 } 55 return found 56 }
下面是find函數中調用的read函數的實現:
Listing 11
33 func read(doc string) ([]item, error) { 34 time.Sleep(time.Millisecond) // Simulate blocking disk read. 35 var d document 36 if err := xml.Unmarshal([]byte(file), &d); err != nil { 37 return nil, err 38 } 39 return d.Channel.Items, nil 40 }
Listing 11中的read函數, 以一個time.Sleep函數開始, 這個調用用來模擬實際系統調用從硬盤中讀取文件的延遲. 相同的延遲對於精確測試find函數與它的併發版本的性能很重要.
下面咱們看看併發版本:
Listing 12
58 func findConcurrent(goroutines int, topic string, docs []string) int { 59 var found int64 60 61 ch := make(chan string, len(docs)) 62 for _, doc := range docs { 63 ch <- doc 64 } 65 close(ch) 66 67 var wg sync.WaitGroup 68 wg.Add(goroutines) 69 70 for g := 0; g < goroutines; g++ { 71 go func() { 72 var lFound int64 73 for doc := range ch { 74 items, err := read(doc) 75 if err != nil { 76 continue 77 } 78 for _, item := range items { 79 if strings.Contains(item.Description, topic) { 80 lFound++ 81 } 82 } 83 } 84 atomic.AddInt64(&found, lFound) 85 wg.Done() 86 }() 87 } 88 89 wg.Wait() 90 91 return int(found) 92 }
併發版本明顯比順序執行版本複雜, 那麼這樣作值得嗎? 最好的方法仍是經過基準測試. 在測試中, 咱們一樣將垃圾回收關閉.
Listing 13
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) } }
Listing 14
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 BenchmarkSequential 3 1483458120 ns/op BenchmarkConcurrent 20 188941855 ns/op : ~87% Faster BenchmarkSequentialAgain 2 1502682536 ns/op BenchmarkConcurrentAgain 20 184037843 ns/op : ~88% Faster
listing 14的基準測試顯示, 當全部go程共用一個操做系統/硬件線程時, 併發版本大約快了87%到88%. 這種狀況是能夠預料到的, 由於全部的go程能夠很好的共享一個操做系統/硬件線程.
下面測試使用並行性.
Listing 15
10 Thousand Documents using 8 goroutines with 1 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-8 3 1490947198 ns/op BenchmarkConcurrent-8 20 187382200 ns/op : ~88% Faster BenchmarkSequentialAgain-8 3 1416126029 ns/op BenchmarkConcurrentAgain-8 20 185965460 ns/op : ~87% Faster
listing 15中的基準測試顯示, 額外的操做系統/硬件線程並不能提高性能.
結論
這篇博客的目的是告訴你如何決定負載是否適合使用併發. 其中IO密集型通常適合使用併發, 而CPU密集型須要使用並行. 有些任務類型(算法), 可能使用併發和並行都不能提升性能.
原文參考: https://www.ardanlabs.com/blog/2018/12/scheduling-in-go-part3.html