Go语言中常见100问题-#97 Not relying on inlining

2024-02-01 17:04:29 浏览数 (1)

利用内联

内联是指用函数体内容替换函数调用。内联过程是由编译器自动完成的,了解内联的基本原理有助于我们对一些场景下的代码进行优化。

先来看一个非常简单的例子,sum是一个求和函数,完成两个数相加。

代码语言:javascript复制
func main() {
 a := 3
 b := 2
 s := sum(a, b)
 println(s)
}

func sum(a, b int) int {
 return a   b
}

编译时使用 -gcflags ,可以输出编译器处理的详尽日志。在我的电脑上运行结果如下:

代码语言:javascript复制
go build -gcflags "-m=2"                                       
# inline
./example1.go:10:6: can inline sum with cost 4 as: func(int, int) int { return a   b }
./example1.go:3:6: can inline main with cost 24 as: func() { a := 3; b := 2; s := sum(a, b); println(s) }
./example1.go:6:10: inlining call to sum

编译器决定将sum函数内联到main函数中。上述代码内联后如下:

代码语言:javascript复制
func main() {
 a := 3
 b := 2
 s := a   b
 println(s)
}

并不是任何函数都可以内联,内联只是对具有一定复杂性的函数有效,所以内联前要进行复杂性评估。如果函数太复杂,则不会内联,编译输出内容与下面类似。

代码语言:javascript复制
./main.go:10:6: cannot inline foo: function too complex:
    cost 84 exceeds budget 80

函数内联后有两个收益,一是消除了函数调用的开销(尽管Go1.17版本基于寄存器的调用约定,相比之前开销已经有所减少);二是编译器可以进一步优化代码。例如,在函数被内联后,编译器可以决定最初应该在堆上逃逸的变量可以分配在栈上。

函数内联是编译器自动完成的,开发者有必要关心吗?需要关心,因为有中间栈内联。中间栈内联是调用其他函数的内联函数,在Go1.9之前,只有叶子函数(不会调用其它函数的函数)才会被内联。现在由于支持栈中内联,所以下面的foo函数也可以被内联。

代码语言:javascript复制
func main(){
 foo()
}

func foo(){
 x:=1
 bar(x)
}

内联后的代码如下:

代码语言:javascript复制
func main() {
 x := 1
 bar(x)
}

有了中间栈内联,在编写程序的时候,我们可以将快速路径(代码逻辑比较简单)内联达到优化程序目的。下面结合 sync.Mutex 的Lock实现,理解其原理。

在不支持中间栈内联之前,Lock方法实现如下:

代码语言:javascript复制
func (m *Mutex) Lock() {
    if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
        // Mutex isn't locked
        if race.Enabled {
            race.Acquire(unsafe.Pointer(m))
        }
        return
    }

    // Mutex is already locked
    var waitStartTime int64
    starving := false
    awoke := false
    iter := 0
    old := m.state
    for {
        // ...    
    }
    if race.Enabled {
        race.Acquire(unsafe.Pointer(m))
    }
}

整个Lock方法实现分为两种情况,如果互斥锁没有被锁定(即atomic.CompareAndSwapInt32为真),处理比较简单。如果互斥锁已经被锁定(即atomic.CompareAndSwapInt32为假),处理起来非常复杂。

然而,无论哪种情况,由于函数的复杂性,Lock都不能被内联。为了使用中间栈内联,对Lock方法进行重构,将处理非常复杂的逻辑提取到一个特定的函数中。具体实现如下:

代码语言:javascript复制
func (m *Mutex) Lock() {
    if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
        if race.Enabled {
            race.Acquire(unsafe.Pointer(m))
        }
        return
    }
    m.lockSlow()     
}

func (m *Mutex) lockSlow() {
    var waitStartTime int64
    starving := false
    awoke := false
    iter := 0
    old := m.state
    for {
        // ...
    }

    if race.Enabled {
        race.Acquire(unsafe.Pointer(m))
    }
}

通过上面的优化,Lock函数复杂性降低,现在可以被内联。得到的收益是在互斥锁没有被锁定的情况下,没有函数调用开销(速度提高了5%左右)。在互斥锁已经被锁定的情况下没有变化,以前需要一个函数调用执行这个逻辑,现在仍然是一个函数调用,即 lockSlow 函数调用。

将简单逻辑处理和复杂逻辑处理区分开,如果简理逻辑处理可以被内联但是复杂逻辑处理不能被内联,我们可以将复杂处理部分提取到一个函数中,这样整体函数如果通过内联评估,在编译时就可以被内联处理。

所以函数内联不仅仅是编译器要关心的问题,作为开发者也需要关心,理解内联的工作机制可以有助于我们对程序进行优化,正如本文上面的例子,利用内联减少调用开销,提升程序运行速度。

0 人点赞