goroutine可能減慢代碼的速度(譯)

不當(dāng)?shù)氖褂胓oroutine,可能會(huì)使CPU忙于移動(dòng)數(shù)據(jù),導(dǎo)致減慢代碼運(yùn)行速度的效果。
這里我們假設(shè)有一個(gè)很大的循環(huán);為了加快計(jì)算速度,將循環(huán)分割成多份,然后分別讓不同的goroutine執(zhí)行。

1、串行版本

我們的使用一個(gè)簡單的串行循環(huán)(除了總結(jié)循環(huán)索引之外什么都不做)作為例子來說明問題。

const (
    limit = 10000000000
)
func SerialSum() int {
    sum := 0
    for i := 0; i < limit; i++ {
        sum += i
    }
    return sum
}

上述代碼只是將 1~limit 之間所有數(shù)字求和。

2、并發(fā)版本

下面我們使用goroutine優(yōu)化

func ConcurrentSum() int {  
    n := runtime.GOMAXPROCS(0)
    sums := make([]int, n)
    wg := sync.WaitGroup{}
    for i := 0; i < n; i++ {
        wg.Add(1)
        go func(i int) {
            start := (limit / n) * i
            end := start + (limit / n)
            for j := start; j < end; j += 1 {
                sums[i] += j
            }   
            wg.Done()
        }(i)
    }
    wg.Wait()
    sum := 0
    for _, s := range sums {
        sum += s
    }
    return sum
}

3、遺憾的結(jié)果

遺憾的是 結(jié)果是負(fù)向的

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

我們使用上述代碼進(jìn)行測試:

$ go test -bench .
goos: darwin
goarch: amd64
pkg: github.com/appliedgo/concurrencyslower
BenchmarkSerialSum-4           1      6090666568 ns/op
BenchmarkConcurrentSum-4       1      15741988135 ns/op
PASS
ok      github.com/appliedgo/concurrencyslower 21.840s

從結(jié)果中我們可以看到,使用goroutine的并發(fā)版本是串行版本的約2.5 倍

4、硬件加速的反擊

為了解釋這種違反直覺的結(jié)果,我們必須看一下所有軟件--CPU芯片下面的東西。
問題的原因在于緩存內(nèi)存有助于加速每個(gè)CPU核心。
為了清晰和簡潔,以下是一個(gè)粗略的過度簡化。 每個(gè)現(xiàn)代CPU都有一個(gè)非平凡的緩存層次結(jié)構(gòu),位于主內(nèi)存和裸CPU內(nèi)核之間,但出于我們的目的,我們只會(huì)查看屬于各個(gè)內(nèi)核的緩存。

5、CPU緩存的目的

一般來說,緩存是一個(gè)非常小但超快的內(nèi)存塊。 它位于CPU芯片上,因此每次讀取或?qū)懭胫禃r(shí),CPU都不必到達(dá)主RAM。 相反,該值存儲(chǔ)在緩存中,后續(xù)讀取和寫入受益于更快的RAM單元和更短的訪問路徑。
CPU的每個(gè)核心都有自己的本地緩存,不與任何其他核心共享。 對于n個(gè)CPU內(nèi)核,這意味著最多可以有n + 1個(gè)相同數(shù)據(jù)的副本; 一個(gè)在主內(nèi)存中,一個(gè)在每個(gè)CPU內(nèi)核的緩存中。
現(xiàn)在,當(dāng)CPU內(nèi)核更改其本地緩存中的值時(shí),必須在某個(gè)時(shí)刻將其同步回主內(nèi)存。 同樣,如果緩存的值在主內(nèi)存中被更改(由另一個(gè)CPU內(nèi)核),則緩存的值無效,需要從主內(nèi)存刷新。

6、緩存行

為了以有效的方式同步高速緩存和主存儲(chǔ)器,數(shù)據(jù)以通常64字節(jié)的塊同步。 這些塊稱為緩存行。
因此,當(dāng)緩存值更改時(shí),整個(gè)緩存行將同步回主內(nèi)存。 同樣,包含此高速緩存行的所有其他CPU核心的高速緩存現(xiàn)在也必須同步此高速緩存行以避免對過時(shí)數(shù)據(jù)進(jìn)行操作。

7、鄰居

這對我們的代碼有何影響? 請記住,并發(fā)循環(huán)使用全局切片來存儲(chǔ)中間結(jié)果。 切片的元素存儲(chǔ)在連續(xù)的空間中。 概率很高,兩個(gè)相鄰的切片元素將共享相同的高速緩存行。
有n個(gè)高速緩存的n個(gè)CPU內(nèi)核重復(fù)讀取和寫入全部位于同一高速緩存行中的切片元素。 因此,只要一個(gè)CPU內(nèi)核使用新的總和更新“其”切片元素,所有其他CPU的高速緩存行就會(huì)失效。 必須將更改的高速緩存行寫回主內(nèi)存,并且所有其他高速緩存必須使用新數(shù)據(jù)更新其各自的高速緩存行。 即使每個(gè)核心訪問切片的不同部分!
這消耗了寶貴的時(shí)間 - 超過了串行循環(huán)更新其單個(gè)和變量所需的時(shí)間。
這就是我們的并發(fā)循環(huán)比串行循環(huán)需要更多時(shí)間的原因。 對切片的所有并發(fā)更新都會(huì)導(dǎo)致繁忙的緩存行同步跳躍。

8、傳送數(shù)據(jù)

既然我們知道了速度放緩的原因,那么解決方案就是顯而易見的。 我們必須將切片轉(zhuǎn)換為n個(gè)單獨(dú)的變量,這些變量有望彼此遠(yuǎn)離存儲(chǔ),以便它們不共享相同的高速緩存行。
所以讓我們改變我們的并發(fā)循環(huán),以便每個(gè)goroutine將其中間和存儲(chǔ)在goroutine-local變量中。 為了將結(jié)果傳遞回主goroutine,我們還必須添加一個(gè)通道。 這反過來允許我們刪除等待組,因?yàn)橥ǖ啦粌H是通信的手段,而且是優(yōu)雅的同步機(jī)制。

9、使用局部變量并發(fā)循環(huán)

func ChannelSum() int {
    n := runtime.GOMAXPROCS(0)
    res := make(chan int)

    for i := 0; i < n; i++ {
        go func(i int, r chan<- int) {
            sum := 0
            start := (limit / n) * i
            end := start + (limit / n)
            for j := start; j < end; j += 1 {
                sum += j
            }
            r <- sum    
        }(i, res)
    }

    sum := 0
    for i := 0; i < n; i++ {
        sum += <-res
    }
    return sum
}

在我們的測試文件中添加第三個(gè)基準(zhǔn)測試功能BenchmarkChannelSum之后,我們現(xiàn)在可以在循環(huán)的所有三個(gè)變體上運(yùn)行基準(zhǔn)測試。

$ go test -bench .
goos: darwin
goarch: amd64
pkg: github.com/appliedgo/concurrencyslower
BenchmarkSerialSum-4          1       6022493632 ns/op
BenchmarkConcurrentSum-4      1       15828807312 ns/op
BenchmarkChannelSum-4         1       1948465461 ns/op
PASS
ok      github.com/appliedgo/concurrencyslower  23.807s`

將中間和擴(kuò)展到各個(gè)局部變量,而不是將它們放在一個(gè)片中,這無疑幫助我們逃避了緩存同步問題。
但是,我們?nèi)绾未_保各個(gè)變量永遠(yuǎn)不會(huì)共享同一個(gè)緩存行? 好吧,啟動(dòng)一個(gè)新的goroutine會(huì)在堆棧上分配2KB到8KB的數(shù)據(jù),這比64字節(jié)的典型緩存行大小要多。 并且由于中間和變量不是從創(chuàng)建它的goroutine之外的任何地方引用的,因此它不會(huì)轉(zhuǎn)移到堆(它可能最終接近其他中間和變量之一)。 所以我們可以非??隙]有兩個(gè)中間和變量會(huì)在同一個(gè)緩存行中結(jié)束。

參考文獻(xiàn)

  1. Slow down your code with goroutines
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • feisky云計(jì)算、虛擬化與Linux技術(shù)筆記posts - 1014, comments - 298, trac...
    不排版閱讀 4,333評論 0 5
  • 又是一年秋招季,哎呀媽呀我被虐的慘來~這不,前幾陣失蹤沒更新博客,其實(shí)是我偷偷把時(shí)間用在復(fù)習(xí)課本了(霧 堅(jiān)持在社區(qū)...
    tengshe789閱讀 2,151評論 0 8
  • 今天看到一位朋友寫的mysql筆記總結(jié),覺得寫的很詳細(xì)很用心,這里轉(zhuǎn)載一下,供大家參考下,也希望大家能關(guān)注他原文地...
    信仰與初衷閱讀 4,832評論 0 30
  • Swift1> Swift和OC的區(qū)別1.1> Swift沒有地址/指針的概念1.2> 泛型1.3> 類型嚴(yán)謹(jǐn) 對...
    cosWriter閱讀 11,641評論 1 32
  • 很靜 天還是有些冷 有霧 沒風(fēng) 沒車 沒人 溜了十幾公里 終于碰到了一個(gè)乘客 他靠近我的車,弱弱地問一句: 師傅,...
    阿磊_6812閱讀 366評論 0 3

友情鏈接更多精彩內(nèi)容