携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第10天,点击查看活动详情
开篇
上一篇文章初探 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 库来做个实验。不熟悉的同学可以先看下我们前一篇文章聊聊 Golang 信号量的设计和实现
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 命令多看一下原因。