【Go】slice的一些使用技巧

thinkeridea發表於2019-02-16

原文連結:https://blog.thinkeridea.com/201901/go/slice_de_yi_xie_shi_yong_ji_qiao.html

sliceGo 語言十分重要的資料型別,它承載著很多使命,從語言層面來看是 Go 語言的內建資料型別,從資料結構來看是動態長度的順序連結串列,由於 Go 不能直接操作記憶體(通過系統呼叫可以實現,但是語言本身並不支援),往往 slice 也可以用來幫助開發者申請大塊記憶體實現緩衝、快取等功能。

Go 語言專案中大量的使用 slice, 我總結三年來對 slice 的一些操作技巧,以方便可以高效的使用 slice, 並使用 slice 解決一些棘手的問題。

slice 的基本操作

先熟悉一些 slice 的基本的操作, 對最常規的 : 操作就可玩出很多花樣。

  • s=ss[:] 引用一個切片或陣列
  • s=s[:0] 清空切片
  • s=s[:10] s=s[10:] s=s[10:20] 擷取接片
  • s=ss[0:10:20] 從切片或陣列引用指定長度和容量的切片

下標索引操作的一些誤區 s[i:l:c] i 是起始偏移的起始位置,l 是起始偏移的長度結束位置, l-i 就是新 slice 的長度, c 是起始偏移的容量結束位置,c-i 就是新 slice 的容量。其中 ilc 並不是當前 slice 的索引,而是引用底層陣列相對當前 slice 起始位置的偏移量,所以是可超出當前 slice 的長度的, 但不能超出當前 slice 的容量,如下操作是合法的:

package main

import (
    "fmt"
)

func main() {
    s := make([]int, 100)
    s[20] = 100
    s1 := s[10:10]
    s2 := s1[10:20]
    fmt.Println(s1)
    fmt.Println(s2)
}

其中 s1[]s2[100 0 0 0 0 0 0 0 0 0], 這裡並不會發生下標越界的情況,一個更好的例子在 csv reader 中的一個例子

<!– more –>

建立 slice

建立切片的方法有很多,下面羅列一些常規的:

  • var s []int 建立 nil切片
  • s := make([]int, 0, 0)s=[]int{} 建立無容量空切片
  • s:= make([]int, 0, 100) 建立有容量空切片
  • s:=make([]int, 100) 建立零值切片
  • s:=array[:] 引用陣列建立切片

內建函式

  • len(s) 獲取切片的長度
  • cap(s) 獲取切片的容量
  • append(s, ...) 向切片追加內容
  • copy(s, s1) 向切片拷貝內容

一個緩衝的簡單示例

遇到過很多拼接字串的方法,各種各樣的都有 fmt builder buffer + 等等,實際上 builderbuffer 都是使用 []byte 的切片作為緩衝來實現的,fmt 往往效能最差,原因是它主要功能不是連線字串而是格式化資料會用到反射等等操作。+ 操作在大量拼接時效能也是很差, 不過小字串少量拼接效果很理想,builder 往往效能不如 buffer 特別是在較短字串拼接上,實際 builderbuffer 實現原理非常類似,builder 在轉成字串時使用了 unsafe 減少了一次記憶體分配,因為小字串因為擴容機制不如 buffer 靈活,所以效能有所不如,大字串降低一次大的記憶體分配就顯得很明顯了。

經常遇到一個需求就是拼接 []int 中個各個元素,很多種實現都有人用,都是需要遍歷轉換 intstring,但是拼接方法千奇百怪,以下提供兩種方法對比(原始碼在GitHub)。

package slice

import (
    "strconv"
    "unsafe"
)

func SliceInt2String1(s []int) string {
    if len(s) < 1 {
        return ""
    }

    ss := strconv.Itoa(s[0])
    for i := 1; i < len(s); i++ {
        ss += "," + strconv.Itoa(s[i])
    }

    return ss
}

func SliceInt2String2(s []int) string {
    if len(s) < 1 {
        return ""
    }

    b := make([]byte, 0, 256)
    b = append(b, strconv.Itoa(s[0])...)
    for i := 1; i < len(s); i++ {
        b = append(b, `,`)
        b = append(b, strconv.Itoa(s[i])...)
    }

    return string(b)
}

func SliceInt2String3(s []int) string {
    if len(s) < 1 {
        return ""
    }

    b := make([]byte, 0, 256)
    b = append(b, strconv.Itoa(s[0])...)
    for i := 1; i < len(s); i++ {
        b = append(b, `,`)
        b = append(b, strconv.Itoa(s[i])...)
    }

    return *(*string)(unsafe.Pointer(&b))
}

SliceInt2String1 使用原始的 + 操作,因為是較小的字串拼接,使用 + 主要是因為在小字串拼接效能優於其它幾種方法,SliceInt2String2SliceInt2String3 都使用了一個 256 容量的 []byte 作為緩衝, 唯一的區別是在返回時一個使用 string 轉換型別,一個使用 unsafe 轉換型別。

寫了一個效能測試(原始碼在GitHub),看一下效果吧:

goos: darwin
goarch: amd64
pkg: github.com/thinkeridea/example/slice
BenchmarkSliceInt2String1-8        3000000           461 ns/op         144 B/op           9 allocs/op
BenchmarkSliceInt2String2-8       20000000           117 ns/op          32 B/op           1 allocs/op
BenchmarkSliceInt2String3-8       10000000           144 ns/op         256 B/op           1 allocs/op
PASS
ok      github.com/thinkeridea/example/slice    5.928s

明顯可以看得出 SliceInt2String2 的效能是 SliceInt2String1 7倍左右,提升很明顯,SliceInt2String2SliceInt2String3 差異很小,主要是因為使用 unsafe 轉換型別導致大記憶體無法釋放,實際這個測試中連線字串只需要 32 個位元組,使用 unsafe 卻導致 256 個位元組無法被釋放,這也正是 builderbuffer 的差別,所以小字串拼接 buffer 效能往往更好。在這裡簡單的通過 []byte 減少記憶體分配次數來實現緩衝。

如果連續拼接一組這樣的操作,比如輸入 [][]int, 輸出 []string原始碼在GitHub):

package slice

import (
    "strconv"
    "unsafe"
)

func SliceInt2String4(s [][]int) []string {
    res := make([]string, len(s))
    for i, v := range s {
        if len(v) < 1 {
            res[i] = ""
            continue
        }

        res[i] += strconv.Itoa(v[0])
        for j := 1; j < len(v); j++ {
            res[i] += "," + strconv.Itoa(v[j])
        }
    }

    return res
}

func SliceInt2String5(s [][]int) []string {
    res := make([]string, len(s))
    b := make([]byte, 0, 256)
    for i, v := range s {
        if len(v) < 1 {
            res[i] = ""
            continue
        }

        b = b[:0]
        b = append(b, strconv.Itoa(v[0])...)
        for j := 1; j < len(v); j++ {
            b = append(b, `,`)
            b = append(b, strconv.Itoa(v[j])...)
        }

        res[i] = string(b)
    }

    return res
}

SliceInt2String5 中使用 b = b[:0] 來促使達到反覆使用一塊緩衝區,寫了一個效能測試(原始碼在GitHub),看一下效果吧:

goos: darwin
goarch: amd64
pkg: github.com/thinkeridea/example/slice
BenchmarkSliceInt2String4-8         300000          4420 ns/op        1440 B/op          82 allocs/op
BenchmarkSliceInt2String5-8        1000000          1102 ns/op         432 B/op          10 allocs/op
PASS
ok      github.com/thinkeridea/example/slice    8.364s

+ 版本提升接近4倍的效能,這是使用 slice 作為緩衝區極好的技巧,使用非常方便,並不用使用 builderbufferslice 操作非常的簡單實用。

append 與 copy

如果合併多個 slice 為一個,有三種方式來合併,主要合併差異來源於建立新 slice 的方法,使用 var news []int 或者 news:=make([]int, 0, len(s1)+len(s2)....) 的方式建立的新變數就需要使用 append 來合併,如果使用 news:=make([]int, len(s1)+len(s2)....) 就需要使用 copy 來合併。不同的方法也有差異,appendcopy 在這個例子中主要差異在於 append 適用於零長度的初始化 slicecopy 適用於確定長度的 slice

寫了一個測試來看看兩者的差異吧(原始碼在GitHub):

func BenchmarkExperiment3Append1(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var s []int
        for j := 0; j < 20; j++ {
            s = append(s, []int{j, j + 1, j + 2, j + 3, j + 4}...)
        }
    }
}

func BenchmarkExperiment3Append2(b *testing.B) {
    for i := 0; i < b.N; i++ {
        s := make([]int, 0, 100)
        for j := 0; j < 20; j++ {
            s = append(s, []int{j, j + 1, j + 2, j + 3, j + 4}...)
        }
    }
}

func BenchmarkExperiment3Copy(b *testing.B) {
    for i := 0; i < b.N; i++ {
        s := make([]int, 100)
        n := 0
        for j := 0; j < 20; j++ {
            n += copy(s[n:], []int{j, j + 1, j + 2, j + 3, j + 4})
        }
    }
}

測試結果如下:

goos: darwin
goarch: amd64
pkg: github.com/thinkeridea/example/slice
BenchmarkExperiment3Append1-8        2000000           782 ns/op        3024 B/op           6 allocs/op
BenchmarkExperiment3Append2-8       10000000           192 ns/op           0 B/op           0 allocs/op
BenchmarkExperiment3Copy-8          10000000           217 ns/op           0 B/op           0 allocs/op
PASS
ok      github.com/thinkeridea/example/slice    6.926s

從結果上來看使用沒有容量的 append 效能真的很糟糕,實際上不要對沒有任何容量的 slice 進行 append 操作是最好的實踐,在準備用 append 的時候應該預先給定一個容量,哪怕這個容量並不是確定的,像前面快取連線字串時一樣,並不能明確使用的空間,先分配256個位元組,這樣的好處是可以減少系統呼叫分配記憶體的次數,即使空間不能用完,也不用太過擔心浪費,append 本身擴容機制也會導致空間不是剛剛好用完的,而初始化的容量往往結合業務場景給的一個均值,這是很好的。

appendcopy 在預先確定長度和容量時 append 效果更好一些,主要原因是 copy 需要一個變數來記錄位置。 如果使用場景中沒有強制限定長度,建議使用 append 因為 append 會根據實際情況再做記憶體分配,較 copy 也更加靈活一些, 而 copy 往往用在長度固定的地方,可以防止資料長度溢位的問題,例如標準庫中 strings.Repeat 函式,它採用指數增長的方式快速填充指定數量的字元,但是如果使用 append 就會發生多餘的記憶體分配,導致長度溢位。

func Repeat(s string, count int) string {
    b := make([]byte, len(s)*count)
    bp := copy(b, s)
    for bp < len(b) {
        copy(b[bp:], b[:bp])
        bp *= 2
    }
    return string(b)
}

csv reader 中的一個例子

官方標準庫 csv 的讀取效能極高,其中 reader 裡面有使用 slice 極好的例子,以下是簡略的程式碼,如果想要全面瞭解程式需要去看標準庫的原始碼:

func (r *Reader) readRecord(dst []string) ([]string, error) {
    line, errRead = r.readLine()
    if errRead == io.EOF {
        return nil, errRead
    }
    r.recordBuffer = r.recordBuffer[:0]
    r.fieldIndexes = r.fieldIndexes[:0]
parseField:
    for {
        if r.TrimLeadingSpace {
            line = bytes.TrimLeftFunc(line, unicode.IsSpace)
        }
            i := bytes.IndexRune(line, r.Comma)
            field := line
            if i >= 0 {
                field = field[:i]
            } else {
                field = field[:len(field)-lengthNL(field)]
            }
            
            r.recordBuffer = append(r.recordBuffer, field...)
            r.fieldIndexes = append(r.fieldIndexes, len(r.recordBuffer))
            if i >= 0 {
                line = line[i+commaLen:]
                continue parseField
            }
            break parseField
    }
    
    if err == nil {
        err = errRead
    }

    // Create a single string and create slices out of it.
    // This pins the memory of the fields together, but allocates once.
    str := string(r.recordBuffer) // Convert to string once to batch allocations
    dst = dst[:0]
    if cap(dst) < len(r.fieldIndexes) {
        dst = make([]string, len(r.fieldIndexes))
    }
    dst = dst[:len(r.fieldIndexes)]
    var preIdx int
    for i, idx := range r.fieldIndexes {
        dst[i] = str[preIdx:idx]
        preIdx = idx
    }
    return dst, err
}

這裡刪除了極多的程式碼,但是能看懂大意,其中 line 是一段 bufio 中的一段引用,所以這塊資料不能返回給使用者,也不能進行併發讀取操作。

r.recordBufferr.fieldIndexescsv 的快取,他們初始的時候容量是0,是不是會有些奇怪,之前還建議 slice 初始一個長度,來減少記憶體分配,csv 這個庫的設計非常的巧妙,假設 csv 每行欄位的個數一樣,資料長度也相近,現實業務確實如此,所以只有讀取第一行資料的時候才會發生大量的 slice 擴容, 之後其它行擴容的可能性非常的小,整個檔案讀取完也不會發生太多次,不得不說設計的太妙了。

r.recordBuffer 用來儲存行中除了分隔符的所有資料,r.fieldIndexes 用來儲存每個欄位資料在 r.recordBuffer 中的索引。每次都通過 r.recordBuffer[:0] 這個的資料獲取,讀取每行資料都反覆使用這塊記憶體,極大的減少記憶體開銷。

更巧妙的設計是 str := string(r.recordBuffer) 原始碼中也有詳細的說明,一次性分配足夠的記憶體, 要知道型別轉換是會發生記憶體拷貝的,分配新的記憶體, 如果每個欄位轉換一次,會發生很多的記憶體拷貝和分配,之後通過 dst[i] = str[preIdx:idx] 引用 str 中的資料達到切分欄位的效果,因為引用字串並不會拷貝字串(字串不可變,引用字串的子串是安全的)所以其代價非常的小。

這段原始碼中還有一個很多人都不知道的 slice 特性的例子,dst = dst[:0]; dst = dst[:len(r.fieldIndexes)] 這兩句話放到一起是不是感覺很不可思議,明明 dst 的長度被清空了,dst[:len(r.fieldIndexes)] 不是會發生索引越界嗎,很多人認為 s[i:l] 這種寫法是當前 slice 的索引,實際並非如此,這裡面的 ij 是底層引用陣列相對當前 slice 引用位置的索引,並不受當前 slice 的長度的影響。

這裡只是簡單引用 csv 原始碼中的一段分析其 slice 的巧妙用法,即把 slice 當做資料快取,也作為分配記憶體的一種極佳的方法,這個示例中的關於 slice 的使用值得反覆推敲。

記憶體池

早些時間閱讀 GitHub 上的一些原始碼,發現一個實現記憶體次的例子,裡面對 slice 的應用非常有特點,在這裡拿來分析一下(GitHub原始碼):

func NewChanPool(minSize, maxSize, factor, pageSize int) *ChanPool {
    pool := &ChanPool{make([]chanClass, 0, 10), minSize, maxSize}
    for chunkSize := minSize; chunkSize <= maxSize && chunkSize <= pageSize; chunkSize *= factor {
        c := chanClass{
            size:   chunkSize,
            page:   make([]byte, pageSize),
            chunks: make(chan []byte, pageSize/chunkSize),
        }
        c.pageBegin = uintptr(unsafe.Pointer(&c.page[0]))
        for i := 0; i < pageSize/chunkSize; i++ {
            // lock down the capacity to protect append operation
            mem := c.page[i*chunkSize : (i+1)*chunkSize : (i+1)*chunkSize]
            c.chunks <- mem
            if i == len(c.chunks)-1 {
                c.pageEnd = uintptr(unsafe.Pointer(&mem[0]))
            }
        }
        pool.classes = append(pool.classes, c)
    }
    return pool
}

這裡採用步進式分頁,保證每頁上的資料塊大小相同,一次性建立整個頁 make([]byte, pageSize) ,之後從頁切分資料塊 mem := c.page[i*chunkSize : (i+1)*chunkSize : (i+1)*chunkSize], 容量和資料塊長度一致,建立一塊較大的記憶體,減少系統呼叫,當然這個例子中還可以建立更大的記憶體,就是每頁容量的總大小,避免建立更多頁,所有的塊資料都引用一塊記憶體。

這裡限制了每個塊的容量,預設引用 slice 的容量是引用起始位置到底層陣列的結尾,但是可以指定容量,這就保證了獲取的資料塊不會因為使用者不遵守約定超出其大小導致資料寫入到其它塊中的問題,設定了容量使用者使用超出容量後就會拷貝出去並建立新的 slice 實在的很妙的用法。

一次分配更大的記憶體可以減少記憶體碎片,更好的複用記憶體。

func (pool *ChanPool) Alloc(size int) []byte {
    if size <= pool.maxSize {
        for i := 0; i < len(pool.classes); i++ {
            if pool.classes[i].size >= size {
                mem := pool.classes[i].Pop()
                if mem != nil {
                    return mem[:size]
                }
                break
            }
        }
    }
    return make([]byte, size)
}

獲取記憶體池中的記憶體就非常簡單,查詢比需要大小更大的塊並返回即可,這不失為一個較好的記憶體複用演算法。

func (pool *ChanPool) Free(mem []byte) {
    size := cap(mem)
    for i := 0; i < len(pool.classes); i++ {
        if pool.classes[i].size == size {
            pool.classes[i].Push(mem)
            break
        }
    }
}

當使用完釋放記憶體時實現的並不是很好,應該判斷釋放的資料是否是當前記憶體的一部分,如果不是的就不能放回到記憶體池中,因為使用者未按約定大小使用,導致大量擴容而使得記憶體池中的資料碎片化,當然使用者一旦發生擴容就會導致記憶體池中的快取塊丟失,導致存在大塊記憶體無法釋放,卻也沒法使用的情況。

之所以分析這個例子主要是分析其使用 slice 的方法和技巧,並不推薦使用該方法管理記憶體。

擴充

更多關於 slice 應用的例子可以參考標準庫 bytesbufiobufferbufio 的使用極其相似,兩個包都是使用 slice 來減少記憶體分配及系統呼叫來達到實現緩衝和快取的例子。

轉載:

本文作者: 戚銀(thinkeridea

本文連結: https://blog.thinkeridea.com/201901/go/slice_de_yi_xie_shi_yong_ji_qiao.html

版權宣告: 本部落格所有文章除特別宣告外,均採用 CC BY 4.0 CN協議 許可協議。轉載請註明出處!

相關文章