Go slice擴容分析之 不是double或1.25那麼簡單

liangxingwei發表於2019-04-03

本文主要是對go slice的擴容機制進行了一些分析。環境,64位centos的docker映象+go1.12.1。

常規操作

擴容會發生在slice append的時候,當slice的cap不足以容納新元素,就會進行growSlice

比如對於下方的程式碼

slice1 := make([]int,1,)
fmt.Println("cap of slice1",cap(slice1))
slice1 = append(slice1,1)
fmt.Println("cap of slice1",cap(slice1))
slice1 = append(slice1,2)
fmt.Println("cap of slice1",cap(slice1))

fmt.Println()

slice1024 := make([]int,1024)
fmt.Println("cap of slice1024",cap(slice1024))
slice1024 = append(slice1024,1)
fmt.Println("cap of slice1024",cap(slice1024))
slice1024 = append(slice1024,2)
fmt.Println("cap of slice1024",cap(slice1024))
複製程式碼

輸出

cap of slice1 1
cap of slice1 2
cap of slice1 4

cap of slice1024 1024
cap of slice1024 1280
cap of slice1024 1280
複製程式碼

網上很多部落格也有提到,slice擴容,cap不夠1024的,直接翻倍;cap超過1024的,新cap變為老cap的1.25倍。

這個說法的相關部分原始碼如下, 具體的程式碼在$GOROOT/src/runtime/slice.go

func growslice(et *_type, old slice, cap int) slice {
    
	// 省略一些判斷...

    newcap := old.cap
    doublecap := newcap + newcap
    if cap > doublecap {
        newcap = cap
    } else {
        if old.len < 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
            }
        }
    }
    // 省略一些後續...
}
複製程式碼

眼尖的朋友可能看到了問題,上文說的擴容機制其實對應的是原始碼中的一個分支,換句話說,其實擴容機制不一定是這樣的,那到底是怎樣的呢?帶著疑問進入下一節

非常規操作

上面的操作是每次append一個元素,考慮另一種情形,一次性append很多元素,會發生什麼呢?比如下面的程式碼,容量各自是多少呢?

package main

import "fmt"

func main() {
    a := []byte{1, 0}
    a = append(a, 1, 1, 1)
    fmt.Println("cap of a is ",cap(a))
    
    b := []int{23, 51}
    b = append(b, 4, 5, 6)
    fmt.Println("cap of b is ",cap(b))
    
    c := []int32{1, 23}
    c = append(c, 2, 5, 6)
    fmt.Println("cap of c is ",cap(c))

    type D struct{
        age byte
        name string

    }
    d := []D{
        {1,"123"},
        {2,"234"},
    }

    d = append(d,D{4,"456"},D{5,"567"},D{6,"678"})
    fmt.Println("cap of d is ",cap(d))
}
複製程式碼

應該是4個8?基於翻倍的思路,cap從2->4->8。

或者4個5?給4個5的猜測基於以下推測:如果在append多個元素的時候,一次擴容不足以滿足元素的放置,如果我是設計者,我會先預估好需要多少容量才可以放置元素,然後再進行一次擴容,好處就是,不需要頻繁申請新的底層陣列,以及不需要頻繁的資料copy。

但是結果有點出人意料。

cap of a is  8
cap of b is  6
cap of c is  8
cap of d is  5
複製程式碼

是否感覺一頭霧水?"不,我知道是這樣。" 獨秀同志,你可以關閉這篇文章了。

為什麼會出現這麼奇怪的現象呢?上正文

gdb分析

光看原始碼已經沒太大的進展了,只能藉助一些輔助工具來看下執行情況,從而更好地分析下原始碼,恰好,GDB就是適合這樣做的工具。

依舊是上面的程式碼,我們編譯下,然後load進gdb

[root@a385d77a9056 jack]# go build -o jack
[root@a385d77a9056 jack]# ls
jack  main.go
[root@a385d77a9056 jack]# gdb jack
GNU gdb (GDB) Red Hat Enterprise Linux 7.6.1-114.el7
Copyright (C) 2013 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-redhat-linux-gnu".
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>...
Reading symbols from /home/goblog/src/jack/jack...done.
Loading Go Runtime support.
(gdb)
複製程式碼

在發生append那一行程式碼打上斷點,然後開始執行程式,為了比較好的說明情況,斷點打到擴容後容量為6的[]int型切片b的append上

gdb) l 10
5	)
6
7	func main() {
8
9		a := []byte{1, 0}
10		a = append(a, 1, 1, 1)
11		fmt.Println("cap of a is ", cap(a))
12
13		b := []int{23, 51}
14		b = append(b, 4, 5, 6)
(gdb) b 14
Breakpoint 2 at 0x4872d5: file /home/goblog/src/jack/main.go, line 14.
(gdb) r
Starting program: /home/goblog/src/jack/jack
cap of a is  8

Breakpoint 2, main.main () at /home/goblog/src/jack/main.go:14
14		b = append(b, 4, 5, 6)
複製程式碼

跳進去斷點,看下執行情況

(gdb) s
runtime.growslice (et=0x497dc0, old=..., cap=5, ~r3=...) at /usr/local/src/go/src/runtime/slice.go:76
76	func growslice(et *_type, old slice, cap int) slice {
(gdb) p *et
$1 = {size = 8, ptrdata = 0, hash = 4149441018, tflag = 7 '\a', align = 8 '\b', fieldalign = 8 '\b', kind = 130 '\202', alg = 0x555df0 <runtime.algarray+80>,
  gcdata = 0x4ce4f8 "\001\002\003\004\005\006\a\b\t\n\v\f\r\016\017\020\022\024\025\026\027\030\031\033\036\037\"%&,2568<BQUX\216\231\330\335\345\377", str = 987, ptrToThis = 45312}
(gdb) p old
$2 = {array = 0xc000074ec8, len = 2, cap = 2}
複製程式碼

比較複雜,一開始的時候唯一能看懂就是

一、傳進來的cap是5,也就是上文提及到的思路目前來看是正確的,當append多個元素的時候,先預估好容量再進行擴容。 二、slice是一個struct,而struct是值型別。

直到後面大概瞭解了流程之後才知道,et是slice中元素的型別的一種後設資料資訊,就分析slice,et中只需要知道size就足夠了,size代表的是,元素在計算機所佔的位元組大小。筆者用的是64位centos的docker映象,int也就是int64,也就是大小為8個位元組。

繼續往下走,這一部分的分析涉及到了另外一部分的程式碼,先貼上

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)
}
複製程式碼

貼上gdb分析的情況,省略一些細枝末節,只摘取了部分較重要的流程

(gdb) n
96		doublecap := newcap + newcap // 結合常規操作列出的原始碼分析,newcap初始化為old.cap,即為2,doublecap為4
(gdb) n
97		if cap > doublecap { // cap是傳進來的引數,值為5,比翻倍後的doublecap=4要大
(gdb) n
98			newcap = cap // 因而newcap賦值為計算後的容量5,而len<1024的分支則沒走進去
(gdb) n
123		case et.size == 1:
(gdb) disp newcap   // 列印newcap的值
3: newcap = 5
(gdb) n
129		case et.size == sys.PtrSize: // et.size即型別的位元組數為8,剛好等於64位系統的指標大小
3: newcap = 5
(gdb) n
132			capmem = roundupsize(uintptr(newcap) * sys.PtrSize) // 得到的capmem是該容量所需的記憶體,核心步驟,下面重點分析,
3: newcap = 5
(gdb) disp capmem  // 列印capmem,結合下面可以看到是48
4: capmem = <optimized out>
(gdb) n
134			newcap = int(capmem / sys.PtrSize) // 得到新的容量
4: capmem = 48
3: newcap = 5
(gdb) n
122		switch {
4: capmem = <optimized out>
3: newcap = 5
(gdb) n
169		if overflow || capmem > maxAlloc { // 這是跳出switch程式碼塊之後的程式碼,不重要,但是我們已經看到想要的結果了,newcap容量剛好是6,也就是上文中得到的cap(b)
4: capmem = 48
3: newcap = 6
複製程式碼

後面的程式碼就是用capmem進行記憶體分配,然後將newcap作為新的slice的cap,我們來分析這一步capmem = roundupsize(uintptr(newcap) * sys.PtrSize)

round-up,向上取整,roundupsize,向上取一個size。(uintptr(newcap) * sys.PtrSize)的乘積應該為5*8=40,經過向上取整之後得到了新的所需記憶體capmem=48,接著所需記憶體/型別大小int(capmem / sys.PtrSize),得到了新的容量,也就是6.

要明白roundupsize為什麼會將40變為48,這裡需要簡單的引進go的記憶體管理。可以跟蹤進roundupsize方法,然後再跟蹤進sizeclasses.go檔案,在這個檔案的開頭,給出了golang物件大小表,大體如下

// class  bytes/obj  bytes/span  objects  tail waste  max waste
//     1          8        8192     1024           0     87.50%
//     2         16        8192      512           0     43.75%
//     3         32        8192      256           0     46.88%
//     4         48        8192      170          32     31.52%
//     5         64        8192      128           0     23.44%
//     6         80        8192      102          32     19.07%
//     7         96        8192       85          32     15.95%
//     8        112        8192       73          16     13.56%
//     9        128        8192       64           0     11.72%
//    10        144        8192       56         128     11.82%

//    ...
//    65      28672       57344        2           0      4.91%
//    66      32768       32768        1           0     12.50%
複製程式碼

其他的暫時不關心,我們先看bytes/obj的這一列,這一列就是go中預定義的物件大小,最小是8b,最大是32K,還有一類就是超出32K的,共67類(超出32K沒列在這個檔案的,66+1=67)。可以看到,並沒有size為40的型別於是40向上取整取到了48,這就是發生在roundupsize的真相。這裡有一個比較專業的名詞,記憶體對齊。具體為什麼需要這樣設計?有興趣的讀者,可以細看golang的記憶體管理,這裡篇幅有限,就不展開了。

非常規操作中還有其他型別的append,這裡就不貼gdb的分析了,一樣都有roundupsize的操作,大同小異,有興趣的朋友可以自行玩一下。

疑問

在append時,roundupsize並不是一個特殊分支才有的操作,我感覺不可能一直都是雙倍擴容和1.25倍擴容啊,懷疑網上挺多部落格說的有問題。

於是又測試了下

e := []int32{1,2,3}
fmt.Println("cap of e before:",cap(e))
e = append(e,4)
fmt.Println("cap of e after:",cap(e))

f := []int{1,2,3}
fmt.Println("cap of f before:",cap(f))
f = append(f,4)
fmt.Println("cap of f after:",cap(f))

cap of e before: 3
cap of e after: 8
cap of f before: 3
cap of f after: 6
複製程式碼

哎,果不其然。擴容後的slice容量,還和型別有關呢。

summary

內容跳的有點亂,總結一下

append的時候發生擴容的動作

  • append單個元素,或者append少量的多個元素,這裡的少量指double之後的容量能容納,這樣就會走以下擴容流程,不足1024,雙倍擴容,超過1024的,1.25倍擴容。

  • 若是append多個元素,且double後的容量不能容納,直接使用預估的容量。

敲重點!!!!此外,以上兩個分支得到新容量後,均需要根據slice的型別size,算出新的容量所需的記憶體情況capmem,然後再進行capmem向上取整,得到新的所需記憶體,除上型別size,得到真正的最終容量,作為新的slice的容量。

以上,全劇終,歡迎討論~

相關文章