“ 不僅限於語法,使用gdb,dlv工具更深層的剖析golang的數據結構”
Golang數據結構
變量:有意義的一個數據塊。
變量名:一個有意義的數據塊的名字。
爲什麼特意會有這個章節?
golang本質是全局按照值傳遞的,也就是copy值,那麼你必須知道你的內存對象是包含哪些內容,才能清楚掌握你在copy值的時候複製了哪些東西,這個很重要,第一部分的正文內容從這裏開始。具體如下類型:
- num
- bool
- string
- array
- slice
- map
- channel
- interface
這些結構實際在地址空間棧是什麼形式的實現?這裏直接看地址空間的內容,以下都是以這個例子進行分析:
package main
func main () {
var n int64 = 11
var b bool = true
var s string = "test-string-1"
var a [3]bool = [3]bool{true, false, true}
var sl []int = []int{1,2,3,4}
var m map[int]string
var c chan int
var in interface{}
_, _, _, _, _, _, _, _ = n, b, s, a, sl, m, c , in
}
數值類型
n:n就是一個8字節的數據塊。
Bool類型
b:就是一個1字節的數據塊。
String類型
string類型在go裏是一個複合類型,s變量是一個16字節的變量。其中str字段指向字符串存儲的地方。
(gdb) pt s
type = struct string {
uint8 *str;
int len;
}
換句話說,s就是代表了一個16字節的數據塊。所以我們每次定義一個string變量,除了字符序列,s的本身結構是分配16個字節在棧上。
賦值語句
0x000000000044ebed <+61>: lea 0x1e81d(%rip),%rax # 0x46d411
0x000000000044ebf4 <+68>: mov %rax,0x48(%rsp)
0x000000000044ebf9 <+73>: movq $0xd,0x50(%rsp)
地址空間存儲
(gdb) p/x uintptr(&s)
$9 = 0xc000030750
(gdb) x/2gx 0xc000030750
0xc000030750: 0x000000000046d411 0x000000000000000d
(gdb) x/s 0x000000000046d411
0x46d411: "test-string-1triggerRatio=value method xadd64 failedxchg64 failed}\n\tsched={pc: but progSize nmidlelocked= out of range t.npagesKey= untyped args -thread limit\nGC assist waitGC worker initMB; alloca"...
數組類型
數組類型,就是在地址空間中連續的一段內存塊,和c一樣(旁白:和c一樣,都是平坦的內存結構)。
(gdb) p &a
$13 = ([3]bool *) 0xc00003070d
(gdb) x/6bx 0xc00003070d
0xc00003070d: 0x01 0x00 0x01 0x0b 0x00 0x00
Slice類型
這是個複合類型,變量本身是一個管理結構(和string一樣),這個管理結構管理着一段連續的內存。
(gdb) pt sl
type = struct []int {
int *array;
int len;
int cap;
}
map 類型 和 channel 類型
其中,map類型和channel類型特別提一點,變量本身本質上是一個指針類型。也就是說上面我們定義了兩個變量m,c,從內存分配的角度來講,只在棧上分配了一個指針變量,並且這個指針還是nil值,所以我們經常看到 go 的一個特殊說明:slice,map,channel 這三種類型必須使用make來創建,就是這個道理。因爲如果僅僅定義了類型變量,那僅僅是代表了分配了這個變量本身的內存空間,並且初始化是nil,一旦你直接用,那麼就會導致非法地址引用的問題。slice 的24個字節的管理空間,map和channel的一個指針8個字節的空間。那麼如果是調用了make,其實就會把下面的結構分配並初始化出來。
(gdb) pt m
type = struct hash<int, string> {
int count;
uint8 flags;
uint8 B;
uint16 noverflow;
uint32 hash0;
struct bucket<int, string> *buckets;
struct bucket<int, string> *oldbuckets;
uintptr nevacuate;
struct runtime.mapextra *extra;
} *
(gdb) pt c
type = struct hchan<int> {
uint qcount;
uint dataqsiz;
void *buf;
uint16 elemsize;
uint32 closed;
runtime._type *elemtype;
uint sendx;
uint recvx;
struct waitq<int> recvq;
struct waitq<int> sendq;
runtime.mutex lock;
} *
那麼make又做了什麼,make 是 golang 的關鍵字,編譯器會自動轉變成函數調用,go 語言的關鍵字基本都是轉換成內部的函數調用,梳理golang的關鍵字對應的轉換表:
(旁白:編譯器牛逼,golang 比c 高級就高級在編譯器幫你做了非常多的事情)
Interface
特別說明下interface,因爲這個也是 golang 的一個特色點,你實現了interface定義的方法集,那麼可以當作這個接口用,換句話說,你實現了這些行爲,就是這個接口對象,怎麼實現的?這個和c++的多態是很像的,和python的行爲多態更像。c++是通過虛表結構來實現的多態。go實現的接口是通過interface結構來實現的。
回憶下 c++ 的多態,c++ 是靜態就定義好了繼承或組合關係,虛表的個數是確定的:
- 對象頭部存在一個虛表指針
- 虛表的內容是編譯器在編譯期間就定好了的
- 每個類都是有自己的虛表的,不同類創建出來的對象虛表指針是指向自己類的虛表
有繼承覆蓋的情況如下:
所以 c++ 的多態就一目瞭然,雖然我們可能不知道當前對象是那個類,當時我們通過頭部的虛表指針找到對應的虛表,按照一樣的偏移offset去獲取到函數指針,這個對象方法自然就會是我們對象正確的方法。
go interface
type iface struct {
tab *itab
data unsafe.Pointer
}
type eface struct {
_type *_type
data unsafe.Pointer
}
當我們把一個對象賦值給接口,調用方法的時候,接口怎麼能獲取到對象正確的方法? iface 接口裏 data 存放私有數據,一般是具體對象地址。關鍵就在 itab 結構,本質上是一個pair(interface,concrete)
。
itab結構
這個結構會在兩種情況下生成:
- 編譯期間( static in compile ):這個是大部分情況,只要是編譯器能分析出來的,就會盡量在編譯期間生成itab結構,然後存放在.rodata只讀數據區域。比如類型賦值到接口的場景。(旁白:絕大部分是這種情況,只要編譯器能幫你乾的就順手幫你幹了,這種情況的運行時開銷幾乎沒有,就是一個間接調用的開銷)
- 運行期間(runtime):有些場景是在編譯期間無法確認itab的,這個只能等到runtime期間,動態的去查找、獲取、生成。比如接口和接口直接相互轉換的場景,這個就只能在運行期間才能確定
這種情況下,帶來的開銷是很大的,所以內部這種情況下是有對itab做一個全局緩存的,優化( interface 類型,具體類型)查找itab的性能
type itab struct {
inter *interfacetype
_type *_type
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.
}
字段含義:
- inter是描述接口類型的變量。
- _type是描述具體對象的類型對象。
- fun是一個可變數組,依次存放着這個pair(interface,concrete)實際的方法地址。
內存佈局:
這麼個結構如果是編譯器生成,是存在.rodata裏。
編譯器靜態生成 itab
舉個例子:
package main
type Node interface {
Add(a, b int32) int32
Sub(a, b int64) int64
}
type SObj struct{ id int32 }
func (adder SObj) Add(a, b int32) int32 { return a + b }
func (adder SObj) Sub(a, b int64) int64 { return a - b }
func main() {
m := Node(SObj{id: 6754})
m.Add(10, 32)
}
這裏通過 SObj 變量類型賦值到接口 Node. 首先呢,這個在編譯期就會檢查是否能夠這樣賦值,允許賦值的滿足的要求就是:SObj實現了Node的聲明的兩個方法。編譯通過之後呢,會生成一個二元組對pair(Node,SObj)。調用過程呢,就是先獲取itab,在獲取fun,根據偏移獲取方法地址。看下彙編代碼:
(gdb) disassemble
Dump of assembler code for function main.main:
=> 0x000000000044ec70 <+0>: mov %fs:0xfffffffffffffff8,%rcx
0x000000000044ec79 <+9>: cmp 0x10(%rcx),%rsp
0x000000000044ec7d <+13>: jbe 0x44ecf8 <main.main+136>
0x000000000044ec7f <+15>: sub $0x40,%rsp
0x000000000044ec83 <+19>: mov %rbp,0x38(%rsp)
0x000000000044ec88 <+24>: lea 0x38(%rsp),%rbp
0x000000000044ec8d <+29>: movl $0x0,0x24(%rsp)
// 根據itab, val 構造出 interface結構 (這個是)
0x000000000044ec95 <+37>: movl $0x1a62,0x24(%rsp)
0x000000000044ec9d <+45>: lea 0x2993c(%rip),%rax # 0x4785e0 <go.itab.main.SObj,main.Node> // itab的地址;編譯期確定
0x000000000044eca4 <+52>: mov %rax,(%rsp)
0x000000000044eca8 <+56>: mov 0x24(%rsp),%eax // value的值6754
0x000000000044ecac <+60>: mov %eax,0x8(%rsp)
0x000000000044ecb0 <+64>: callq 0x407fd0 <runtime.convT2I32>
0x000000000044ecb5 <+69>: mov 0x18(%rsp),%rax
0x000000000044ecba <+74>: mov 0x10(%rsp),%rcx
0x000000000044ecbf <+79>: mov %rcx,0x28(%rsp) // interface m的地址
0x000000000044ecc4 <+84>: mov %rax,0x30(%rsp)
0x000000000044ecc9 <+89>: mov 0x28(%rsp),%rax
0x000000000044ecce <+94>: test %al,(%rax)
0x000000000044ecd0 <+96>: mov 0x18(%rax),%rax // 獲取到SObj.Add的地址
0x000000000044ecd4 <+100>: mov 0x30(%rsp),%rcx
// 傳參數 10,20
0x000000000044ecd9 <+105>: movabs $0x200000000a,%rdx
0x000000000044ece3 <+115>: mov %rdx,0x8(%rsp)
0x000000000044ece8 <+120>: mov %rcx,(%rsp)
// 調用到 SObj.Add 方法
0x000000000044ecec <+124>: callq *%rax
0x000000000044ecee <+126>: mov 0x38(%rsp),%rbp
0x000000000044ecf3 <+131>: add $0x40,%rsp
0x000000000044ecf7 <+135>: retq
0x000000000044ecf8 <+136>: callq 0x446fb0 <runtime.morestack_noctxt>
0x000000000044ecfd <+141>: jmpq 0x44ec70 <main.main>
End of assembler dump.
(gdb) p m
$3 = {tab = 0x4785e0 <SObj,main.Node>, data = 0xc00005e000}
(gdb) p &m
$4 = (main.Node *) 0xc000030778
(gdb) p $rsp + 0x28
$5 = (void *) 0xc000030778
(gdb) x/1gx 0x28 + $rsp
0xc000030778: 0x00000000004785e0
(gdb) x/1gx 0x00000000004785e0 + 0x18
0x4785f8 <go.itab.main.SObj,main.Node+24>: 0x000000000044ed70
(gdb) info symbol 0x000000000044ed70
main.(*SObj).Add in section .text of /root/go-proj/test_interface_1
查看二進制 .rodata 段
[root@bogon go-proj]# objdump -xt -j .rodata ./test_interface_1|grep SObj
00000000004785e0 g O .rodata 0000000000000028 go.itab.main.SObj,main.Node
這裏還要提一點,這裏定義的兩個方法reciver都是變量值,而不是指針,但其實golang默認的都是按照引用操作的,reciver爲指針的版本一定會生成,取值則是按照反引用取值的。所以編譯器其實生成了四個函數。
000000000044ec30 g F .text 0000000000000015 main.SObj.Add
000000000044ed70 g F .text 000000000000008c main.(*SObj).Add
000000000044ec50 g F .text 0000000000000019 main.SObj.Sub
000000000044ee00 g F .text 0000000000000097 main.(*SObj).Sub
業務邏輯是在 main.(*SObj).Add和main.(*SObj).Sub 裏面,另外兩個是包裝函數。所以,當reciver爲值的時候,你傳指針或者值都可以。編譯期可以幫你判定搞定。當reciver爲引用的時候,你只能傳引用,因爲編譯期沒法搞定,因爲這種情況就是假定我們可能要修改原對象,你如果傳值,值copy之後,就不是原來的值了,這就違背了基本語義。
動態生成 itab
這個主要用在接口<->接口的場景。這種情況的itab表是動態生成的,編譯期是沒有靜態生成的。這種兩個都是接口的,你就必須是動態的去找,因爲只有在runtime的時候才能知道里面賦值的到底是什麼具體類型。對於itab會有一個全局hash緩存表,緩存優化性能。
舉個例子:
package main
type Student struct { name string }
type Ione interface { getName() string }
type Itwo interface { getName() string }
func (s Student) getName() string { return s.name }
func main() {
var i Ione
var t Itwo
s := Student{ name:"concrete obj" }
i = s
t = i // interface 2 interface
_ = t
}
其中由於 i = s 是concrete類型到interface的賦值,這裏編譯器可以直接生成靜態的itab結構。而由於 t = i 是接口到接口的賦值,這個編譯器是無法去生成靜態itab的(不要看到這裏的簡單例子,覺得自己肉眼可以在編譯期間確定靜態itab表。如果放到項目的大環境,比如純操作接口的一個函數裏面,你是無法分析出interface是否綁定到某個對象)。
(旁白:動態的有點是更靈活,缺點是現場性能損耗)
t = i 是調用 convI2I 轉換函數生成 itab <Itwo, Student>。
關鍵路徑:
convI2I -> getitab
-> new itab & itab.init
-> itabAdd
堅持思考,方向比努力更重要。關注我:奇伢雲存儲