Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

延遲執行 - defer語法

defer 語法支持是Go 語言中一大特性,通過 defer 關鍵字,我們可以聲明一個延遲執行函數,當調用者返回之前開始執行該函數,一般用來完成資源、鎖、連接等釋放工作,或者 recover 可能發生的panic

三大特性

defer延遲執行語法有三大特性:

defer函數的傳入參數在定義時就已經明確

func main() {
	i := 1
	defer fmt.Println(i)
	i++
	return
}

上面代碼輸出1,而不是2。

defer函數是按照後進先出的順序執行

func main() {
	for i := 1; i <= 5; i++ {
		defer fmt.Print(i)
	}
}

上面代碼輸出54321,而不是12345

defer函數可以讀取和修改函數的命名返回值

func main() {
	fmt.Println(test())
}

func test() (i int) {
	defer func() {
		i++
	}()
	return 100
}

上面代碼輸出輸出101,而不是100或者1。

白話defer原理

defer函數底層數據結構是_defer結構體,多個defer函數會構建成一個_defer鏈表,後面加入的defer函數會插入鏈表的頭部,該鏈表鏈表頭部會鏈接到G上。當函數執行完成返回的時候,會從_defer鏈表頭部開始依次執行defer函數。這也就是defer函數執行時會LIFO的原因。_defer鏈接結構示意圖如下:

defer原理示意圖

創建_defer結構體是需要進行內存分配的,爲了減少分配_defer結構體時資源消耗,Go底層使用了defer緩衝池(defer pool),用來緩存上次使用完的_defer結構體,這樣下次可以直接使用,不必再重新分配內存了。defer緩衝池一共有兩級:per-P級defer緩衝池和全局defer緩衝池。當創建_defer結構體時候,優先從當前M關聯的P的緩衝池中取得_defer結構體,即從per-P緩衝池中獲取,這個過程是無鎖操作。如果per-P緩衝池中沒有,則在嘗試從全局defer緩衝池獲取,若也沒有獲取到,則重新分配一個新的_defer結構體。

當defer函數執行完成之後,Go底層會將分配的_defer結構體進行回收,先存放在per-P級defer緩衝池中,若已存滿,則存放在全局defer緩衝池中。

源碼分析

我們以下代碼作爲示例,分析defer實現機制:

package main

func main() {
	defer greet("friend")
	println("welcome")
}

func greet(text string) {
	print("hello " + text)
}

在分析之前,我們先來看下defer結構體:

type _defer struct {
	siz     int32 // 參數和返回值共佔用空間大小,這段空間會在_defer結構體後面,用於defer註冊時候保存參數,並在執行時候拷貝到調用者參數與返回值空間。
	started bool // 標記defer是否已經執行
	heap    bool // 標記該_defer結構體是否分配在堆上

	openDefer bool // 標誌是否使用open coded defer方式處理defer
	sp        uintptr  // 調用者棧指針,執行時會根據sp判斷該defer是否是當前執行調用者註冊的
	pc        uintptr  // deferprocStack或deferproc的返回地址
	fn        *funcval // defer函數,是funcval類型
	_panic    *_panic  // panic鏈表,用於panic處理
	link      *_defer // 鏈接到下一個_defer結構體,即該在_defer之前註冊的_defer結構體

	fd   unsafe.Pointer // funcdata for the function associated with the frame
	varp uintptr        // value of varp for the stack frame
	framepc uintptr
}

_defer結構體中siz字段記錄着defer函數參數和返回值大小,如果defer函數擁有參數,則Go會把其參數拷貝到該defer函數對應的_defer結構體後面的內存塊中。

_defer結構體中fn字段是指向一個funcval類型的指針,funcval結構體的fn字段字段指向defer函數的入口地址。對應上面示例代碼中就是greet函數的入口地址

上面示例代碼中編譯後的Go彙編代碼如下,點擊在線查看彙編代碼

main_pc0:
        TEXT    "".main(SB), ABIInternal, $40-0
        MOVQ    (TLS), CX
        CMPQ    SP, 16(CX)
        JLS     main_pc151
        SUBQ    $40, SP
        MOVQ    BP, 32(SP)
        LEAQ    32(SP), BP
        FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        FUNCDATA        $3, gclocals·9fb7f0986f647f17cb53dda1484e0f7a(SB)
        PCDATA  $2, $0
        PCDATA  $0, $0
        MOVL    $16, (SP)
        PCDATA  $2, $1
        LEAQ    "".greet·f(SB), AX
        PCDATA  $2, $0
        MOVQ    AX, 8(SP)
        PCDATA  $2, $1
        LEAQ    go.string."friend"(SB), AX
        PCDATA  $2, $0
        MOVQ    AX, 16(SP)
        MOVQ    $6, 24(SP)
        CALL    runtime.deferproc(SB)
        TESTL   AX, AX
        JNE     main_pc135
        JMP     main_pc84
main_pc84:
        CALL    runtime.printlock(SB)
        PCDATA  $2, $1
        LEAQ    go.string."welcome\n"(SB), AX
        PCDATA  $2, $0
        MOVQ    AX, (SP)
        MOVQ    $8, 8(SP)
        CALL    runtime.printstring(SB)
        CALL    runtime.printunlock(SB)
        XCHGL   AX, AX
        CALL    runtime.deferreturn(SB)
        MOVQ    32(SP), BP
        ADDQ    $40, SP
        RET
main_pc135:
        XCHGL   AX, AX
        CALL    runtime.deferreturn(SB)
        MOVQ    32(SP), BP
        ADDQ    $40, SP
        RET

需要注意的是上面彙編代碼是go1.12版本的彙編代碼。

從上面彙編代碼我們可以發現defer實現有兩個階段,第一個階段使用runtime.deferproc函數進行defer註冊階段。這一階段主要工作是創建defer結構,然後將其註冊到defer鏈表中。在註冊完成之後,會根據runtime.deferproc函數返回結果進行下一步處理,若是1則說明,defer函數有panic處理,則直接跳過defer後面的代碼,直接去執行runtime.deferreturn(對應就是上面彙編代碼JNE main_pc135邏輯),若是0則是正常流程,則繼續後面的代碼(對應上面彙編代碼就是 JMP main_pc84)。

第二個階段是調用runtime.deferreturn函數執行defer執行階段。這個階段遍歷defer鏈表,獲取defer結構,然後執行defer結構中存放的defer函數信息。

defer註冊階段

defer註冊階段是調用deferproc函數將創建defer結構體,並將其註冊到defer鏈表中。

func deferproc(siz int32, fn *funcval) {
	if getg().m.curg != getg() { // 判斷當前G是否處在用戶棧空間上,若不是則拋出異常
		throw("defer on system stack")
	}

	sp := getcallersp()
	argp := uintptr(unsafe.Pointer(&fn)) + unsafe.Sizeof(fn) // 獲取defer函數參數起始地址
	callerpc := getcallerpc()

	d := newdefer(siz)
	if d._panic != nil {
		throw("deferproc: d.panic != nil after newdefer")
	}
	d.fn = fn
	d.pc = callerpc
	d.sp = sp
	switch siz {
	case 0:
		// Do nothing.
	case sys.PtrSize: // defer函數等於8字節大小(64位系統下),則直接將_defer結構體後面8字節空間
		*(*uintptr)(deferArgs(d)) = *(*uintptr)(unsafe.Pointer(argp))
	default:
		memmove(deferArgs(d), unsafe.Pointer(argp), uintptr(siz))
	}

	return0()
}

上面代碼中getcallersp()返回調用者SP地址。deferproc的調用者是main函數,getcallersp()返回的SP地址指向的deferproc的return address。

getcallerpc()返回調用者PC,此時PC指向的CALL runtime.deferproc(SB)指令的下一條指令,即TESTL AX, AX

結合彙編和deferproc代碼,我們畫出defer註冊時狀態圖:

接下來,我們來看下newdefer函數是如何分配defer結構體的。

func newdefer(siz int32) *_defer {
	var d *_defer
	sc := deferclass(uintptr(siz)) // 根據defer函數參數大小,計算出應該使用上面規格的defer緩衝池
	gp := getg()
	if sc < uintptr(len(p{}.deferpool)) { // defer緩衝池只支持5種緩衝池,從0到4,若sc規格不小於5(說明defer參數大小大於64字節),
	// 則無法使用緩衝池,則需從內存中分配
		pp := gp.m.p.ptr() // pp指向當前M關聯的P
		if len(pp.deferpool[sc]) == 0 && sched.deferpool[sc] != nil { // 若當前P的defer緩衝池爲空,且全局緩衝池有可用的defer,那麼先從全局緩衝拿一點過來存放在P的緩衝池中
			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 { // 若果需要的defer緩衝池不滿足所需的規格,或者緩衝池中沒有可用的時候,切換到系統棧上,進行defer結構內存分配。
		systemstack(func() {
			total := roundupsize(totaldefersize(uintptr(siz)))
			d = (*_defer)(mallocgc(total, deferType, true))
		})
	}
	d.siz = siz
	d.heap = true // 標記分配到堆上
	d.link = gp._defer // 插入到鏈表頭部
	gp._defer = d
	return d
}

總結下newdefer函數邏輯:

  1. 首先根據defer函數的參數大小,使用deferclass計算出相應所需要的defer規格,如果defer緩衝池支持該規格,則嘗試從defer緩衝池取出對應的defer結構體。
  2. 從defer緩衝池中取可用defer結構體時候,會首先從per-P defer緩衝池中取,若per-P defer緩衝池爲空,則嘗試從全局緩衝池中取一些可用defer結構體,然後放在per-P緩衝池,然後再從per-P緩衝池中取。
  3. 若defer緩衝池不支持該規格,或者緩衝池無可用緩衝,則切換到系統棧上進行defer結構分配。

defer緩衝池規格

defer緩衝池,是按照defer函數參數大小範圍分爲五種規格,若不在五種規格之類,則不提供緩衝池功能,那麼每次defer註冊時候時候都必須進行內存分配創建defer結構體:

緩衝池規格defer函數參數大小範圍對應per-P緩衝池位置對應全局緩衝池位置
class00p.deferpool[0]sched.deferpool[0]
class1[1, 16]p.deferpool[1]sched.deferpool[1]
class2[17, 32]p.deferpool[2]sched.deferpool[2]
class3[33, 48]p.deferpool[3]sched.deferpool[3]
class4[49, 64]p.deferpool[4]sched.deferpool[4]

defer函數參數大小與緩衝池規格轉換是通過deferclass函數轉換的:

func deferclass(siz uintptr) uintptr {
	if siz <= minDeferArgs { // minDeferArgs是個常量,值是0
		return 0
	}
	return (siz - minDeferArgs + 15) / 16
}

per-P級defer緩衝池與全局級defer緩衝池結構

per-P級defer緩衝池結構使用兩個字段deferpooldeferpoolbuf構成緩衝池:

type p struct {
	...
	deferpool    [5][]*_defer // pool of available defer structs of different sizes (see panic.go)
	deferpoolbuf [5][32]*_defer
	...
}

p結構體中deferpool數組的元素是_defer指針類型的切片,該切片的底層數組是deferpoolbuf數組的元素:

func (pp *p) init(id int32) {
	...
	for i := range pp.deferpool {
		pp.deferpool[i] = pp.deferpoolbuf[i][:0]
	}
	...
}

全局級defer緩衝池保存在全局sched的deferpool字段中,sched是schedt類型變量,deferpool是由5個_defer類型指針構成鏈表組成的數組:

type schedt struct {
	...
	deferlock mutex // 由於存在多個P併發的從全局緩衝池中獲取defer結構體,所以需要一個鎖
	deferpool [5]*_defer
	...
}

defer執行階段

當函數返回之前,Go會調用deferreturn函數,開始執行defer函數。總之defer流程可以簡單概括爲:Go語言通過先註冊(通過調用deferproc函數),然後函數返回之前執行defer函數(通過調用deferreturn函數),實現了defer延遲執行功能。

func deferreturn(arg0 uintptr) {
	gp := getg()
	d := gp._defer
	if d == nil { // defer鏈表爲空,直接返回。deferreturn是一個遞歸調用,每次調用都會從defer鏈表彈出一個defer進行執行,當defer鏈表爲空時候,說明所有defer都已經執行完成
		return
	}
	sp := getcallersp()
	if d.sp != sp { // defer保存的sp與當前調用deferreturn的調用者棧頂sp不一致,則直接返回
		return
	}

	switch d.siz {
	case 0:
	case sys.PtrSize: // 若defer參數大小是8字節,則直接將defer參數複製給arg0
		*(*uintptr)(unsafe.Pointer(&arg0)) = *(*uintptr)(deferArgs(d))
	default: // 否則進行內存移動,將defer的參數複製到arg0中,此後arg0存放的是延遲函數的參數
		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)))
}

deferreturn函數通過jmpdefer實現遞歸調用,jmpdefer是通過彙編實現的,jmpdefer函數完成兩個功能:調用defer函數和deferreturn再次調用。deferreturn遞歸調用時候,遞歸終止條件有兩個:1. defer鏈表爲空。2. defer保存的sp與當前調用deferreturn調用者棧頂sp不一致。第一個條件很好了解,第二個循環終止條件存在原因,我們稍後探究。

我們需要理解arg0這個變量用途。arg0看似是deferreturn的參數,實際上是用來存儲延遲函數的參數。

在調用jmpdefer之前,會先調用freedefer將當前defer結構釋放回收:

func freedefer(d *_defer) {
	if d._panic != nil { // freedefer調用時_panic一定是nil
		freedeferpanic() // freedeferpanic作用是拋出異常:freedefer with d._panic != nil
	}
	if d.fn != nil { // freedefer調用時fn一定已經置爲nil
		freedeferfn() // freedeferfn作用是拋出異常:freedefer with d.fn != nil
	}
	if !d.heap { // defer結構不是在堆上分配,則無需進行回收
		return
	}
	sc := deferclass(uintptr(d.siz)) // 根據defer參數和返回值大小,判斷規格,以便決定放在哪種規格defer緩衝池中
	if sc >= uintptr(len(p{}.deferpool)) {
		return
	}
	pp := getg().m.p.ptr()
	if len(pp.deferpool[sc]) == cap(pp.deferpool[sc]) { // 當前P的defer緩衝池已滿,則將P的defer緩衝池defer取出一般放在全局defer緩衝池中
		systemstack(func() {
			var first, last *_defer
			for len(pp.deferpool[sc]) > cap(pp.deferpool[sc])/2 {
				n := len(pp.deferpool[sc])
				d := pp.deferpool[sc][n-1]
				pp.deferpool[sc][n-1] = nil
				pp.deferpool[sc] = pp.deferpool[sc][:n-1]
				if first == nil {
					first = d
				} else {
					last.link = d
				}
				last = d
			}
			lock(&sched.deferlock)
			last.link = sched.deferpool[sc]
			sched.deferpool[sc] = first
			unlock(&sched.deferlock)
		})
	}

	// 重置defer參數
	d.siz = 0
	d.started = false
	d.sp = 0
	d.pc = 0
	d.link = nil

	pp.deferpool[sc] = append(pp.deferpool[sc], d) // 將當前defer放入P的defer緩衝池中
}

我們來看下jmpdefer實現:

TEXT runtime·jmpdefer(SB), NOSPLIT, $0-16
	MOVQ	fv+0(FP), DX	# DX寄存器存儲jmpdefer第一個參數fn,fn是funcval類型指針
	MOVQ	argp+8(FP), BX	# BX寄存器存儲jmpdefer第二個參數,該參數是個指針類型,指向arg0
	LEAQ	-8(BX), SP	# 將BX存放的arg0的地址減少8,獲取得到調用deferreturn時棧頂地址(此時棧頂存放的是deferreturn的return address),最後將該地址存放在SP寄存器中
	MOVQ	-8(SP), BP	# 重置BP寄存器
	SUBQ	$5, (SP)	# 此時SP寄存器指向的是deferreturn的return address。該指令是將調用deferreturn的return address減少5,
	# 而減少5之後,return adderss恰好指向了`CALL runtime.deferreturn(SB)`,這就實現了deferreturn遞歸調用
	MOVQ 0(DX), BX # DX存儲的是fn,其是funcval類型指針,所以獲取真正函數入口地址需要0(DX),該指令等效於BX = Mem[R[DX] + 0]。
	# 寄存器邏輯操作不瞭解的話,可以參看前面Go彙編章節
	JMP	BX	# 通過JMP指令調用延遲函數

從上面代碼可以看出來,jmpdefer通過彙編更改了延遲函數調用的return address,使return address指向deferreturn入口地址,這樣當延遲函數執行完成之後,會繼續調用deferreturn函數,從而實現了deferreturn遞歸調用。deferreturn和jmpdefer最後實現的邏輯的僞代碼如下:

function deferreturn() {
	var arg int
	for _, d := range deferLinkList {
		arg = d.arg
		d.fn(arg)
		deferreturn()
	}
}

畫出deferreturn調用內存和棧的狀態圖,幫助理解:

最後我們來探究一下deferreturn第二個終止條件,考慮下面的場景:

func A() {
	defer B()
	defer C()
}

func C() {
	defer D()
}

將上面代碼轉換成成底層實現的僞代碼如下:

func A() {
	deferproc(B) // 註冊延遲函數B
	deferproc(C) // 註冊延遲函數C
	deferreturn() // 開始執行延遲函數
}

func C() {
	deferproc(D) // 註冊延遲函數C
	deferreturn() // 開始執行延遲函數
}

當調用A函數的deferreturn函數時,會從defer鏈表中取出延遲函數C進行執行,當執行C函數時,其內部也有一個defer函數,C函數最後也會調用deferreturn函數,當C函數中調用deferreturn函數時,defer鏈表結構如下:

              sp指向C的棧頂   sp指向A的棧頂
                    |           |
                    |           |
                    v           v
g._defer ---------> D --------> B

當C中的deferreturn執行完defer鏈表中延遲函數D之後,開始執行B的時候,由於B的sp指向的是A的棧頂,不等於C的棧頂,此時滿足終止條件2,C中的deferreturn會退出執行,此時A的deferreturn開始繼續執行(A的deferreturn調用其C的deferreturn函數,相當於一個大循環裏面套一個小循環,現在是小循環退出了,大循環還是會繼續的),此時由於B的sp指向就是A的棧頂,B函數會執行。

deferreturn循環終止第二個條件就是爲了解決諸於此類的場景。

優化歷程

上面我們分析的代碼中defer結構是分配到堆上,其實爲了優化defer語法性能,Go在實現過程可能會將defer結構分配在棧上。我們來看看Go各個版本對defer都做了哪些優化?

package main

func main() {
	defer greet()
}

func greet() {
	print("hello")
}

我們以上面代碼爲例,看看其在go1.12、go1.13、go1.14這幾個版本下的核心彙編代碼:

go1.12版本

leaq    "".greet·f(SB), AX
pcdata  $2, $0
movq    AX, 8(SP)
call    runtime.deferproc(SB)
testl   AX, AX
jne     main_pc73
.loc 1 5 0
xchgl   AX, AX
call    runtime.deferreturn(SB)

go1.12版本中通過調用 runtime.deferproc 函數,將defer函數包裝成 _defer 結構並註冊到defer鏈表中,該 _defer 結構體是分配在堆內存中,需要進行垃圾回收的。

go1.13版本

leaq    "".greet·f(SB), AX
pcdata  $0, $0
movq    AX, ""..autotmp_0+32(SP)
pcdata  $0, $1
leaq    ""..autotmp_0+8(SP), AX
pcdata  $0, $0
movq    AX, (SP)
call    runtime.deferprocStack(SB)
testl   AX, AX
jne     main_pc83
.loc 1 5 0
xchgl   AX, AX
call    runtime.deferreturn(SB)

go1.13版本中通過調用 runtime.deferprocStack 函數,將defer函數包裝成 _defer 結構並註冊到defer鏈表中,該 _defer 結構體是分配在棧上,不需要進行垃圾回收處理,這個地方就是go1.13相比go1.12所做的優化點。

go1.14版本

leaq    "".greet·f(SB), AX
pcdata  $0, $0
pcdata  $1, $1
movq    AX, ""..autotmp_1+8(SP)
.loc 1 5 0
movb    $0, ""..autotmp_0+7(SP)
call    "".greet(SB)
movq    16(SP), BP
addq    $24, SP
ret
call    runtime.deferreturn(SB)

go1.14版本不再調用deferproc/deferprocStack 函數來處理,而是在 return 返回之前直接調用該 defer函數(即inline方式),性能相比go1.13又得到進一步提升,go官方把這種處理方式稱爲open-coded defer。實際上go1.14中禁止優化和內聯之後,defer函數其底層實現方式就和go1.13一樣了。

需要注意的是 open-coded defer 使用是有限制的,它不能用於for循環中的defer函數,還有就是defer的數量也是有限制的,最多支持8個defer函數,對於for循環或者數量過的defer,將使用deferproc/deferprocStack方式實現。關於 open-coded defer 設計細節可以參見官方設計文檔:Proposal: Low-cost defers through inline code, and extra funcdata to manage the panic case

此外 open-coded defer 雖大大提高了 defer 函數執行的性能,但 panicrecover 的執行性能會大大變慢,這是因爲 panic 處理過程中會掃描 open-coded defer 的棧幀。具體參見open-coded defer代碼提交記錄open-coded defer帶來的好處的是明顯,畢竟panic是比較少發生的。

go1.14也增加了 -d defer 編譯選項,可以查看defer實現時候使用哪一種方式:

go build -gcflags="-d defer" main.go

總結一下defer優化歷程:

版本優化內容
Go1.12及以前defer分配到堆上,是heap-allocated defer
Go1.13支持在棧上分配defer結構,減少堆上分配和GC的開銷,是stack-allocated defer
G01.14支持開放式編碼defer,不再使用defer結構,直接在函數尾部調用延遲函數,是open-coded defer

進一步閱讀