Go defer實現原理剖析 頂 原 薦

1. 前言

defer語句用於延遲函數的調用,每次defer都會把一個函數壓入棧中,函數返回前再把延遲的函數取出並執行。

爲了方便描述,我們把創建defer的函數稱爲主函數,defer語句後面的函數稱爲延遲函數。

延遲函數可能有輸入參數,這些參數可能來源於定義defer的函數,延遲函數也可能引用主函數用於返回的變量,也就是說延遲函數可能會影響主函數的一些行爲,這些場景下,如果不瞭解defer的規則很容易出錯。

其實官方說明的defer的三個原則很清楚,本節試圖彙總defer的使用場景並做簡單說明。

2. 熱身

按照慣例,我們看幾個有意思的題目,用於檢驗對defer的瞭解程度。

2.1 題目一

下面函數輸出結果是什麼?

func deferFuncParameter() {
    var aInt = 1

    defer fmt.Println(aInt)

    aInt = 2
    return
}

題目說明:
函數deferFuncParameter()定義一個整型變量並初始化爲1,然後使用defer語句打印出變量值,最後修改變量值爲2.

參考答案:
輸出1。延遲函數fmt.Println(aInt)的參數在defer語句出現時就已經確定了,所以無論後面如何修改aInt變量都不會影響延遲函數。

2.2 題目二

下面程序輸出什麼?

package main

import "fmt"

func printArray(array *[3]int) {
    for i := range array {
        fmt.Println(array[i])
    }
}

func deferFuncParameter() {
    var aArray = [3]int{1, 2, 3}

    defer printArray(&aArray)

    aArray[0] = 10
    return
}

func main() {
    deferFuncParameter()
}

函數說明:
函數deferFuncParameter()定義一個數組,通過defer延遲函數printArray()的調用,最後修改數組第一個元素。printArray()函數接受數組的指針並把數組全部打印出來。

參考答案:
輸出10、2、3三個值。延遲函數printArray()的參數在defer語句出現時就已經確定了,即數組的地址,由於延遲函數執行時機是在return語句之前,所以對數組的最終修改值會被打印出來。

2.3 題目三

下面函數輸出什麼?

func deferFuncReturn() (result int) {
    i := 1

    defer func() {
       result++
    }()

    return i
}

函數說明:
函數擁有一個具名返回值result,函數內部聲明一個變量i,defer指定一個延遲函數,最後返回變量i。延遲函數中遞增result。

參考答案:
函數輸出2。函數的return語句並不是原子的,實際執行分爲設置返回值-->ret,defer語句實際執行在返回前,即擁有defer的函數返回過程是:設置返回值-->執行defer-->ret。所以return語句先把result設置爲i的值,即1,defer語句中又把result遞增1,所以最終返回2。

3. defer規則

Golang官方博客裏總結了defer的行爲規則,只有三條,我們圍繞這三條進行說明。

3.1 規則一:延遲函數的參數在defer語句出現時就已經確定下來了

官方給出一個例子,如下所示:

func a() {
    i := 0
    defer fmt.Println(i)
    i++
    return
}

defer語句中的fmt.Println()參數i值在defer出現時就已經確定下來,實際上是拷貝了一份。後面對變量i的修改不會影響fmt.Println()函數的執行,仍然打印"0"。

注意:對於指針類型參數,規則仍然適用,只不過延遲函數的參數是一個地址值,這種情況下,defer後面的語句對變量的修改可能會影響延遲函數。

3.2 規則二:延遲函數執行按後進先出順序執行,即先出現的defer最後執行

這個規則很好理解,定義defer類似於入棧操作,執行defer類似於出棧操作。

設計defer的初衷是簡化函數返回時資源清理的動作,資源往往有依賴順序,比如先申請A資源,再跟據A資源申請B資源,跟據B資源申請C資源,即申請順序是:A-->B-->C,釋放時往往又要反向進行。這就是把deffer設計成FIFO的原因。

每申請到一個用完需要釋放的資源時,立即定義一個defer來釋放資源是個很好的習慣。

3.3 規則三:延遲函數可能操作主函數的具名返回值

定義defer的函數,即主函數可能有返回值,返回值有沒有名字沒有關係,defer所作用的函數,即延遲函數可能會影響到返回值。

若要理解延遲函數是如何影響主函數返回值的,只要明白函數是如何返回的就足夠了。

3.3.1 函數返回過程

有一個事實必須要了解,關鍵字return不是一個原子操作,實際上return只代理彙編指令ret,即將跳轉程序執行。比如語句return i,實際上分兩步進行,即將i值存入棧中作爲返回值,然後執行跳轉,而defer的執行時機正是跳轉前,所以說defer執行時還是有機會操作返回值的。

舉個實際的例子進行說明這個過程:

func deferFuncReturn() (result int) {
    i := 1

    defer func() {
       result++
    }()

    return i
}

該函數的return語句可以拆分成下面兩行:

result = i
return

而延遲函數的執行正是在return之前,即加入defer後的執行過程如下:

result = i
result++
return

所以上面函數實際返回i++值。

關於主函數有不同的返回方式,但返回機制就如上機介紹所說,只要把return語句拆開都可以很好的理解,下面分別舉例說明

3.3.1 主函數擁有匿名返回值,返回字面值

一個主函數擁有一個匿名的返回值,返回時使用字面值,比如返回"1"、"2"、"Hello"這樣的值,這種情況下defer語句是無法操作返回值的。

一個返回字面值的函數,如下所示:

func foo() int {
    var i int

    defer func() {
        i++
    }()

    return 1
}

上面的return語句,直接把1寫入棧中作爲返回值,延遲函數無法操作該返回值,所以就無法影響返回值。

3.3.2 主函數擁有匿名返回值,返回變量

一個主函數擁有一個匿名的返回值,返回使用本地或全局變量,這種情況下defer語句可以引用到返回值,但不會改變返回值。

一個返回本地變量的函數,如下所示:

func foo() int {
    var i int

    defer func() {
        i++
    }()

    return i
}

上面的函數,返回一個局部變量,同時defer函數也會操作這個局部變量。對於匿名返回值來說,可以假定仍然有一個變量存儲返回值,假定返回值變量爲"anony",上面的返回語句可以拆分成以下過程:

anony = i
i++
return

由於i是整型,會將值拷貝給anony,所以defer語句中修改i值,對函數返回值不造成影響。

3.3.3 主函數擁有具名返回值

主函聲明語句中帶名字的返回值,會被初始化成一個局部變量,函數內部可以像使用局部變量一樣使用該返回值。如果defer語句操作該返回值,可能會改變返回結果。

一個影響函返回值的例子:

func foo() (ret int) {
    defer func() {
        ret++
    }()

    return 0
}

上面的函數拆解出來,如下所示:

ret = 0
ret++
return

函數真正返回前,在defer中對返回值做了+1操作,所以函數最終返回1。

4. defer實現原理

本節我們嘗試瞭解一些defer的實現機制。

4.1 defer數據結構

源碼包src/src/runtime/runtime2.go:_defer定義了defer的數據結構:

type _defer struct {
    sp      uintptr   //函數棧指針
    pc      uintptr   //程序計數器
    fn      *funcval  //函數地址
    link    *_defer   //指向自身結構的指針,用於鏈接多個defer
}

我們知道defer後面一定要接一個函數的,所以defer的數據結構跟一般函數類似,也有棧地址、程序計數器、函數地址等等。

與函數不同的一點是它含有一個指針,可用於指向另一個defer,每個goroutine數據結構中實際上也有一個defer指針,該指針指向一個defer的單鏈表,每次聲明一個defer時就將defer插入到單鏈表表頭,每次執行defer時就從單鏈表表頭取出一個defer執行。

下圖展示一個goroutine定義多個defer時的場景:

從上圖可以看到,新聲明的defer總是添加到鏈表頭部。

函數返回前執行defer則是從鏈表首部依次取出執行,不再贅述。

一個goroutine可能連續調用多個函數,defer添加過程跟上述流程一致,進入函數時添加defer,離開函數時取出defer,所以即便調用多個函數,也總是能保證defer是按FIFO方式執行的。

4.2 defer的創建和執行

源碼包src/runtime/panic.go定義了兩個方法分別用於創建defer和執行defer。

  • deferproc(): 在聲明defer處調用,其將defer函數存入goroutine的鏈表中;
  • deferreturn():在return指令,準確的講是在ret指令前調用,其將defer從goroutine鏈表中取出並執行。

可以簡單這麼理解,在編譯在階段,聲明defer處插入了函數deferproc(),在函數return前插入了函數deferreturn()。

5. 總結

  • defer定義的延遲函數參數在defer語句出時就已經確定下來了
  • defer定義順序與實際執行順序相反
  • return不是原子操作,執行過程是: 保存返回值(若有)-->執行defer(若有)-->執行ret跳轉
  • 申請資源後立即使用defer關閉資源是好習慣
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章