再探 Golang 内联优化

2,207 阅读4分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第10天,点击查看活动详情

开篇

上一篇文章初探 Golang 内联 中我们了解了内联的原理,规则。其实 Golang 标准库里,以 sync 包为代表,其实针对锁的实现,进行了专门的内联优化。

今天我们来实战验证一下。

感兴趣的同学建议先看下 Mutex 和 RWMutex 的源码,旗帜鲜明地把 fast 和 slow 拆开:

image.png

这里的 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 命令多看一下原因。