译文 Go 常见错误集锦之切片使用不当会造成内存泄漏的那些场景

yudotyang · 2021年10月13日 · 183 次阅读

大家好,我是 Go 学堂的渔夫子。本文是对《100 Go Mistackes:How to Avoid Them》一书的翻译。因翻译水平有限,难免存在翻译准确性问题,敬请谅解。

在某些情况下,对一个已存在的切片或数组进行切分操作可能会导致内存泄漏。本文我们将介绍导致内存泄漏的场景以及如何避免内存泄漏。

01 因切片容量而导致内存泄漏

假设我们有一个二进制的协议。该协议使用前 5 个字节标识消息类型。我们基于该协议接收一个很大的消息,同时我们会将最近收到的 1000 条消息的类型存储在内存中,即存储在一个切片中(例如,出于校验目的)。我们的函数的大体结构如下:

func consumeMessages() {
    for {
        msg := receiveMessage() 
        storeMessageType(getMessageType(msg)) 
        // Do something with msg
    }
}

func getMessageType(msg []byte) []byte { 
    return msg[:5]
}

① 接收一个 [] byte 消息并赋值给 msg 变量

② 存储消息类型

③ 通过切分 msg 切片计算消息类型

storeMessageType 函数将存储最近的 1000 条消息的消息类型(1000 个字节类型的切片)。然后测试该实现,功能正常。然后,我们将其部署到生产环境下,然而我们观察到在生产环境的大流量下会消耗很大的内存。这是为什么呢?

当我们使用 msg[:5] 对 msg 进行切分操作时,实际上是创建了一个长度为 5 的新切片。因为新切片和原切片共享同一个底层数据。所以它的容量依然是跟源切片 msg 的容量一样。即使实际的 msg 不再被引用,但剩余的元素依然在内存中。下图演示了一个 msg 接收了一个拥有 100 万个元素的示例:

正如我们在图中看到的,下一次迭代后,虽然 msg 被重新赋值了,但原来的切片的底层数组依然是 100 万字节。虽然我们只想存储每个消息的前 5 字节代表的消息类型(即 5*1000 个字节),但同时我们将每条消息的整个容量的数据也存储在了内存中。

那么,我们该如何解决呢?最简单的方法就是在 getMessageType 函数内部将消息类型拷贝到一个新的切片上,来替代对 msg 进行切分:

func getMessageType(msg []byte) []byte {
    msgType := make([]byte, 5)
    copy(msgType, msg)
    return msgType
}

msgType 是一个 5 字节的切片。该实现是通过内建函数 copy 将元素复制到目标切片中。因为该函数只拷贝 min(len(dst), len(src)) 个元素到目标切片中。同时新切片的容量又等于切片长度。因此,无论接收到的消息是多少大,我们只存储了 5 个元素 **。

总之,在我们刚才的应用程序中,对一个已存在的切片或数组进行切分,本质上是创建了一个底层数组和源切片一样大小的新的切片,从而导致了高内存消耗。使用内建的 copy 函数,可以按实际需要控制消耗的内存。

02 因指针类型导致内存泄露

在上一节我们了解到,对一个已有的切片进行切分操作,由于新切分的切片的容量和原有的切片的容量是一样的,所以原有的元素依然存储在内存中。

那么,在内存中元素会被 GC 回收吗?

下面的示例函数 keepFirstElementOnly 是只返回切片的第一个元素:

func keepFirstElementOnly(ids []string) []string {
    return string[:1]
}

如果我们传递给 keepFirstElementOnly 函数一个有 100 个字符串的切片,那么,剩下的 99 个字符串会被 GC 回收吗?在该例子中是会被回收的。容量将保持为 100 个元素,但会收集剩余的 99 个字符串将减少所消耗的内存。

现在,我们通过指针的方式传递元素,看看会发生什么:

func keepFirstElementOnly(ids []*string) []*string {
    return customers[:1]
}

现在剩余的 99 个元素还会被 GC 回收吗?在该示例中是不可以的。

规则如下:若切片的元素类型是指针或带指针字段的结构体,那么元素将不会被 GC 回收。如果我们想返回一个容量为 1 的切片,我们可以使用 copy 函数或使用满切片表达式(s[:1:1])。另外,如果我们想保持容量,则需要将剩余的元素填充为 nil:

func keepFirstElementOnly(ids []*string) []*string {
    for i := 1; i < len(ids); i++ {
        ids[i] = nil
    }
    return ids[:1]
}

对于剩余所有的元素,我们手动的填充为 nil。在本示例中,我们会返回一个具有和输入参数切片的容量大小一致的切片,但剩下的 *string 类型的元素会被 GC 自动回收。

小结

本节中,我们看到了两种潜在的内存泄露问题。第一种是关于在已有的切片或数组上进行切分操作而保留了原有切片的容量大小导致内存泄露。如果我们在一个大的切片上只切分出一个小的切片,那么大量内存将会保持分配状态但没有得到应用。 第二种是当我们在切分一个元素类型为指针类型的切片或切片的类型是含有指针字段的结构体时,GC 不会自动回收这些元素。在我们列举的例子中,我们通过将剩余元素手动置为 nil 已达到自动回收的目的。

更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio
Orichalcum GoCN 每日新闻 (2021-10-14) 中提及了此贴 10月14日 11:16
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册