go調度: 第三部分-併發

前言html

這個是用來說述go調度器機制和特性的第三部分. 這個主要關注併發. git

博客三部分的順序:github

1) go調度: 第一部分-操做系統調度算法

2) go調度: 第二部分-go調度器網絡

3) go調度: 第三部分-併發多線程

 

介紹併發

當我在解決一個問題, 尤爲是一個新問題的時候, 開始階段, 我不會考慮併發是否是有用. 我尋找一個順序化解決方案, 而且確保這個方案有效. 而後, 我進行評估, 來看看併發是否合適. 有些狀況下, 併發是很合適的, 而有些狀況下則未必.ide

在第一部分,我講了操做系統的調度器的特性, 若是你想寫多線程應用, 這個是頗有必要的. 在第二部分, 我講了go的調度器特性, 我認爲, 這個對使用go語言寫多線程是頗有意義的. 在這篇中, 我將結合操做系統調度和go程調度, 來提供一個對於使用go語言寫多線程的更加深刻的理解.函數

這篇博客的目的是:性能

(1) 辨別你代碼應用的場景是否適合使用併發.

(2) 展現如何根據應用場景改變併發的使用.

 

什麼是併發

併發意味着亂序執行. 一系列指令, 能夠被順序執行, 也能夠在知足限制的狀況下亂序執行, 可是仍是能夠產生相同的結果. 對於你眼前的這個問題, 亂序執行必須能夠展現出明顯的價值, 也就是說, 併發能夠明顯的提升性能, 同時, 沒有代碼複雜度的增長能夠容忍. 根據你的問題, 亂序執行有時候是不可行的.

有一個重要的點須要注意, 併發不等於並行. 並行意味着同一時間執行多條指令. 這個與併發的概念並不相同. 只有在有至少2個操做系統線程(運行在兩個硬件線程之上), 而且有至少2go程的狀況下, 每一個操做系統/硬件線程運行一組指令的狀況下, 並行纔會發生.

圖1

在圖1, 你看到兩個邏輯處理器(P)運行在兩個不一樣的操做系統線程(M), 這兩個M對應着不一樣的硬件線程. 這種狀況下, 兩個go(G1G2)處於並行狀態. 在每一個邏輯處理器上, 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

bubbleSortConcurrent25行調用了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

相關文章
相關標籤/搜索