1. 程式人生 > 程式設計 >golang和cache一致性

golang和cache一致性

之前關於golang排程以及垃圾回收相關文章中,都有提到cache一致性的問題。今天來簡單說一下cache相關內容,以及在golang中需要注意的事情。

cache結構

圖中是一個儲存結構示意圖,cpu和主存直接使用的是L3的結構。金字塔越上面,相互之間的訪問速度越快但是資料量越小,越往下訪問速度越慢但資料量越大。

在單核CPU結構中,為了緩解CPU指令流水中cycle衝突,L1分成了指令(L1P)和資料(L1D)兩部分,而L2則是指令和資料共存。多核CPU增設了L3三級快取,L1和L2是CPU核自己使用,但是L3快取是多核共享的。

cache區域性性原理

區域性性分為時間區域性性空間區域性性

,時間區域性性是說,當前訪問到的資料隨後時間也可能會訪問到。空間區域性性是指,當前訪問的的地址附近的地址,之後可能會被訪問到。根據區域性性原理,我們把容易訪問到的資料快取在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偽共享導致的記憶體抖動,儘量避免多個執行緒去頻繁操作一個相同變數或者是地址相鄰變數。

參考資料

zhuanlan.zhihu.com/p/30127242 blog.csdn.net/yhb10478183…