Golang-中使用-Slice-索引-Map-替代-Map-获得性能提升

起因在我们的多个线上游戏项目中,很多模块和服务为了提高响应速度,都在内存中存放了大量的(缓存)数据以便获得最快的访问速度。 通常情况下,为了使用方便,使用了 go 自身的 map 作为存放容器。当有超过几十万 key 值,并且 map 的 value 是一个复杂的 struct 时,额外引入的 GC 开销是无法忽视的。在 cpu 使用统计图中,我们总是观测到较为规律的短时间峰值。这个峰值在使用 1.3 版本的 go 中显得特别突出(stop the world 问题)。后续版本 go gc 不断优化,到我们现在使用的 1.10 已经是非常快速的并发 gc 并且只会有很短暂的 stw。 不过在各种 profile 的图中,我们依然观察到了大量的 runtime.scanobject 开销! 在一个14年开始的讨论中,就以及发现了 大 map 带来(特别是指针作为 value 的 map)的 gc 开销。遗憾的是在 2019 年的今天这个问题仍然存在。 在上述的讨论帖子中,有一个 Contributor randall77 提到: Hash tables will still have overflow pointers so they will still need to be scanned and there are no plans to fix that.不明白他的 overflow pointers 指的什么,但是看起来如果你有一个大的,指针作为 value 的 map 时,gc 的 scanobject 耗时就不会少。 ...

September 9, 2019 · 1 min · jiezi

javascript数组方法splice和slice的作用和区别的总结

splice() 和 slice()唯一的共同点是都是对数组的操作,还有就是长的很像,有时候容易搞混。 这两个最的区别:splice()会改变原来的数组,返回的是被改变的内容,比如说通过splice删掉了某一项,那么返回的是删掉的这一项,当然还是会以数组的形式返回。举个栗子 let animals = ['ant', 'bison','camel','duck','elephant'] console.log(animals.splice(2,1)) //['camel']被删掉的是索引未为2的一项,返回的也只有这一项所以如果想删掉某一项,并不需要得到一个新的数组,只需要 animals.splice(2,1) console.log(animasl)//['ant', 'bison','duck','elephant']// 用某个元素替换掉数组里的某个元素 直接修改原来的数组Array.prototype.replaceAryItem = function(index,val) { this.splice(index,1,val)}slice不会对原数组进行改变,会返回一个新的数组。利用slice同样也可以实现根据索引删除某一项 // 删除数组里的某一项 返回一个新的数组 不直接修改数组Array.prototype.removeAryItemByIndex = function(index) { return this.slice(0,index).concat(this.slice(index+1))}

June 9, 2019 · 1 min · jiezi

【Go】优雅的读取http请求或响应的数据

原文链接:https://blog.thinkeridea.com/…从 http.Request.Body 或 http.Response.Body 中读取数据方法或许很多,标准库中大多数使用 ioutil.ReadAll 方法一次读取所有数据,如果是 json 格式的数据还可以使用 json.NewDecoder 从 io.Reader 创建一个解析器,假使使用 pprof 来分析程序总是会发现 bytes.makeSlice 分配了大量内存,且总是排行第一,今天就这个问题来说一下如何高效优雅的读取 http 中的数据。背景介绍我们有许多 api 服务,全部采用 json 数据格式,请求体就是整个 json 字符串,当一个请求到服务端会经过一些业务处理,然后再请求后面更多的服务,所有的服务之间都用 http 协议来通信(啊, 为啥不用 RPC,因为所有的服务都会对第三方开放,http + json 更好对接),大多数请求数据大小在 1K4K,响应的数据在 1K8K,早期所有的服务都使用 ioutil.ReadAll 来读取数据,随着流量增加使用 pprof 来分析发现 bytes.makeSlice 总是排在第一,并且占用了整个程序 1/10 的内存分配,我决定针对这个问题进行优化,下面是整个优化过程的记录。pprof 分析这里使用 https://github.com/thinkeridea/go-extend/blob/master/exnet/exhttp/expprof/pprof.go 中的 API 来实现生产环境的 /debug/pprof 监测接口,没有使用标准库的 net/http/pprof 包因为会自动注册路由,且长期开放 API,这个包可以设定 API 是否开放,并在规定时间后自动关闭接口,避免存在工具嗅探。服务部署上线稳定后(大约过了一天半),通过 curl 下载 allocs 数据,然后使用下面的命令查看分析。$ go tool pprof allocsFile: xxxType: alloc_spaceTime: Jan 25, 2019 at 3:02pm (CST)Entering interactive mode (type “help” for commands, “o” for options)(pprof) topShowing nodes accounting for 604.62GB, 44.50% of 1358.61GB totalDropped 776 nodes (cum <= 6.79GB)Showing top 10 nodes out of 155 flat flat% sum% cum cum% 111.40GB 8.20% 8.20% 111.40GB 8.20% bytes.makeSlice 107.72GB 7.93% 16.13% 107.72GB 7.93% github.com/sirupsen/logrus.(*Entry).WithFields 65.94GB 4.85% 20.98% 65.94GB 4.85% strings.Replace 54.10GB 3.98% 24.96% 56.03GB 4.12% github.com/json-iterator/go.(*frozenConfig).Marshal 47.54GB 3.50% 28.46% 47.54GB 3.50% net/url.unescape 47.11GB 3.47% 31.93% 48.16GB 3.55% github.com/json-iterator/go.(*Iterator).readStringSlowPath 46.63GB 3.43% 35.36% 103.04GB 7.58% handlers.(*AdserviceHandler).returnAd 42.43GB 3.12% 38.49% 84.62GB 6.23% models.LogItemsToBytes 42.22GB 3.11% 41.59% 42.22GB 3.11% strings.Join 39.52GB 2.91% 44.50% 87.06GB 6.41% net/url.parseQuery从结果中可以看出采集期间一共分配了 1358.61GB top 10 占用了 44.50% 其中 bytes.makeSlice 占了接近 1/10,那么看看都是谁在调用 bytes.makeSlice 吧。(pprof) web bytes.makeSlice从上图可以看出调用 bytes.makeSlice 的最终方法是 ioutil.ReadAll, (受篇幅影响就没有截取 ioutil.ReadAll 上面的方法了),而 90% 都是 ioutil.ReadAll 读取 http 数据调用,找到地方先别急想优化方案,先看看为啥 ioutil.ReadAll 会导致这么多内存分配。func readAll(r io.Reader, capacity int64) (b []byte, err error) { var buf bytes.Buffer // If the buffer overflows, we will get bytes.ErrTooLarge. // Return that as an error. Any other panic remains. defer func() { e := recover() if e == nil { return } if panicErr, ok := e.(error); ok && panicErr == bytes.ErrTooLarge { err = panicErr } else { panic(e) } }() if int64(int(capacity)) == capacity { buf.Grow(int(capacity)) } , err = buf.ReadFrom(r) return buf.Bytes(), err}func ReadAll(r io.Reader) ([]byte, error) { return readAll(r, bytes.MinRead)}以上是标准库 ioutil.ReadAll 的代码,每次会创建一个 var buf bytes.Buffer 并且初始化 buf.Grow(int(capacity)) 的大小为 bytes.MinRead, 这个值呢就是 512,按这个 buffer 的大小读取一次数据需要分配 2~16 次内存,天啊简直不能忍,我自己创建一个 buffer 好不好。看一下火焰图????吧,其中红框标记的就是 ioutil.ReadAll 的部分,颜色比较鲜艳。优化读取方法自己创建足够大的 buffer 减少因为容量不够导致的多次扩容问题。buffer := bytes.NewBuffer(make([]byte, 4096)), err := io.Copy(buffer, request.Body)if err !=nil{ return nil, err}恩恩这样应该差不多了,为啥是初始化 4096 的大小,这是个均值,即使比 4096 大基本也就多分配一次内存即可,而且大多数数据都是比 4096 小的。但是这样真的就算好了吗,当然不能这样,这个 buffer 个每请求都要创建一次,是不是应该考虑一下复用呢,使用 sync.Pool 建立一个缓冲池效果就更好了。以下是优化读取请求的简化代码:package adapterimport ( “bytes” “io” “net/http” “sync” “github.com/json-iterator/go” “github.com/sirupsen/logrus” “github.com/thinkeridea/go-extend/exbytes”)type Adapter struct { pool sync.Pool}func New() *Adapter { return &Adapter{ pool: sync.Pool{ New: func() interface{} { return bytes.NewBuffer(make([]byte, 4096)) }, }, }}func (api *Adapter) GetRequest(r *http.Request) (*Request, error) { buffer := api.pool.Get().(*bytes.Buffer) buffer.Reset() defer func() { if buffer != nil { api.pool.Put(buffer) buffer = nil } }() _, err := io.Copy(buffer, r.Body) if err != nil { return nil, err } request := &Request{} if err = jsoniter.Unmarshal(buffer.Bytes(), request); err != nil { logrus.WithFields(logrus.Fields{ “json”: exbytes.ToString(buffer.Bytes()), }).Errorf(“jsoniter.UnmarshalJSON fail. error:%v”, err) return nil, err } api.pool.Put(buffer) buffer = nil // …. return request, nil}使用 sync.Pool 的方式是不是有点怪,主要是 defer 和 api.pool.Put(buffer);buffer = nil 这里解释一下,为了提高 buufer 的复用率会在不使用时尽快把 buffer 放回到缓冲池中,defer 之所以会判断 buffer != nil 主要是在业务逻辑出现错误时,但是 buffer 还没有放回缓冲池时把 buffer 放回到缓冲池,因为在每个错误处理之后都写 api.pool.Put(buffer) 不是一个好的方法,而且容易忘记,但是如果在确定不再使用时 api.pool.Put(buffer);buffer = nil 就可以尽早把 buffer 放回到缓冲池中,提高复用率,减少新建 buffer。这样就好了吗,别急,之前说服务里面还会构建请求,看看构建请求如何优化吧。package adapterimport ( “bytes” “fmt” “io” “io/ioutil” “net/http” “sync” “github.com/json-iterator/go” “github.com/sirupsen/logrus” “github.com/thinkeridea/go-extend/exbytes”)type Adapter struct { pool sync.Pool}func New() *Adapter { return &Adapter{ pool: sync.Pool{ New: func() interface{} { return bytes.NewBuffer(make([]byte, 4096)) }, }, }}func (api *Adapter) Request(r *Request) (*Response, error) { var err error buffer := api.pool.Get().(*bytes.Buffer) buffer.Reset() defer func() { if buffer != nil { api.pool.Put(buffer) buffer = nil } }() e := jsoniter.NewEncoder(buffer) err = e.Encode(r) if err != nil { logrus.WithFields(logrus.Fields{ “request”: r, }).Errorf(“jsoniter.Marshal failure: %v”, err) return nil, fmt.Errorf(“jsoniter.Marshal failure: %v”, err) } data := buffer.Bytes() req, err := http.NewRequest(“POST”, “http://xxx.com”, buffer) if err != nil { logrus.WithFields(logrus.Fields{ “data”: exbytes.ToString(data), }).Errorf(“http.NewRequest failed: %v”, err) return nil, fmt.Errorf(“http.NewRequest failed: %v”, err) } req.Header.Set(“User-Agent”, “xxx”) httpResponse, err := http.DefaultClient.Do(req) if httpResponse != nil { defer func() { io.Copy(ioutil.Discard, httpResponse.Body) httpResponse.Body.Close() }() } if err != nil { logrus.WithFields(logrus.Fields{ “url”: “http://xxx.com”, }).Errorf(“query service failed %v”, err) return nil, fmt.Errorf(“query service failed %v”, err) } if httpResponse.StatusCode != 200 { logrus.WithFields(logrus.Fields{ “url”: “http://xxx.com”, “status”: httpResponse.Status, “status_code”: httpResponse.StatusCode, }).Errorf(“invalid http status code”) return nil, fmt.Errorf(“invalid http status code”) } buffer.Reset() , err = io.Copy(buffer, httpResponse.Body) if err != nil { return nil, fmt.Errorf(“adapter io.copy failure error:%v”, err) } respData := buffer.Bytes() logrus.WithFields(logrus.Fields{ “response_json”: exbytes.ToString(respData), }).Debug(“response json”) res := &Response{} err = jsoniter.Unmarshal(respData, res) if err != nil { logrus.WithFields(logrus.Fields{ “data”: exbytes.ToString(respData), “url”: “http://xxx.com”, }).Errorf(“adapter jsoniter.Unmarshal failed, error:%v”, err) return nil, fmt.Errorf(“adapter jsoniter.Unmarshal failed, error:%v”, err) } api.pool.Put(buffer) buffer = nil // … return res, nil}这个示例和之前差不多,只是不仅用来读取 http.Response.Body 还用来创建一个 jsoniter.NewEncoder 用来把请求压缩成 json 字符串,并且作为 http.NewRequest 的 body 参数, 如果直接用 jsoniter.Marshal 同样会创建很多次内存,jsoniter 也使用 buffer 做为缓冲区,并且默认大小为 512, 代码如下:func (cfg Config) Froze() API { api := &frozenConfig{ sortMapKeys: cfg.SortMapKeys, indentionStep: cfg.IndentionStep, objectFieldMustBeSimpleString: cfg.ObjectFieldMustBeSimpleString, onlyTaggedField: cfg.OnlyTaggedField, disallowUnknownFields: cfg.DisallowUnknownFields, } api.streamPool = &sync.Pool{ New: func() interface{} { return NewStream(api, nil, 512) }, } // ….. return api}而且序列化之后会进行一次数据拷贝:func (cfg *frozenConfig) Marshal(v interface{}) ([]byte, error) { stream := cfg.BorrowStream(nil) defer cfg.ReturnStream(stream) stream.WriteVal(v) if stream.Error != nil { return nil, stream.Error } result := stream.Buffer() copied := make([]byte, len(result)) copy(copied, result) return copied, nil}既然要用 buffer 那就一起吧^^,这样可以减少多次内存分配,下读取 http.Response.Body 之前一定要记得 buffer.Reset(), 这样基本就已经完成了 http.Request.Body 和 http.Response.Body 的数据读取优化了,具体效果等上线跑一段时间稳定之后来查看吧。效果分析上线跑了一天,来看看效果吧$ go tool pprof allocs2File: connect_serverType: alloc_spaceTime: Jan 26, 2019 at 10:27am (CST)Entering interactive mode (type “help” for commands, “o” for options)(pprof) topShowing nodes accounting for 295.40GB, 40.62% of 727.32GB totalDropped 738 nodes (cum <= 3.64GB)Showing top 10 nodes out of 174 flat flat% sum% cum cum% 73.52GB 10.11% 10.11% 73.52GB 10.11% git.tvblack.com/tvblack/connect_server/vendor/github.com/sirupsen/logrus.(*Entry).WithFields 31.70GB 4.36% 14.47% 31.70GB 4.36% net/url.unescape 27.49GB 3.78% 18.25% 54.87GB 7.54% git.tvblack.com/tvblack/connect_server/models.LogItemsToBytes 27.41GB 3.77% 22.01% 27.41GB 3.77% strings.Join 25.04GB 3.44% 25.46% 25.04GB 3.44% bufio.NewWriterSize 24.81GB 3.41% 28.87% 24.81GB 3.41% bufio.NewReaderSize 23.91GB 3.29% 32.15% 23.91GB 3.29% regexp.(*bitState).reset 23.06GB 3.17% 35.32% 23.06GB 3.17% math/big.nat.make 19.90GB 2.74% 38.06% 20.35GB 2.80% git.tvblack.com/tvblack/connect_server/vendor/github.com/json-iterator/go.(*Iterator).readStringSlowPath 18.58GB 2.56% 40.62% 19.12GB 2.63% net/textproto.(*Reader).ReadMIMEHeader哇塞 bytes.makeSlice 终于从前十中消失了,真的太棒了,还是看看 bytes.makeSlice 的其它调用情况吧。(pprof) web bytes.makeSlice从图中可以发现 bytes.makeSlice 的分配已经很小了, 且大多数是 http.Request.ParseForm 读取 http.Request.Body 使用 ioutil.ReadAll 原因,这次优化的效果非常的好。看一下更直观的火焰图????吧,和优化前对比一下很明显 ioutil.ReadAll 看不到了优化期间遇到的问题比较惭愧在优化的过程出现了一个过失,导致生产环境2分钟故障,通过自动部署立即回滚才得以快速恢复,之后分析代码解决之后上线才完美优化,下面总结一下出现的问题吧。在构建 http 请求时我分了两个部分优化,序列化 json 和读取 http.Response.Body 数据,保持一个观点就是尽早把 buffer 放回到缓冲池,因为 http.DefaultClient.Do(req) 是网络请求会相对耗时,在这个之前我把 buffer 放回到缓冲池中,之后读取 http.Response.Body 时在重新获取一个 buffer,大概代码如下:package adapterimport ( “bytes” “fmt” “io” “io/ioutil” “net/http” “sync” “github.com/json-iterator/go” “github.com/sirupsen/logrus” “github.com/thinkeridea/go-extend/exbytes”)type Adapter struct { pool sync.Pool}func New() *Adapter { return &Adapter{ pool: sync.Pool{ New: func() interface{} { return bytes.NewBuffer(make([]byte, 4096)) }, }, }}func (api *Adapter) Request(r *Request) (*Response, error) { var err error buffer := api.pool.Get().(*bytes.Buffer) buffer.Reset() defer func() { if buffer != nil { api.pool.Put(buffer) buffer = nil } }() e := jsoniter.NewEncoder(buffer) err = e.Encode(r) if err != nil { return nil, fmt.Errorf(“jsoniter.Marshal failure: %v”, err) } data := buffer.Bytes() req, err := http.NewRequest(“POST”, “http://xxx.com”, buffer) if err != nil { return nil, fmt.Errorf(“http.NewRequest failed: %v”, err) } req.Header.Set(“User-Agent”, “xxx”) api.pool.Put(buffer) buffer = nil httpResponse, err := http.DefaultClient.Do(req) // …. buffer = api.pool.Get().(*bytes.Buffer) buffer.Reset() defer func() { if buffer != nil { api.pool.Put(buffer) buffer = nil } }() _, err = io.Copy(buffer, httpResponse.Body) if err != nil { return nil, fmt.Errorf(“adapter io.copy failure error:%v”, err) } // …. api.pool.Put(buffer) buffer = nil // … return res, nil}上线之后马上发生了错误 http: ContentLength=2090 with Body length 0 发送请求的时候从 buffer 读取数据发现数据不见了或者数据不够了,我去这是什么鬼,马上回滚恢复业务,然后分析 http.DefaultClient.Do(req) 和 http.NewRequest,在调用 http.NewRequest 是并没有从 buffer 读取数据,而只是创建了一个 req.GetBody 之后在 http.DefaultClient.Do 是才读取数据,因为在 http.DefaultClient.Do 之前把 buffer 放回到缓冲池中,其它 goroutine 获取到 buffer 并进行 Reset 就发生了数据争用,当然会导致数据读取不完整了,真实汗颜,对 http.Client 了解太少,争取有空撸一遍源码。总结使用合适大小的 buffer 来减少内存分配,sync.Pool 可以帮助复用 buffer, 一定要自己写这些逻辑,避免使用三方包,三方包即使使用同样的技巧为了避免数据争用,在返回数据时候必然会拷贝一个新的数据返回,就像 jsoniter 虽然使用了 sync.Pool 和 buffer 但是返回数据时还需要拷贝,另外这种通用包并不能给一个非常贴合业务的初始 buffer 大小,过小会导致数据发生拷贝,过大会太过浪费内存。程序中善用 buffer 和 sync.Pool 可以大大的改善程序的性能,并且这两个组合在一起使用非常的简单,并不会使代码变的复杂。转载:本文作者: 戚银(thinkeridea)本文链接: https://blog.thinkeridea.com/201901/go/you_ya_de_du_qu_http_qing_qiu_huo_xiang_ying_de_shu_ju.html版权声明: 本博客所有文章除特别声明外,均采用 CC BY 4.0 CN协议 许可协议。转载请注明出处! ...

January 26, 2019 · 6 min · jiezi

【Go】slice的一些使用技巧

原文链接:https://blog.thinkeridea.com/201901/go/slice_de_yi_xie_shi_yong_ji_qiao.htmlslice 是 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 的容量,如下操作是合法的:package mainimport ( “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 + 等等,实际上 builder 和 buffer 都是使用 []byte 的切片作为缓冲来实现的,fmt 往往性能最差,原因是它主要功能不是连接字符串而是格式化数据会用到反射等等操作。+ 操作在大量拼接时性能也是很差, 不过小字符串少量拼接效果很理想,builder 往往性能不如 buffer 特别是在较短字符串拼接上,实际 builder 和 buffer 实现原理非常类似,builder 在转成字符串时使用了 unsafe 减少了一次内存分配,因为小字符串因为扩容机制不如 buffer 灵活,所以性能有所不如,大字符串降低一次大的内存分配就显得很明显了。经常遇到一个需求就是拼接 []int 中个各个元素,很多种实现都有人用,都是需要遍历转换 int 到 string,但是拼接方法千奇百怪,以下提供两种方法对比(源码在GitHub)。package sliceimport ( “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: darwingoarch: amd64pkg: github.com/thinkeridea/example/sliceBenchmarkSliceInt2String1-8 3000000 461 ns/op 144 B/op 9 allocs/opBenchmarkSliceInt2String2-8 20000000 117 ns/op 32 B/op 1 allocs/opBenchmarkSliceInt2String3-8 10000000 144 ns/op 256 B/op 1 allocs/opPASSok github.com/thinkeridea/example/slice 5.928s明显可以看得出 SliceInt2String2 的性能是 SliceInt2String1 7倍左右,提升很明显,SliceInt2String2 与 SliceInt2String3 差异很小,主要是因为使用 unsafe 转换类型导致大内存无法释放,实际这个测试中连接字符串只需要 32 个字节,使用 unsafe 却导致 256 个字节无法被释放,这也正是 builder 和 buffer 的差别,所以小字符串拼接 buffer 性能往往更好。在这里简单的通过 []byte 减少内存分配次数来实现缓冲。如果连续拼接一组这样的操作,比如输入 [][]int, 输出 []string (源码在GitHub):package sliceimport ( “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: darwingoarch: amd64pkg: github.com/thinkeridea/example/sliceBenchmarkSliceInt2String4-8 300000 4420 ns/op 1440 B/op 82 allocs/opBenchmarkSliceInt2String5-8 1000000 1102 ns/op 432 B/op 10 allocs/opPASSok 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):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: darwingoarch: amd64pkg: github.com/thinkeridea/example/sliceBenchmarkExperiment3Append1-8 2000000 782 ns/op 3024 B/op 6 allocs/opBenchmarkExperiment3Append2-8 10000000 192 ns/op 0 B/op 0 allocs/opBenchmarkExperiment3Copy-8 10000000 217 ns/op 0 B/op 0 allocs/opPASSok 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 中的一个例子官方标准库 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[ichunkSize : (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[ichunkSize : (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协议 许可协议。转载请注明出处! ...

January 24, 2019 · 6 min · jiezi

【Go】深入剖析slice和array

array 和 slice 看似相似,却有着极大的不同,但他们之间还有着千次万缕的联系 slice 是引用类型、是 array 的引用,相当于动态数组,这些都是 slice 的特性,但是 slice 底层如何表现,内存中是如何分配的,特别是在程序中大量使用 slice 的情况下,怎样可以高效使用 slice?今天借助 Go 的 unsafe 包来探索 array 和 slice 的各种奥妙。数组slice 是在 array 的基础上实现的,需要先详细了解一下数组。 维基上如此介绍数组:在计算机科学中,数组数据结构(英语:array data structure),简称数组(英语:Array),是由相同类型的元素(element)的集合所组成的数据结构,分配一块连续的内存来存储,利用元素的索引(index)可以计算出该元素对应的存储地址。 数组设计之初是在形式上依赖内存分配而成的,所以必须在使用前预先请求空间。这使得数组有以下特性:请求空间以后大小固定,不能再改变(数据溢出问题);在内存中有空间连续性的表现,中间不会存在其他程序需要调用的数据,为此数组的专用内存空间;在旧式编程语言中(如有中阶语言之称的C),程序不会对数组的操作做下界判断,也就有潜在的越界操作的风险(比如会把数据写在运行中程序需要调用的核心部分的内存上)。根据维基的介绍,了解到数组是存储在一段连续的内存中,每个元素的类型相同,即是每个元素的宽度相同,可以根据元素的宽度计算元素存储的位置。通过这段介绍总结一下数组有一下特性:分配在连续的内存地址上元素类型一致,元素存储宽度一致空间大小固定,不能修改可以通过索引计算出元素对应存储的位置(只需要知道数组内存的起始位置和数据元素宽度即可)会出现数据溢出的问题(下标越界)Go 中的数组如何实现的呢,恰恰就是这么实现的,实际上几乎所有计算机语言,数组的实现都是相似的,也拥有上面总结的特性。Go 语言的数组不同于 C 语言或者其他语言的数组,C 语言的数组变量是指向数组第一个元素的指针;而 Go 语言的数组是一个值,Go 语言中的数组是值类型,一个数组变量就表示着整个数组,意味着 Go 语言的数组在传递的时候,传递的是原数组的拷贝。在程序中数组的初始化有两种方法 arr := [10]int{} 或 var arr [10]int,但是不能使用 make 来创建,数组这节结束时再探讨一下这个问题。使用 unsafe来看一下在内存中都是如何存储的吧:package mainimport ( “fmt” “unsafe”)func main() { var arr = [3]int{1, 2, 3} fmt.Println(unsafe.Sizeof(arr)) size := unsafe.Sizeof(arr[0]) // 获取数组指定索引元素的值 fmt.Println((int)(unsafe.Pointer(uintptr(unsafe.Pointer(&arr[0])) + 1size))) // 设置数组指定索引元素的值 (int)(unsafe.Pointer(uintptr(unsafe.Pointer(&arr[0])) + 1size)) = 10 fmt.Println(arr[1])}这段代码的输出如下 (Go Playground):12210首先说 12 是 fmt.Println(unsafe.Sizeof(arr)) 输出的,unsafe.Sizeof 用来计算当前变量的值在内存中的大小,12 这个代表一个 int 有4个字节,3 * 4 就是 12。这是在32位平台上运行得出的结果, 如果在64位平台上运行数组的大小是 24。从这里可以看出 [3]int 在内存中由3个连续的 int 类型组成,且有 12 个字节那么长,这就说明了数组在内存中没有存储多余的数据,只存储元素本身。size := unsafe.Sizeof(arr[0]) 用来计算单个元素的宽度,int在32位平台上就是4个字节,uintptr(unsafe.Pointer(&arr[0])) 用来计算数组起始位置的指针,1size 用来获取索引为1的元素相对数组起始位置的偏移,unsafe.Pointer(uintptr(unsafe.Pointer(&arr[0])) + 1size)) 获取索引为1的元素指针,(int) 用来转换指针位置的数据类型, 因为 int 是4个字节,所以只会读取4个字节的数据,由元素类型限制数据宽度,来确定元素的结束位置,因此得到的结果是 2。上一个步骤获取元素的值,其中先获取了元素的指针,赋值的时候只需要对这个指针位置设置值就可以了, (int)(unsafe.Pointer(uintptr(unsafe.Pointer(&arr[0])) + 1size)) = 10 就是用来给指定下标元素赋值。package mainimport ( “fmt” “unsafe”)func main() { n:= 10 var arr = [n]int{} fmt.Println(arr)}如上代码,动态的给数组设定长度,会导致编译错误 non-constant array bound n, 由此推导数组的所有操作都是编译时完成的,会转成对应的指令,通过这个特性知道数组的长度是数组类型不可或缺的一部分,并且必须在编写程序时确定。可以通过 GOOS=linux GOARCH=amd64 go tool compile -S array.go 来获取对应的汇编代码,在 array.go 中做一些数组相关的操作,查看转换对应的指令。之前的疑问,为什么数组不能用 make 创建? 上面分析了解到数组操作是在编译时转换成对应指令的,而 make 是在运行时处理(特殊状态下会做编译器优化,make可以被优化,下面 slice 分析时来讲)。slice因为数组是固定长度且是值传递,很不灵活,所以在 Go 程序中很少看到数组的影子。然而 slice 无处不在,slice 以数组为基础,提供强大的功能和遍历性。slice 的类型规范是[]T,slice T元素的类型。与数组类型不同,slice 类型没有指定的长度。 slice 申明的几种方法:s := []int{1, 2, 3} 简短的赋值语句var s []int var 申明make([]int, 3, 8) 或 make([]int, 3) make 内置方法创建s := ss[:5] 从切片或者数组创建 slice 有两个内置函数来获取其属性:len 获取 slice 的长度cap 获取 slice 的容量slice 的属性,这东西是什么,还需借助 unsafe 来探究一下。package mainimport ( “fmt” “unsafe”)func main() { s := make([]int, 10, 20) s[2] = 100 s[9] = 200 size := unsafe.Sizeof(0) fmt.Printf("%x\n", (uintptr)(unsafe.Pointer(&s))) fmt.Println((int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size))) fmt.Println((int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size2))) fmt.Println(([20]int)(unsafe.Pointer((uintptr)(unsafe.Pointer(&s)))))}这段代码的输出如下 (Go Playground):c00007ce901020[0 0 100 0 0 0 0 0 0 200 0 0 0 0 0 0 0 0 0 0]这段输出除了第一个,剩余三个好像都能看出点什么, 10 不是创建 slice 的长度吗,20 不就是指定的容量吗, 最后这个看起来有点像 slice 里面的数据,但是数量貌似有点多,从第三个元素和第十个元素来看,正好是给 slice 索引 2 和 10 指定的值,但是切片不是长度是 10 个吗,难道这个是容量,容量刚好是 20个。 第二和第三个输出很好弄明白,就是 slice 的长度和容量, 最后一个其实是 slice 引用底层数组的数据,因为创建容量为 20,所以底层数组的长度就是 20,从这里了解到切片是引用底层数组上的一段数据,底层数组的长度就是 slice 的容量,由于数组长度不可变的特性,当 slice 的长度达到容量大小之后就需要考虑扩容,不是说数组长度不能变吗,那 slice 怎么实现扩容呢, 其实就是在内存上分配一个更大的数组,把当前数组上的内容拷贝到新的数组上, slice 来引用新的数组,这样就实现扩容了。说了这么多,还是没有看出来 slice 是如何引用数组的,额…… 之前的程序还有一个输出没有搞懂是什么,难道这个就是底层数组的引用。package mainimport ( “fmt” “unsafe”)func main() { arr := [10]int{1, 2, 3} arr[7] = 100 arr[9] = 200 fmt.Println(arr) s1 := arr[:] s2 := arr[2:8] size := unsafe.Sizeof(0) fmt.Println("———-s1———") fmt.Printf("%x\n", (uintptr)(unsafe.Pointer(&s1))) fmt.Printf("%x\n", uintptr(unsafe.Pointer(&arr[0]))) fmt.Println((int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s1)) + size))) fmt.Println((int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s1)) + size2))) fmt.Println(s1) fmt.Println(([10]int)(unsafe.Pointer((uintptr)(unsafe.Pointer(&s1))))) fmt.Println("———-s2———") fmt.Printf("%x\n", (uintptr)(unsafe.Pointer(&s2))) fmt.Printf("%x\n", uintptr(unsafe.Pointer(&arr[0]))+size2) fmt.Println((int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s2)) + size))) fmt.Println((int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s2)) + size2))) fmt.Println(s2) fmt.Println(([8]int)(unsafe.Pointer((*uintptr)(unsafe.Pointer(&s2)))))}以上代码输出如下(Go Playground):[1 2 3 0 0 0 0 100 0 200] ———-s1——— c00001c0a0 c00001c0a0 10 10 [1 2 3 0 0 0 0 100 0 200] [1 2 3 0 0 0 0 100 0 200] ———-s2——— c00001c0b0 c00001c0b0 6 8 [3 0 0 0 0 100][3 0 0 0 0 100 0 200]这段输出看起来有点小复杂,第一行输出就不用说了吧,这个是打印整个数组的数据。先分析一下 s1 变量的下面的输出吧,s1 := arr[:] 引用了整个数组,所以在第5、6行输出都是10,因为数组长度为10,所有 s1 的长度和容量都为10,那第3、4行输出是什么呢,他们怎么都一样呢,之前分析数组的时候 通过 uintptr(unsafe.Pointer(&arr[0])) 来获取数组起始位置的指针的,那么第4行打印的就是数组的指针,这么就了解了第三行输出的是上面了吧,就是数组起始位置的指针,所以 (uintptr)(unsafe.Pointer(&s1)) 获取的就是引用数组的指针,但是这个并不是数组起始位置的指针,而是 slice 引用数组元素的指针,为什么这么说呢?接着看 s2 变量下面的输出吧,s2 := arr[2:8] 引用数组第3~8的元素,那么 s2 的长度就是 6。 根据经验可以知道 s2 变量输出下面第3行就是 slice 的长度,但是为啥第4行是 8 呢,slice 应用数组的指定索引起始位置到数组结尾就是 slice 的容量, 所以 所以从第3个位置到末尾,就是8个容量。在看第1行和第2行的输出,之前分析数组的时候通过 uintptr(unsafe.Pointer(&arr[0]))+size2 来获取数组指定索引位置的指针,那么这段第2行就是数组索引为2的元素指针,(*uintptr)(unsafe.Pointer(&s2)) 是获取切片的指针,第1行和第2行输出一致,所以 slice 实际是引用数组元素位置的指针,并不是数组起始位置的指针。 总结:slice 是的起始位置是引用数组元素位置的指针。slice 的长度是引用数组元素起始位置到结束位置的长度。slice 的容量是引用数组元素起始位置到数组末尾的长度。经过上面一轮分析了解到 slice 有三个属性,引用数组元素位置指针、长度和容量。实际上 slice 的结构像下图一样:slice 增长slice 是如何增长的,用 unsafe 分析一下看看:package mainimport ( “fmt” “unsafe”)func main() { s := make([]int, 9, 10) // 引用底层的数组地址 fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s))) s = append(s, 1) // 引用底层的数组地址 fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s))) s = append(s, 1) // 引用底层的数组地址 fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s)))}以上代码的输出(Go Playground):c000082e90 9 10 c000082e90 10 10 c00009a00011 20从结果上看前两次地址是一样的,初始化一个长度为9,容量为10的 slice,当第一次 append 的时候容量是足够的,所以底层引用数组地址未发生变化,此时 slice 的长度和容量都为10,之后再次 append 的时候发现底层数组的地址不一样了,因为 slice 的长度超过了容量,但是新的 slice 容量并不是11而是20,这要说 slice 的机制了,因为数组长度不可变,想扩容 slice就必须分配一个更大的数组,并把之前的数据拷贝到新数组,如果一次只增加1个长度,那就会那发生大量的内存分配和数据拷贝,这个成本是很大的,所以 slice 是有一个增长策略的。Go 标准库 runtime/slice.go 当中有详细的 slice 增长策略的逻辑:func growslice(et *_type, old slice, cap int) slice { ….. // 计算新的容量,核心算法用来决定slice容量增长 newcap := old.cap doublecap := newcap + newcap if cap > doublecap { newcap = cap } else { if old.len < 1024 { newcap = doublecap } else { for 0 < newcap && newcap < cap { newcap += newcap / 4 } if newcap <= 0 { newcap = cap } } } // 根据et.size调整新的容量 var overflow bool var lenmem, newlenmem, capmem uintptr 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 = roundupsize(uintptr(newcap) * et.size) overflow = uintptr(newcap) > maxSliceCap(et.size) newcap = int(capmem / et.size) } …… var p unsafe.Pointer if et.kind&kindNoPointers != 0 { p = mallocgc(capmem, nil, false) // 分配新的内存 memmove(p, old.array, lenmem) // 拷贝数据 memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem) } else { p = mallocgc(capmem, et, true) // 分配新的内存 if !writeBarrier.enabled { memmove(p, old.array, lenmem) } else { for i := uintptr(0); i < lenmem; i += et.size { typedmemmove(et, add(p, i), add(old.array, i)) // 拷贝数据 } } } return slice{p, old.len, newcap} // 新slice引用新的数组,长度为旧数组的长度,容量为新数组的容量}基本呢就三个步骤,计算新的容量、分配新的数组、拷贝数据到新数组,社区很多人分享 slice 的增长方法,实际都不是很精确,因为大家只分析了计算 newcap 的那一段,也就是上面注释的第一部分,下面的 switch 根据 et.size 来调整 newcap 一段被直接忽略,社区的结论是:“如果 selic 的容量小于1024个元素,那么扩容的时候 slice 的 cap 就翻番,乘以2;一旦元素个数超过1024个元素,增长因子就变成1.25,即每次增加原来容量的四分之一” 大多数情况也确实如此,但是根据 newcap 的计算规则,如果新的容量超过旧的容量2倍时会直接按新的容量分配,真的是这样吗?package mainimport ( “fmt”)func main() { s := make([]int, 10, 10) fmt.Println(len(s), cap(s)) s2 := make([]int, 40) s = append(s, s2…) fmt.Println(len(s), cap(s))}以上代码的输出(Go Playground):10 1050 52这个结果有点出人意料, 如果是2倍增长应该是 10 * 2 * 2 * 2 结果应该是80, 如果说新的容量高于旧容量的两倍但结果也不是50,实际上 newcap 的结果就是50,那段逻辑很好理解,但是switch 根据 et.size 来调整 newcap 后就是52了,这段逻辑走到了 case et.size == sys.PtrSize 这段,详细的以后做源码分析再说。 总结 当 slice 的长度超过其容量,会分配新的数组,并把旧数组上的值拷贝到新的数组逐个元素添加到 slice 并操过其容量, 如果 selic 的容量小于1024个元素,那么扩容的时候 slice 的 cap 就翻番,乘以2;一旦元素个数超过1024个元素,增长因子就变成1.25,即每次增加原来容量的四分之一。批量添加元素,当新的容量高于旧容量的两倍,就会分配比新容量稍大一些,并不会按上面第二条的规则扩容。当 slice 发生扩容,引用新数组后,slice 操作不会再影响旧的数组,而是新的数组(社区经常讨论的传递 slice 容量超出后,修改数据不会作用到旧的数据上),所以往往设计函数如果会对长度调整都会返回新的 slice,例如 append 方法。slice 是引用类型?slice 不发生扩容,所有的修改都会作用在原数组上,那如果把 slice 传递给一个函数或者赋值给另一个变量会发生什么呢,slice 是引用类型,会有新的内存被分配吗。package mainimport ( “fmt” “strings” “unsafe”)func main() { s := make([]int, 10, 20) size := unsafe.Sizeof(0) fmt.Printf("%p\n", &s) fmt.Printf("%x\n", *(uintptr)(unsafe.Pointer(&s))) fmt.Println((int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size))) fmt.Println((int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size2))) slice(s) s1 := s fmt.Printf("%p\n", &s1) fmt.Printf("%x\n", *(uintptr)(unsafe.Pointer(&s1))) fmt.Println((int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s1)) + size))) fmt.Println((int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s1)) + size2))) fmt.Println(strings.Repeat("-", 50)) *(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s1)) + size)) = 20 fmt.Printf("%x\n", *(uintptr)(unsafe.Pointer(&s))) fmt.Println((int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size))) fmt.Println((int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size2))) fmt.Printf("%x\n", *(uintptr)(unsafe.Pointer(&s1))) fmt.Println((int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s1)) + size))) fmt.Println((int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s1)) + size2))) fmt.Println(s) fmt.Println(s1) fmt.Println(strings.Repeat("-", 50)) s2 := s s2 = append(s2, 1) fmt.Println(len(s), cap(s), s) fmt.Println(len(s1), cap(s1), s1) fmt.Println(len(s2), cap(s2), s2)}func slice(s []int) { size := unsafe.Sizeof(0) fmt.Printf("%p\n", &s) fmt.Printf("%x\n", *(uintptr)(unsafe.Pointer(&s))) fmt.Println((int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size))) fmt.Println((int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size2)))}这个例子(Go Playground)比较长就不逐一分析了,在这个例子里面调用函数传递 slice 其变量的地址发生了变化, 但是引用数组的地址,slice 的长度和容量都没有变化, 这说明是对 slice 的浅拷贝,拷贝 slice 的三个属性创建一个新的变量,虽然引用底层数组还是一个,但是变量并不是一个。第二个创建 s1 变量,使用 s 为其赋值,发现 s1 和函数调用一样也是 s 的浅拷贝,之后修改 s1 的长度发现 s1 的长度发生变化,但是 s 的长度保持不变, 这也说明 s1 就是 s 的浅拷贝。这样设计有什么优势呢,第三步创建 s2 变量, 并且 append 一个元素, 发现 s2 的长度发生变化了, s 并没有,虽然这个数据就在底层数组上,但是用常规的方法 s 是看不到第11个位置上的数据的, s1 因为长度覆盖到第11个元素,所有能够看到这个数据的变化。这里能看到采用浅拷贝的方式可以使得切片的属性各自独立,而不会相互影响,这样可以有一定的隔离性,缺点也很明显,如果两个变量都引用同一个数组,同时 append, 在不发生扩容的情况下,总是最后一个 append 的结果被保留,可能引起一些编程上疑惑。 总结 slice 是引用类型,但是和 C 传引用是有区别的, C 里面的传引用是在编译器对原变量数据引用, 并不会发生内存分配,而 Go 里面的引用类型传递和赋值会进行浅拷贝,在32位平台上有12个字节的内存分配, 在64位上有24字节的内存分配。 传引用和引用类型是有区别的, slice 是引用类型。slice 的三种状态slice 有三种状态:零切片、空切片、nil切片。零切片所有的类型都有零值,如果 slice 所引用数组元素都没有赋值,就是所有元素都是类型零值,那这就是零切片。package mainimport “fmt"func main() { var s = make([]int, 10) fmt.Println(s) var s1 = make([]int, 10) fmt.Println(s1) var s2 = make([]string, 10) fmt.Println(s2)}以上代码输出(Go Playground):[0 0 0 0 0 0 0 0 0 0] [<nil> <nil> <nil> <nil> <nil> <nil> <nil> <nil> <nil> <nil>][ ]零切片很好理解,数组元素都为类型零值即为零切片,这种状态下的 slice 和正常的 slice 操作没有任何区别。空切片空切片可以理解就是切片的长度为0,就是说 slice 没有元素。 社区大多数解释空切片为引用底层数组为 zerobase 这个特殊的指针。但是从操作上看空切片所有的表现就是切片长度为0,如果容量也为零底层数组就会指向 zerobase ,这样就不会发生内存分配, 如果容量不会零就会指向底层数据,会有内存分配。package mainimport ( “fmt” “reflect” “strings” “unsafe”)func main() { var s []int s1 := make([]int, 0) s2 := make([]int, 0, 0) s3 := make([]int, 0, 100) arr := [10]int{} s4 := arr[:0] fmt.Println(strings.Repeat(”–s–", 10)) fmt.Println((reflect.SliceHeader)(unsafe.Pointer(&s))) fmt.Println(s) fmt.Println(s == nil) fmt.Println(strings.Repeat("–s1–", 10)) fmt.Println((reflect.SliceHeader)(unsafe.Pointer(&s1))) fmt.Println(s1) fmt.Println(s1 == nil) fmt.Println(strings.Repeat("–s2–", 10)) fmt.Println((reflect.SliceHeader)(unsafe.Pointer(&s2))) fmt.Println(s2) fmt.Println(s2 == nil) fmt.Println(strings.Repeat("–s3–", 10)) fmt.Println((reflect.SliceHeader)(unsafe.Pointer(&s3))) fmt.Println(s3) fmt.Println(s3 == nil) fmt.Println(strings.Repeat("–s4–", 10)) fmt.Println((*reflect.SliceHeader)(unsafe.Pointer(&s4))) fmt.Println(s4) fmt.Println(s4 == nil)}以上代码输出(Go Playground):–s—-s—-s—-s—-s—-s—-s—-s—-s—-s– {0 0 0} [] –s1—-s1—-s1—-s1—-s1—-s1—-s1—-s1—-s1—-s1– {18349960 0 0} [] –s2—-s2—-s2—-s2—-s2—-s2—-s2—-s2—-s2—-s2– {18349960 0 0} [] –s3—-s3—-s3—-s3—-s3—-s3—-s3—-s3—-s3—-s3– {824634269696 0 100} [] –s4—-s4—-s4—-s4—-s4—-s4—-s4—-s4—-s4—-s4– {824633835680 0 10}[]以上示例中除了 s 其它的 slice 都是空切片,打印出来全部都是 [],s 是nil切片下一小节说。要注意 s1 和 s2 的长度和容量都为0,且引用数组指针都是 18349960, 这点太重要了,因为他们都指向 zerobase 这个特殊的指针,是没有内存分配的。nil切片什么是nil切片,这个名字说明nil切片没有引用任何底层数组,底层数组的地址为nil就是nil切片。上一小节中的 s 就是一个nil切片,它的底层数组指针为0,代表是一个 nil 指针。总结零切片就是其元素值都是元素类型的零值的切片。空切片就是数组指针不为nil,且 slice 的长度为0。nil切片就是引用底层数组指针为 nil 的 slice。操作上零切片、空切片和正常的切片都没有任何区别,但是nil切片会多两个特性,一个nil切片等于 nil 值,且进行 json 序列化时其值为 null,nil切片还可以通过赋值为 nil 获得。数组与 slice 大比拼对数组和 slice 做了性能测试,源码在 GitHub。对不同容量和数组和切片做性能测试,代码如下,分为:100、1000、10000、100000、1000000、10000000func BenchmarkSlice100(b *testing.B) { for i := 0; i < b.N; i++ { s := make([]int, 100) for i, v := range s { s[i] = 1 + i _ = v } }}func BenchmarkArray100(b *testing.B) { for i := 0; i < b.N; i++ { a := [100]int{} for i, v := range a { a[i] = 1 + i _ = v } }}测试结果如下:goos: darwin goarch: amd64 pkg: github.com/thinkeridea/example/array_slice/test BenchmarkSlice100-8 20000000 69.8 ns/op 0 B/op 0 allocs/op BenchmarkArray100-8 20000000 69.0 ns/op 0 B/op 0 allocs/op BenchmarkSlice1000-8 5000000 318 ns/op 0 B/op 0 allocs/op BenchmarkArray1000-8 5000000 316 ns/op 0 B/op 0 allocs/op BenchmarkSlice10000-8 200000 9024 ns/op 81920 B/op 1 allocs/op BenchmarkArray10000-8 500000 3143 ns/op 0 B/op 0 allocs/op BenchmarkSlice100000-8 10000 114398 ns/op 802816 B/op 1 allocs/op BenchmarkArray100000-8 20000 61856 ns/op 0 B/op 0 allocs/op BenchmarkSlice1000000-8 2000 927946 ns/op 8003584 B/op 1 allocs/op BenchmarkArray1000000-8 5000 342442 ns/op 0 B/op 0 allocs/op BenchmarkSlice10000000-8 100 10555770 ns/op 80003072 B/op 1 allocs/op BenchmarkArray10000000-8 50 22918998 ns/op 80003072 B/op 1 allocs/op PASSok github.com/thinkeridea/example/array_slice/test 23.333s从上面的结果可以发现数组和 slice 在1000以内的容量上时性能机会一致,而且都没有内存分配,这应该是编译器对 slice 的特殊优化。从100001000000容量时数组的效率就比slice好了一倍有余,主要原因是数组在没有内存分配做了编译优化,而 slice 有内存分配。但是10000000容量往后数组性能大幅度下降,slice 是数组性能的两倍,两个都在运行时做了内存分配,其实这么大的数组还真是不常见,也没有比较做编译器优化了。slice 与数组的应用场景总结slice 和数组有些差别,特别是应用层上,特性差别很大,那什么时间使用数组,什么时间使用切片呢。之前做了性能测试,在1000以内性能几乎一致,只有100001000000时才会出现数组性能好于 slice,由于数组在编译时确定长度,也就是再编写程序时必须确认长度,所有往常不会用到更大的数组,大多数都在1000以内的长度。我认为如果在编写程序是就已经确定数据长度,建议用数组,而且竟可能是局部使用的位置建议用数组(避免传递产生值拷贝),比如一天24小时,一小时60分钟,ip是4个 byte这种情况是可以用时数组的。为什么推荐用数组,只要能在编写程序是确定数据长度我都会用数组,因为其类型会帮助阅读理解程序,dayHour := [24]Data 一眼就知道是按小时切分数据存储的,如要传递数组时可以考虑传递数组的指针,当然会带来一些操作不方便,往常我使用数组都是不需要传递给其它函数的,可能会在 struct 里面保存数组,然后传递 struct 的指针,或者用 unsafe 来反解析数组指针到新的数组,也不会产生数据拷贝,并且只增加一句转换语句。slice 会比数组多存储三个 int 的属性,而且指针引用会增加 GC 扫描的成本,每次传递都会对这三个属性进行拷贝,如果可以也可以考虑传递 slice 的指针,指针只有一个 int 的大小。 对于不确定大小的数据只能用 slice,否则就要自己做扩容很麻烦, 对于确定大小的集合建议使用数组。转载:本文作者: 戚银(thinkeridea)本文链接: https://blog.thinkeridea.com/…版权声明: 本博客所有文章除特别声明外,均采用 CC BY 4.0 CN协议 许可协议。转载请注明出处! ...

January 13, 2019 · 7 min · jiezi