• Go map发生内存泄漏解决方法


    正文

    Go 程序运行时,有些场景下会导致进程进入某个“高点”,然后就再也下不来了。

    比如,多年前曹大写过的一篇文章讲过,在做活动时线上涌入的大流量把 goroutine 数抬升了不少,流量恢复之后 goroutine 数也没降下来,导致 GC 的压力升高,总体的 CPU 消耗也较平时上升了 2 个点左右。

    有一个 issue 讨论为什么 allgs(runtime 中存储所有 goroutine 的一个全局 slice) 不收缩,一个好处是:goroutine 复用,让 goroutine 的创建更加得便利,而这也正是 Go 语言的一大优势。

    最近在看《100 mistakes》,书里专门有一节讲 map 的内存泄漏。其实这也是另一个在经历大流量后,无法“恢复”的例子:map 占用的内存“只增不减”。

    之前写过的一篇《深度解密 Go 语言之 map》里讲到过 map 的内部数据结构,并且分析过创建、遍历、删除的过程。

    在 Go runtime 层,map 是一个指向 hmap 结构体的指针,hmap 里有一个字段 B,它决定了 map 能存放的元素个数。

    hamp 结构体代码

    1

    2

    3

    4

    5

    6

    type hmap struct {

        count     int

        flags     uint8

        B         uint8

        // ...

    }

    若我们想初始化一个长度为 100w 元素的 map,B 是多少呢?

    用 B 可以计算 map 的元素个数:loadfactor * 2^B,loadfactor 目前是 6.5,当 B=17 时,可放 851,968 个元素;当 B=18,可放 1,703,936 个元素。因此当我们将 map 的长度初始化为 100w 时,B 的值应是 18。

    loadfactor 是装载因子,用来衡量平均一个 bucket 里有多少个 key。

    查看占用的内存数量

    如何查看占用的内存数量呢?用 runtime.MemStats:

    1

    2

    3

    4

    5

    6

    7

    8

    9

    10

    11

    12

    13

    14

    15

    16

    17

    18

    19

    20

    21

    22

    23

    24

    25

    26

    27

    28

    29

    package main

    import (

        "fmt"

        "runtime"

    )

    const N = 128

    func randBytes() [N]byte {

        return [N]byte{}

    }

    func printAlloc() {

        var m runtime.MemStats

        runtime.ReadMemStats(&m)

        fmt.Printf("%d MB\n", m.Alloc/1024/1024)

    }

    func main() {

        n := 1_000_000

        m := make(map[int][N]byte, 0)

        printAlloc()

        for i := 0; i < n; i++ {

            m[i] = randBytes()

        }

        printAlloc()

        for i := 0; i < n; i++ {

            delete(m, i)

        }

        runtime.GC()

        printAlloc()

        runtime.KeepAlive(m)

    }

    如果不加最后的 KeepAlive,m 会被回收掉。

    当 N = 128 时,运行程序:

    1

    2

    3

    4

    $ go run main2.go

    0 MB

    461 MB

    293 MB

    可以看到,当删除了所有 kv 后,内存占用依然有 293 MB,这实际上是创建长度为 100w 的 map 所消耗的内存大小。当我们创建一个初始长度为 100w 的 map:

    1

    2

    3

    4

    5

    6

    7

    8

    9

    10

    11

    12

    13

    14

    15

    16

    17

    package main

    import (

        "fmt"

        "runtime"

    )

    const N = 128

    func printAlloc() {

        var m runtime.MemStats

        runtime.ReadMemStats(&m)

        fmt.Printf("%d MB\n", m.Alloc/1024/1024)

    }

    func main() {

        n := 1_000_000

        m := make(map[int][N]byte, n)

        printAlloc()

        runtime.KeepAlive(m)

    }

    运行程序,得到 100w 长度的 map 的消耗的内存为:

    1

    2

    $ go run main3.go

    293 MB

    这时有一个疑惑,为什么在向 map 写入了 100w 个 kv 之后,占用内存变成了 461MB?

    我们知道,当 val 大小 <= 128B 时,val 其实是直接放在 bucket 里的,按理说,写入 kv 与否,这些 bucket 占用的内存都在那里。换句话说,写入 kv 之后,占用的内存应该还是 293MB,实际上却是 461MB。

    这里的原因其实是在写入 100w kv 期间 map 发生了扩容,buckets 进行了搬迁。我们可以用 hack 的方式打印出 B 值:

    1

    2

    3

    4

    5

    6

    7

    8

    9

    10

    11

    12

    13

    14

    func main() {

        //...

        var B uint8

        for i := 0; i < n; i++ {

            curB := *(*uint8)(unsafe.Pointer(uintptr(unsafe.Pointer(*(**int)(unsafe.Pointer(&m)))) + 9))

            if B != curB {

                fmt.Println(curB)

                B = curB

            }

            m[i] = randBytes()

        }

        //...

        runtime.KeepAlive(m)

    }

    运行程序,B 值从 1 一直变到 18。搬迁的过程可以参考前面提到的那篇 map 文章,这里不再赘述。

    而如果我们初始化的时候直接将 map 的长度指定为 100w,那内存变化情况为:

    1

    2

    3

    293 MB

    293 MB

    293 MB

    当 val 小于 128B 时,初始化 map 后内存占用量一直不变。原因是 put 操作只是在 bucket 里原地写入 val,而 delete 操作则是将 val 清零,bucket 本身还在。因此,内存占用大小不变。

    而当 val 大小超过 128B 后,bucket 不会直接放 val,转而变成一个指针。我们将 N 设为 129,运行程序:

    1

    2

    3

    0 MB

    197 MB

    38 MB

    虽然 map 的 bucket 占用内存量依然存在,但 val 改成指针存储后内存占用量大大降低。且 val 被删掉后,内存占用量确实降低了。

    总之,map 的 buckets 数只会增,不会降。所以在流量冲击后,map 的 buckets 数增长到一定值,之后即使把元素都删了也无济于事。内存占用还是在,因为 buckets 占用的内存不会少。

    对于 map 内存泄漏的解法

    • 重启;
    • 将 val 类型改成指针;
    • 定期地将 map 里的元素全量拷贝到另一个 map 里。

    好在一般有大流量冲击的互联网业务大都是 toC 场景,上线频率非常高。有的公司能一天上线好几次,在问题暴露之前就已经重启恢复了,问题不大。

  • 相关阅读:
    (1)(1.16) Maxbotix I2C声纳
    Linux commands: du and the options you should be using
    [架构之路-242]:目标系统 - 纵向分层 - 应用程序的类型与演进过程(单机应用程序、网络应用程序、分布式应用程序、云端应用程序、云原生应用程序)
    Linux安装Docker
    在列表末尾追加元素的方法myList.append()
    Windows10安装Ubuntu子系统及子系统安装openjdk8
    Linux实用操作(固定IP、进程控制、监控、文件解压缩)
    深度探索Copilot插件
    工厂方法(Factory Methods),抽象工厂(Abstract Factory )
    结构指针的使用
  • 原文地址:https://blog.csdn.net/sinat_40572875/article/details/127883250