不當(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é)束。