atomic不是免費午餐
很多初級甚至中級開發會濫用atomic,因為在他們的世界觀里atomic比mutex輕量,性能總是優于鎖的。
這話不能算錯,但有個很重要的前提,那就是原子操作競爭不激烈的時候。
“競爭激烈”是指什么呢,指的是有很多線程在同一個資源上大量執行原子操作的情況。
落在這種情況下原子操作反而會成為性能拖油瓶。我們來看一個經典的原子計數器:
func AddAtomic() uint64 {
var count atomic.Uint64
var wg sync.WaitGroup
for range 10 {
wg.Add(1)
go func() {
for range 100000000 {
count.Add(1)
}
wg.Done()
}()
}
wg.Wait()
return count.Load()
}
代碼模擬了10個線程頻繁操作計數器的場景。論并發安全這段代碼是既簡潔又安全的。很多人可能還會覺得這段代碼是很高效的,畢竟用了原子操作嘛。
不過別著急,測試性能之前我們再想想還有沒有其他做法。考慮到這是一個單向遞增的計數器,我們只需要保證每次的加操作最終都能完成,并且因為加法的交換律和結合律,這些操作的相對順序也可以打亂。換句話說,我們可以不關心counter的中間狀態,每個線程自己聚合所有的加操作,最后再一次性加給counter。代碼就會變成下面這樣:
func AddAtomicLocal() uint64 {
var count atomic.Uint64
var wg sync.WaitGroup
for range 10 {
wg.Add(1)
go func() {
cc := uint64(0)
for range 100000000 {
cc++
}
count.Add(cc)
wg.Done()
}()
}
wg.Wait()
return count.Load()
}
現在每個線程維護自己的計數器,在運行結束統一操作counter。熟悉Java的讀者應該能看出來這是LongAdder,唯一的區別是我們沒用threadlocal。
兩種方法累加的次數都是一樣的,而且大部分人都認為原子操作很輕量,那么它們的性能理論上應該不會差太多,方法1稍微慢一點。我們寫點性能測試看下:
func BenchmarkAtomic(b *testing.B) {
for b.Loop() {
result := AddAtomic()
if result != 1000000000 {
b.Fatal("error")
}
}
}
func BenchmarkAtomicLocal(b *testing.B) {
for b.Loop() {
result := AddAtomicLocal()
if result != 1000000000 {
b.Fatal("error")
}
}
}
下面是測試結果,分別用go1.24.5在Intel和Apple的機器上進行測試:


結果出人意料,在兩款不同的芯片上,方案1都慢了接近300倍。看似“輕量”的原子操作竟然是性能殺手。
我們可以找個Linux系統用perf分析一下原因。在Linux上兩者直接也有百倍的差距。
上面一個樣本是方案1的,下面的則是方案2的:


我沒有監聽全部的性能事件,那樣一來會讓程序變得很慢,二來對我們重要的只有其中的幾個事件,太多的信息會成為雜音。
我們先來看branches和branch-misses,前者是程序運行中一個執行多少分支判斷,后者是cpu預執行分支失敗的次數,簡單地說misses越少程序性能越高。所以兩個方案在分支總數差不多的情況下方案2比1的預測失敗數量低20倍,所以方案2在這一點上勝出。
接著就是緩存命中率了,這一點無需過多解釋,命中率越高性能越好。方案二同樣比一高了10%。
然而這兩點只能解釋一個數量級的差異,但我們現在的差距是300倍。
仔細觀察緩存讀取次數,我們會驚奇地發現方案1的讀取次數是10,029,023,298,100億次。我們的測試程序也正好運行了累加器函數10次,也是100億次操作。這是方案2的整整18000倍。
為什么會有這個結果呢?這就是原子操作的缺點之一了:x86和arm上的原子操作都是針對某塊內存上的數據進行操作。這意味著不管是原子讀還是原子寫,都要直接操作內存。現代cpu不會自己直接接觸內存,都需要數據先進入cpu的高速緩存才能進行操作。這就是為什么方案1會有如此之高的緩存讀取次數。原子操作需要這樣的代價,因為共享的資源隨時會被修改,因此只能每次從內存中存取最新的數據。
而方案2的累加操作是在線程獨立的本地局部變量上進行的,這些操作沒必要走內存,可以直接在寄存器上完成。
寄存器和高速緩存的速度差異不同體系結構和廠家的產品大相徑庭,但寄存器的速度一定大于等于高速緩存。因此更依賴寄存器的方案2自然會比緩存命中率更低且需要大量操作緩存的方案1快上兩個數量級。
除此之外原子操作還帶來了另外的副作用,這在perf的報告中沒有顯現——多個線程頻繁修改同一個資源,會帶來大量的更新cpu緩存的核間通信以及線程為了原子性可能會出現很多同步操作。核間通信本身有延遲,緩存狀態更新后cpu遇到下次的原子讀取/寫入就得先更新緩存才能執行操作,一來一去慢的可不是一星半點。線程之間的同步則來自于原子操作帶來的內存屏障,cpu為了保證能讓屏障正常生效,需要讓一些cpu核心上的指令等待另一些核心上的指令執行完成,不同的cpu實現這點的方法不同,但也會帶來可觀的延遲。
所以綜合上面三個原因,看似輕量級的原子操作在“競爭激烈”的場景下出現了嚴重的性能問題。
不過盡管方案2很快,但它也有缺點,那就是counter不會及時更新。在這里我們可以忍受這一點,但也有的場景是無法接受這種延遲的。
總結
atomic不是免費午餐,是要支付使用代價的。除了潛在的性能問題,還會有難以察覺的并發問題。
所以為了追求性能,應該停止濫用atomic。可以適當地像方案2或者Java的LongAdder那樣選擇一些per-cpu的算法或者數據結構。


浙公網安備 33010602011771號