golang defer原理

golang defer原理

  • defer的特性
    • 1. 延迟调用
    • 2. 后进先出
    • 3. defer 作用域
    • 4. 异常处理
  • 性能测试
  • 源码解析
    • 数据结构
    • 创建defer
      • 1. 存储在栈上的defer
      • 2. 存储在heap上的defer
    • defer的调用
  • defer的开销与优化
  • go 1.13 对defer的优化

这篇文章是系列文章的第一篇,系列文章主要包括:

  1. golang defer的原理
  2. golang panic和recover()函数的原理(包括golang对于错误处理方式)
  3. defer性能损耗的讨论以及最重要的应用场景
  4. defer在golang 1.13 上的性能

defer的特性

首先,我们通过一些实际的例子来看 defer 的特性。

1. 延迟调用

package main

import (
	"fmt"
	"log"
)

func main() {
	defer log.Println("defer sim lou")
	fmt.Println("ending")
}

输出结果:

ending
2019/10/22 22:50:32 defer sim lou

可以看到 defer 定义的函数是在 main 函数逻辑执行完了之后才执行的,也就是延迟调用。

2. 后进先出

package main

import (
	"log"
	"strconv"
)

func main() {
	for i := 1; i <= 6; i++ {
		defer log.Println("defer sim lou-" + strconv.Itoa(i) + ".")
	}
	log.Println("ending.")
}

输出结果:

2019/10/22 22:56:37 ending.
2019/10/22 22:56:37 defer sim lou-6.
2019/10/22 22:56:37 defer sim lou-5.
2019/10/22 22:56:37 defer sim lou-4.
2019/10/22 22:56:37 defer sim lou-3.
2019/10/22 22:56:37 defer sim lou-2.
2019/10/22 22:56:37 defer sim lou-1.

根据输出可以看到,defer 函数的执行顺序是 LIFO,也就是后进先出的。

3. defer 作用域

package main

import (
	"log"
)

func main() {
	func() {
		defer log.Println("defer sim lou.")
	}()
	log.Println("main.ending.")
}

输出结果是:

2019/10/22 22:58:59 defer sim lou.
2019/10/22 22:58:59 main.ending.

可以看到 defer 的作用于是函数级别的, 也就是defer 生效在 defer 定义所在的函数栈。

4. 异常处理

package main

import (
	"log"
)

func main() {
	defer func() {
		if e := recover(); e != nil {
			log.Println("defer sim lou.")
		}
	}()
	panic("throw panic")
}

输出结果是:

2019/10/23 23:45:28 defer sim lou.

性能测试

defer 不是免费的午餐,任何一次的 defer 都存在性能问题,这样一个简单的性能对比:

基于go 1.13:

package main

import (
	"sync"
	"testing"
)

var lock sync.Mutex

func NoDefer() {
	lock.Lock()
	lock.Unlock()
}
func Defer() {
	lock.Lock()
	defer lock.Unlock()
}

func BenchmarkNoDefer(b *testing.B) {
	for i := 0; i < b.N; i++ {
		NoDefer()
	}
}

func BenchmarkDefer(b *testing.B) {
	for i := 0; i < b.N; i++ {
		Defer()
	}
}

benchmark结果是:

goos: darwin
goarch: amd64
BenchmarkNoDefer-12     108125792               11.0 ns/op
BenchmarkDefer-12       35371854                32.2 ns/op
PASS
ok      command-line-arguments  3.299s

下面将深入源码探究 defer 存在如此之高性能损耗的原因。

源码解析

我们看这一段源码会被翻译成汇编代码:

func main() {
	defer log.Println("defer sim lou")
	fmt.Println("ending")
}

go tool compile -S defer_test1.go >> defer_test1.s

"".main STEXT size=314 args=0x0 locals=0xb0
	........
	0x0097 00151 (defer_test1.go:9)	CALL	runtime.deferprocStack(SB)
	........
	0x00ff 00255 ($GOROOT/src/fmt/print.go:274)	CALL	fmt.Fprintln(SB)
	0x0104 00260 (defer_test1.go:11)	XCHGL	AX, AX
	0x0105 00261 (defer_test1.go:11)	CALL	runtime.deferreturn(SB)
	0x010a 00266 (defer_test1.go:11)	MOVQ	168(SP), BP
	0x0112 00274 (defer_test1.go:11)	ADDQ	$176, SP
	0x0119 00281 (defer_test1.go:11)	RET

通过汇编我们可以知道: defer 的调用被编译为了 runtime.deferprocStack 的调用(这个是go1.13的优化,1.13之前是runtime.deferproc调用)。返回前还被插入了 runtime.deferreturn 调用。

runtime.deferprocStack
runtime.deferreturn

下面我们看看这两个调用具体发生了什么事情。

数据结构

要先介绍一下 defer 的基础单元 _defer 结构体,如下:

type _defer struct {
	siz     int32 // includes both arguments and results
	started bool
	heap    bool
	sp      uintptr // sp at time of defer
	pc      uintptr
	fn      *funcval
	_panic  *_panic // panic that is running defer
	link    *_defer
}

type funcval struct {
	fn uintptr
	// variable-size, fn-specific data here
}

siz:所有传入参数的总大小
started:该 defer 是否已经执行过
heap: 表明该defer是否存储在heap上
sp:函数栈指针寄存器,一般指向当前函数栈的栈顶
pc:程序计数器,有时称为指令指针(IP),线程利用它来跟踪下一个要执行的指令。在大多数处理器中,PC指向的是下一条指令,而不是当前指令
fn:指向传入的函数地址和参数
_panic:指向 _panic 链表
link:指向 _defer 链表

所以一个defer对象持有一个defer列表的队头。根据SDK的注释:一些的defer被分配在函数栈上(go1.13的优化),一些被分配在堆上。

创建defer

1. 存储在栈上的defer

func deferprocStack(d *_defer) {
	......
	d.started = false
	d.heap = false
	d.sp = getcallersp()
	d.pc = getcallerpc()
	// The lines below implement:
	//   d.panic = nil
	//   d.link = gp._defer
	//   gp._defer = d
	*(*uintptr)(unsafe.Pointer(&d._panic)) = 0
	*(*uintptr)(unsafe.Pointer(&d.link)) = uintptr(unsafe.Pointer(gp._defer))
	*(*uintptr)(unsafe.Pointer(&gp._defer)) = uintptr(unsafe.Pointer(d))

	return0()
	// No code can go here - the C return register has
	// been set and must not be clobbered.
}

由于defer是在栈上创建的,所以直接以参数传递给 deferprocStack 函数。

  • 初始化栈上的defer的参数;
  • 获取调用 defer 函数的函数栈指针、传入函数的参数具体地址以及PC (程序计数器),也就是下一个要执行的指令。这些相当于是预备参数,便于后续的流转控制;
  • 将当前defer插入到当前 goroutine 的defer队列的队首;
  • 最后调用 return0 进行返回,这个函数非常重要。能够避免在 deferprocStack 中又因为返回 return,而诱发 deferreturn 方法的调用。其根本原因是一个停止 panic 的延迟方法会使 deferprocStack 返回 1,但在机制中如果 deferprocStack 返回不等于 0,将会总是检查返回值并跳转到函数的末尾。而 return0 返回的就是 0,因此可以防止重复调用

2. 存储在heap上的defer

我们看看下面代码:

func main() {
	for i:=1; i<=2; i++ {
		defer log.Println("defer sim lou, ", strconv.Itoa(i))
	}
	fmt.Println("ending")
}

编译之后的代码:

........
0x00d5 00213 (defer_test1_1.go:11)	CALL	runtime.deferproc(SB)
.......
0x00e4 00228 (defer_test1_1.go:11)	CALL	runtime.deferreturn(SB)
........

我们可以看到代码这次defer翻译成的代码是 deferproc 函数。

func deferproc(siz int32, fn *funcval) { // arguments of fn follow fn
	......
	sp := getcallersp()
	argp := uintptr(unsafe.Pointer(&fn)) + unsafe.Sizeof(fn)
	callerpc := getcallerpc()

	d := newdefer(siz)
	......
	d.fn = fn
	d.pc = callerpc
	d.sp = sp
	switch siz {
	case 0:
		// Do nothing.
	case sys.PtrSize:
		*(*uintptr)(deferArgs(d)) = *(*uintptr)(unsafe.Pointer(argp))
	default:
		memmove(deferArgs(d), unsafe.Pointer(argp), uintptr(siz))
	}

	return0()
	// No code can go here - the C return register has
	// been set and must not be clobbered.
}
  • 获取调用 defer 函数的函数栈指针、传入函数的参数具体地址以及PC (程序计数器),也就是下一个要执行的指令。这些相当于是预备参数,便于后续的流转控制
  • 创建一个新的 defer 最小单元 _defer,填入先前准备的参数
  • 调用 memmove 将传入的参数存储到新 _defer (当前使用)中去,便于后续的使用
  • 最后调用 return0 进行返回,这个函数非常重要。能够避免在 deferproc 中又因为返回 return,而诱发 deferreturn 方法的调用。其根本原因是一个停止 panic 的延迟方法会使 deferproc 返回 1,但在机制中如果 deferproc 返回不等于 0,将会总是检查返回值并跳转到函数的末尾。而 return0 返回的就是 0,因此可以防止重复调用

上面会调用 newdefer 函数来在堆上创建新的 defer 对象。

func newdefer(siz int32) *_defer {
	var d *_defer
	sc := deferclass(uintptr(siz))
	gp := getg()
	if sc < uintptr(len(p{}.deferpool)) {
		pp := gp.m.p.ptr()
		if len(pp.deferpool[sc]) == 0 && sched.deferpool[sc] != nil {
			// Take the slow path on the system stack so
			// we don't grow newdefer's stack.
			systemstack(func() {
				lock(&sched.deferlock)
				for len(pp.deferpool[sc]) < cap(pp.deferpool[sc])/2 && sched.deferpool[sc] != nil {
					d := sched.deferpool[sc]
					sched.deferpool[sc] = d.link
					d.link = nil
					pp.deferpool[sc] = append(pp.deferpool[sc], d)
				}
				unlock(&sched.deferlock)
			})
		}
		if n := len(pp.deferpool[sc]); n > 0 {
			d = pp.deferpool[sc][n-1]
			pp.deferpool[sc][n-1] = nil
			pp.deferpool[sc] = pp.deferpool[sc][:n-1]
		}
	}
	if d == nil {
		// Allocate new defer+args.
		systemstack(func() {
			total := roundupsize(totaldefersize(uintptr(siz)))
			d = (*_defer)(mallocgc(total, deferType, true))
		})
		if debugCachedWork {
			// Duplicate the tail below so if there's a
			// crash in checkPut we can tell if d was just
			// allocated or came from the pool.
			d.siz = siz
			d.link = gp._defer
			gp._defer = d
			return d
		}
	}
	d.siz = siz
	d.heap = true
	d.link = gp._defer
	gp._defer = d
	return d
}

申请defer对象时候,会使用 Per-P 里面的defer 对象池。释放defer是通过调用freedefer实现。

  • 从池中获取可以使用的 _defer,则复用作为新的基础单元
  • 若在池中没有获取到可用的,则调用 mallocgc 重新申请一个新的
  • 设置 defer 的基础属性,最后修改当前 Goroutine 的 _defer 指向

通过这个方法我们可以注意到两点,如下:

  • defer 与 Goroutine(g) 有直接关系,所以讨论 defer 时基本离不开 g 的关联
  • 新的 defer 总是会在现有的链表中的最前面,也就是 defer 的特性后进先出

这个函数主要承担了获取新的 _defer 的作用,它有可能是从 deferpool 中获取的,也有可能是重新申请的。

defer的调用

eferreturn 是由编译器插入到函数末尾的, 它自身只是简单的将需要被 defer 的入口地址取出,然后跳转并执行:

// 如果存在,则运行 defer 函数
// 编译器会将这个调用插入到任何包含 defer 的函数的末尾。
// 如果存在一个被 defer 的函数,此调用会调用 runtime.jmpdefer
// 这将跳转到被延迟的函数,使得它看起来像是在调用 deferreturn 之前由 deferreturn 的调用者调用。
// 产生的结果就是反复地调用 deferreturn,直到没有更多的 defer 函数为止。
func deferreturn(arg0 uintptr) {
	gp := getg()
	d := gp._defer
	if d == nil {
		return
	}
	sp := getcallersp()
	if d.sp != sp {
		return
	}

	// Moving arguments around.
	//
	// Everything called after this point must be recursively
	// nosplit because the garbage collector won't know the form
	// of the arguments until the jmpdefer can flip the PC over to
	// fn.
	switch d.siz {
	case 0:
		// Do nothing.
	case sys.PtrSize:
		*(*uintptr)(unsafe.Pointer(&arg0)) = *(*uintptr)(deferArgs(d))
	default:
		memmove(unsafe.Pointer(&arg0), deferArgs(d), uintptr(d.siz))
	}
	fn := d.fn
	d.fn = nil
	gp._defer = d.link
	freedefer(d)
	jmpdefer(fn, uintptr(unsafe.Pointer(&arg0)))
}

该方法中主要做了如下事项:

  • 清空当前节点 _defer 被调用的函数调用信息
  • 释放当前节点的 _defer 的存储信息并放回池中(便于复用)
  • 跳转到调用 defer 关键字的调用函数处

在这段代码中,跳转方法 jmpdefer 格外重要。因为它显式的控制了流转,代码如下:

// func jmpdefer(fv *funcval, argp uintptr)
// argp is a caller SP.
// called from deferreturn.
// 1. pop the caller
// 2. sub 5 bytes from the callers return
// 3. jmp to the argument
TEXT runtime·jmpdefer(SB), NOSPLIT, $0-16
	MOVQ	fv+0(FP), DX	// fn
	MOVQ	argp+8(FP), BX	// caller sp
	LEAQ	-8(BX), SP	// caller sp after CALL
	MOVQ	-8(SP), BP	// restore BP as if deferreturn returned (harmless if framepointers not in use)
	SUBQ	$5, (SP)	// return to CALL again
	MOVQ	0(DX), BX
	JMP	BX	// but first run the deferred function

这个 jmpdefer 巧妙的地方在于,它通过调用方 sp 来推算了 deferreturn 的入口地址, 从而在完成某个 defer 调用后,会再次回到 deferreturn 的初始位置,继续反复调用,造成尾递归的假象。尾递归可以避免创建新的函数栈从而提升性能。

通过源码的分析,我们发现它做了两个很 “奇怪” 又很重要的事,如下:

  • MOVQ -8(SP), BP:-8(BX) 这个位置保存的是 deferreturn 执行完毕后的地址
  • SUBQ $5, (SP):SP 的地址减 5 ,其减掉的长度就恰好是 runtime.deferreturn 的长度

你可能会问,为什么是 5?好吧。翻了半天最后看了一下汇编代码…嗯,相减的确是 5 没毛病,如下:

	0x0105 00261 (defer_test1.go:11)	CALL	runtime.deferreturn(SB)
	0x010a 00266 (defer_test1.go:11)	MOVQ	168(SP), BP

照上述逻辑的话,那 deferreturn 就是一个 “递归” 了哦。每次都会重新回到 deferreturn 函数,那它在什么时候才会结束呢,如下:

unc deferreturn(arg0 uintptr) {
    gp := getg()
    d := gp._defer
    if d == nil {
        return
    }
    ...
}

也就是会不断地进入 deferreturn 函数,判断链表中是否还存着 _defer。若已经不存在了,则返回,结束掉它。简单来讲,就是处理完全部 defer 才允许你真的离开它。

defer的开销与优化

我们先总结以下 defer 的实现思路:

  1. 将一个需要分配一个用于记录被 defer 的调用的 _defer 实例,并将入口地址及其参数复制保存, 安插到 goroutine 对应的延迟调用链表中。
  2. 在函数末尾处,通过编译器的配合,在调用被 defer 的函数前,将 _defer 实例归还,而后通过尾递归的方式 来对需要 defer 的函数进行调用。

从这两个过程来看,一次 defer 的成本来源于 _defer 对象的分配与回收、被 defer 函数的参数的拷贝。

go 1.13 对defer的优化

go 1.13的release note中显示 defer 性能提升30%。根据前面的分析,编译器会根据应用场景去选择使用 deferproc 还是 deferprocStack 方法,他们分别是针对分配在堆上和栈上的使用场景。

官方说明的 Go1.13 defer 性能提高 30%,主要来源于其延迟对象的堆栈分配规则的改变,措施是由编译器通过对 defer 的 for-loop 迭代深度进行分析,如果 loopdepth 为 1,则设置逃逸分析的结果,将分配到栈上,否则分配到堆上。

参考:
尾调用优化: http://www.ruanyifeng.com/blog/2015/04/tail-call.html
尾递归:https://www.cnblogs.com/zhanggui/p/7722541.html

你可能感兴趣的:(Golang)