False-sharing 以及其解法 (以 Golang 為例)
解釋 false-sharing 前,我們需要簡單介紹 CPU 的快取。
CPU 的快取是以 cache line 為單位(目前大部分 CPU 的 cache line 是 64 byte),這代表當 CPU 從記憶體撈變數時,會把該變數附近的變數一起撈進 cache 裡,如圖一:
Core1 要從記憶體讀取 a 變數時,會順便把旁邊的 b 變數一起讀進快取(這部分我猜是因為 Spatial Locality,鄰近的變數很容易同時被存取,因此一起讀進快去)。
但這樣的架構在多 CPU 系統會產生一個問題,若有一段記憶體的變數同時存在兩個 CPU 的 cache line 中,如圖二:
Core1 更新 a ,如圖三:
Core2 要讀取 b 時會產生 cache miss,而後重新將整段記憶體的變數重讀進 cache 中,即便 b 在快取的值沒被更新,如圖四
簡而言之, false-sharing 就是因為 CPU 更新變數導致 CPU 被迫更新快取。當有一組共用變數經常在多個 CPU 存取時,這會造成不小的性能消耗。
常用的解法是利用無意義的變數填充,讓每個會被多個 CPU 共用的變數獨佔一整個 cache line,即 cache padding。
例如程式中有個常被 gorontine 共用的物件如下:
可以在每個變數中間增加 uint64 陣列提升效能:
在 Golang 可以寫一段測試 code 跑 benchmark:
我用 2014 年出產的 MBA 跑 benchmark 結果如下:
$> go test -bench=.
BenchmarkNoPad-4 2000000000 0.07 ns/op
BenchmarkPad-4 2000000000 0.02 ns/op
PASS
ok 1.777s
由上測試結果可以看到加入的無意義的填充變數以後程式效能有不小的提升,理論上當 CPU 數量越多提升的效能應該更明顯。
其他語言應該也可以用同樣的方法處理(例如 Java),只是 Golang 的 benchmark 用來量測相對方便,所以本文用 Golang 做範例。
實際運用在產品上時需要注意兩點:
- 使用前請先確定 CPU 的 cache line 大小,這攸關要用多少無意義的變數填充。
- 謹慎使用,畢竟 cache padding 也是會吃記憶體資源,務必依照使用情境跑 benchmark,確認加入 cache padding 以後效能有所提升。
完整的測試 code 在 https://github.com/genchilu/concurrencyPractice/tree/master/golang/pad