再探 Golang 內聯優化
轉自:
https://juejin.cn/post/7128243201936687117
開篇
上一篇文章初探 Golang 內聯 中我們瞭解了內聯的原理,規則。其實 Golang 標準庫裏,以 sync 包爲代表,其實針對鎖的實現,進行了專門的內聯優化。
今天我們來實戰驗證一下。
感興趣的同學建議先看下 Mutex 和 RWMutex 的源碼,旗幟鮮明地把 fast 和 slow 拆開:
這裏的 lockSlow 及其複雜,而 fast path 則只有一個原子操作 CAS。走了內聯之後,就可以消除函數調用的成本,對於 Mutex 這種高併發場景是非常提升性能的。
cost 測試
內聯中最關鍵的一個因素在於 cost 評估。今天自己用最基礎的 sync.Mutex 做了個實驗,看看一個鎖能不能撐破 80 的上限,示例代碼:
package main
import (
"sync"
)
func main() {
mCost()
}
func mCost() {
var s sync.Mutex
s.Lock()
s.Unlock()
}
可以看到,我們的測試代碼及其簡單,就是聲明一個鎖,上鎖,解鎖,裏面甚至什麼操作都沒幹。下面我們跑一下來看看 mCost 是否可以被內聯:
go build -gcflags="-m -m" main.go
=======================================
# command-line-arguments
./main.go:11:6: cannot inline mCost: function too complex: cost 156 exceeds budget 80
./main.go:13:8: inlining call to sync.(*Mutex).Lock
./main.go:14:10: inlining call to sync.(*Mutex).Unlock
./main.go:7:6: can inline main with cost 59 as: func() { mCost() }
./main.go:12:6: s escapes to heap:
./main.go:12:6: flow: sync.m = &s:
./main.go:12:6: from s (address-of) at ./main.go:14:3
./main.go:12:6: from sync.m := s (assign-pair) at ./main.go:14:10
./main.go:12:6: flow: {heap} = sync.m:
./main.go:12:6: from sync.m.state (dot of pointer) at ./main.go:14:10
./main.go:12:6: from &sync.m.state (address-of) at ./main.go:14:10
./main.go:12:6: from atomic.AddInt32(&sync.m.state, int32(-1)) (call parameter) at ./main.go:14:10
./main.go:12:6: moved to heap: s
很不幸,一個 Mutex 就達到了 156 的 cost。
如果我們把 Mutex 換成 RWMutex 呢?
func mCost() {
var s sync.RWMutex
s.Lock()
s.Unlock()
}
=============================
# command-line-arguments
./main.go:11:6: cannot inline mCost: function too complex: cost 126 exceeds budget 80
./main.go:7:6: can inline main with cost 59 as: func() { mCost() }
./main.go:12:6: s escapes to heap:
./main.go:12:6: flow: {heap} = &s:
./main.go:12:6: from s (address-of) at ./main.go:13:3
./main.go:12:6: from (*sync.RWMutex).Lock(s) (call parameter) at ./main.go:13:8
./main.go:12:6: s escapes to heap:
./main.go:12:6: flow: {heap} = &s:
./main.go:12:6: from s (address-of) at ./main.go:14:3
./main.go:12:6: from (*sync.RWMutex).Unlock(s) (call parameter) at ./main.go:14:10
./main.go:12:6: moved to heap: s
這個時候 cost 從 156 降到了 126。
再試試原子操作呢?
package main
import (
"sync/atomic"
)
func main() {
mCost()
}
func mCost() {
var state int32
atomic.CompareAndSwapInt32(&state, 0, 1)
}
==================================================
# command-line-arguments
./main.go:11:6: can inline mCost with cost 10 as: func() { var state int32; state = <nil>; atomic.CompareAndSwapInt32(&state, 0, 1) }
./main.go:7:6: can inline main with cost 12 as: func() { mCost() }
./main.go:8:7: inlining call to mCost
./main.go:8:7: state escapes to heap:
./main.go:8:7: flow: {heap} = &state:
./main.go:8:7: from &state (address-of) at ./main.go:8:7
./main.go:8:7: from atomic.CompareAndSwapInt32(&state, 0, 1) (call parameter) at ./main.go:8:7
./main.go:8:7: moved to heap: state
./main.go:12:6: state escapes to heap:
./main.go:12:6: flow: {heap} = &state:
./main.go:12:6: from &state (address-of) at ./main.go:13:29
./main.go:12:6: from atomic.CompareAndSwapInt32(&state, 0, 1) (call parameter) at ./main.go:13:28
./main.go:12:6: moved to heap: state
天壤之別,這次直接降到了 10,而且可以內聯了。這裏也可以看出來原子操作要比鎖輕量級太多。這也是爲什麼,sync 包中 Mutex 和 RWMutex 的加鎖拆分爲 fast 和 slow 兩個路徑。
因爲所有 fast 路徑都只依賴原子操作,如果能把 slow 拆出去作爲單獨的方法,那麼原方法整體就可以被內聯,這樣保證了絕大多數情況下我們走 fast 路徑會更快。
這裏要說明一下,很多同學會有疑問,比如我有個方法 A,裏面包含了個對其他函數 B 的調用。這個時候我們計算 A 的 cost 時是否會把 B 的 cost 也包含進來?
(因爲可以想象,假定 B 是個很複雜的操作。這個問題的答案會顯著影響 A 的 cost,可能 A 的其他操作幾乎不佔用什麼 cost)
下一節我們用官方擴展的 semaphore 庫來做個實驗。
semaphore 能否內聯
結論先行:semaphore 庫雖然理論上也是可以拆分爲 fast 和 slow,但畢竟依賴的是鎖,不是原子操作。所以 cost 一下子就超過 80,無法內聯。
我們直接拿 semaphore 包來驗證一下。爲了示意,這裏就省略了很多代碼。可以理解爲,我們將 Acquire 中原來複雜的操作拆成了 s.acquireSlow 方法(copy 了過去),這樣 Acquire 裏面的代碼就很簡單了,複雜度收斂到 s.acquireSlow 裏面。如下:
func (s *Weighted) Acquire(ctx context.Context, n int64) error {
s.mu.Lock()
if s.size-s.cur >= n && s.waiters.Len() == 0 {
s.cur += n
s.mu.Unlock()
return nil
}
return s.acquireSlow(ctx, n)
}
func (s *Weighted) acquireSlow(ctx context.Context, n int64) error {
if n > s.size {
s.mu.Unlock()
<-ctx.Done()
return ctx.Err()
}
ready := make(chan struct{})
w := waiter{n: n, ready: ready}
elem := s.waiters.PushBack(w)
s.mu.Unlock()
select {
case <-ctx.Done():
xxxx
case <-ready:
xxx
}
}
當我們跑 go build gcflag 時,你會發現
./semaphore.go:40:6: cannot inline (*Weighted).Acquire: function too complex: cost 242 exceeds budget 80
此時的 Acquire cost 爲 242。
那如果我們把 acquireSlow 裏面這個 select 刪掉呢?(純粹爲了測試 cost,功能先忽略),此時 acquireSlow 變得非常簡短:
func (s *Weighted) acquireSlow(ctx context.Context, n int64) error {
if n > s.size {
// Don't make other Acquire calls block on one that's doomed to fail.
s.mu.Unlock()
<-ctx.Done()
return ctx.Err()
}
s.mu.Unlock()
return nil
}
=====================================
./semaphore.go:50:6: cannot inline (*Weighted).acquireSlow: function too complex: cost 289 exceeds budget 80
./semaphore.go:40:6: cannot inline (*Weighted).Acquire: function too complex: cost 242 exceeds budget 80
這裏有兩個發現:
-
Acquire 的 cost 一丁點都沒變,還是 242。意味着在一個方法內部對其他函數的調用,並不會出現嵌套計算 cost 這種情況(猜想是因爲,即便 inline,涉及內層函數調用那裏,展開的時候也可以還是個函數調用,不代表全都展開);
-
即便我們把 acquireSlow 改成這麼簡單,還是有 channel,有 Unlock,最後 cost 還是達到了 289。原子操作真香。
而此時,如果我們不再動 acquireSlow,只是縮減一下 Acquire 中其他邏輯,再看看:
func (s *Weighted) Acquire(ctx context.Context, n int64) error {
s.mu.Lock()
if s.size-s.cur >= n && s.waiters.Len() == 0 {
// s.cur += n
s.mu.Unlock()
return nil
}
return s.acquireSlow(ctx, n)
}
這裏的改動只是把 s.cur += n 註釋掉,再來跑一下看看結果:
./semaphore.go:50:6: cannot inline (*Weighted).acquireSlow: function too complex: cost 289 exceeds budget 80
./semaphore.go:40:6: cannot inline (*Weighted).Acquire: function too complex: cost 238 exceeds budget 80
發現了麼?acquireSlow 自然還是 289 的 cost 不會變,但 Acquire 的 cost 從 242 降到了 238。這也印證了我們的想法。
總結
-
Mutex 和 RWMutex 加解鎖的 cost 在 156 上下;
-
嵌套的內層函數調用不會被計算到原函數的 cost 中,所以我們可以考慮參照 Mutex 拆分 fast/slow path 的方式來對熱區代碼做一些內斂優化;
-
鎖的 cost 是原子操作的十幾倍,能用原子操作解決的可以優先考慮;
-
簡單的 += 都要消耗 4 個 cost,可想而知,做內聯優化一定要小心,用 gcflag 命令多看一下原因。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/bo4ajRZm_DtT8qp0LPPfQg