原文连接: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 中的一个例子缓存
建立 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
+
等等,实际上 builder
和 buffer
都是使用 []byte
的切片做为缓冲来实现的,fmt
每每性能最差,缘由是它主要功能不是链接字符串而是格式化数据会用到反射等等操做。+
操做在大量拼接时性能也是不好, 不太小字符串少许拼接效果很理想,builder
每每性能不如 buffer
特别是在较短字符串拼接上,实际 builder
和 buffer
实现原理很是相似,builder
在转成字符串时使用了 unsafe
减小了一次内存分配,由于小字符串由于扩容机制不如 buffer
灵活,因此性能有所不如,大字符串下降一次大的内存分配就显得很明显了。app
常常遇到一个需求就是拼接 []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协议 许可协议。转载请注明出处!