Go語言介面內部實現

2020-07-16 10:05:16
前幾節我們介紹了介面的基本概念和用法,定義介面只需簡單宣告一個方法集合即可,定義新型別時不需要顯式地宣告要實現的介面,介面的使用也很簡單。

那麼介面的底層是如何實現的呢?如何實現動態呼叫的呢?介面的動態呼叫到底有多大的額外開銷?本節我們就來深入講解一下介面的底層實現。

閱讀本節需要讀者了解Go語言介面的基礎知識和Go語言組合基礎和函數呼叫規約,以及對 ELF 可執行檔案格式有基本了解。本節內容有點偏底層,有一定的難度,如果閱讀起來有困難,可以先跳過去,有時間再慢慢讀。

資料結構

從前面章節了解到,介面變數必須初始化才有意義,沒有初始化的介面變數的預設值是 nil,沒有任何意義。具體型別範例傳遞給介面稱為介面的範例化。在介面的範例化的過程中,編譯器通過特定的資料結構描述這個過程。

首先介紹非空介面的內部資料結構,空介面的底層更簡單,放到最後介紹。非空介面的底層資料結構是 iface,程式碼位於Go語言安裝目錄的 src/runtime/runtime2.go 檔案中。

iface 資料結構

非空介面初始化的過程就是初始化一個 iface 型別的結構,範例如下:
//src/runtime/runtime2.go
type iface struct {
    tab *itab                //itab 存放型別及方法指標資訊
    data unsafe.Pointer      //資料資訊
}
可以看到 iface 結構很簡單,有兩個指標型別欄位。
  • itab:用來存放介面自身型別和系結的範例型別及範例相關的函數指標,具體內容後面有詳細介紹。
  • 資料指標 data:指向介面系結的範例的副本,介面的初始化也是一種值拷貝。

data 指向具體的範例資料,如果傳遞給介面的是值型別,則 data 指向的是範例的副本;如果傳遞給介面的是指標型別,則 data 指向指標的副本。總而言之,無論介面的轉換,還是函數呼叫,Go 遵循一樣的規則——值傳遞。

接下來看一下 itab 資料結構,itab 是介面內部實現的核心和基礎。範例如下:
//src/runtime/runtime2.go
type itab struct {
    inter *interfacetype      //介面自身的靜態型別
    _type *_type              //_type 就是介面存放的具體範例的型別(動態型別)
    //hash 存放具體型別的 Hash 值
    hash uint32               // copy of _type.hash. Used for type switches.
    _   [4]byte
    fun [1]uintptr            // variable sized. fun[0]==0 means _type does not implement inter.
}
itab 有 5 個欄位:
  • inner:是指向介面型別元資訊的指標。
  • _type:是指向介面存放的具體型別元資訊的指標,iface 裡的 data 指標指向的是該型別的值。一個是型別資訊,另一個是型別的值。
  • hash:是具體型別的 Hash 值,_type 裡面也有 hash,這裡冗餘存放主要是為了介面斷言或型別查詢時快速存取。
  • fun:是一個函數指標,可以理解為 C++ 物件模型裡面的虛擬函數指標,這裡雖然只有一個元素,實際上指標陣列的大小是可變的,編譯器負責填充,執行時使用底層指標進行存取,不會受 struct 型別越界檢查的約束,這些指標指向的是具體型別的方法。

itab 這個資料結構是非空介面實現動態呼叫的基礎,itab 的資訊被編譯器和連結器儲存了下來,存放在可執行檔案的唯讀儲存段(.rodata)中。itab 存放在靜態分配的儲存空間中,不受 GC 的限制,其記憶體不會被回收。

接下來介紹 _type 資料結構,Go語言是一種強型別的語言,編譯器在編譯時會做嚴格的型別校驗。所以 Go 必然為每種型別維護一個型別的元資訊,這個元資訊在執行和反射時都會用到,Go語言的型別元資訊的通用結構是 _type(程式碼位於 src/runtime/type.go), 其他型別都是以 _type 為內嵌宇段封裝而成的結構體。
//src/runtime/type.go
type type struct {
    size uintptr     // 大小
    ptrdata uintptr  //size of memory prefix holding all pointers
    hash uint32      //型別Hash
    tflag tflag      //型別的特徵標記
    align uint8      //_type 作為整體交量存放時的對齊位元組數
    fieldalign uint8 //當前結構欄位的對齊位元組數
    kind uint8       //基礎型別列舉值和反射中的 Kind 一致,kind 決定了如何解析該型別
    alg *typeAlg     //指向一個函數指標表,該錶有兩個函數,一個是計算型別 Hash 函
                     //數,另一個是比較兩個型別是否相同的 equal 函數
    //gcdata stores the GC type data for the garbage collector.
    //If the KindGCProg bit is set in kind, gcdata is a GC program.
    //Otherwise it is a ptrmask bitmap. See mbitmap.go for details.
    gcdata *byte      //GC 相關資訊
    str nameOff       //str 用來表示型別名稱字串在編譯後二進位制檔案中某個 section
                      //的偏移量
                      //由連結器負責填充
    ptrToThis typeOff //ptrToThis 用來表示型別元資訊的指標在編譯後二進位制檔案中某個
                      //section 的偏移量
                      //由連結器負責填充
}
_type 包含所有型別的共同元資訊,編譯器和執行時可以根據該元資訊解析具體型別、型別名存放位置、型別的 Hash 值等基本資訊。

這裡需要說明一下:_type 裡面的 nameOff 和 typeOff 最終是由連結器負責確定和填充的,它們都是一個偏移量(offset),型別的名稱和型別元資訊實際上存放在連線後可執行檔案的某個段(section)裡,這兩個值是相對於段內的偏移量,執行時提供兩個轉換查詢函數。例如:

//src/runtime/type.go
//獲取 _type 的 name
func resolveNameOff(ptrInModule unsafe.Pointer , off nameOff) name {}
//獲取 _type 的副本
func resolveTypeOff(ptrInModule unsafe.Pointer , off typeOff) *_type {}

注意:Go語言型別元資訊最初由編譯器負責構建,並以表的形式存放在編譯後的物件檔案中,再由連結器在連結時進行段合併、符號重定向(填充某些值)。這些型別資訊在介面的動態呼叫和反射中被執行時參照。

接下來看一下介面的型別元資訊的資料結構。範例如下:
//描述介面的型別
type interfacetype struct {
    typ _type       //型別通用部分
    pkgpath name    //介面所屬包的名字資訊, name 記憶體放的不僅有名稱,還有描述資訊
    mhdr []imethod  //介面的方法
}
//介面方法元資訊
type imethod struct {
    name nameOff //方法名在編譯後的 section 裡面的偏移量
    ityp typeOff //方法型別在編譯後的 section 裡面的偏移量
}

介面呼叫過程分析

前面討論了介面內部的基本資料結構,下面就來通過跟蹤介面範例化和動態呼叫過程,使用 Go 原始碼和反組合程式碼相結合的方式進行研究。下面是一段非常簡單的介面呼叫程式碼。
//iface.go
package main

type Caler interface {
    Add (a , b int) int
    Sub (a , b int) int
}

type Adder struct {id int }

//go:noinline
func (adder Adder) Add(a, b int) int { return a + b }

//go:noinline
func (adder Adder) Sub(a , b int) int { return a - b }

func main () {
    var m Caler=Adder{id: 1234}
    m.Add(10, 32)
}
生成組合程式碼:

go build -gcflags= "-S - N -l" iface.go >iface.s 2>&1

接下來分析 main 函數的組合程式碼,非關鍵邏輯已經去掉:
"".main STEXT size=151 args=0x0 locals=0x40
    ...
    0x000f 00015 (src/iface.go:16) SUBQ $64, SP
    0x0013 00019 (src/iface.go:16) MOVQ BP, 56(SP)
    0x0018 00024 (src/iface.go:16) LEAQ 56(SP), BP
為 main 函數堆戰開闢空間並儲存原來的 BP 指標,這是函數呼叫前編譯器的固定動作。

var m Caler = Adder {id: 1234} 語句組合程式碼分析:

0x00ld 00029 (src/iface.go:17) MOVQ    $0, ""..autotmp_1+32(SP)
0x0026 00038 (src/iface.go:17) MOVQ    $1234, ""..autotmp_1+32(SP)

在堆上初始化區域性物件 Adder,先初始化為 0,後初始化為 1234。

0x002f 00047 (src/iface.go:17) LEAQ    go.itab."".Adder,"".Caler(SB),AX
0x0036 00054 (src/iface.go:17) MOVQ    AX, (SP)

這兩條語句非常關鍵,首先 LEAQ 指令是一個獲取地址的指令,go.itab."".Adder,"".Caler(SB) 是一個全域性符號參照,通過該符號能夠獲取介面初始化時 itab 資料結構的地址。

注意:這個標號在連結器連結的過程中會替換為具體的地址。我們知道 (SP) 裡面存放的是指向 itab(Caler,Adder) 的元資訊的地址,這裡 (SP) 是函數呼叫第一個引數的位置。範例如下:

0x003a 00058 (src/iface.go:17) LEAQ ""..autotmp_1+32(SP), AX
0x003f 00063 (src/iface.go:17) MOVQ AX, 8(SP)
0x0044 00068 (src/iface.go:17) PCDATA $0, $0

複製剛才的 Adder 型別物件的地址到 8(SP),8(SP) 是函數呼叫的第二個引數位置。範例如下:

0x0044 00068 (src/iface.go:17) CALL    runtime.convT2I64(SB)

runtime.convT2I64 函數是執行時介面動態呼叫的核心函數。runtime 中有一類這樣的函數,看一下 runtime.convT2I64 的原始碼:
func convT2I64(tab *itab, elem unsafe.Pointer) (i iface) {
    t := tab._type
    if raceenabled {
        raceReadObjectPC(t, elem, getcallerpc(unsafe.Pointer(&tab)), funcPC(convT2I64))
    }
    if msanenabled {
        msanread (elem, t.size)
    }
    var x unsafe.Pointer
    if *(uint64) (elem) == 0 {
        x = unsafe.Pointer(&zeroVal[0])
    } else {
        x = mallocgc(8, t, false)
        *(*uint64) (x) = *(*uint64) (elem)
    }
    i.tab = tab
    i.data = x
    return
}
從上述原始碼可以清楚地看出,runtime.convT2I64 的兩個引數分別是 *itab 和 unsafe.Pointer 型別,這兩個引數正是上文傳遞進去的兩個引數值:go.itab."".Adder, "".Caler(SB) 和指向 Adder 物件複製的指標。

runtime.convT2I64 的返回值是一個 iface 資料結構,其意義就是根據 itab 元資訊和物件值複製的指標構建和初始化 iface 資料結構,iface 資料結構是實現介面動態呼叫的關鍵。至此己經完成了介面初始化的工作,即完成了 iface 資料結構的構建過程。下一步就是介面方法呼叫了。範例如下:

0x0049 00073 (src/iface.go:17) MOVQ 24(SP), AX
0x004e 00078 (src/iface.go:17) MOVQ 16(SP), CX
0x0053 00083 (src/iface.go:17 ) MOVQ CX, "".m+40(SP)
0x0058 00088 (src/iface.go:17 ) MOVQ AX, "".m+48(SP)

16(SP) 和 24(SP) 存放的是函數 runtime.convT2I64 的返回值,分別是指向 itab 和 data 的指標,將指向 itab 的指標複製到 40(SP),將指向物件 data 的指標複製到 48(SP) 位置。

m.Add(10, 32) 對應的組合程式碼如下:

0x00Sd 00093 (src/iface.go:18) MOVQ "".m+40(SP), AX
0x0062 00098 (src/iface.go:18) MOVQ 32(AX), AX
0x0066 00102 (src/iface.go:18) MOVQ "".m+48(SP), ex
0x006b 00107 (src/iface.go:18) MOVQ $10, 8(SP)
0x0074 00116 (src/iface.go:18) MOVQ $32, 16(SP)
0x007d 00125 (src/iface.go:18) MOVQ CX, (SP)
0x0081 00129 (src/iface.go:18) PCDATA $0, $0
0x0081 00129 (src/iface.go:18) CALL AX

第 1 條指令是將 itab 的指標(位於 40(SP))複製到 AX 暫存器。第 2 條指令是 AX 將 itab 的偏移 32 位元組的值複製到 AX。再來看一下 itab 的資料結構:
type itab struct {
    inter *interfacetype
    _type *type
    link *itab
    hash uint32 //copy of _type.hash.Used for type switches.
    bad bool    //type does not implement interface
    inhash bool //has this itab been added to hash?
    unused [2]byte
    fun [1] uintptr //variable sized
}
32(AX) 正好是函數指標的位置, 即存放 Adder *Add() 方法指標的地址(注意:編譯器將接收者為值型別的 Add 方法轉換為指標的 Add 方法,編譯器的這種行為是為了方便呼叫和優化)。

第 3 條指令和第 6 條指令是將物件指標作為接下來函數呼叫的第 1 個引數。

第 4 條和第 5 條指令是準備函數的第 2、第 3 個引數。

第 8 條指令是呼叫 Adder 型別的 Add 方法。

此函數呼叫時,物件的值的副本作為第 1 個引數,呼叫格式可以表述為 func(reciver, param1, param2)

至此,整個介面的動態呼叫完成。從中可以清楚地看到,介面的動態呼叫分為兩個階段:
  • 第一階段就是構建 iface 動態資料結構,這一階段是在介面範例化的時候完成的,對映到 Go 語句就是 var m Caler = Adder{id: 1234}
  • 第二階段就是通過函數指標間接呼叫介面系結的實體方法的過程,對映到 Go 語句就是 m.Add(10, 32)。

接下來看一下 go.itab. "".Adder, "".Caler(SB) 這個符號在哪裡?我們使用 readelf 工具來靜態地分析編譯後的 ELF 格式的可執行程式。例如:
#編譯
#go build -gcflag s= "-N -l" iface.go
#readelf -s -W iface legrep 'itab'
    60:000000000047b220 0 OBJECT LOCAL DEFAULT 5 runtime.itablink
    61:000000000047b230 0 OBJECT LOCAL DEFAULT 5 runtime.eitablink
    88:00000000004aa100 48 OBJECT GLOBAL DEFAULT 8 go.itab.main.Adder, main.Caler
    214:00000000004aa080 40 OBJECT GLOBAL DEFAULT 8 go.itab.runtime.errorString, error
    418:00000000004095e0 1129 FUNC GLOBAL DEFAULT 1 runtime.getitab
    419:0000000000409a50 1665 FUNC GLOBAL DEFAULT 1 runtime.additab
    420:000000000040a0e0 257 FUNC GLOBAL DEFAULT 1 runtime.itabsinit
可以看到符號表裡面 go.itab.main.Adder, main.Caler 對應本程式裡面 itab 的元資訊,它被存放在第 8 個段中。我們來看一下第 8 個段是什麼段?

#readelf -S -W iface |egrep '\[8] | I Nr'
[Nr] Name Type Address Off Size ES Flg Lk Inf Al
[8]. noptrdata PROGBITS 00000000004aa000 OaaOOO 000a78 00 WA 0 0 32

可以看到這個介面動態轉換的資料元資訊存放在 .noptrdata 段中,它是由連結器負責初始化的。可以進一步使用 dd 工具讀取井分析其內容,本書就不再繼續深入這個細節,留給感興趣的讀者繼續分析。

介面呼叫代價

前面討論了介面動態呼叫過程,這個過程有兩部分多餘時耗,一個是介面範例化的過程,也就是 iface 結構建立的過程,一旦範例化後,這個介面和具體型別的 itab 資料結構是可以複用的;另一個是介面的方法呼叫,它是一個函數指標的間接呼叫。

同時我們應考慮到介面呼叫是一種動態的計算後的跳轉呼叫,這對現代的計算機 CPU 的執行很不友好,會導致 CPU 快取失效和分支預測失敗,這也有一部分的效能損失。當然最直接的辦法就是對比測試,看看介面動態呼叫的效能損失到底有多大。

測試用例

直接選用 GitHub 上的一個測試用例,稍作改寫,程式碼如下。
package main
import (
    "testing"
)
type identifier interface {
    idInline() int32
    idNoInline() int32
}
type id32 struct{ id int32 }
func (id *id32) idinline() int32 { return id.id }

//go:noinline
func (id *id32) idNoinline() int32 { return id.id }

var escapeMePlease *id32

//主要作用是強制變數記憶體在 heap 上分配
//go:noinline
func escapeToHeap(id *id32) identifier {
    escapeMePlease = id
    return escapeMePlease
}

//直接呼叫
func BenchmarkMethodCall_direct(b *testing.B) { //
    var myID int32

    b.Run("single/noinline", func(b *testing.B) {
        m := escapeToHeap(&id32{id: 6754}).(*id32)
        b.ResetTimer ()
        for i := 0; i < b.N; i++ {
            //CALL "".(*id32).idNoinline(SB)
            //MOVL 8(SP), AX
            //MOVQ "".&myID+40(SP), CX
            //MOVL AX, (CX)
            myID = m.idNoInline()
        }
    }
    b.Run ("single/inline", func(b *testing.B) {
        m := escapeToHeap(&id32{id: 6754}).(*id32)
        b.ResetTimer()
        for i: = 0; i < b.N; i++ {
            //MOVL (DX), SI
            //MOVL SI, (CX)
            myID = m.idinline()
        }
    })
}
//介面呼叫
func BenchmarkMethodCall_interface(b *testing.B) { //
    var myID int32
    b.Run("single/noinline", func(b *testing.B) {
        m := escapeToHeap(&id32{id: 6754})
        b.ResetTimer()
        for i := 0; i < b.N ; i++ {
            // MOVQ 32(AX), CX
            // MOVQ "".m.data+40(SP), DX
            // MOVQ DX, (SP)
            // CALL CX
            // MOVL 8(SP), AX
            // MOVQ "".&myID+48(SP), CX
            // MOVL AX, (CX)
            myID = m.idNoInline()
        }
    })
    b.Run("single/inline", func(b *testing.B) {
        m := escapeToHeap(&id32{id: 6754})
        b.ResetTimer()
        for i := 0; i < b.N; i++ {
            //MOVQ 24(AX), CX
            //MOVQ "".m.data+40(SP), DX
            //MOVQ DX, (SP)
            //CALL CX
            //MOVL 8(SP), AX
            //MOVQ "". &myID+48(SP), ex
            //MOVL AX, (CX)
            myID = m.idinline()
        }
    })
} //
func main() {}

測試過程和結果

//直接呼叫
#go test -bench= 'BenchmarkMethodCall_direct/single/noinline' -cpu=1 -count=5 iface_bench_test.go
goos:linux
goarch:amd64
BenchmarkMethodCall_direct/single/noinline 2000000000 2.00 ns/op
BenchmarkMethodCall_direct/single/noinline 2000000000 1.97 ns/op
BenchmarkMethodCall_direct/single/noinline 2000000000 1.97 ns/op
BenchmarkMethodCall_direct/single/noinline 2000000000 1.94 ns/op
BenchmarkMethodCall_direct/single/noinline 2000000000 1.97 ns/op
PASS
ok command-line-arguments 20.682s

//介面呼叫
#go test -bench='BenchmarkMethodCall_interface/single/noinline' -cpu=1 -count=5 iface_bench_test.go
goos:linux
goarch:amd64
BenchmarkMethodCall_interface/single/noinline 1000000000 2.18 ns/op
BenchmarkMethodCall_interface/single/noinline 1000000000 2.16 ns/op
BenchmarkMethodCall_interface/single/noinline 1000000000 2.17 ns/op
BenchmarkMethodCall_interface/single/noinline 1000000000 2.15 ns/op
BenchmarkMethodCall_interface/single/noinline 1000000000 2.16 ns/op
PASS
ok command-line-arguments 11.930s

結果分析

直接呼叫平均時耗為 1.97ns/op,介面呼叫的平均時耗為 2.16ns/op, (2.16-1.97)/1.97 約等於 9.64%。可以看到測試結果符合預期,每次疊代介面要慢 0.19ns,大約有 9% 的效能損失。

但是要清楚這個百分比並不能真實地反映介面的效率問題,首先呼叫的方法是一個很簡單的方法,方法的耗時占比很小,無形中放大了介面呼叫的耗時。如果方法裡面有複雜的邏輯,則真實的效能損失遠遠小於9%。

從絕對值的角度來看更合理,那就是每次介面呼叫大約比直接呼叫慢 0.2ns ,從這個角度看,動態呼叫的效能損失幾乎可以忽略不計。

空介面資料結構

前面我們了解到空介面 interface{} 是沒有任何方法集的介面,所以空介面內部不需要維護和動態記憶體分配相關的資料結構 itab 。空介面只關心存放的具體型別是什麼,具體型別的值是什麼,所以空介面的底層資料結構也很簡單,具體如下:
//go/src/runtime/runtime2.go
//空介面
type eface struct {
    _type *_type
    data unsafe.Pointer
}
從 eface 的資料結構可以看出,空介面不是真的為空,其保留了具體範例的型別和值拷貝,即便存放的具體型別是空的,空介面也不是空的。

由於空介面自身沒有方法集,所以空介面變數範例化後的真正用途不是介面方法的動態呼叫。空介面在Go語言中真正的意義是支援多型,有如下幾種方式使用了空介面(將空介面型別還原):
  • 通過介面型別斷言
  • 通過介面型別查詢
  • 通過反射

至此,介面內部實現原理全部講完,大家在了解和學習介面內部實現的知識的同時,更應該學習和思考分析過程中的方法和技巧,使用該方法可以繼續分析介面斷言、介面查詢和介面賦值的內部實現機制。