不懂如何减少内存分配
减少内存分配是Go应用程序的一个常见优化事项。本系列文章已介绍了不少减少堆上内存分配的方法:
- 优化字符串连接(Go语言中常见100问题-#39 Under-optimized string concatenation):使用strings.Builder而不是 操作符来连接字符串
- 避免无用的字符串转换(Go语言中常见100问题-#40 Useless string conversions):尽量避免将[]byte转成字符串
- 避免低效的切片和map初始化(Go语言中常见100问题-#22 Being confused about nil vs. empty slices):如果切片长度已知,采用预分策略
- 数据结构对齐减少结构大小(Go语言中常见100问题-#94 Not being aware of data alignment)。
本文将讨论三种减少内存分配的常用方法:
- 调整API
- 编译器优化
- 使用sync.Pool
调整API
提供良好的API接口,下面看一个具体例子,即 io.Reader接口。
代码语言:javascript复制type Reader interface {
Read(p []byte) (n int, err error)
}
Read方法接收一个切片并返回读取到的字节数。假如 io.Reader接口反过来设计:传递一个表示需要读取多少字节的参数int并返回一个切片。代码如下,从语义上来说,没有问题。但是在这种情况下,返回的切片会自动逃逸到堆中。如果由调用者提供切片,很有可能该切片在栈上分配,但不一定意味着它不会逃逸:具体情况编译器会判断。通过这种很小的变化,将决定权交给调用者,而不是由调用的 Read 方法来约束。有时这种微小的API变化也会对内存分配产生良好的影响。
编译器优化
Go编译器的目标之一是尽可能优化我们编写的代码,通过下面map例子说明。在Go语言中,不能使用切片作为map的key。在某些情况下,特别是涉及I/O的应用中,可能想将收到的[]byte数据作为key, 这时必须先将它转成一个字符串。具体代码如下:
代码语言:javascript复制type cache struct {
m map[string]int
}
func (c *cache) get(bytes []byte) (v int, contains bool) {
key := string(bytes)
v, contains = c.m[key]
return
}
然而,如果直接使用 string(bytes) 在map中查询,Go语言编译器会实现一个特定的优化:
代码语言:javascript复制func (c *cache) get(bytes []byte) (v int, contains bool) {
v, contains = c.m[string(bytes)]
return
}
通过小小调整,编译器会避免进行字节到字符串的转换,所以第二个版本比第一个要快。此外,通过上面的例子表明相似的代码可能导致Go编译器生成不同的汇编代码。我们应该多多了解类似的编译器优化,编写性能更好的程序。与此同时也要关注Go新版本,了解最新优化特性。
sync.Pool
如果想减少内存中对象的分配数量,一种处理方法是使用 sync.Pool. 注意 sync.Pool 不能当做缓存理解:没有可以设置的固定大小和最大容量。应该把它理解为一个重复使用的公共对象的池。假设想要实现一个 write 函数,接收一个 io.Writer入参,调用 getResponse 函数获得一个 []byte 切片,然后将切片数据写入到 io.Writer。实现代码如下(为了逻辑清晰,省略了错误处理)。
代码语言:javascript复制func write(w io.Writer) {
b := getResponse()
_, _ = w.Write(b)
}
每次调用 getResponse 函数都会返回一个新的 byte切片。如果想复用切片以达到减少切片分配该怎么做呢?假设getResponse函数返回的切片最大长度为 1024字节。这种情况下,我们可以使用大杀器 sync.Pool.
创建一个 sync.Pool 对象需要提供一个用于初始化的工厂函数: func() any。实例代码如下,这里提供的初始化函数会返回一个长度为1024字节的切片。在 write 函数中,尝试从sync.Pool对象池中获取一个字节切片,如果对象池为空,则调用New函数创建一个新的切片,否则会直接从对象池中取。注意关键一步操作是对 buffer = buffer[:0]
,因为获取的buffer存在之前残留的数据,所以需要重置掉。使用完 buffer后,调用 pool.Put将buffer归还到对象池。
var pool = sync.Pool{
New: func() any {
return make([]byte, 1024)
},
}
func write(w io.Writer) {
buffer := pool.Get().([]byte)
buffer = buffer[:0] // 重置缓冲区
defer pool.Put(buffer)
getResponse(buffer)
_, _ = w.Write(buffer)
}
sync.Pool对象对外提供两个方法:
- Get() any : 从对象池中返回一个对象
- Put(any) : 将用完的对象归还到对象池中
如上图所示,Get方法会从池子中拿一个对象给调用方,如果池子是空的,则调用New函数创建一个新的对象。使用完对象后,可以调用 Put 方法将对象归还到池子中。池子中的对象什么时候会被销毁呢?我们无法控制,完全由系统GC说了算,在两轮GC后对象会被系统回收。
可以看到第二版本的 write 函数可以减少[]byte创建,如果对象池中有直接取用,整体上会减少创建切片成本开销。在需要频繁分配对象场合,可以考虑使用sync.Pool,利用临时对象可以避免重复分配同类型的数据,并且sync.Pool可供多个goroutine同时使用,是并发安全的。