原文連接:https://blog.thinkeridea.com/201901/go/slice_de_yi_xie_shi_yong_ji_qiao.htmlhtml
slice
是 Go
語言十分重要的數據類型,它承載着不少使命,從語言層面來看是 Go
語言的內置數據類型,從數據結構來看是動態長度的順序鏈表,因爲 Go
不能直接操做內存(經過系統調用能夠實現,可是語言自己並不支持),每每 slice
也能夠用來幫助開發者申請大塊內存實現緩衝、緩存等功能。git
在 Go
語言項目中大量的使用 slice
, 我總結三年來對 slice
的一些操做技巧,以方即可以高效的使用 slice
, 並使用 slice
解決一些棘手的問題。github
先熟悉一些 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
的容量。其中 i
、l
、c
並非當前 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[:]
引用數組建立切片內置函數app
len(s)
獲取切片的長度cap(s)
獲取切片的容量append(s, ...)
向切片追加內容copy(s, s1)
向切片拷貝內容遇到過不少拼接字符串的方法,各類各樣的都有 fmt
builder
buffer
+
等等,實際上 builder
和 buffer
都是使用 []byte
的切片做爲緩衝來實現的,fmt
每每性能最差,緣由是它主要功能不是鏈接字符串而是格式化數據會用到反射等等操做。+
操做在大量拼接時性能也是不好, 不太小字符串少許拼接效果很理想,builder
每每性能不如 buffer
特別是在較短字符串拼接上,實際 builder
和 buffer
實現原理很是相似,builder
在轉成字符串時使用了 unsafe
減小了一次內存分配,由於小字符串由於擴容機制不如 buffer
靈活,因此性能有所不如,大字符串下降一次大的內存分配就顯得很明顯了。
常常遇到一個需求就是拼接 []int
中個各個元素,不少種實現都有人用,都是須要遍歷轉換 int
到 string
,可是拼接方法千奇百怪,如下提供兩種方法對比(源碼在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
使用原始的 +
操做,由於是較小的字符串拼接,使用 +
主要是由於在小字符串拼接性能優於其它幾種方法,SliceInt2String2
與 SliceInt2String3
都使用了一個 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倍左右,提高很明顯,SliceInt2String2
與 SliceInt2String3
差別很小,主要是由於使用 unsafe
轉換類型致使大內存沒法釋放,實際這個測試中鏈接字符串只須要 32
個字節,使用 unsafe
卻致使 256
個字節沒法被釋放,這也正是 builder
和 buffer
的差異,因此小字符串拼接 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
做爲緩衝區極好的技巧,使用很是方便,並不用使用 builder
和 buffer
, slice
操做很是的簡單實用。
若是合併多個 slice
爲一個,有三種方式來合併,主要合併差別來源於建立新 slice
的方法,使用 var news []int
或者 news:=make([]int, 0, len(s1)+len(s2)....)
的方式建立的新變量就須要使用 append
來合併,若是使用 news:=make([]int, len(s1)+len(s2)....)
就須要使用 copy
來合併。不一樣的方法也有差別,append
和 copy
在這個例子中主要差別在於 append
適用於零長度的初始化 slice
, copy
適用於肯定長度的 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
自己擴容機制也會致使空間不是剛恰好用完的,而初始化的容量每每結合業務場景給的一個均值,這是很好的。
append
和 copy
在預先肯定長度和容量時 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
裏面有使用 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.recordBuffer
和 r.fieldIndexes
是 csv
的緩存,他們初始的時候容量是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
的索引,實際並不是如此,這裏面的 i
和 j
是底層引用數組相對當前 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
應用的例子能夠參考標準庫 bytes
與 bufio
, buffer
與 bufio
的使用極其類似,兩個包都是使用 slice
來減小內存分配及系統調用來達到實現緩衝和緩存的例子。
轉載:
本文做者: 戚銀(thinkeridea)
本文連接: https://blog.thinkeridea.com/201901/go/slice_de_yi_xie_shi_yong_ji_qiao.html
版權聲明: 本博客全部文章除特別聲明外,均採用 CC BY 4.0 CN協議 許可協議。轉載請註明出處!