pprof-内存-深入

160 阅读7分钟

case

最近在看内存泄漏的例子。

下面的代码,是一个博客说s0 = strings.Clone(s1[:50])替代s0 = s1[:50]可以避免内存泄漏。

var s0 string // 包级变量

// 模拟内存泄漏的函数
func f(s1 string) {
   //s0 = s1[:50]
   s0 = strings.Clone(s1[:50]) // NOTE 创建一个新的独立内存块。函数内部用make分配的
   // 现在,s0 和 s1 共享相同的底层内存块
   // 即使 s1 不再使用,s0 仍然引用这个内存块
   // 因此,整个内存块无法被垃圾回收
}

用runtime.ReadMemStats获取的Alloc可以简单的验证确实没有了内存泄漏。 但是我想看下之前内存分配的情况,以及修改后内存分配的情况。

下面的代码是单独验证strings.Clone的内存分配的。

在这个过程中,我遇到了很多问题,涉及几个知识点,我这里将其记录下来。

func main() {
   // 设置堆内存采样频率为每次分配都采样
   runtime.MemProfileRate = 1
   // 创建内存 profile 文件
   f, err := os.Create("mem.prof")
   if err != nil {
      fmt.Println("无法创建内存 profile 文件:", err)
      return
   }
   defer f.Close()

   // 打印初始内存使用情况
   printMemUsage("Initial")

   // 创建一个 1 MiB 的字符串
   s1 := string(make([]byte, 1<<20)) // 1 MiB

   // 打印分配 1 MiB 字符串后的内存使用情况
   printMemUsage("After s1 allocation")

   // 调用 strings.Clone(s1[:50])
   for i := 0; i < 2000; i++ {
      s0 := strings.Clone(s1[:500]) // NOTE go build -gcflags="-m -N -l" -o main clone分配了多少内存.go
      // 防止 s0 被优化掉
      _ = s0
      // 防止 s0 被优化掉
      // runtime.KeepAlive(s0)
   }

   // 打印调用 strings.Clone 后的内存使用情况
   printMemUsage("After strings.Clone")

   // 强制进行垃圾回收
   fmt.Println("第1次强制GC")
   runtime.GC()
   time.Sleep(100 * time.Millisecond) // 给GC时间执行

   // 打印垃圾回收后的内存使用情况
   printMemUsage("After GC")

   // 写入内存 profile
   err = pprof.WriteHeapProfile(f)
   if err != nil {
      return
   }
}

func printMemUsage(phase string) {
   var m runtime.MemStats
   runtime.ReadMemStats(&m)
   fmt.Printf("[%s] Alloc = %v MiB", phase, bToMb(m.Alloc))
   fmt.Printf("\tTotalAlloc = %v MiB", bToMb(m.TotalAlloc))
   fmt.Printf("\tSys = %v MiB", bToMb(m.Sys))
   fmt.Printf("\tHeapReleased = %v MiB", bToMb(m.HeapReleased))
   fmt.Printf("\tNumGC = %v\n", m.NumGC)
}

func bToMb(b uint64) uint64 {
   return b / 1024 / 1024
}

问题1:内存调用图看不到strings.Clone

几个原因

  1. pprof 的堆内存分析只采集堆上的内存分配,不会采集栈上的内存分配。

    所以用逃逸分析看下关注的变量(strings.Clone内部b := make([]byte, len(s))`)有没有逃逸到堆。没在堆上则采样不到,也就不会在pprof调用图中。

  2. 内联函数导致采样内存分配被算入父函数:

    strings.Clone被内联到main函数,采样的时候都算main函数的。

    解决: 编译时禁用内联-l和优化-N,同时-m打印逃逸分析信息: go build -gcflags="-m -N -l" -o main xx.go

  3. 开关-N,-l可能会影响逃逸分析。反正我发现strings.Clone内部b := make([]byte, len(s))会在启用内联和优化时逃逸,禁用时不逃逸。

  4. 采集频率和触发采样的内存阈值大于你观察变量的内存分配。

    • 内存分析的采样频率由 runtime.MemProfileRate 变量控制。
    • 默认情况下,内存采样频率是每分配 512 KB 内存采样一次。
    • 设置内存采样频率为每 1 KB 分配采样一次 runtime.MemProfileRate = 1024
    • 设置内存采样频率为每次分配都采样: runtime.MemProfileRate = 1
    • 基于上述知识点,所以我把strings.Clone(s1[:50])增大为strings.Clone(s1[:500]),这样每次分配(512KB)都可以被采样到方便观察。
  5. 内存调用链图。经过上面的调整。保证关注的变量会逃逸到heap,同时保证每次内存分配都会被采样。 理论上肯定可以看到在调用图中看到了吧。结果还是看不到,因为

    • 知识点: 即使所有内存分配都被记录在 pprof 文件中,图形显示时仍然会省略一些较小的分配。
    • pprof 工具提供了一个 -nodefraction 参数,用于控制节点(框)过滤的阈值。默认情况下,-nodefraction 的值为 0.005(即 0.5%),这意味着小于总内存 0.5% 的分配会被过滤掉。
    • pprof 工具还提供了一个 -edgefraction 参数,用于控制边的过滤阈值。默认情况下,-edgefraction 的值为 0.001(即 0.1%),这意味着小于总内存 0.1% 的边会被过滤掉。
    • 同时使用 -nodefraction-edgefraction 参数,以完全禁用过滤。go tool pprof -nodefraction=0 -edgefraction=0 heap.pprof使得所有内存分配和边都会被显示在图形中。
  6. 本地用web命令得到下图,图中链接提示你如何阅读生成的图形,以及若干命令。

    • image.png
    • 实线和虚线:表示直接调用和间接调用。
    • list过滤和weblist过滤
    • github.com/google/ppro… 这个有需要还可以继续研读,现在了解的基本够用了。
    • 这几个命令 image.png

最终的分析命令

  1. 打开heap.prof的命令是

    go tool pprof -nodefraction=0 -edgefraction=0 -alloc_space mem.prof 注意是alloc_space是自程序启动以来分配的总内存大小(字节),如果是usage_space会漏掉一些调用链,因为后者只表示当前堆上存活的内存大小(字节)。参考我上篇博客

  2. [可选操作] pprof命令行执行sample_index=inuse_space可以切换采样类型

  3. 执行web命令

    • main分配了2MB+1000KB(2000500=1000,000,估计有内存对齐是2000512=1024,000)正好1000KB。

    • 验证内存对齐,把循环改为1次或者去掉,可以看到单次就是分配了512B

    • 按照上篇博客的分析,每个根节点表示一个携程调用链,因为pprof采样是分携程采样的。图中有3个根节点。

    • 3048 kb of 3049.82,of之前数字表示当前main的分配,其余是子函数的内存分配。

    • image.png

  4. 执行list main或者weblist main命令在浏览器显示。 *

    • image.png

其他

s1 := string(make([]byte, 1<<20))为什么会分配2MB

图中 s1 := string(make([]byte, 1<<20)) 这一行代码显示分配了 2MB,而不是预期的 1MB,这是因为在 Go 语言中,字符串和字节切片之间的转换会涉及内存的复制。

详细解释:

  1. make([]byte, 1<<20) 这部分代码创建了一个 1MB (1 << 20 = 1048576 字节) 的字节切片。这块内存是实际存储字节数据的。
  2. string(...) 这部分代码将字节切片转换为字符串。在 Go 1.0 版本之后,string([]byte) 会创建一个新的字符串,并将字节切片的内容复制到新的字符串的底层数组中。这意味着,除了原始的字节切片占用的 1MB 内存外,还需要额外的 1MB 内存来存储新的字符串。

因此,总共分配了 2MB 的内存:1MB 用于字节切片,1MB 用于字符串。

内存布局示意图:

+-----------------+     +-----------------+
| 字节切片 (1MB)   |---->| 字符串 (1MB)     |
+-----------------+     +-----------------+
| 数据 (1MB)      |     | 数据 (1MB,复制) |
+-----------------+     +-----------------+

为什么需要复制?

Go 语言中的字符串是不可变的(immutable)。这意味着一旦创建,就不能修改字符串的内容。为了保证字符串的不可变性,string([]byte) 必须创建一个新的字符串,而不是直接使用原始的字节切片。如果直接使用原始的字节切片,那么修改字节切片的内容就会影响到字符串,这违反了字符串的不可变性原则。

如何避免复制?

在某些特定的场景下,如果你非常注重性能,并且可以接受一些限制,可以采用 unsafe 包来进行零拷贝转换。但是,这种方法需要非常小心,因为它绕过了 Go 语言的类型安全机制,可能会导致一些难以调试的问题。通常情况下,不建议使用这种方法,除非你对 Go 的内存模型和 unsafe 包非常熟悉。

以下是使用 unsafe 包进行零拷贝转换的示例(仅供参考,不建议在生产环境中使用):

Go

import (
    "reflect"
    "unsafe"
)

func BytesToString(b []byte) string {
    bh := (*reflect.SliceHeader)(unsafe.Pointer(&b))
    sh := reflect.StringHeader{
        Data: bh.Data,
        Len:  bh.Len,
    }
    return *(*string)(unsafe.Pointer(&sh))
}

func StringToBytes(s string) []byte {
    sh := (*reflect.StringHeader)(unsafe.Pointer(&s))
    bh := reflect.SliceHeader{
        Data: sh.Data,
        Len:  sh.Len,
        Cap:  sh.Len,
    }
    return *(*[]byte)(unsafe.Pointer(&bh))
}

func main() {
        b := make([]byte, 1<<20)
        s := BytesToString(b)
        // ...
        b2 := StringToBytes(s)
        // ...
}

总结:

s1 := string(make([]byte, 1<<20)) 分配了 2MB 内存的原因是字符串和字节切片之间的转换会进行内存复制,以保证字符串的不可变性。在大多数情况下,这种复制是必要的,也是值得的。如果确实需要避免复制,可以使用 unsafe 包,但需要谨慎使用。在你的代码中,显示分配 2MB 是符合预期的行为。