Golang 數據結構到底是怎麼回事?gdb調一調?

“ 不僅限於語法,使用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++ 是靜態就定義好了繼承或組合關係,虛表的個數是確定的:

  1. 對象頭部存在一個虛表指針
  2. 虛表的內容是編譯器在編譯期間就定好了的
  3. 每個類都是有自己的虛表的,不同類創建出來的對象虛表指針是指向自己類的虛表
    在這裏插入圖片描述
    有繼承覆蓋的情況如下:
    在這裏插入圖片描述
    所以 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.
}

字段含義:

  1. inter是描述接口類型的變量。
  2. _type是描述具體對象的類型對象。
  3. 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

堅持思考,方向比努力更重要。關注我:奇伢雲存儲
![

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章