【Go】slice的一些使用技巧

thinkeridea · · 211 次点击 · 开始浏览    置顶
这是一个创建于 的主题,其中的信息可能已经有所发展或是发生改变。
原文链接:[https://blog.thinkeridea.com/201901/go/slice_de_yi_xie_shi_yong_ji_qiao.html](https://blog.thinkeridea.com/201901/go/slice_de_yi_xie_shi_yong_ji_qiao.html) `slice` 是 `Go` 语言十分重要的数据类型,它承载着很多使命,从语言层面来看是 `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` 的容量。其中 `i` 、`l` 、`c` 并不是当前 `slice` 的索引,而是引用底层数组相对当前 `slice` 起始位置的偏移量,所以是可超出当前 `slice` 的长度的, 但不能超出当前 `slice` 的容量,如下操作是合法的: ```go 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 中的一个例子](#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` `+` 等等,实际上 `builder` 和 `buffer` 都是使用 `[]byte` 的切片作为缓冲来实现的,`fmt` 往往性能最差,原因是它主要功能不是连接字符串而是格式化数据会用到反射等等操作。`+` 操作在大量拼接时性能也是很差, 不过小字符串少量拼接效果很理想,`builder` 往往性能不如 `buffer` 特别是在较短字符串拼接上,实际 `builder` 和 `buffer` 实现原理非常类似,`builder` 在转成字符串时使用了 `unsafe` 减少了一次内存分配,因为小字符串因为扩容机制不如 `buffer` 灵活,所以性能有所不如,大字符串降低一次大的内存分配就显得很明显了。 经常遇到一个需求就是拼接 `[]int` 中个各个元素,很多种实现都有人用,都是需要遍历转换 `int` 到 `string`,但是拼接方法千奇百怪,以下提供两种方法对比([源码在GitHub](https://github.com/thinkeridea/example/blob/master/slice/slice_int_to_string.go))。 ```go 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](https://github.com/thinkeridea/example/blob/master/slice/slice_int_to_string_test.go)),看一下效果吧: ``` 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](https://github.com/thinkeridea/example/blob/master/slice/slice_int_to_string.go#L52)): ```go 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](https://github.com/thinkeridea/example/blob/master/slice/slice_int_to_string_test.go#L85)),看一下效果吧: ``` 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` 操作非常的简单实用。 ## append 与 copy 如果合并多个 `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](https://github.com/thinkeridea/example/blob/master/slice/append_and_copy_test.go)): ```go 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` 就会发生多余的内存分配,导致长度溢出。 ```go 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` 极好的例子,以下是简略的代码,如果想要全面了解程序需要去看标准库的源码: ```go 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源码](https://github.com/funny/slab/blob/master/chan_pool.go)): ```go 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` 实在的很妙的用法。 一次分配更大的内存可以减少内存碎片,更好的复用内存。 ```go 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) } ``` 获取内存池中的内存就非常简单,查找比需要大小更大的块并返回即可,这不失为一个较好的内存复用算法。 ```go 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/))** **本文链接: [https://blog.thinkeridea.com/201901/go/slice_de_yi_xie_shi_yong_ji_qiao.html](https://blog.thinkeridea.com/201901/go/slice_de_yi_xie_shi_yong_ji_qiao.html)** **版权声明: 本博客所有文章除特别声明外,均采用 [CC BY 4.0 CN协议](http://creativecommons.org/licenses/by/4.0/deed.zh) 许可协议。转载请注明出处!**

入群交流(该群和以上内容无关):Go中文网 QQ交流群:731990104 或 加微信入微信群:274768166 备注:入群; 公众号:Go语言中文网

211 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传