從原始碼解析 Go 的切片型別以及擴容機制

JaysonWang 發表於 2022-06-07
Go

首先我們來看看 Go 官方團隊是如何定義切片的,在 A Tour of Go 中是這樣寫道:

An array has a fixed size. A slice, on the other hand, is a dynamically-sized, flexible view into the elements of an array. In practice, slices are much more common than arrays.

簡單的說切片就是一個建立在陣列之上的動態伸縮、靈活的「檢視」。實際專案中,切片會比陣列更常用。以下是顯式在陣列上建立切片和建立切片的切片的方法(實際專案中更常用的應該是通過 make 內建函式來建立):

//
// main.go
//

func main() {
    arr := [...]int{1, 3, 5, 7, 9}

    s1 := arr[:3]
    fmt.Printf("s1 = %v, len = %d, cap = %d\n", s1, len(s1), cap(s1))
    // s1 = [1 3 5], len = 3, cap = 5

    s2 := arr[2:]
    fmt.Printf("s2 = %v, len = %d, cap = %d\n", s2, len(s2), cap(s2))
    // s2 = [5 7 9], len = 3, cap = 3

    s3 := s1[3:]
    fmt.Printf("s3 = %v, len = %d, cap = %d\n", s3, len(s3), cap(s3))
    // s3 = [], len = 0, cap = 2

    s4 := s1[2:3:3]
    fmt.Printf("s4 = %v, len = %d, cap = %d\n", s4, len(s4), cap(s4))
    // s4 = [5], len = 1, cap = 1

    s5 := []int{2, 4, 6, 8}
    fmt.Printf("s5 = %v, len = %d, cap = %d\n", s5, len(s5), cap(s5))
    // s5 = [2 4 6 8], len = 4, cap = 4
}

需要說明的是,基於切片的切片和直接建立的切片(make 方法)可以看作是底層隱含了一個匿名陣列(新建的陣列或是引用其他切片的底層陣列),這與前面對切片的定義並不相違背。

約定

本文基於 go1.16 版本的原始碼進行分析,由於自 go.17 之後 Go 使用「基於暫存器的呼叫約定」替代了「基於堆疊的呼叫約定」。這個修改讓編譯後的二進位制檔案更小同時也帶來了些微的效能提升,但是同時稍微增加了分析彙編程式碼的複雜程度。為了便於分析呼叫和展示執行時的一些特性,所以這裡採用 go1.16 版本進行分析。

關於「基於暫存器的呼叫約定」可以參考以下內容進行了解:

切片的底層資料結構

在 Go 語言中切片實際上是一個包含三個欄位的結構體,該結構體的定義可以在 runtime/slice.go 中找到:

//
// runtime/slice.go
//

type slice struct {
    array unsafe.Pointer    // 指向底層陣列,是某一塊記憶體的首地址
    len   int               // 切片的長度
    cap   int               // 切片的容量
}

當我們需要通過對底層資料結構做一些手腳來達到某些目的時,我們可以使用 reflect 包中匯出的結構體定義:

//
// reflect/value.go
//

// SliceHeader is the runtime representation of a slice.
// It cannot be used safely or portably and its representation may
// change in a later release.
// Moreover, 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.
type SliceHeader struct {
    Data uintptr
    Len  int
    Cap  int
}

如果感覺文字看起來比較抽象,可以從下面的這張圖中去理解(其中 data 指向陣列的的起始位置):
Structure of the Slice

切片的技巧與陷阱

如果單純看記憶體中的資料只是一串無意義的 0 或者 1 ,而賦予這些資料意義的則是我們的程式如何解釋他們。由於切片的 Data 欄位只是一個指向某一塊記憶體的地址,而我們可以通過一些「危險」的方式賦予記憶體意義,從而實現一些 Go 語法上不允許的事情。請注意:在使用這些技巧時,你應該清晰地明白你正在做的事情與相應的副作用

字串與位元組切片的零拷貝轉換

Go 中的字串其實就是一段固定長度的位元組陣列,而我們知道切片就是建立在陣列之上的檢視,所以我們可以這麼做:

func String(bs []byte) (s string) {
    hdr := (*reflect.StringHeader)(unsafe.Pointer(&s))
    hdr.Data = (*reflect.SliceHeader)(unsafe.Pointer(&bs)).Data
    hdr.Len = (*reflect.SliceHeader)(unsafe.Pointer(&bs)).Len
    return
}

func Bytes(s string) (bs []byte) {
    hdr := (*reflect.SliceHeader)(unsafe.Pointer(&bs))
    hdr.Data = (*reflect.StringHeader)(unsafe.Pointer(&s)).Data
    hdr.Len = (*reflect.StringHeader)(unsafe.Pointer(&s)).Len
    hdr.Cap = hdr.Len
    return
}
一道面試陷阱題

首先我們知道 Go 都是以傳值方式呼叫的(切片、通道的內部實現都是一個結構體),接著我們來看一個陷阱:

func AppendSlice(s []int) {
    s = append(s, 1234)
    fmt.Printf("s = %v, len = %d, cap = %d\n", s, len(s), cap(s))
    // s = [1234], len = 1, cap = 8
}

func main() {
    s := make([]int, 0, 8)

    AppendSlice(s)
    fmt.Printf("s = %v, len = %d, cap = %d\n", s, len(s), cap(s))
    // s = [], len = 0, cap = 8
}

這裡的切片 s 的容量足夠容納 append 之後的元素,但為什麼在 main 函式中列印的切片是空的呢?可以自己思考下後再檢視答案:

答案:由於切片在執行時世紀上是一個結構體,同時 Go 是通過傳值方式進行呼叫的。所以實際上我們可以換個方式再看這個程式碼:

type Slice struct {
    Data uintptr
    Len  int
    Cap  int
}

func AppendSlice(s Slice) {
    if s.Len+1 > s.Cap {
        // grow slice ...
    }

    *(*int)(unsafe.Pointer(s.Data + uintptr(s.Len)*8)) = 1024
    s.Len += 1
}

func main() {
    s := Slice{Data: 0x12345, Len: 0, Cap: 8}

    AppendSlice(s)
    fmt.Printf("s = %+v, len = %d, cap = %d\n", s, s.Len, s.Cap)
}

相信看到這裡你已經懂了我想表達的內容。最後,我們 append 的內容實際已經寫到記憶體裡,由於 main 函式中的切片 s.len 還是 0 ,導致我們無法看到這個元素,我們可以通過以下方法重新發現:

func main() {
    s := make([]int, 0, 8)

    AppendSlice(s)
    fmt.Printf("s = %v, len = %d, cap = %d\n", s, len(s), cap(s))
    // s = [], len = 0, cap = 8

    (*reflect.SliceHeader)(unsafe.Pointer(&s)).Len = 1
    fmt.Printf("s = %v, len = %d, cap = %d\n", s, len(s), cap(s))
    // s = [1234], len = 1, cap = 8
}

切片的擴容

在日常開發中,經常會通過 append 內建方法將一個或多個值新增到切片的末尾來達到擴容的目的。由於切片是基於一個陣列的「檢視」,而陣列的大小是不可變的,所以在 append 過程中如果陣列的長度不足以新增更多的值,就需要對底層陣列進行擴容。可以通過如下程式碼從外部看一下擴容是怎麼樣的:

//
// main.go
//

func main() {
    var s []int
    fmt.Printf("s = %v, len = %d, cap = %d\n", s, len(s), cap(s))
    // s = [], len = 0, cap = 0

    s = append(s, 1)
    fmt.Printf("append(1) => %v, len = %d, cap = %d\n", s, len(s), cap(s))
    // append(1) => [1], len = 1, cap = 1

    s = append(s, 2)
    fmt.Printf("append(2) => %v, len = %d, cap = %d\n", s, len(s), cap(s))
    // append(2) => [1 2], len = 2, cap = 2

    s = append(s, 3)
    fmt.Printf("append(3) => %v, len = %d, cap = %d\n", s, len(s), cap(s))
    // append(3) => [1 2 3], len = 3, cap = 4

    s = append(s, 4, 5)
    fmt.Printf("append(4, 5) => %v, len = %d, cap = %d\n", s, len(s), cap(s))
    // append(4, 5) => [1 2 3 4 5], len = 5, cap = 8

    s = append(s, 6, 7, 8, 9)
    fmt.Printf("append(6, 7, 8, 9) => %v, len = %d, cap = %d\n\n", s, len(s), cap(s))
    // append(6, 7, 8, 9) => [1 2 3 4 5 6 7 8 9], len = 9, cap = 16

    s1 := []int{1, 2, 3}
    fmt.Printf("s1 = %v, len = %d, cap = %d\n", s1, len(s1), cap(s1))
    // s1 = [1 2 3], len = 3, cap = 3

    s1 = append(s1, 4)
    fmt.Printf("append(4) => %v, len = %d, cap = %d\n", s1, len(s1), cap(s1))
    // append(4) => [1 2 3 4], len = 4, cap = 6

    s1 = append(s1, 5, 6, 7)
    fmt.Printf("append(5, 6, 7) => %v, len = %d, cap = %d\n\n", s1, len(s1), cap(s1))
    // append(5, 6, 7) => [1 2 3 4 5 6 7], len = 7, cap = 12

    s2 := []int{0}
    fmt.Printf("s2 => len = %d, cap = %d\n", len(s2), cap(s2))
    // s2 => len = 1, cap = 1

    for i := 0; i < 13; i++ {
        for j, n := 0, 1<<i; j < n; j++ {
            s2 = append(s2, j)
        }
        fmt.Printf("append(<%d>...) => len = %d, cap = %d\n", 1<<i, len(s2), cap(s2))
        // append(<1>...) => len = 2, cap = 2
        // append(<2>...) => len = 4, cap = 4
        // append(<4>...) => len = 8, cap = 8
        // append(<8>...) => len = 16, cap = 16
        // append(<16>...) => len = 32, cap = 32
        // append(<32>...) => len = 64, cap = 64
        // append(<64>...) => len = 128, cap = 128
        // append(<128>...) => len = 256, cap = 256
        // append(<256>...) => len = 512, cap = 512
        // append(<512>...) => len = 1024, cap = 1024
        // append(<1024>...) => len = 2048, cap = 2304
        // append(<2048>...) => len = 4096, cap = 4096
        // append(<4096>...) => len = 8192, cap = 9216
    }
}

觀察輸出結果,切片增長的趨勢大致上是以翻倍的形式進行,接下來我們來驗證一下。

從反彙編開始

內建方法 append 的函式簽名為 func append(slice []Type, elems ...Type) []Type,該方法並沒有具體的方法體實現,而是在編譯器執行中間程式碼生成時將 append 方法替換成真實的執行時函式。我們來看示例程式碼:

//
// main.go
//

func main() {
    var s []int
    s = append(s, 1234)
    s = append(s, 5678)
}

// go tool compile -N -l -S main.go > main.S
// 
// -N disable optimizations : 禁止優化
// -l disable inlining : 禁止內聯
// -S print assembly listing : 輸出彙編

可以使用 go tool compile 命令匯出以上程式碼的彙編內容,如下所示:

"".main STEXT size=270 args=0x0 locals=0x68 funcid=0x0
        // func main()
    0x0000 00000 (main.go:3)    TEXT    "".main(SB), ABIInternal, $104-0

    // 檢查是否需要擴充套件棧空間
    0x0000 00000 (main.go:3)    MOVQ    (TLS), CX
    0x0009 00009 (main.go:3)    CMPQ    SP, 16(CX)
    0x000d 00013 (main.go:3)    JLS     260

    // 為 main 函式開闢棧空間
    0x0013 00019 (main.go:3)    SUBQ    $104, SP
    0x0017 00023 (main.go:3)    MOVQ    BP, 96(SP)
    0x001c 00028 (main.go:3)    LEAQ    96(SP), BP

    // 初始化切片 s, 分別為切片的三個欄位賦零值
    0x0021 00033 (main.go:4)    MOVQ    $0, "".s+72(SP)       // s.Data = null
    0x002a 00042 (main.go:4)    XORPS   X0, X0                // 這裡使用 128 位的 XMM 暫存器一次性初始化兩個欄位
    0x002d 00045 (main.go:4)    MOVUPS  X0, "".s+80(SP)       // s.Len = s.Cap = 0
    0x0032 00050 (main.go:5)    JMP     52

    // 第一次插入並進行切片擴容
    // func growslice(et *_type, old slice, cap int) slice
    0x0034 00052 (main.go:5)    LEAQ    type.int(SB), AX        // 獲取切片元素型別的指標
    0x003b 00059 (main.go:5)    MOVQ    AX, (SP)                // 第一個引數 et 壓棧
    0x003f 00063 (main.go:5)    XORPS   X0, X0                  // 使用 128 位的 XMM 暫存器減少使用的指令數量
    0x0042 00066 (main.go:5)    MOVUPS  X0, 8(SP)               // 第二個引數 old 的 .Data 和 .Len 欄位初始化
    0x0047 00071 (main.go:5)    MOVQ    $0, 24(SP)              // 第二個引數 old 的 .Cap 欄位初始化
    0x0050 00080 (main.go:5)    MOVQ    $1, 32(SP)              // 第三個引數 cap 壓棧, 值為 1
    0x0059 00089 (main.go:5)    CALL    runtime.growslice(SB)   // 呼叫 runtime.growslice 方法進行切片擴容
    0x005e 00094 (main.go:5)    MOVQ    40(SP), AX              // 返回值 r.Data
    0x0063 00099 (main.go:5)    MOVQ    56(SP), CX              // 返回值 r.Cap
    0x0068 00104 (main.go:5)    MOVQ    48(SP), DX              // 返回值 r.Len = 0
    0x006d 00109 (main.go:5)    LEAQ    1(DX), BX               // 返回值 r.Len = 0 的值加 1 賦值給 BX
    0x0071 00113 (main.go:5)    JMP     115
    0x0073 00115 (main.go:5)    MOVQ    $1234, (AX)             // 將需要 append 的元素儲存到 .Data 所指向的位置
    0x007a 00122 (main.go:5)    MOVQ    AX, "".s+72(SP)         // s.Data = r.Data
    0x007f 00127 (main.go:5)    MOVQ    BX, "".s+80(SP)         // s.Len = r.Len
    0x0084 00132 (main.go:5)    MOVQ    CX, "".s+88(SP)         // s.Cap = r.Cap

    // 檢查是否需要切片擴容, 再執行插入操作
    0x0089 00137 (main.go:6)    LEAQ    2(DX), SI               // 返回值 r.Len = 0 的值加 2 賦值給 SI
    0x008d 00141 (main.go:6)    CMPQ    CX, SI                  // 對比 r.Cap 和 Len 的值 (r.Cap - Len)
    0x0090 00144 (main.go:6)    JCC     148                     // >= unsigned
    0x0092 00146 (main.go:6)    JMP     190                     // 如果 r.Cap < Len, 則跳轉到 190 進行切片擴容
    0x0094 00148 (main.go:6)    JMP     150                     // 如果 r.Cap >= Len, 則直接將元素新增到末尾
    0x0096 00150 (main.go:6)    LEAQ    (AX)(DX*8), DX          // 儲存元素的地址 DX = (r.Data + r.Len(0) * 8)
    0x009a 00154 (main.go:6)    LEAQ    8(DX), DX               // r.Len = 0 且已經有一個元素, 所以這裡需要 +8
    0x009e 00158 (main.go:6)    MOVQ    $5678, (DX)             // 寫入資料
    0x00a5 00165 (main.go:6)    MOVQ    AX, "".s+72(SP)         // s.Data = r.Data
    0x00aa 00170 (main.go:6)    MOVQ    SI, "".s+80(SP)         // s.Len = r.Len
    0x00af 00175 (main.go:6)    MOVQ    CX, "".s+88(SP)         // s.Cap = r.Cap

    // 清理 main 函式的棧空間並返回
    0x00b4 00180 (main.go:7)    MOVQ    96(SP), BP
    0x00b9 00185 (main.go:7)    ADDQ    $104, SP
    0x00bd 00189 (main.go:7)    RET

    // 第二次插入空間不夠, 需要再次擴容
    // func growslice(et *_type, old slice, cap int) slice
    0x00be 00190 (main.go:5)    MOVQ    DX, ""..autotmp_1+64(SP)    // 備份 DX = r.Len = 0 到一個臨時變數上
    0x00c3 00195 (main.go:6)    LEAQ    type.int(SB), DX            // 獲取切片元素型別的指標
    0x00ca 00202 (main.go:6)    MOVQ    DX, (SP)                    // 第一個引數 et 壓棧
    0x00ce 00206 (main.go:6)    MOVQ    AX, 8(SP)                   // 第二個引數 old.Data 壓棧
    0x00d3 00211 (main.go:6)    MOVQ    BX, 16(SP)                  // 第二個引數 old.Len = 1 壓棧
    0x00d8 00216 (main.go:6)    MOVQ    CX, 24(SP)                  // 第二個引數 old.Cap 壓棧
    0x00dd 00221 (main.go:6)    MOVQ    SI, 32(SP)                  // 第三個引數 cap = 2 壓棧
    0x00e2 00226 (main.go:6)    CALL    runtime.growslice(SB)       // 執行切片擴容, 擴容之後只有 Cap, Data 會變
    0x00e7 00231 (main.go:6)    MOVQ    40(SP), AX                  // 覆蓋切片 s.Data = r.Data
    0x00ec 00236 (main.go:6)    MOVQ    48(SP), CX                  // 覆蓋切片 s.Len = r.Len
    0x00f1 00241 (main.go:6)    MOVQ    56(SP), DX                  // 覆蓋切片 s.Cap = r.Cap
    0x00f6 00246 (main.go:6)    LEAQ    1(CX), SI                   // 將 r.Len + 1 保證後續步驟的暫存器能對上
    0x00fa 00250 (main.go:6)    MOVQ    DX, CX                      // 保證後續步驟的暫存器能相對應
    0x00fd 00253 (main.go:6)    MOVQ    ""..autotmp_1+64(SP), DX    // 恢復 DX 的值
    0x0102 00258 (main.go:6)    JMP     150

    // 執行棧擴充套件
    0x0104 00260 (main.go:6)    NOP
    0x0104 00260 (main.go:3)    CALL    runtime.morestack_noctxt(SB)
    0x0109 00265 (main.go:3)    JMP     0

以上彙編程式碼中有一些比較有意思的點,我們來說道說道:

  • 使用 XMM 暫存器對應的 XORPSMOVUPS 指令一次性初始化 16 位元組的資料,而如果使用 MOVQ 一次只能清空 8 個位元組,這意味著需要兩倍的指令才能達到相同的目的。
  • 使用 LEAQ 來計算而不是 ADDQMULQ。原因是 LEAQ 的指令非常短,還能做簡單的算式運算。並且 LEAQ 指令不佔用 ALU,對並行的支援比較友好。

回到正題,通過彙編程式碼可以觀察到在執行時執行擴容操作的是 runtime.growslice 方法(位於 runtime/slice.go 檔案內),接下來我們就來詳細扒拉扒拉它是如何實現切片擴容的。

關於切片擴容的相關提案:

  • [proposal: Go 2: allow cap(make([]T, m, n)) > n][14]

深入擴容的實現

首先我們來看看 runtime.growslice 的方法簽名以及註釋內容:

//
// runtime/slice.go
//

// growslice handles slice growth during append.
// It is passed the slice element type, the old slice, and the desired new minimum capacity,
// and it returns a new slice with at least that capacity, with the old data
// copied into it.
// The new slice's length is set to the old slice's length,
// NOT to the new requested capacity.
// This is for codegen convenience. The old slice's length is used immediately
// to calculate where to write new values during an append.
// TODO: When the old backend is gone, reconsider this decision.
// The SSA backend might prefer the new length or to return only ptr/cap and save stack space.
func growslice(et *_type, old slice, cap int) slice {}

從方法簽名以及註釋可以得知:該方法會根據切片的元素型別當前切片以及新切片所需的最小容量(即新切片的長度)進行擴容,返回一個至少具有指定容量的新切片,並將舊切片中的資料拷貝過去。

簽名中 slice 就是上面我們提到的切片結構體,另外一個 _type 型別則比較陌生,先來看看這個型別是怎麼定義的:

//
// runtime/type.go
//

// Needs to be in sync with ../cmd/link/internal/ld/decodesym.go:/^func.commonsize,
// ../cmd/compile/internal/reflectdata/reflect.go:/^func.dcommontype and
// ../reflect/type.go:/^type.rtype.
// ../internal/reflectlite/type.go:/^type.rtype.
type _type struct {
    size       uintptr // 型別所佔記憶體的大小
    ptrdata    uintptr // size of memory prefix holding all pointers
    hash       uint32  // 型別的雜湊值,在介面斷言和介面查詢中使用
    tflag      tflag   // 型別的特徵標記
    align      uint8   // _type 作為整體儲存時的對齊位元組數
    fieldAlign uint8   // 當前結構欄位的對齊位元組數
    kind       uint8   // 基礎型別的列舉值,與 reflect.Kind 的值相同,決定了如何解析該型別
    // function for comparing objects of this type
    // (ptr to object A, ptr to object B) -> ==?
    equal func(unsafe.Pointer, unsafe.Pointer) bool
    // gcdata stores the GC type data for the garbage collector.
    // If the KindGCProg bit is set in kind, gcdata is a GC program.
    // Otherwise it is a ptrmask bitmap. See mbitmap.go for details.
    gcdata    *byte    // GC 的相關資訊
    str       nameOff  // 型別的名稱字串在二進位制檔案中的偏移值。由連結器負責填充
    ptrToThis typeOff  // 型別元資訊(即當前結構體)的指標在編譯後二進位制檔案中的偏移值。由連結器負責填充
}

從上面的彙編程式碼中,可以看到傳入的符號是 type.int(SB) ,這個符號會在連結階段進行符號重定位時進行填充和替換。其中我們只需要用到 _type 型別中的 size 欄位(用於計算新切片佔用記憶體的大小)。

第一部分:計算新切片的容量

回到程式碼中,首先來看前半部分的實現。這部分程式碼主要是為了計算得到新切片的容量,方便之後申請記憶體使用。直接看程式碼:

//
// runtime/slice.go 
//
// -- 為了便於分析和展示, 程式碼經過刪減 --
//
// et: 切片的元素型別
// old: 當前切片
// cap: 新切片所需的最小容量
func growslice(et *_type, old slice, cap int) slice {
    newcap := old.cap
    doublecap := newcap + newcap
    if cap > doublecap {
        newcap = cap
    } else {
        if old.cap < 1024 {
            newcap = doublecap
        } else {
            // Check 0 < newcap to detect overflow
            // and prevent an infinite loop.
            for 0 < newcap && newcap < cap {
                newcap += newcap / 4
            }
            // Set newcap to the requested cap when
            // the newcap calculation overflowed.
            if newcap <= 0 {
                newcap = cap
            }
        }
    }
    // ...
}

根據以上程式碼可以整理出如下擴容策略:

  • 如果新切片所需的最小容量大於當前切片容量的兩倍,那麼就直接用新切片所需的最小容量
  • 如果新切片所需的最小容量小於等於當前切片的容量的兩倍

    • 如果當前切片的容量小於 1024 ,則直接把當前切片的容量翻倍作為新切片的容量
    • 如果當前切片的容量大於等於 1024 ,則每次遞增切片容量的 1/4 倍,直到大於新切片所需的最小容量為止。

總結以上擴容策略,我們可以用如下虛擬碼表示:

if NewCap > CurrCap * 2
    return NewCap

if CurrCap < 1024
    return CurrCap * 2

while CurrCap < NewCap
    CurrCap += CurrCap / 4

第二部分:進行記憶體分配

當得到新切片的容量之後,最簡單的方式就是直接向系統申請 ElementTypeSize * NewSliceCap 的記憶體。但這麼做不可避免的會產生很多的記憶體碎片,同時對高效能不友好(在堆記憶體不足時,需要通過 brk 系統呼叫擴充套件堆)。

Go 的記憶體管理

那我們應該如何實現高效能的記憶體管理的同時減少記憶體碎片的產生?我們需要實現以下功能:

  • 記憶體池技術:一次從作業系統中申請大塊記憶體,避免使用者態到核心態的切換
  • 垃圾回收:動態、自動的垃圾回收機制讓記憶體可以重複使用
  • 記憶體分配演算法:可以實現高效的記憶體分配同時避免競爭和碎片化

Go 執行時的記憶體分配演算法基於 TCMalloc 實現。該演算法的核心思想就是將記憶體劃分為多個不同的級別進行管理從而降低鎖的粒度。具體的記憶體管理如何實現以及為何這麼實現可以參考以下文章學習:

簡單的說 Go 將小於 32K 位元組的記憶體分為從 8B - 32K 等大約70種不同的規格(span)。如果申請的記憶體小於 32K 則直接向上取到某一個規格(可能會造成一定的浪費)。如果申請的記憶體大於 32K 則直接從 Go 持有的堆中按頁(一個頁面為 8K 位元組)向上取整進行申請。

在切片擴容中的應用

接下來回到正題,看看記憶體管理是如何在切片擴容中應用的:

//
// runtime/slice.go 
//

// -- 為了便於分析和展示, 程式碼經過刪減 --
func growslice(et *_type, old slice, cap int) slice {
    // ...
    var overflow bool
    // lenmem    -> 當前切片元素所佔用記憶體的大小
    // newlenmem -> 在 append 之後切片元素所佔用的記憶體大小
    // capmem    -> 實際申請到的記憶體大小
    var lenmem, newlenmem, capmem uintptr
    // Specialize for common values of et.size.
    // For 1 we don't need any division/multiplication.
    // For sys.PtrSize, compiler will optimize division/multiplication into a shift by a constant.
    // For powers of 2, use a variable shift.
    switch {
    case et.size == 1:
        lenmem = uintptr(old.len)
        newlenmem = uintptr(cap)
        capmem = roundupsize(uintptr(newcap))
        overflow = uintptr(newcap) > maxAlloc
        newcap = int(capmem)
    case et.size == sys.PtrSize:
        lenmem = uintptr(old.len) * sys.PtrSize
        newlenmem = uintptr(cap) * sys.PtrSize
        capmem = roundupsize(uintptr(newcap) * sys.PtrSize)
        overflow = uintptr(newcap) > maxAlloc/sys.PtrSize
        newcap = int(capmem / sys.PtrSize)
    case isPowerOfTwo(et.size):
        var shift uintptr
        if sys.PtrSize == 8 {
            // Mask shift for better code generation.
            shift = uintptr(sys.Ctz64(uint64(et.size))) & 63
        } else {
            shift = uintptr(sys.Ctz32(uint32(et.size))) & 31
        }
        lenmem = uintptr(old.len) << shift
        newlenmem = uintptr(cap) << shift
        capmem = roundupsize(uintptr(newcap) << shift)
        overflow = uintptr(newcap) > (maxAlloc >> shift)
        newcap = int(capmem >> shift)
    default:
        lenmem = uintptr(old.len) * et.size
        newlenmem = uintptr(cap) * et.size
        capmem, overflow = math.MulUintptr(et.size, uintptr(newcap))
        capmem = roundupsize(capmem)
        newcap = int(capmem / et.size)
    }
}

其實只要看 default 分支中的邏輯即可,其他分支只是根據元素型別的大小來做針對性的優化(通過移位等運算來少執行幾個指令)。在這段程式碼中,決定最終申請多少記憶體的是 roundupsize 方法:

//
// runtime/msize.go
//

// Malloc small size classes.
//
// See malloc.go for overview.
// See also mksizeclasses.go for how we decide what size classes to use.

// Returns size of the memory block that mallocgc will allocate if you ask for the size.
func roundupsize(size uintptr) uintptr {
    if size < _MaxSmallSize { // _MaxSmallSize = 32768
        if size <= smallSizeMax-8 { // smallSizeMax = 1024
            // smallSizeDiv = 8
            return uintptr(class_to_size[size_to_class8[divRoundUp(size, smallSizeDiv)]])
        } else {
            // smallSizeMax = 1024, largeSizeDiv = 128
            return uintptr(class_to_size[size_to_class128[divRoundUp(size-smallSizeMax, largeSizeDiv)]])
        }
    }
    if size+_PageSize < size { // _PageSize = 8192
        return size
    }
    return alignUp(size, _PageSize)
}


//
// runtime/stubs.go
//

// divRoundUp returns ceil(n / a).
func divRoundUp(n, a uintptr) uintptr {
    // a is generally a power of two. This will get inlined and
    // the compiler will optimize the division.
    return (n + a - 1) / a
}

// alignUp rounds n up to a multiple of a. a must be a power of 2.
func alignUp(n, a uintptr) uintptr {
    return (n + a - 1) &^ (a - 1)
}

該方法傳入 size 引數計算自 NewSliceCap * ElementType.Size 。如果傳入的 size 小於 32K 則會從以下規格中選擇一個「剛好能滿足要求的規格」返回:

// 0 表示大物件(large object)
var class_to_size = [_NumSizeClasses]uint16{ // _NumSizeClasses = 68
        0,     8,    16,    24,    32,    48,    64,    80,    96,   112, 
      128,   144,   160,   176,   192,   208,   224,   240,   256,   288, 
      320,   352,   384,   416,   448,   480,   512,   576,   640,   704, 
      768,   896,  1024,  1152,  1280,  1408,  1536,  1792,  2048,  2304, 
     2688,  3072,  3200,  3456,  4096,  4864,  5376,  6144,  6528,  6784, 
     6912,  8192,  9472,  9728, 10240, 10880, 12288, 13568, 14336, 16384, 
    18432, 19072, 20480, 21760, 24576, 27264, 28672, 32768, 
}

如果申請的記憶體大於 32K 則會直接從堆中申請「剛好能滿足的要求」的頁(頁大小為 8K)。相關內容可以參考以下原始碼:

  • runtime/malloc.go:記憶體管理相關的實現
  • runtime/mksizeclasses.go:生生以上規格的邏輯

第三部分:拷貝物件和返回

由於實際申請得到的記憶體容量可能會大於需求的容量,所以在確定申請記憶體的規格之後需要再次確定所申請到的記憶體規格到底可以容納多少元素。如下程式碼所示:

//
// runtime/slice.go 
//

// -- 為了便於分析和展示, 程式碼經過刪減 --
func growslice(et *_type, old slice, cap int) slice {
    // ...
    capmem = roundupsize(capmem)
    newcap = int(capmem / et.size)
    // ...
}

在擴容過程中有關記憶體大小的幾個變數這裡做個統一的解釋:

  • lenmem:在 append 之前切片中所有元素所佔用的記憶體大小(OldSlice.len * ElementTypeSize
  • newlenmem:在 append 之後切片中所有元素所佔用記憶體大小((OldSlice.len + AppendSize) * ElementTypeSize
  • size:在 append 之後新切片的容量(第一部分計算得到)所需要的記憶體大小(NewSliceCap * ElementTypeSize
  • capmem:經過記憶體規格匹配之後實際申請到的記憶體大小(第二部分計算得到)

接下來就需要將資料從舊切片拷貝到新切片中,直接看程式碼:

//
// runtime/slice.go 
//

// -- 為了便於分析和展示, 程式碼經過刪減 --
func growslice(et *_type, old slice, cap int) slice {
    // ...
    // p 指向申請到的記憶體的首地址
    var p unsafe.Pointer
    if et.ptrdata == 0 { // 如果元素型別中不包含指標
        // 申請 capmem 個位元組
        p = mallocgc(capmem, nil, false)
        // 由於可能會申請到大於需求容量的記憶體,所以需要將目前用不到的記憶體清零
        // 在需求容量之內的記憶體會由之後的程式負責填充和賦值
        memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
    } else {
        // 申請指定大小的記憶體並將所有記憶體清零
        p = mallocgc(capmem, et, true)
        // 為記憶體新增寫屏障
        if lenmem > 0 && writeBarrier.enabled {
            // Only shade the pointers in old.array since we know the destination slice p
            // only contains nil pointers because it has been cleared during alloc.
            bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(old.array), lenmem-et.size+et.ptrdata)
        }
    }
    // 將舊切片中的的資料拷貝過來,從 old.array 拷貝 lenmem 個位元組到 p
    memmove(p, old.array, lenmem)

    // 返回新切片
    return slice{p, old.len, newcap}
}


//
// runtime/stubs.go
// 

// memmove copies n bytes from "from" to "to".
func memmove(to, from unsafe.Pointer, n uintptr)

至此,所有擴容相關的工作就結束了,接下來會由程式的其他部分將資料填充到剛剛擴容的切片中。這裡我們使用如下程式碼加上一張圖來總結以上的所有內容,試試你能不能將以上各個部分的邏輯在圖中對應起來。程式碼如下:

type Triangle [3]byte

func main() {
    s := []Triangle{{1, 1, 1}, {2, 2, 2}}

    s = append(s, Triangle{3, 3, 3}, Triangle{4, 4, 4})
    fmt.Printf("s = %v, len = %d, cap = %d\n", s, len(s), cap(s))
    // s = [[1 1 1] [2 2 2] [3 3 3] [4 4 4]], len = 4, cap = 5
}

根據以上程式碼,我們可以畫出如下的結構圖(其中長方體表示一個 Triangle 型別,佔用 3 個位元組的空間):
Structure of the memory

驗證切片擴容

紙上得來終覺淺,絕知此事要躬行。上面所分析的內容畢竟只是理論和猜測,我們需要通過實踐來驗證所總結的規律靠不靠譜。

小於 32K 的切片擴容

首先來試試在小於 32K 的切片上進行擴容,有如下程式碼:

func main() {
    var s []int

    s = append(s, 1, 2, 3, 4, 5)
    fmt.Printf("s = %v, len = %d, cap = %d\n", s, len(s), cap(s))
    // s = [1 2 3 4 5], len = 5, cap = 6

    s = append(s, 6, 7, 8, 9)
    fmt.Printf("s = %v, len = %d, cap = %d\n", s, len(s), cap(s))
    // s = [1 2 3 4 5 6 7 8 9], len = 9, cap = 12
}

我們按照上面所總結的規律來一步步驗證,有如下過程:

var s []int // s.data = null, s.len = 0, s.cap = 0

s = append(s, 1, 2, 3, 4, 5)
// 舊切片的容量不足,需要進行擴容
// 執行擴容方法:growslice(et = type.int, old = {null, 0, 0}, cap = 5)
//    1. 由於 cap > 2 * old.cap ,所以新切片的容量至少需要為 5 (NewSliceCap)
//    2. 執行記憶體規格匹配,需要的記憶體容量為 5 * 8(int) = 40 位元組,查表可得實際使用的記憶體規格為 48 位元組
//    3. 所以實際的切片容量為 48(capmem) / 8(et.size) = 6
// 驗證正確

s = append(s, 6, 7, 8, 9)
// 舊切片容量為 6 ,需求容量為 9 ,需要進行擴容
// 執行擴容方法:growslice(et = type.int, old = {<addr>, 5, 6}, cap = 9)
//    1. 由於 cap < 2 * old.cap = 12 且 old.cap < 1024 ,所以新切片的容量至少需要為 2 * old.cap = 12
//    2. 執行記憶體規格匹配,需要的記憶體容量為 12 * 8(int) = 96 位元組,查表可得實際使用的記憶體規格為 96 位元組
//    3. 所以實際的切片容量為 96(capmem) / 8(et.size) = 12
// 驗證正確

大於 32K 的切片擴容

我們通過長度為 128 的位元組陣列(佔用 1024 個位元組)來逐步驗證大於 32K 的切片擴容:

type Array [128]int // 128 * 8 = 1024 Bytes

func main() {
    var s []Array

    s = append(s, Array{}, Array{}, Array{}, Array{}, Array{}, Array{}, Array{}) // 7K
    fmt.Printf("s, len = %d, cap = %d\n", len(s), cap(s))
    // s, len = 7, cap = 8

    s = append(s, make([]Array, 26)...) // 33K
    fmt.Printf("s, len = %d, cap = %d\n", len(s), cap(s))
    // s, len = 33, cap = 40
}

同樣,按照總結的規律進行推算,有如下過程:

var s []Array // s.data = null, s.len = 0, s.cap = 0

s = append(s, Array{}, Array{}, Array{}, Array{}, Array{}, Array{}, Array{})
// 舊切片的容量不足,需要進行擴容
// 執行擴容方法:growslice(et = type.Array, old = {null, 0, 0}, cap = 7)
//    1. 由於 cap > 2 * old.cap ,所以新切片的容量至少需要為 7 (NewSliceCap)
//    2. 執行記憶體規格匹配,需要的記憶體容量為 7 * 1024(Array) = 7168 位元組,查表可得實際使用的記憶體規格為 8192 位元組
//    3. 所以實際的切片容量為 8192(capmem) / 1024(et.size) = 8
// 驗證正確

s = append(s, make([]Array, 26)...)
// 舊切片容量為 8 ,需求容量為 33 ,需要進行擴容
// 執行擴容方法:growslice(et = type.Array, old = {<addr>, 7, 8}, cap = 33)
//    1. 由於 cap > 2 * old.cap = 16 ,所以新切片的容量至少需要為 cap = 33
//    2. 執行記憶體規格匹配,需要的記憶體容量為 33 * 1024(int) = 33792 位元組,由於大於 32768 ,按頁向上取整得到 40960 位元組
//    3. 所以實際的切片容量為 40960(capmem) / 1024(et.size) = 40
// 驗證正確

到這裡本文的所有內容就結束了,希望你能有所收穫。如有錯漏望不吝賜教!

其他參考資料

以下是一些關於 Go 彙編的參考資料