再探 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

這裏有兩個發現:

而此時,如果我們不再動 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。這也印證了我們的想法。

總結

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/bo4ajRZm_DtT8qp0LPPfQg