Go语言 从大型对象释放内存

laik7k3q  于 2023-06-03  发布在  Go
关注(0)|答案(1)|浏览(389)

我发现了一些我不明白的东西。希望大家能帮忙!
资源:

  1. https://medium.com/@chaewonkong/solving-memory-leak-issues-in-go-http-clients-ba0b04574a83
  2. https://www.golinuxcloud.com/golang-garbage-collector/
    我在几篇文章中读到这样的建议,即我们可以通过在不再需要大切片和Map(我猜这适用于所有引用类型)之后将它们设置为nil来简化GC的工作。下面是我读到的一个例子:
func ProcessResponse(resp *http.Response) error {
    data, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        return err
    }
    // Process data here

    data = nil // Release memory
    return nil
}

我的理解是,当函数ProcessResponse完成时,data变量将超出范围,基本上将不再存在。然后,GC将验证没有对[]byte片(data指向的片)的引用,并将清除内存。
data设置为nil如何改进垃圾收集?
谢谢!

j0pj023g

j0pj023g1#

正如其他人已经指出的那样:在返回之前设置data = nil不会改变GC方面的任何内容。go编译器会应用优化,golang的垃圾收集器在不同的阶段工作。用最简单的术语(有许多遗漏和过度简化):设置data = nil并删除对底层切片的所有引用不会触发不再被引用的内存的原子式释放。一旦切片不再被引用,它将被标记为这样,并且相关的内存将不会被释放,直到下一次扫描。
垃圾收集是一个很难的问题,很大程度上是因为它不是那种有最佳解决方案的问题,这种解决方案将为所有用例产生最佳结果。多年来,go运行时已经发展了很多,在运行时垃圾收集器上做了大量的工作。结果是,很少有情况下,一个简单的someVar = nil会产生很小的差异,更不用说明显的差异了。
如果你正在寻找一些简单的经验法则类型的提示,可以影响与垃圾收集(或一般的运行时内存管理)相关的运行时开销,我确实知道一个似乎被你的问题中的这句话模糊地涵盖了:
我建议通过设置大的切片和Map来简化GC的工作
在分析代码时,这可以产生明显的结果。假设你正在阅读一大块需要处理的数据,或者你必须执行一些其他类型的批处理操作并返回一个切片,看到人们这样写并不罕见:

func processStuff(input []someTypes) []resultTypes {
    data := []resultTypes{}
    for _, in := range input {
        data = append(data, processT(in))
    }
    return data
}

这可以很容易地通过将代码更改为以下内容来优化:

func processStuff(input []someTypes) []resultTypes {
    data := make([]resultTypes, 0, len(input)) // set cap
    for _, in := range input {
        data = append(data, processT(in))
    }
    return data
}

在第一个实现中,您创建了一个lencap为0的切片。第一次调用append时,您将超出切片的当前容量,这将导致运行时分配内存。如here所述,新容量的计算相当简单,分配内存并复制数据:

t := make([]byte, len(s), (cap(s)+1)*2)
copy(t, s)

从本质上讲,每次当你附加的切片已满时调用append(即len == cap),你将分配一个新的切片,可以容纳:(len + 1) * 2元素。在第一个例子中,datalencap == 0开始,让我们看看这意味着什么:

1st iteration: append creates slice with cap (0+1) *2, data is now len 1, cap 2
2nd iteration: append adds to data, now has len 2, cap 2
3rd iteration: append allocates a new slice with cap (2 + 1) *2, copies the 2 elements from data to this slice and adds the third, data is now reassigned to a slice with len 3, cap 6
4th-6th iterations: data grows to len 6, cap 6
7th iteration: same as 3rd iteration, although cap is (6 + 1) * 2, everything is copied over, data is reassigned a slice with len 7, cap 14

如果切片中的数据结构较大(即许多嵌套结构,许多间接等),那么这种频繁的重新分配和复制可能会变得非常昂贵。如果你的代码包含很多这样的循环,它 * 将 * 开始出现在pprof中(你将开始看到很多时间被花在调用gcmalloc上)。此外,如果你处理15个输入值,你的数据切片最终会看起来像这样:

dataSlice {
    len: 15
    cap: 30
    data underlying_array[30]
}

这意味着您将为30个值分配内存,而您只需要15个值,并且您将在4个越来越大的块中分配内存,每次realloc都复制数据。
相比之下,第二个实现将在循环之前分配一个看起来像这样的数据片:

data {
    len: 0
    cap: 15
    data underlying_array[15]
}

它是一次性分配的,因此不需要重新分配和复制,并且返回的切片将占用内存中一半的空间。从这个意义上说,我们从一开始就分配更大的内存,以减少以后所需的增量分配和复制调用的数量,这将总体上降低运行时成本。

如果我不知道需要多少内存怎么办

这个问题问得好这个例子并不总是适用。在本例中,我们知道需要多少元素,并且可以相应地分配内存。有时候,世界就是这样的。如果您不知道最终需要多少数据,那么您可以:

1.做一个有根据的猜测:GC是困难的,而且与您不同,编译器和go运行时缺乏模糊逻辑,人们必须提出一个现实的,合理的猜测。有时候会很简单:* “好吧,我从那个数据源获取数据,我们只存储最后N个元素,所以最坏的情况是,我将处理N个元素”*,有时它会更模糊,例如:您正在处理包含SKU、产品名称和库存计数的CSV。您知道SKU的长度,您可以假设库存数量是1到5位数之间的整数,产品名称平均长度为2-3个单词。英语单词的平均长度为6个字符,因此您可以大致了解CSV行有多少字节:比如SKU == 10个字符,80个字节,产品描述2.5 * 6 * 8 = 120个字节,以及用于库存计数的~4个字节+2个逗号和一个换行符,使得平均预期行长度为207个字节,为了谨慎起见,我们称其为200。统计输入文件,将其字节大小除以200,您应该对行数有一个可用的、稍微保守的估计。在代码末尾添加一些日志记录,将上限与估计值进行比较,您可以相应地调整预测计算。
1.分析您的代码。有时候你会发现自己在做一个新的特性,或者一个全新的项目,而你没有历史数据来进行猜测。在这种情况下,您可以简单地 * 猜测 *,运行一些测试场景,或者启动一个测试环境,为您的代码版本提供生产数据并分析代码。当你在为一个或两个切片/Map积极分析内存使用/运行时成本时,我必须强调这是优化。如果这是一个瓶颈或值得注意的问题(例如:运行时存储器分配阻碍了总体剖析)。在绝大多数情况下,这种优化水平将牢牢地落在微观优化的保护伞下。坚持80-20原则

回顾

不,在99%的情况下,将一个简单的slice变量设置为nil不会有太大的不同。在创建和追加Map/切片时,更有可能产生影响的是通过使用make()+指定一个合理的cap值来减少额外的分配。其他可以产生影响的事情是使用指针类型/接收器,尽管这是一个更复杂的主题。现在,我只想说,我一直在研究一个代码库,它必须对远远超出典型uint64范围的数字进行操作,不幸的是,我们必须能够以比float64更精确的方式使用小数。我们已经通过使用像holiman/uint256这样的东西解决了uint64问题,它使用指针接收器,并使用shopspring/decimal解决了十进制问题,它使用值接收器并复制所有内容。在花了大量时间优化代码之后,我们已经达到了使用小数时不断复制值的性能影响已经成为一个问题的地步。看看这些包如何实现简单的操作,如加法,并尝试找出哪个操作更昂贵:

// original
a, b := 1, 2
a += b
// uint256 version
a, b := uint256.NewUint(1), uint256.NewUint(2)
a.Add(a, b)
// decimal version
a, b := decimal.NewFromInt(1), decimal.NewFromInt(2)
a = a.Add(b)

在我最近的工作中,这些只是我花时间优化的几件事,但从中最重要的一件事是:

过早优化是万恶之源

当你在处理更复杂的问题/代码时,你需要花费大量的精力来寻找切片或Map的分配周期作为潜在的瓶颈和优化。你可以,也应该采取措施避免太浪费(例如:如果您知道所述切片的最终长度,则设置切片上限),但是您不应该浪费太多时间手工制作每一行,直到该代码的内存占用尽可能小。费用为:代码更脆弱/更难维护和阅读,潜在的整体性能恶化(说真的,你可以相信go运行时做得很好),大量的血,汗和眼泪,以及生产力的急剧下降。

相关问题