Go SliceHeader 和 StringHeader,你知道嗎?

煎魚發表於2021-10-21

大家好,我是煎魚。

在 Go 語言中總是有一些看上去奇奇怪怪的東西,咋一眼一看感覺很熟悉,但又不理解其在 Go 程式碼中的實際意義,面試官卻愛問...

今天要給大家介紹的是 SliceHeader 和 StringHeader 結構體,瞭解清楚他到底是什麼,又有什麼用,並且會在最後給大家介紹 0 拷貝轉換的內容。

一起愉快地開始吸魚之路。

SliceHeader

SliceHeader 如其名,Slice + Header,看上去很直觀,實際上是 Go Slice(切片)的執行時表現。

SliceHeader 的定義如下:

type SliceHeader struct {
 Data uintptr
 Len  int
 Cap  int
}
  • Data:指向具體的底層陣列。
  • Len:代表切片的長度。
  • Cap:代表切片的容量。

既然知道了切片的執行時表現,那是不是就意味著我們可以自己造一個?

在日常程式中,可以利用標準庫 reflect 提供的 SliceHeader 結構體造一個:

func main() {
  // 初始化底層陣列
 s := [4]string{"腦子", "進", "煎魚", "了"}
 s1 := s[0:1]
 s2 := s[:]

  // 構造 SliceHeader
 sh1 := (*reflect.SliceHeader)(unsafe.Pointer(&s1))
 sh2 := (*reflect.SliceHeader)(unsafe.Pointer(&s2))
 fmt.Println(sh1.Len, sh1.Cap, sh1.Data)
 fmt.Println(sh2.Len, sh2.Cap, sh2.Data)
}

你認為輸出結果是什麼,這兩個新切片會指向同一個底層陣列的記憶體地址嗎?

輸出結果:

1 4 824634330936
4 4 824634330936

兩個切片的 Data 屬性所指向的底層陣列是一致的,Len 屬性的值不一樣,sh1 和 sh2 分別是兩個切片。

疑問

為什麼兩個新切片所指向的 Data 是同一個地址的呢?

這其實是 Go 語言本身為了減少記憶體佔用,提高整體的效能才這麼設計的。

將切片複製到任意函式的時候,對底層陣列大小都不會影響。複製時只會複製切片本身(值傳遞),不會涉及底層陣列。

也就是在函式間傳遞切片,其只拷貝 24 個位元組(指標欄位 8 個位元組,長度和容量分別需要 8 個位元組),效率很高。

這種設計也引出了新的問題,在平時通過 s[i:j] 所生成的新切片,兩個切片底層指向的是同一個底層陣列。

假設在沒有超過容量(cap)的情況下,對第二個切片操作會影響第一個切片

這是很多 Go 開發常會碰到的一個大 “坑”,不清楚的排查了很久的都不得而終。

StringHeader

除了 SliceHeader 外,Go 語言中還有一個典型代表,那就是字串(string)的執行時表現。

StringHeader 的定義如下:

type StringHeader struct {
   Data uintptr
   Len  int
}
  • Data:存放指標,其指向具體的儲存資料的記憶體區域。
  • Len:字串的長度。

可得知 “Hello” 字串的底層資料如下:

var data = [...]byte{
    'h', 'e', 'l', 'l', 'o',
}

底層的儲存示意圖如下:

圖片

圖來自網路

真實演示例子如下:

func main() {
 s := "腦子進煎魚了"
 s1 := "腦子進煎魚了"
 s2 := "腦子進煎魚了"[7:]

 fmt.Printf("%d \n", (*reflect.StringHeader)(unsafe.Pointer(&s)).Data)
 fmt.Printf("%d \n", (*reflect.StringHeader)(unsafe.Pointer(&s1)).Data)
 fmt.Printf("%d \n", (*reflect.StringHeader)(unsafe.Pointer(&s2)).Data)
}

你認為輸出結果是什麼,變數 s 和 s1、s2 會指向同一個底層記憶體空間嗎?

輸出結果:

17608227 
17608227 
17608234 

從輸出結果來看,變數 s 和 s1 指向同一個記憶體地址。變數 s2 雖稍有偏差,但本質上也是指向同一塊。

因為其是字串的切片操作,是從第 7 位索引開始,因此正好的 17608234-17608227 = 7。也就是三個變數都是指向同一塊記憶體空間,這是為什麼呢?

這是因為在 Go 語言中,字串都是隻讀的,為了節省記憶體,相同字面量的字串通常對應於同一字串常量,因此指向同一個底層陣列

0 拷貝轉換

為什麼會有人關注到 SliceHeader、StringHeader 這類執行時細節呢,一大部分原因是業內會有開發者,希望利用其實現零拷貝的 string 到 bytes 的轉換

常見轉換程式碼如下:

func string2bytes(s string) []byte {
 stringHeader := (*reflect.StringHeader)(unsafe.Pointer(&s))

 bh := reflect.SliceHeader{
  Data: stringHeader.Data,
  Len:  stringHeader.Len,
  Cap:  stringHeader.Len,
 }

 return *(*[]byte)(unsafe.Pointer(&bh))
}

但這其實是錯誤的,官方明確表示:

the Data field is not sufficient to guarantee the data it references will not be garbage collected, so programs must keep a separate, correctly typed pointer to the underlying data.

SliceHeader、StringHeader 的 Data 欄位是一個 uintptr 型別。由於 Go 語言只有值傳遞。

因此在上述程式碼中會出現將 Data 作為值拷貝的情況,這就會導致無法保證它所引用的資料不會被垃圾回收(GC)

應該使用如下轉換方式:

func main() {
 s := "腦子進煎魚了"
 v := string2bytes1(s)
 fmt.Println(v)
}

func string2bytes1(s string) []byte {
 stringHeader := (*reflect.StringHeader)(unsafe.Pointer(&s))

 var b []byte
 pbytes := (*reflect.SliceHeader)(unsafe.Pointer(&b))
 pbytes.Data = stringHeader.Data
 pbytes.Len = stringHeader.Len
 pbytes.Cap = stringHeader.Len

 return b
}

在程式必須保留一個單獨的、正確型別的指向底層資料的指標。

在效能方面,若只是期望單純的轉換,對容量(cap)等欄位值不敏感,也可以使用以下方式:

func string2bytes2(s string) []byte {
 return *(*[]byte)(unsafe.Pointer(&s))
}

效能對比:

string2bytes1-1000-4   3.746 ns/op  0 allocs/op
string2bytes1-1000-4   3.713 ns/op  0 allocs/op
string2bytes1-1000-4   3.969 ns/op  0 allocs/op

string2bytes2-1000-4   2.445 ns/op  0 allocs/op
string2bytes2-1000-4   2.451 ns/op  0 allocs/op
string2bytes2-1000-4   2.455 ns/op  0 allocs/op

會相當標準的轉換效能會稍快一些,這種強轉也會導致一個小問題。

程式碼如下:

func main() {
 s := "腦子進煎魚了"
 v := string2bytes2(s)
 println(len(v), cap(v))
}
func string2bytes2(s string) []byte {
 return *(*[]byte)(unsafe.Pointer(&s))
}

輸出結果:

18 824633927632

這種強轉其會導致 byte 的切片容量非常大,需要特別注意。一般還是推薦使用標準的 SliceHeader、StringHeader 方式就好了,也便於後來的維護者理解。

總結

在這篇文章中,我們介紹了字串(string)和切片(slice)的兩個執行時表現,分別是 StringHeader 和 SliceHeader。

同時瞭解到其執行時表現後,我們還針對其兩者的地址指向,常見坑進行了說明。

最後我們進一步深入,面向 0 拷貝轉換的場景進行了介紹和效能分析。

你平時有沒有遇到過這塊的疑惑或問題呢,歡迎大家一起討論!

若有任何疑問歡迎評論區反饋和交流,最好的關係是互相成就,各位的點贊就是煎魚創作的最大動力,感謝支援。

文章持續更新,可以微信搜【腦子進煎魚了】閱讀,本文 GitHub github.com/eddycjy/blog 已收錄,學習 Go 語言可以看 Go 學習地圖和路線,歡迎 Star 催更。

參考

  • Go語言slice的本質-SliceHeader
  • 陣列、字串和切片
  • 零拷貝實現string 和bytes的轉換疑問

相關文章