【譯】Golang中的調度(3):併發編程 - Concurrency

爲了更好理解Go調度器的內在機制,我會以三個部分的內容分別進行闡述,鏈接如下:

  1. Golang中的調度(1):OS調度器 - OS Scheduler
  2. Golang中的調度(2):Go調度器 - Go Scheduler
  3. Golang中的調度(3):併發編程- Concurrency

本部分內容主要討論併發編程。

引言

當我遇到問題,尤其是新問題時,我並不知道是否適合使用併發解決。首先,我會採用順序化的解決方案並保證它能正常工作,然後,經過易讀性和技術審查,我會開始思考併發方式是否合理可行。有時,併發明顯很合適,但也有不清楚是否合適的時候。

在第一部分內容中,我解釋了OS調度器的機制,如果你計劃編寫多線程程序,我認爲理解它們很重要。在第二部分內容中,我解釋了Go調度器的機制,如果你想使用Go編寫併發程序,我認爲理解它們也很重要。在本部分內容中,我將綜合考慮OS和Go調度器的機制,以助你更深入地瞭解什麼是併發。

什麼是併發

併發意味着“亂序”執行,即採取一組原本會按順序執行的指令,找到一種方法來無序執行它們仍然產生相同的結果。對於這個問題,顯而易見的是,無序執行會增加價值。當我說價值時,意思是爲複雜性成本增加能獲得足夠的性能增益。這取決於問題,亂序執行可能無法實現或者毫無意義。

同樣重要的是要了解併發與並行並不相同。並行是指同時執行兩個或多條指令,這是與併發不同的概念。只有在你擁有至少兩個可用的OS/硬件線程並且你至少有兩個Goroutines時纔可以並行處理,每個Goroutines在每個OS /硬件線程上獨立執行指令。

圖 1 併發與並行

圖1所示,兩個邏輯處理器(P1和P2),每個邏輯處理器(P)上的獨立OS線程(M)分別連接到計算機上的獨立硬件線程(Core),你可以看到兩個Goroutine(G1和G2)正在並行執行,同時在各自的OS /硬件線程上執行它們的指令。在每個邏輯處理器中,三個Goroutine輪流共享各自的OS線程。所有這些Goroutine併發運行,以不特定的順序執行它們的指令,並在OS線程上共享時間。

麻煩的是,有時在沒有並行性的情況下利用併發實際上會降低程序吞吐量(throughput)。同樣有趣的是,有時將併發與並行結合使用並不會給你帶來比原本可以實現的更大的性能提升。

任務(Workloads)

如何知道何時“亂序”執行是可行的或有意義的?清楚你正在處理的任務類型是一個很好的開始。在考慮併發時,需要了解兩種類型的任務。

  • CPU密集型:這種類型的任務永遠不會導致Goroutines自然地進入和退出等待態的情況。不斷進行計算的任務,如計算Pi到第N位是CPU密集型的工作。
  • IO密集型:這種類型的任務會導致Goroutines自然進入等待態。這類工作任務包括通過網絡訪問資源,對操作系統進行系統調用或等待事件發生。一個需要讀文件的Goroutine是IO密集型,我將同步事件(互斥量,原子)也歸入此類,因爲這些事件會導致Goroutine進入等待態。

對於CPU密集型任務,你需要並行地去併發。一個單獨的OS /硬件線程處理多個Goroutine效率不高,因爲Goroutine不會在執行任務過程中進入和退出等待態。Goroutine的數量多於OS /硬件線程的數量,這會減慢任務的執行速度,這是Goroutine移入和移出OS線程(上下文切換)的延時(Latency)開銷所致。Goroutine的上下文切換會爲任務造成一個“Stop the world(停滯)”事件,因爲在切換期間沒有任何任務可以執行。

對於IO密集型任務,你無需並行即可使用併發。單個OS /硬件線程可以高效地處理多個Goroutine,因爲Goroutine在執行任務過程中會自然進入和退出等待態。Goroutine的數量多於OS /硬件線程的數量,可以加快任務的執行,因爲在OS線程上移入和移出Goroutine的延時開銷不會造成“ Stop The World”事件的發生。任務自然暫停,這允許不同的Goroutine有效利用相同的OS /硬件線程,而不是讓OS /硬件線程閒置。

如何知道每個硬件線程搭載多少個Goroutine才能提供最佳吞吐量?Goroutine太少,硬件線程將有更多的閒置時間。Goroutine太多,將造成更多的上下文切換延遲開銷。這是你要考慮的事情,但超出了本部分內容討論的範圍。

加法運算

我們不需要複雜的代碼即可可視化並理解這些語義。提供如表1中的add函數,該函數目的是對整數集合求和。

表 1

36 func add(numbers []int) int {
37     var v int
38     for _, n := range numbers {
39         v += n
40     }
41     return v
42 }

問題:add函數是否適合於無序執行?我相信答案是yes。整數的集合可以分解成更小的集合,並且這些集合可以同時處理。一旦將所有較小的集合求和,就可以將總和加在一起以產生與順序執行版本相同的答案。

但是,有另外一個問題需要考慮。應該創建和處理幾個較小的集合以獲得最佳吞吐量呢?要回答這個問題,你必須知道add函數執行的是哪種任務類型。add函數正在執行CPU密集型的任務,因爲該函數正在執行純數學運算,並且沒有任何動作會使Goroutine進入自然等待態。這意味着每個OS /硬件線程僅使用一個Goroutine即可獲得良好的吞吐量。

下面的表2是併發版的add函數。

注意:如何編寫併發版本的add函數,其實可以有很多種方法,所以不要侷限在我本次的特定實現上。如果你有可讀性更高的版本,且其性能相同或更好,我會很樂意你的分享。

表 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 }

在表2中,addConcurrent函數是併發版的add函數。併發版寫了26行代碼,而非併發版只需要5行。代碼行數較多,我只提其中重要的點幫助你理解。

48行:每個Goroutine都會拿到自己單獨的但數量較小的數字集合。集合的大小是通過將整個集合的大小除以Goroutine的數量得來的。

53行:創建Goroutine池添加執行任務。

57-59行:最後一個Goroutine將添加可能比其他Goroutine大的剩餘數字集合。

66行:將所有小集合的和加在一起成爲最終集合總和。

併發版本肯定比順序版本更復雜,那值得這樣做嗎?最好的回答方式是使用基準測試(Benchmark)。我使用了1000萬個數字的集合,並且關閉了垃圾回收器來進行基準測試,使用add函數與addConcurrent函數進行對比。

表 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)
    }
}

表3列出了基準測試函數,表4是所有Goroutines只有一個OS /硬件線程可用時的結果,順序版本使用1個Goroutine,併發版本使用runtime.NumCPU(我機器上8個Goroutine)個Goroutine數來執行。在這種情況下,併發版本沒有並行性地併發。

表 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

注意:在本地計算機上運行基準測試是很複雜的,有太多因素會導致基準測試不準確。因此,確保你的計算機儘可能空閒,並多運行幾次基準測試。你要確保結果一致,就用測試工具運行兩次基準測試以獲得最一致的結果。

表4中的基準測試表明,當所有Goroutines僅具有一個OS /硬件線程時,順序版本比並發版大約快10%到13%。這是我預料之中的,因爲併發版本較非併發版,在單個OS線程上會有Goroutines上下文切換的開銷。

表 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

如表5所示,每個Goroutine有單獨的OS /硬件線程執行。順序版本使用1個Goroutine,併發版本使用runtime.NumCPU(我機器上8個Goroutine)個Goroutine數來執行。在這種情況下,併發版本並行性地併發。

表5中的基準測試表明,當每個Goroutine都有單獨的OS /硬件線程時,併發版本比順序版本快41%至43%。這符合我的期望,因爲所有Goroutine現在都並行運行,八個Goroutine同時執行其併發任務。

排序

要知道並非所有CPU密集型的任務都適合併發,當分解工作或合併所有結果的代價非常昂貴時,這就是正確的論點。可以參考被稱爲冒泡排序的算法示例。以下是Go實現冒泡排序的代碼。

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 }

表6是用Go寫的冒泡排序的例子。該算法會在掃描集合時進行數字交換,根據集合的順序,在對所有數字進行排序之前,可能需要對集合進行多次掃描。

問題:bubbleSort函數是否適合亂序執行?我相信答案是no。整數的集合可以分解成更小的集合,並且這些集合可以同時排序。但是,在完成所有併發任務之後,沒有有效的方法將較小的集合排序在一起。表7是冒泡排序的併發版本示例。

表 7

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 }

在表7中,給出了bubbleSortConcurrent函數,它是bubbleSort函數的併發版。它使用多個Goroutines同時對集合的各個子集合部分進行排序。如表8所示,給定36個數字,分成若干個子集合,如果所有的子集合結果不能在25行代碼時完成有序,那麼將再執行一次bubbleSort函數。

表 8

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

由於冒泡排序的本質是要遍歷整個集合,因此在第25行調用bubbleSort函數將抵消使用併發所帶來的性能收益。因此,冒泡排序,使用併發並不會提高算法性能。

讀文件

已經提到了兩個CPU密集型的任務,那麼IO密集型任務呢?當Goroutines自然地進出等待態時,情況是否會有所不同?看一看讀取文件並執行文本搜索任務的IO密集型示例。

第一個版本是find函數的順序版。

表 9

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 }

如表9所示,在第43行,聲明瞭一個名爲found的變量,表示在給定文檔中找到指定主題的次數。在第44行,遍歷文檔,並使用read函數在第45行讀取文檔數據。最後,在第49-53行中,strings.Contains函數用於檢查是否可以從文檔讀取的內容中找到該主題。如果找到主題,則fonud變量加1。

表10是find調用的read函數實現。

表 10

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 }

表10中的read函數以time.Sleep調用開始,睡眠一毫秒用以模擬讀文件的阻塞操作。35到39行,解析XML編碼的數據並將結果存入d指向的結構體中。最後,在39行將結構體內容返回調用方。

表11是find函數的併發版。

表 11

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 }

如表11所示,併發版本使用了30行代碼,而非併發版本只有13行代碼。我實現併發版本的目標是控制用於處理未知數量文檔的Goroutine數量,因此使用channel在Goroutines池中傳遞數據。

61到64行,初始化並填充一個傳遞數據的channel,。

65行,發送方關閉channel,所以當接收方處理完所有文檔後,Goroutines都會自然終止。

70行,創建Goroutine池。

73到83行,執行相應子任務,計數lFound。

84行,各個Goroutine計數lFound加在一起成爲最終計數found。

併發版本肯定比順序版本複雜,值得嗎?再次回答這個問題的最好方式依然是基準測試。在基準測試中,我使用了1000個文檔集,並且關閉了垃圾回收器,比較使用find函數的順序版和findConcurrent函數的併發版。

表 12

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)
    }
}

表13 是所有Goroutines只有一個OS /硬件線程可用時的結果,順序版本使用1個Goroutine,併發版使用runtime.NumCPU(我機器上8個Goroutine)個Goroutine數來執行。在這種情況下,併發版本沒有並行性地併發。

表 13

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

表13中的基準測試結果表明,當所有Goroutines僅具有一個OS /硬件線程時,併發版比順序版快大約87%至88%。這是我預料之中的,因爲所有Goroutines都有效地共享了單個OS /硬件線程。Goroutine的上下文切換髮生調用的read函數中,這能使得單個OS /硬件線程處理更多的任務。

表14是將併發與並行結合使用時的基準測試結果。

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

表14中的基準測試結果表明,引入額外的OS /硬件線程並沒有提供更好的性能。

結論

這篇文章的目的是提供必須考慮的因素指導,以確定任務是否適合使用併發。我嘗試提供不同類型的算法和任務的示例,以便你可以看到因素上的差異以及需要考慮不同的工程決策。

你可以清楚地看到,對於IO密集型任務,不需要並行處理就可以大大提高性能,這與你在CPU密集型任務中看到的相反。對於像冒泡排序這樣的算法,併發的使用會增加複雜性,而且也不會帶來性能上的增益。確定你要處理的任務是否適合併發,這很重要。

英文原文鏈接:

Scheduling In Go : Part III - Concurrency

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