golang和cache一致性
之前關於golang排程以及垃圾回收相關文章中,都有提到cache一致性的問題。今天來簡單說一下cache相關內容,以及在golang中需要注意的事情。
cache結構
圖中是一個儲存結構示意圖,cpu和主存直接使用的是L3的結構。金字塔越上面,相互之間的訪問速度越快但是資料量越小,越往下訪問速度越慢但資料量越大。
在單核CPU結構中,為了緩解CPU指令流水中cycle衝突,L1分成了指令(L1P)和資料(L1D)兩部分,而L2則是指令和資料共存。多核CPU增設了L3三級快取,L1和L2是CPU核自己使用,但是L3快取是多核共享的。
cache區域性性原理
區域性性分為時間區域性性和空間區域性性
時間區域性性
舉兩個例子(忽略程式碼意圖)
//程式碼1
for (loop=0; loop<100; loop++) {
for (i=0; i<N; i++) {
count = count + x[i]
}
}
//程式碼2
for (i=0; i<N; i++) {
for (loop=0; loop<100; loop++) {
count = count + x[i]
}
}
複製程式碼
很明顯,下面的程式碼具有更好的時間區域性性,因為在程式碼2中內層的loop迴圈,x[i]被重複使用。
空間區域性性
//程式碼1
for(j=0; j<500; j++){
for(i=0; i<500; i++){
a[i][j]=i;
}
}
//程式碼2
for(i=0; i<500; i++){
for(j=0; j<500; j++){
a[i][j]=i;
}
}
複製程式碼
相比之下,程式碼2執行效能更高。程式碼1是按列遍歷,每次都需要跳過N個陣列元素去找下一個,程式碼2按行遍歷,具有較好的空間區域性性。
cache偽共享
處理器和主存使用快取行(cache lines)進行資料交換。一個快取行是一個64 byte的記憶體塊,它在記憶體和快取系統之間進行交換。每個核心會分配它自己需要的cache副本。
當多執行緒並行執行,正在訪問相同資料,甚至是相鄰的資料單元,他們會訪問相同的快取行。任何核心上執行的任何執行緒能夠從相同的快取行獲取各自的拷貝。
如果給一個核心,他上面的執行緒修改它的cache行副本,然後會通過硬體MESI機制,同一cache行的所有其他副本都會被標記為無效。當一個執行緒嘗試讀寫髒cache行,需要重新訪問主存去獲取新的cache行副本(大約要100~300個時鐘週期)。
goroutines併發中的cache一致性問題
這裡我寫了一個很簡單的例子,數字從1加到100000。測試了三個版本,分別是開100000個goroutines處理,開cpu數量的goroutines處理,以及單個執行緒去處理。
package main
import (
"fmt"
"runtime"
"sync"
"sync/atomic"
"time"
)
func addConcurrent(bignum int) {
var c int32
atomic.StoreInt32(&c,0)
start := time.Now()
for i := 0; i < bignum; i++ {
go atomic.AddInt32(&c,1)
}
for {
if c == int32(bignum) {
fmt.Println(time.Since(start))
break
}
}
}
func addCPUNum(bignum int) {
var c int32
wg := &sync.WaitGroup{}
core := runtime.NumCPU()
start := time.Now()
wg.Add(core)
for i := 0; i < core; i++ {
go func(wg *sync.WaitGroup) {
for j := 0; j < bignum/core; j++ {
atomic.AddInt32(&c,1)
}
wg.Done()
}(wg)
}
wg.Wait()
fmt.Println(time.Since(start))
}
func addOneThread(bignum int) {
var c int32
start := time.Now()
for i := 0; i < bignum; i++ {
atomic.AddInt32(&c,1)
}
fmt.Println(time.Since(start))
}
func main() {
bigNum := 100000
addConcurrent(bigNum)
addCPUNum(bigNum)
addOneThread(bigNum)
}
複製程式碼
直接執行程式碼,按順序得到如下輸出
26.995877ms
1.789892ms
508.077µs
複製程式碼
100000 goroutines花費26ms、cpu num數量goroutines花費1.78ms,單執行緒只用了508us。 簡單來分析一下,顯然100000個goroutines處理這種cpu-bound的工作很不利,我之前go排程文章裡講過,執行緒上下文切換有延遲代價。io-bound處理可以在io wait的時候去切換別的執行緒做其他事情,但是對於cpu-bound,它會一直處理work,執行緒切換會損害效能。
這裡還有另外一個重要因素,那就是cache偽共享(false sharing)。每個core都會去共享變數c
的相同cache行,頻繁操作c會導致記憶體抖動(cache和主存直接的換頁操作)。
可以看到cpu number個執行緒並行處理時間是單執行緒處理時間的三倍,這個延遲代價還是很大的。
因此,在golang程式中需要避免因為cache偽共享導致的記憶體抖動,儘量避免多個執行緒去頻繁操作一個相同變數或者是地址相鄰變數。