Go 中的垃圾收集和指针的正确使用

Garbage collection and correct usage of pointers in Go

我来自 Python/Ruby/JavaScript 背景。我了解指针的工作原理,但是,我不完全确定如何在以下情况下利用它们。

假设我们有一个虚构的网络 API 搜索一些图像数据库和 returns 一个 JSON 描述找到的每个图像中显示的内容:

[
    {
        "url": "https://c8.staticflickr.com/4/3707/11603200203_87810ddb43_o.jpg",
        "description": "Ocean islands",
        "tags": [
            {"name":"ocean", "rank":1},
            {"name":"water", "rank":2},
            {"name":"blue", "rank":3},
            {"name":"forest", "rank":4}
        ]
    },

    ...

    {
        "url": "https://c3.staticflickr.com/1/48/164626048_edeca27ed7_o.jpg",
        "description": "Bridge over river",
        "tags": [
            {"name":"bridge", "rank":1},
            {"name":"river", "rank":2},
            {"name":"water", "rank":3},
            {"name":"forest", "rank":4}
        ]
    }
]

我的目标是在 Go 中创建一个数据结构,将每个标签映射到图像列表 URLs,如下所示:

{
    "ocean": [
        "https://c8.staticflickr.com/4/3707/11603200203_87810ddb43_o.jpg"
    ],
    "water": [
        "https://c8.staticflickr.com/4/3707/11603200203_87810ddb43_o.jpg",
        "https://c3.staticflickr.com/1/48/164626048_edeca27ed7_o.jpg"
    ],
    "blue": [
        "https://c8.staticflickr.com/4/3707/11603200203_87810ddb43_o.jpg"
    ],
    "forest":[
        "https://c8.staticflickr.com/4/3707/11603200203_87810ddb43_o.jpg", 
        "https://c3.staticflickr.com/1/48/164626048_edeca27ed7_o.jpg"
    ],
    "bridge": [
        "https://c3.staticflickr.com/1/48/164626048_edeca27ed7_o.jpg"
    ],
    "river":[
        "https://c3.staticflickr.com/1/48/164626048_edeca27ed7_o.jpg"
    ]
}

如您所见,每张图片URL可以同时属于多个标签。如果我有数千张图片甚至更多标签,如果按每个标签的值复制图像 URL 字符串,则此数据结构会变得非常大。这是我想利用指针的地方。

我可以用 Go 中的两个结构来表示 JSON API 响应,func searchImages() 模仿假的 API:

package main

import "fmt"


type Image struct {
    URL string
    Description string
    Tags []*Tag
}

type Tag struct {
    Name string
    Rank int
}

// this function mimics json.NewDecoder(resp.Body).Decode(&parsedJSON)
func searchImages() []*Image {
    parsedJSON := []*Image{
        &Image {
            URL: "https://c8.staticflickr.com/4/3707/11603200203_87810ddb43_o.jpg",
            Description: "Ocean islands",
            Tags: []*Tag{
                &Tag{"ocean", 1},
                &Tag{"water", 2},
                &Tag{"blue", 3},
                &Tag{"forest", 4},
            }, 
        },
        &Image {
            URL: "https://c3.staticflickr.com/1/48/164626048_edeca27ed7_o.jpg",
            Description: "Bridge over river",
            Tags: []*Tag{
                &Tag{"bridge", 1},
                &Tag{"river", 2},
                &Tag{"water", 3},
                &Tag{"forest", 4},
            }, 
        },
    }
    return parsedJSON
}

现在导致内存中数据结构非常大的次优映射函数如下所示:

func main() {
    result := searchImages()

    tagToUrlMap := make(map[string][]string)

    for _, image := range result {
        for _, tag := range image.Tags {
            // fmt.Println(image.URL, tag.Name)
            tagToUrlMap[tag.Name] = append(tagToUrlMap[tag.Name], image.URL)
        }
    }

    fmt.Println(tagToUrlMap)
}

我可以修改它以使用指向 Image 结构 URL 字段的指针,而不是按值复制它:

    // Version 1

    tagToUrlMap := make(map[string][]*string)

    for _, image := range result {
        for _, tag := range image.Tags {
            // fmt.Println(image.URL, tag.Name)
            tagToUrlMap[tag.Name] = append(tagToUrlMap[tag.Name], &image.URL)
        }
    }

它有效,我的第一个问题是在我以这种方式构建映射后 result 数据结构发生了什么? Image URL 字符串字段会以某种方式留在内存中,而 result 的其余部分将被垃圾收集吗?或者 result 数据结构会一直保留在内存中直到程序结束,因为某些东西指向它的成员吗?

另一种方法是将 URL 复制到中间变量并使用指向它的指针:

    // Version 2

    tagToUrlMap := make(map[string][]*string)

    for _, image := range result {
        imageUrl = image.URL
        for _, tag := range image.Tags {
            // fmt.Println(image.URL, tag.Name)    
            tagToUrlMap[tag.Name] = append(tagToUrlMap[tag.Name], &imageUrl)
        }
    }

这样更好吗? result 数据结构会被正确地垃圾回收吗?

或者也许我应该在 Image 结构中使用指向字符串的指针?

type Image struct {
    URL *string
    Description string
    Tags []*Tag
}

有更好的方法吗?我也很感激 Go 上任何深入描述指针的各种用途的资源。谢谢!

https://play.golang.org/p/VcKWUYLIpH7

更新: 我担心最佳的内存消耗并且不会产生最多不需要的垃圾。我的目标是使用尽可能少的内存。

Will the [...] be garbage collected correctly?

是的。

您永远不必担心仍在使用的东西会被收集,一旦不再使用,您就可以依靠收集的所有东西。

所以关于GC的问题从来不是"Will it be collected correctly?"而是"Do I generate unnecessary garbage?"。现在,这个实际问题与数据 结构 的关系并不大,而是取决于(在堆上)创建的 neu 对象的数量。所以这是一个关于如何使用数据结构的问题,更不用说结构本身了。使用基准测试和 运行 使用 -benchmem 进行测试。

(高端性能可能还考虑了 GC 必须做多少工作:扫描指针可能需要时间。暂时忘记它。)

另一个相关问题是关于内存消耗。复制字符串只复制三个单词,而复制 *string 复制一个单词。所以在这里使用 *string.

没有太多安全问题

很遗憾,相关问题(产生的垃圾量和总内存消耗)没有明确的答案。不要想太多这个问题,使用适合你的目的,测量和重构。

前言:我在github.com/icza/gox library, see stringsx.Pool.

中发布了呈现的字符串池

首先介绍一下背景。 string Go 中的值由类似结构的小型数据结构表示 reflect.StringHeader:

type StringHeader struct {
        Data uintptr
        Len  int
}

所以基本上传递/复制一个 string 值传递/复制这个小结构值,无论 string 的长度如何,它只有 2 个字。在 64 位架构上,它只有 16 个字节,即使 string 有上千个字符。

所以基本上 string 值已经充当指针。引入另一个指针,如 *string 只会使使用复杂化,并且您不会真正获得任何显着的内存。为了内存优化,忘记使用 *string.

It works and my first question is what happens to the result data structure after I build the mapping in this way? Will the Image URL string fields be left in memory somehow and the rest of the result will be garbage collected? Or will the result data structure stay in memory until the end of the program because something points to its members?

如果你有一个指向结构值字段的指针值,那么整个结构将保存在内存中,它不能被垃圾回收。请注意,虽然可以释放为结构的其他字段保留的内存,但当前的 Go 运行时和垃圾收集器不会这样做。因此,为了实现最佳内存使用,您应该忘记存储结构字段的地址(除非您还需要完整的结构值,但仍然需要小心存储字段地址和 slice/array 元素地址)。

这是因为结构值的内存被分配为一个连续的段,因此只保留一个引用字段会强烈地碎片化可用/空闲内存,并使内存管理最佳化更难,效率更低。对这些区域进行碎片整理还需要复制引用字段的内存区域,这将需要 "live-changing" 指针值(更改内存地址)。

因此,虽然使用指向 string 值的指针可能会为您节省一些内存,但增加的复杂性和额外的间接寻址使它变得不值得。

那怎么办?

"Optimal"解法

所以最干净的方法是继续使用 string 个值。

还有一项我们之前没有谈到的优化。

您可以通过解组 JSON API 响应来获得结果。这意味着如果在 JSON 响应中多次包含相同的 URL 或标记值,将为它们创建不同的 string 值。

这是什么意思?如果在 JSON 响应中有两次相同的 URL,在解组之后,您将有 2 个不同的 string 值,它们将包含 2 个不同的指针,指向 2 个不同的分配字节序列(字符串内容否则将是相同的)。 encoding/json 包不做 string 实习.

这是一个证明这一点的小应用程序:

var s []string
err := json.Unmarshal([]byte(`["abc", "abc", "abc"]`), &s)
if err != nil {
    panic(err)
}

for i := range s {
    hdr := (*reflect.StringHeader)(unsafe.Pointer(&s[i]))
    fmt.Println(hdr.Data)
}

上面的输出(在Go Playground上试试):

273760312
273760315
273760320

我们看到了 3 个不同的指针。它们可以相同,因为 string 值是不可变的。

json 包不检测重复的 string 值,因为检测会增加内存和计算开销,这显然是不需要的。但在我们的例子中,我们追求最佳内存使用,因此 "initial",额外的计算确实值得获得大量内存。

那么让我们来做我们自己的字符串实习吧。怎么做?

解组 JSON 结果后,在构建 tagToUrlMap 映射期间,让我们跟踪遇到的 string 值,如果后续 string 值之前已经看过,只需使用那个较早的值(它的字符串描述符)。

这是一个非常简单的字符串内部实现:

var cache = map[string]string{}

func interned(s string) string {
    if s2, ok := cache[s]; ok {
        return s2
    }
    // New string, store it
    cache[s] = s
    return s
}

让我们在上面的示例代码中测试这个 "interner":

var s []string
err := json.Unmarshal([]byte(`["abc", "abc", "abc"]`), &s)
if err != nil {
    panic(err)
}

for i := range s {
    hdr := (*reflect.StringHeader)(unsafe.Pointer(&s[i]))
    fmt.Println(hdr.Data, s[i])
}

for i := range s {
    s[i] = interned(s[i])
}

for i := range s {
    hdr := (*reflect.StringHeader)(unsafe.Pointer(&s[i]))
    fmt.Println(hdr.Data, s[i])
}

上面的输出(在Go Playground上试试):

273760312 abc
273760315 abc
273760320 abc
273760312 abc
273760312 abc
273760312 abc

太棒了!正如我们所看到的,在使用我们的 interned() 函数之后,我们的数据结构中只使用了 "abc" 字符串的一个实例(这实际上是第一次出现)。这意味着所有其他实例(假设没有其他人使用它们)可以并且将会被正确地垃圾收集(由垃圾收集器,在未来的某个时间)。

这里不要忘记一件事:字符串内部使用 cache 字典存储所有以前遇到的字符串值。因此,要释放这些字符串,您也应该 "clear" 这个缓存映射,最简单的方法是为其分配一个 nil 值。

事不宜迟,让我们看看我们的解决方案:

result := searchImages()

tagToUrlMap := make(map[string][]string)

for _, image := range result {
    imageURL := interned(image.URL)

    for _, tag := range image.Tags {
        tagName := interned(tag.Name)
        tagToUrlMap[tagName] = append(tagToUrlMap[tagName], imageURL)
    }
}

// Clear the interner cache:
cache = nil

验证结果:

enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", "  ")
if err := enc.Encode(tagToUrlMap); err != nil {
    panic(err)
}

输出是(在 Go Playground 上尝试):

{
  "blue": [
    "https://c8.staticflickr.com/4/3707/11603200203_87810ddb43_o.jpg"
  ],
  "bridge": [
    "https://c3.staticflickr.com/1/48/164626048_edeca27ed7_o.jpg"
  ],
  "forest": [
    "https://c8.staticflickr.com/4/3707/11603200203_87810ddb43_o.jpg",
    "https://c3.staticflickr.com/1/48/164626048_edeca27ed7_o.jpg"
  ],
  "ocean": [
    "https://c8.staticflickr.com/4/3707/11603200203_87810ddb43_o.jpg"
  ],
  "river": [
    "https://c3.staticflickr.com/1/48/164626048_edeca27ed7_o.jpg"
  ],
  "water": [
    "https://c8.staticflickr.com/4/3707/11603200203_87810ddb43_o.jpg",
    "https://c3.staticflickr.com/1/48/164626048_edeca27ed7_o.jpg"
  ]
}

进一步的内存优化:

我们使用内置 append() 函数将新图像 URL 添加到标签。 append() 可能(并且通常确实)分配比需要更大的切片(考虑未来增长)。在我们的 "build" 过程之后,我们可以通过我们的 tagToUrlMap 地图和 "trim" 这些切片到所需的最小值。

可以这样做:

for tagName, urls := range tagToUrlMap {
    if cap(urls) > len(urls) {
        urls2 := make([]string, len(urls))
        copy(urls2, urls)
        tagToUrlMap[tagName] = urls2
    }
}