當你通過介面引用使用一個變數時,你知道 Go 執行時到底做了哪些工作嗎?這個問題並不容易回答。這是因為在 Go 中,一個型別實現了一個介面,但是這個型別並沒有包含任何對這個介面的引用。與上一篇部落格《Go語言內幕(1):主要概念與專案結構》一樣,你可以用 Go 編譯器的知識來回答這個問題。關於 Go 編譯器的內容我們已經在上一篇中已經討論過一部分了。
在這裡,讓我們更加深入地探索 Go 編譯器:建立一個簡單的 Go 程式來看一下 Go 內部在型別轉換時到底做了哪些工作。通過這個例子,我會解釋結點樹是如何生成並被使用的。同樣地,你也可以將這篇部落格的知識應用到其它 Go 編譯器特徵的研究中。
前言
要完成這個實驗,我們需要直接使用 Go 編譯器(而不是使用 Go 工具)。你可以通過下面的命令來使用:
1 |
go tool 6g test.go |
這個命令會編譯 test.go 原始檔並生成目標檔案。這裡, 6g 是 AMD64 架構上編譯器的名稱。請注意,如果你在不同的架構上,請使用不同的編譯器。
在直接使用編譯器的時候,我們可能會用到一些命令列引數(詳細內容請參考這裡)。在這個實驗中,我們會用到 -W 引數,這個引數會輸出結點樹的佈局結構。
建立一個簡單的 Go 程式
首先,我們需要先編寫一個簡單的 Go 程式。 我編寫的程式如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
package main type I interface { DoSomeWork() } type T struct { a int } func (t *T) DoSomeWork() { } func main() { t := &T{} i := I(t) print(i) } |
這段程式碼非常簡單,不是嗎?其中第 17 輸出了變數 i 的值,這一行程式碼看上去多此一舉。但是,如果沒有這一行程式碼,程式中就沒有使用到變數 i,那麼整個程式就不會被編譯。接下來,我們將使用 -W 引數來編譯我們的程式:
1 |
go tool 6g -W test.go |
完成編譯後,你會看到輸出中包含了程式中定義的每個方法的結點樹。在我們這個例子中有 main 和 init 方法。init 方法是隱式生成的,所有的程式都會有這個方法。此處,我們暫將該方法擱置在一邊。
對於每個方法,編譯器都會輸出兩個版本的結點樹。第一個是剛解析完原始檔生成的原始結點樹。另外一個則是完成型別檢查以及一些必須的修改後的結點樹。
分析 main 方法的結點樹
讓我們仔細看一下 main 方法的最初版本結點樹,儘量搞清楚 Go 編譯器到底做了哪些工作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
DCL l(15) . NAME-main.t u(1) a(1) g(1) l(15) x(0+0) class(PAUTO) f(1) ld(1) tc(1) used(1) PTR64-*main.T AS l(15) colas(1) tc(1) . NAME-main.t u(1) a(1) g(1) l(15) x(0+0) class(PAUTO) f(1) ld(1) tc(1) used(1) PTR64-*main.T . PTRLIT l(15) esc(no) ld(1) tc(1) PTR64-*main.T . . STRUCTLIT l(15) tc(1) main.T . . . TYPE <S> l(15) tc(1) implicit(1) type=PTR64-*main.T PTR64-*main.T DCL l(16) . NAME-main.i u(1) a(1) g(2) l(16) x(0+0) class(PAUTO) f(1) ld(1) tc(1) used(1) main.I AS l(16) tc(1) . NAME-main.autotmp_0000 u(1) a(1) l(16) x(0+0) class(PAUTO) esc(N) tc(1) used(1) PTR64-*main.T . NAME-main.t u(1) a(1) g(1) l(15) x(0+0) class(PAUTO) f(1) ld(1) tc(1) used(1) PTR64-*main.T AS l(16) colas(1) tc(1) . NAME-main.i u(1) a(1) g(2) l(16) x(0+0) class(PAUTO) f(1) ld(1) tc(1) used(1) main.I . CONVIFACE l(16) tc(1) main.I . . NAME-main.autotmp_0000 u(1) a(1) l(16) x(0+0) class(PAUTO) esc(N) tc(1) used(1) PTR64-*main.T VARKILL l(16) tc(1) . NAME-main.autotmp_0000 u(1) a(1) l(16) x(0+0) class(PAUTO) esc(N) tc(1) used(1) PTR64-*main.T PRINT l(17) tc(1) PRINT-list . NAME-main.i u(1) a(1) g(2) l(16) x(0+0) class(PAUTO) f(1) ld(1) tc(1) used(1) main.I |
下面的分析過程中,我會刪除結點樹中一些不必要的資訊。
第一個結點非常的簡單:
1 2 |
DCL l(15) . NAME-main.t l(15) PTR64-*main.T |
第一個結點是一個宣告結點。 l(15) 說明這個結點的定義在原始碼的第 15 行。這個宣告結點引用了表示 main.t 變數的名稱結點。這個變數是定義在 main 包中指向 main.T 型別的一個 64 位指標。你去看一下原始碼中的第 15 行就很容易就明白這個宣告代表著什麼了。
接下來這個結點又是一個宣告結點。這一次,這個宣告結點宣告瞭一個屬於 main.T 型別的變數 main.i。
1 2 |
DCL l(16) . NAME-main.i l(16) main.I |
然後,編譯器建立了另外一個變數 autotmp_0000, 並將變數 main.t 賦值給該變數。
1 2 3 |
AS l(16) tc(1) . NAME-main.autotmp_0000 l(16) PTR64-*main.T . NAME-main.t l(15) PTR64-*main.T |
最後,我們終於看到我們真正感興趣的結點。
1 2 3 4 |
AS l(16) . NAME-main.i l(16)main.I . CONVIFACE l(16) main.I . . NAME-main.autotmp_0000 PTR64-*main.T |
我們可以看到編譯器將一個特殊的結點 CONVIFACE 賦值給了變數 main.i。但是,這並沒有告訴我們在這個賦值背後到底發生了什麼。為了搞清楚幕後真相,我們需要去分析一下修改完成後的 main 方法結點樹(你可以在輸出資訊的 “after walk main” 這一小節中看到相關的資訊)。
編譯器怎麼翻譯賦值結點
下面,你將看到編譯器到底是如何翻譯賦值結點的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
AS-init . AS l(16) . . NAME-main.autotmp_0003 l(16) PTR64-*uint8 . . NAME-go.itab.*"".T."".I l(16) PTR64-*uint8 . IF l(16) . IF-test . . EQ l(16) bool . . . NAME-main.autotmp_0003 l(16) PTR64-*uint8 . . . LITERAL-nil I(16) PTR64-*uint8 . IF-body . . AS l(16) . . . NAME-main.autotmp_0003 l(16) PTR64-*uint8 . . . CALLFUNC l(16) PTR64-*byte . . . . NAME-runtime.typ2Itab l(2) FUNC-funcSTRUCT-(FIELD- . . . . . NAME-runtime.typ·2 l(2) PTR64-*byte, FIELD- . . . . . NAME-runtime.typ2·3 l(2) PTR64-*byte PTR64-*byte, FIELD- . . . . . NAME-runtime.cache·4 l(2) PTR64-*PTR64-*byte PTR64-*PTR64-*byte) PTR64-*byte . . . CALLFUNC-list . . . . AS l(16) . . . . . INDREG-SP l(16) runtime.typ·2 G0 PTR64-*byte . . . . . ADDR l(16) PTR64-*uint8 . . . . . . NAME-type.*"".T l(11) uint8 . . . . AS l(16) . . . . . INDREG-SP l(16) runtime.typ2·3 G0 PTR64-*byte . . . . . ADDR l(16) PTR64-*uint8 . . . . . . NAME-type."".I l(16) uint8 . . . . AS l(16) . . . . . INDREG-SP l(16) runtime.cache·4 G0 PTR64-*PTR64-*byte . . . . . ADDR l(16) PTR64-*PTR64-*uint8 . . . . . . NAME-go.itab.*"".T."".I l(16) PTR64-*uint8 AS l(16) . NAME-main.i l(16) main.I . EFACE l(16) main.I . . NAME-main.autotmp_0003 l(16) PTR64-*uint8 . . NAME-main.autotmp_0000 l(16) PTR64-*main.T |
正如在輸入中看到的那樣,編譯器首先給賦值結點增加了一個初始化結點列表(AS-init)用以分配節點,在 AS-init 結點中,它建立一個新的變數 main.autotmp_0003,並將 go.itab.*””.T.””.I 變數的值賦給新生成的變數。隨後檢查這個變數是否為 nil。如果變數為 nil,編譯器使用如下引數呼叫 runtime.type2Itab 函式:
1 2 3 |
a pointer to the main.T type , a pointer to the main.I interface type, and a pointer to the go.itab.*””.T.””.I variable. |
從這部分程式碼很容易看出,這個變數是用於快取從 main.T 轉換到 main.I 的中間結果。
getitab 方法內部
邏輯上來說,下一步就是找到 runtime.typ2Itab 方法。下面就是這個方法:
1 2 3 4 5 |
func typ2Itab(t *_type, inter *interfacetype, cache **itab) *itab { tab := getitab(inter, t, false) atomicstorep(unsafe.Pointer(cache), unsafe.Pointer(tab)) return tab } |
很明顯,runtime.typ2Itab 方法中第二行只是簡單地建立了一個 tab 變數,所以真正的工作都是在 getitab 方法中完成的。因此,我們再去探索 getitab 方法。因為這個方法的程式碼量非常巨大,所以我只拷貝了其中最重要的一部分。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
m = (*itab)(persistentalloc(unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*ptrSize, 0, &memstats.other_sys)) m.inter = interm._type = typ ni := len(inter.mhdr) nt := len(x.mhdr) j := 0 for k := 0; k < ni; k++ { i := &inter.mhdr[k] iname := i.name ipkgpath := i.pkgpath itype := i._type for ; j < nt; j++ { t := &x.mhdr[j] if t.mtyp == itype && t.name == iname && t.pkgpath == ipkgpath { if m != nil { *(*unsafe.Pointer)(add(unsafe.Pointer(&m.fun[0]), uintptr(k)*ptrSize)) = t.ifn } } } } |
首先,我們為結果分配了一段記憶體空間:
1 |
(*itab)(persistentalloc(unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*ptrSize, 0, &memstats.other_sys)) |
為什麼我們要分配記憶體空間而且還是以這樣奇怪的方式呢?要回答這個問題,我們需要看一下 itab 結構體的定義。
1 2 3 4 5 6 7 8 |
type itab struct { inter *interfacetype _type *_type link *itab bad int32 unused int32 fun [1]uintptr // variable sized } |
最後一個屬性 fun 定義為一個只有一個元素的陣列,但是這個陣列的長度實際上是可變的。隨後,我們會看到這個可變陣列中儲存了指向在型別中定義的方法的指標。這些方法對應於介面型別的方法。 Go 語言作者使用動態記憶體分配的方法為這個屬性分配空間(是的,如果你使用 unsafe 包時,這麼做是可行的)。分配記憶體的大小為介面中方法的數量乘以指標的大小再加上結構體本身的大小之和。
1 |
unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*ptrSize |
接下來,你會看到一個巢狀迴圈。首先,我們遍歷所有介面的方法。對於介面中的每一個方法,我們都會盡力在型別中找到一個對應的方法(這些方法儲存於 mhdr 集合中)。檢查兩個方法是否相同的方法是相當明瞭的。
1 |
*(*unsafe.Pointer)(add(unsafe.Pointer(&m.fun[0]), uintptr(k)*ptrSize)) = t.ifn |
這裡做了一點效能上的改進:這些介面以及預置型別的方法都是以字母順序排列的,這個巢狀迴圈只需要 O(n + m) 而不是O(n * m),其中 n 和 m 分別對應於方法的數量。
你還記得賦值的最後一部分嗎?
1 2 3 4 5 |
AS l(16) . NAME-main.i l(16) main.I . EFACE l(16) main.I . . NAME-main.autotmp_0003 l(16) PTR64-*uint8 . . NAME-main.autotmp_0000 l(16) PTR64-*main.T |
這裡,我們將 EFACE 結點賦值給 main.i 變數。這個結點(EFACE)包含了對變數 main.autotmp_0003 的引用–指向由 runtime.typ2Itab 方法返回的 itab 結構的指標,還包含對 autotmp_0000 變數的引用 , autotmp_0000 變數中包含了與 main.t 變數相同的值。以上就是我們通過介面引用呼叫方法所需的全部資訊了。
因此,main.i 變數儲存了定義在執行時包中 iface 結構體的一個例項:
1 2 3 4 |
type iface struct { tab *itab data unsafe.Pointer } |
下一篇講什麼?
到目前為止,我們也只分析了 Go 編譯器與 Go 執行時的一小部分程式碼。還有大量的有意思的內容等待我們去探索,比如目標檔案、連結器、重定位等。在接下來的部落格中我會來依次分析這些內容。