別告訴我這是真的?goroutine 可能使程序變慢

2020-01-12   Go語言中文網

如何使用 goroutine 才能使你的 CPU 滿負載運行呢

下面,我們將會展示一個關於 for 循環的代碼,將輸入分成幾個序列添加到 Goroutines 裡面!我敢打賭你之前可能有過幾次這種情況,但是每次引入 gorountine 都讓你的代碼變得更快嗎?

下面是一個簡單的循環示例,它似乎很容易變成並發代碼,但正如我們將看到的,並發版本不僅不會更快,實際上需要花費兩倍的時間。

串行循環,我們以一個把索引相加的簡單的串行循環作為示例

// SerialSum 把 0 到 limit 的相加package concurrencyslowerimport ("runtime""sync")const (limit = 10000000000)// 實現 sumfunc SerialSum() int {sum := 0for i := 0; i < limit; i++ {sum += i}return sum}

並發循環

這個循環只占用一個(邏輯)CPU,因此,資深的 Gopher 們可能會採用將其分解為 Goroutines 裡面運行,示例代碼的 Goroutine 是可以獨立於其餘代碼運行,因此可以分布在所有可用的 CPU 內核中。

/* ConcurrentSum 函數會使用所有可用內核,獲取可用邏輯核心的數量,通常這是 2*c,其中 c 是物理核心數,2 是每個核心的超線程數 。 n:=runtime。GOMAXPROCS(0)我們需要從某個地方收集 n 個 Goroutines 的結果。每個 Goroutine 都有一個元素的全局切片,sums:= make([]int, n)現在我們可以產生 Goroutines,WaitGroup 幫助我們檢測所有 Goroutine 何時完成*/func ConcurrentSum() int {wg := sync.WaitGroup{}for i := 0; i < n; i++ {// 為每個 Goroutine 增加一個 one ADDwg.Add(1)go func(i int) {// 將輸入分割到每個塊start := (limit / n) * iend := start + (limit / n)// 在每個塊中運行各自的 loopfor j := start; j < end; j += 1 {sums[i] += j}// waitgroup 減一wg.Done()}(i)}// Done()wg。Wait()// 從各個塊中收集sum := 0for _ ,  s := range sums {sum += s}return sum}

然而運行速度不降反增?那麼以上兩個版本運行速度如何呢,讓我們引入兩個壓力測試文件來一探究竟

package concurrencyslowerimport "testing"func BenchmarkSerialSum(b *testing.B) {for i := 0; i < b.N; i++ {SerialSum()}}func BenchmarkConcurrentSum(b *testing.B) {for i := 0; i < b.N; i++ {ConcurrentSum()}}

我的 CPU 是一個小型筆記本電腦 CPU (兩個超線程內核,Go runtime 看作是 4 個邏輯內核),預計,並發版本應該顯示出明顯的速度增益,然而,真實運行速度如何呢?

$ go test -bench。goos: darwingoarch: amd64pkg: github.com/appliedgo/concurrencyslowerBenchmarkSerialSum-4           1      6090666568 ns/opBenchmarkConcurrentSum-4       1      15741988135 ns/opPASSok      github.com/appliedgo/concurrencyslower 21.840s

prefix-4 表明測試使用所有四個邏輯核。但是,儘管並發循環使用了所有四個邏輯核,花費的時間是串行循環的兩倍多,這裡發生了什麼?

硬體加速起到了相反作用

為了解釋這個反直覺的結果,我們必須看一下支撐軟體運行的基礎,CPU 的組成原理。

CPU 的緩存內存有助於加速每個 CPU 運行速度。

為了簡單起見,以下是一個粗略的過度簡化,所以親愛的 CPU 設計師,請對我寬容。每個現代 CPU 都有一個非平凡的緩存層次結構,位於主內存和裸 CPU 內核之間,在這裡,我們只談論查看屬於各個內核的各級緩存。

CPU 緩存的目的

一般來說,緩存是一個非常小但超快的內存塊,它位於 CPU 晶片上,因此每次讀取或寫入值時,CPU 都不必到達主 RAM。相反,該值存儲在緩存中,後續讀取和寫入受益於更快的 RAM 單元和更短的訪問路徑,CPU 的每個核都有自己的本地緩存,不與任何其他核共享。對於 n 個 CPU 內核,這意味著最多可以有 n + 1 個相同數據的副本。一個在主內存中,一個在每個 CPU 內核的緩存中。

現在,當 CPU 內核更改其本地緩存中的值時,必須在某個時刻將其同步回主內存。同樣,如果緩存的值在主內存中被更改(由另一個 CPU 內核),則緩存的值無效,需要從主內存刷新。

(譯註:原文有一個可以播放的動圖,可以查看原文播放:https://appliedgo.net/concurrencyslower/)

緩存行命中

  • 為了以有效的方式同步高速緩存和主存儲器,數據以通常 64 位元組的塊同步,這些塊稱為緩存行,因此,當緩存值更改時,整個緩存行將同步回主內存。同樣,包含此高速緩存行的所有其他 CPU 核心的高速緩存現在也必須同步此高速緩存行以避免對過時數據進行操作。

鄰里

這對我們的代碼有何影響?請記住,並發循環使用全局切片來存儲中間結果。切片的元素存儲在連續的空間中,機率很高,兩個相鄰的切片元素將共享相同的高速緩存行。

現在戲劇開始了,n 個具有 n 個高速緩存的 CPU 內核重複讀取和寫入全部位於同一高速緩存行中的切片元素,因此,只要一個 CPU 內核使用新的總和更新它的切片元素,所有其他 CPU 的高速緩存行就會失效,必須將更改的高速緩存行寫回主內存,並且所有其他高速緩存必須使用新數據更新其各自的高速緩存行。即使每個核心訪問切片的不同部分!

這消耗了寶貴的時間,超過了串行循環更新其單個和變量所需的時間。

這就是我們的並發循環比串行循環需要更多時間的原因,對切片的所有並發更新都會導致繁忙的緩存行同步更新。

總而言之,既然我們知道了處理速度變慢的原因,那麼方案是顯而易見的。我們必須將切片轉換為 n 個單獨的變量,這些變量可能被隔離存儲,以便它們不共享相同的高速緩存行。

所以讓我們改變我們的並發循環,以便每個 Goroutine 將其中間處理值存儲在 Goroutine 的 local 變量中。為了將結果傳遞迴至主 Goroutine,我們還必須添加一個通道。這反過來允許我們刪除 WaitGroup 機制,因為通道不僅是通信的手段,而且是優雅的同步機制。

局部變量並發循環

// ChannelSum()產生 n 個 Goroutines,它們在本地存儲它們的中間和,然後通過一個通道傳回結果func ChannelSum() int {n := runtime.GOMAXPROCS(0)//A channel of 收集所有中間值res := make(chan int)for i := 0; i < n; i++ {//Goroutine 接受第二個參數,結果參數 . 箭頭 <- 標明只讀參數 .go func(i int ,  r chan<- int) {// 本地變量取代了全局變量sum := 0// 採用了分塊處理start := (limit / n) * iend := start + (limit / n)// 計算中間值for j := start; j < end; j += 1 {sum += j}// 傳遞結果r <- sum// 入參}(i ,  res)}sum := 0// This loop reads n values from the channel. We know exactly how many elements we will receive through the channel ,  hence we need no// 讀取 n 個值  , n 事先確定for i := 0; i < n; i++ {// 讀取值並相加// 無值時通道被阻塞,完美的的同步機制  ,// 本通道無值等待,直到 讀取到所有的 n 個值後才關閉 .sum += <-res}return sum}

測試文件中增加 BenchmarkChannelSum 測試結果如下

$ go test -bench .goos: darwingoarch: amd64pkg: github.com/appliedgo/concurrencyslowerBenchmarkSerialSum-4          1       6022493632 ns/opBenchmarkConcurrentSum-4      1       15828807312 ns/opBenchmarkChannelSum-4         1       1948465461 ns/opPASSok      github.com/appliedgo/concurrencyslower  23.807s

將使用局部變量存儲處理中的值,而不是將結果它們放在一個切片中,這無疑幫助我們逃避了緩存同步問題。

但是,我們如何確保各個變量永遠不會共享同一個緩存行 ? 好吧,啟動一個新的 Goroutine 會在堆棧上分配 2KB 到 8KB 的數據,這比 64 位元組的典型緩存行大小要多,並且由於中間和變量不是從創建它的 Goroutine 之外的任何地方引用的,因此它不會轉移到堆(它可能最終接近其他中間和變量之一)。所以我們可以非常肯定沒有兩個中間和變量會在同一個緩存行中結束。

如何獲取代碼

使用 go get,注意 -d 參數阻止自動安裝二進位到 $GOPATH/bin。

go get -d github.com/appliedgo/concurrencyslower

轉到目標目錄

cd $GOPATH/src/github.com/appliedgo/concurrencyslower

運行壓測文件

go test -bench .

注意,代碼運行的 Go 版本為 1.12,如果你的環境 Go module 參數為 enable ( 譯者注,自行查看 Go module ),可以通過如下方法獲取代碼

$GOPATH/pkg/mod/github.com/appliedgo/concurrencyslower@

如果 $GOPATH 丟失,默認使用 go get ~/go 或者 %USERPROFILE%\\go

結論

未來的 CPU 的架構或未來的 Go 版本可能會緩解以上這個測試問題。因此,如果您運行此代碼,壓測可能顯示與本文中不同的結果,屬於正常的。

通常,讓 Goroutines 更新全局變量不是一個好主意。記住 Go 諺語:不要通過共享內存進行通信,通過通信共享內存。

這篇博客文章的靈感來自 Reddit 的討論主題


via: https://appliedgo.net/concurrencyslower/

作者:Christoph[1]譯者:dylanpoe[2]校對:polaris1119[3]

本文由 GCTT[4] 原創編譯,Go 中文網[5] 榮譽推出

參考資料

[1]

Christoph: https://appliedgo.net/about/

[2]

dylanpoe: https://github.com/dylanpoe

[3]

polaris1119: https://github.com/polaris1119

[4]

GCTT: https://github.com/studygolang/GCTT

[5]

Go 中文網: https://studygolang.com/