Dig101: Go 之靈活的 slice

newbmiao發表於2020-01-13

檢視更多:歷史集錄


Dig101: dig more, simplified more and know more

Slice 作為 go 常用的資料型別,在日常編碼中非常常見。 相對於陣列的定長不可變,slice 使用起來就靈活了許多。

0x01 slice 到底是什麼?

首先我們看下原始碼中 slice 結構的定義

// src/runtime/slice.go
type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

slice 資料結構如上,Data 指向底層引用的陣列記憶體地址, len 是已用長度,cap 是總容量。 為驗證如上所述,我們嘗試宣告一個 slice a,獲取 a 的 sliceHeader 頭資訊,並用%p獲取&a, sh, a, a[0]的地址 看看他們的地址是否相同。

a := make([]int, 1, 3)
//reflect.SliceHeader 為 slice執行時資料結構
sh := (*reflect.SliceHeader)(unsafe.Pointer(&a))
fmt.Printf("slice header: %#v\naddress of a: %p &a[0]: %p |  &a: %p sh:%p ", 
    sh, a, &a[0],&a, sh)

//slice header: &reflect.SliceHeader{Data:0xc000018260, Len:1, Cap:3}
//address of a: 0xc000018260 &a[0]: 0xc000018260 | &a: 0xc00000c080 sh:0xc00000c080 

結果發現a和&a[0]地址相同。 這個好理解,切片指向地址即對應底層引用陣列首個元素地址 而&a和sh及sh.Data指向地址相同。這個是因為這三個地址是指 slice 自身地址。 這裡【slice 自身地址不同於 slice 指向的底層資料結構地址】, 清楚這一點對於後邊的一些問題會更容易判斷。

這裡作為一個小插曲,我們看下當fmt.Printf("%p",a)時發生了什麼 內部呼叫鏈 fmtPointer -> Value.Pointer 然後根據 Pointer 方法對應 slice 的註釋如下

// If v's Kind is Slice, the returned pointer is to the first
// element of the slice. If the slice is nil the returned value
// is 0.  If the slice is empty but non-nil the return value is non-zero.

發現沒,正是我們上邊說的,slice 不為空時,返回了第一個元素的地址 有點迷惑性是不是,但其實作為使用 slice 的我們,更關心的是底層指向的資料不是麼。

再一點就是,基於 go 中所有賦值和引數傳遞都是值傳遞,對於大陣列而言,拷貝一個指向他的 slice 就高效多了 上一篇Go 之 for-range 排坑指南 有過描述, 詳見 0x03 對大陣列這樣遍歷有啥問題?

總結下, slice 是一個有底層陣列引用的結構裡,有長度,有容量。

就這麼簡單? 不,光這樣還不足以讓它比陣列更好用。 slice 還支援非常方便的切片操作和 append 時自動擴容,這讓他更加 flexible

0x02 slice 能比較麼?

答案是【只能和 nil 比較】

s := make([]int, 5)
a := s
println(s == a) 
//invalid operation: s == a (slice can only be compared to nil)

這個也其實好理解,當你比較兩個 slice,你是想比較他們自身呢?(必然不同啊,因為有值拷貝) 還是比較他們底層的陣列?(那長度和容量也一起比較麼) 確實沒有什麼意義去做兩個 slice 的比較。

0x03 花樣的切片操作

slice 通過三段引數來操作:x[from:len:cap] 即對 x 從from索引位置開始,擷取len長度,cap大小的新切片返回 但是 len 和 cap 不能大於 x 原來的 len 和 cap 三個引數都可省略,預設為x[0:len(x):cap(x)] 切片操作同樣適用於 array 如下都是通過src[:]常規對切片(指向的底層陣列)或陣列的引用

s:=make([]int,5)
x:=s[:]

arr:=[5]int{}
y:=arr[:]

配合 copy 和 append,slice 的操作還有很多,官方 wikiSlice Tricks 有更豐富的例子 比如更通用的拷貝 b = append(a[:0:0], a...) 比如 cut 或 delete 時增加對不使用指標的 nil 標記釋放 (防止記憶體洩露)

//Cut
copy(a[i:], a[j:])
for k, n := len(a)-j+i, len(a); k < n; k++ {
    a[k] = nil // or the zero value of T
}
a = a[:len(a)-j+i]

//Delete
if i < len(a)-1 {
  copy(a[i:], a[i+1:])
}
a[len(a)-1] = nil // or the zero value of T
a = a[:len(a)-1]

不熟悉的話,建議好好練習一下去感受

0x04 append 時發生了什麼?

總的來說,append 時會按需自動擴容

  • 容量足夠,無擴容則直接拷貝待 append 的資料到原 slice 底層指向的陣列 之後(原 slice 的 len 之後),並返回指向該陣列首地址的新 slice(len 改變)
  • 容量不夠,有擴容則拷貝原有 slice 所指向部分資料到新開闢的陣列,並對待 append 的資料附加到其後,並返回新陣列首地址的新 slice​(底層陣列,len,cap 均改變)

如下程式碼所示,容量不夠時觸發了擴容重新開闢底層陣列,x 和 s 底層指向的陣列已不是同一個

s := make([]int, 5)
x := append(s, 1)
fmt.Printf("x dataPtr: %p len: %d cap: %d\ns dataPtr: %p len: %d cap: %d", 
    x, len(x), cap(x), 
    s, len(s), cap(s))
// x dataPtr: 0xc000094000 len: 6 cap: 10
// s dataPtr: 0xc000092030 len: 5 cap: 5

0x05 append 內部優化

具體查閱原始碼,你會發現編譯時將 append 分為三類並優化

除按需擴容外

  • x = append(y, make([]T, y)...) 使用 memClr 提高初始化效率
  • x = append(l1, l2...) 或者 x = append(slice, string) 直接複製 l2
  • x = append(src, a, b, c) 確定待 append 數目下,直接做賦值優化

具體編譯優化如下 註釋有簡化,詳見internal/gc/walk.go: append

switch {
case isAppendOfMake(r):
// x = append(y, make([]T, y)...) will rewrite to

    // s := l1
    // n := len(s) + l2
    // if uint(n) > uint(cap(s)) {
    //   s = growslice(T, s, n)
    // }
    // s = s[:n]
    // lptr := &l1[0]
    // sptr := &s[0]
    // if lptr == sptr || !hasPointers(T) {
    //   // growslice did not clear the whole underlying array 
         // (or did not get called)
    //   hp := &s[len(l1)]
    //   hn := l2 * sizeof(T)
    //   memclr(hp, hn)
    // }

    //使用memClr提高初始化效率
    r = extendslice(r, init)
case r.IsDDD(): // DDD is ... syntax
// x = append(l1, l2...) will rewrite to

    // s := l1
    // n := len(s) + len(l2)
    // if uint(n) > uint(cap(s)) {
    //   s = growslice(s, n)
    // }
    // s = s[:n]
    // memmove(&s[len(l1)], &l2[0], len(l2)*sizeof(T))

    //直接複製l2
    r = appendslice(r, init) // also works for append(slice, string).
default:
// x = append(src, a, b, c) will rewrite to

    // s := src
    // const argc = len(args) - 1
    // if cap(s) - len(s) < argc {
    //     s = growslice(s, len(s)+argc)
    // }
    // n := len(s)
    // s = s[:n+argc]
    // s[n] = a
    // s[n+1] = b
    // ...

    //確定待append數目下,直接做賦值優化
    r = walkappend(r, init, n)
}

這裡關於 append 實現有幾點可以提下

擴容的策略是什麼?

答案是【總的來說是至少返回要求的長度 n 最大則為翻倍】 具體情況是:

  • len<1024 時 2 倍擴容
  • 大於且未溢位時 1.25 倍擴容
  • 溢位則直接按申請大小擴容
  • 最後按mallocgc記憶體分配大小適配來確定 len. (n-2n 之間)

​擴容留出最多一倍的餘量,主要還是為了減少可能的擴容頻率。​ mallocgc 記憶體適配實際是 go 記憶體管理做了記憶體分配的優化, 當然內部也有記憶體對齊的考慮。 雨痕 Go 學習筆記第四章記憶體分配,對這一塊有很詳盡的分析,值得一讀。

至於為啥要記憶體對齊可以參見Golang 是否有必要記憶體對齊?,一篇不錯的文章。

擴容判斷中uint的作用是啥?

//n為目標slice總長度,型別int,cap(s)型別也為int
if uint(n) > uint(cap(s))
    s = growslice(T, s, n)
}

答案是【為了避免溢位的擴容】

int 有正負,最大值math.MaxInt64 = 1<<63 - 1 uint 無負數最大值math.MaxUint64 = 1<<64 - 1 uint 正值是 int 正值範圍的兩倍,int 溢位了變為負數,uint(n)則必大於原 s 的 cap,條件成立 到 growslice 內部,對於負值的 n 會 panic,以此避免了溢位的擴容

記憶體清零初始化: memclrNoHeapPointers vs typedmemclr?

答案是【這個取決於待清零的記憶體是否已經初始化為 type-safe(型別安全)狀態,及型別是否包含指標】

具體來看,memclrNoHeapPointers使用場景是

  • 帶清零記憶體是初始化過的,且不含指標
  • 帶清零記憶體未初始化過的,裡邊內容是 “垃圾值”(即非 type-safe),需要初始化並清零

其他場景就是typedmemclr, 而且如果用於清零的 Type(型別) 包含指標,他會多一步 WriteBarrier(寫屏障),用於為 GC(垃圾回收) 執行時標記物件的記憶體修改,減少 STW(stop the world)

所以memclrNoHeapPointers第一個使用場景為啥不含指標就不用解釋了。

想了解更多可以看看zero-initialization-versus-zeroing 以及相關原始碼的註釋memclrNoHeapPointerstypedmemclr

本文程式碼見 NewbMiao/Dig101-Go

歡迎關注,不定期挖點技術 歡迎關注我,不定期挖點技術

文章首發:公眾號 newbmiao

更多原創文章乾貨分享,請關注公眾號
  • Dig101: Go 之靈活的 slice
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章