Go Wiki: 调试 Go 程序中的性能问题

– 最初由 Dmitry Vyukov 撰写

假设你有一个 Go 程序,并想提高它的性能。有几种工具可以帮助完成这项任务。这些工具可以帮助你识别各种类型的热点(CPU、IO、内存),热点是你需要重点关注以显著提高性能的地方。然而,也可能出现另一种结果——这些工具可以帮助你识别程序中明显的性能缺陷。例如,你在每次查询前准备一个 SQL 语句,而实际上你可以在程序启动时只准备一次。另一个例子是,如果一个 O(N^2) 的算法不知何故被用在了本应使用 O(N) 算法的地方。为了识别这种情况,你需要对配置文件中看到的内容进行健全性检查。例如,对于第一种情况,在 SQL 语句准备上花费大量时间将是一个危险信号。

了解性能的各种限制因素也很重要。例如,如果程序通过一个 100 Mbps 的网络链路通信,并且它已经占用了 >90Mbps,那么你对程序本身能做的来提高其性能的空间就很小了。对于磁盘 IO、内存消耗和计算任务也有类似的限制因素。考虑到这一点,我们可以来看看可用的工具。

注意:这些工具可能会相互干扰。例如,精确的内存剖析会扭曲 CPU 剖析,goroutine 阻塞剖析会影响调度器跟踪,等等。请单独使用这些工具以获得更精确的信息。

CPU 剖析器

Go 运行时包含内置的 CPU 剖析器,它可以显示哪些函数占用了多少百分比的 CPU 时间。有 3 种方法可以访问它:

  1. 最简单的方法是使用 `go test` 命令的 `-cpuprofile` 标志([https://pkg.go.dev/cmd/go/#hdr-Description_of_testing_flags](https://pkg.go.dev/cmd/go/#hdr-Description_of_testing_flags))。例如,以下命令

    $ go test -run=none -bench=ClientServerParallel4 -cpuprofile=cprof net/http
    

    将剖析给定的基准测试,并将 CPU 剖析结果写入 `cprof` 文件。然后

    $ go tool pprof --text http.test cprof
    

    将打印出最耗时的函数列表。

    有几种可用的输出类型,最有用的是:`--text`、`--web` 和 `--list`。运行 `go tool pprof` 以获取完整列表。此选项的明显缺点是它仅适用于测试。

  2. [`net/http/pprof`](https://pkg.go.dev/net/http/pprof): 这是网络服务器的理想解决方案。你只需导入 `net/http/pprof`,然后通过以下方式收集剖析数据:

    $ go tool pprof --text mybin  http://myserver:6060:/debug/pprof/profile
    
  3. 手动收集剖析数据。你需要导入 [`runtime/pprof`](https://pkg.go.dev/runtime/pprof/) 并将以下代码添加到 `main` 函数中:

    if *flagCpuprofile != "" {
        f, err := os.Create(*flagCpuprofile)
        if err != nil {
            log.Fatal(err)
        }
        pprof.StartCPUProfile(f)
        defer pprof.StopCPUProfile()
    }
    

    剖析结果将写入指定的文件,可视化方式与第一个选项相同。

这是使用 `--web` 选项可视化剖析结果的示例:[cpu_profile.png]

你可以使用 `--list=funcname` 选项来检查单个函数。例如,以下剖析结果显示时间花费在 `append` 函数上:

 .      .   93: func (bp *buffer) WriteRune(r rune) error {
 .      .   94:     if r < utf8.RuneSelf {
 5      5   95:         *bp = append(*bp, byte(r))
 .      .   96:         return nil
 .      .   97:     }
 .      .   98:
 .      .   99:     b := *bp
 .      .  100:     n := len(b)
 .      .  101:     for n+utf8.UTFMax > cap(b) {
 .      .  102:         b = append(b, 0)
 .      .  103:     }
 .      .  104:     w := utf8.EncodeRune(b[n:n+utf8.UTFMax], r)
 .      .  105:     *bp = b[:n+w]
 .      .  106:     return nil
 .      .  107: }

还有 3 个特殊的条目,当剖析器无法展开堆栈时会使用它们:`GC`、`System` 和 `ExternalCode`。`GC` 表示花费在垃圾回收上的时间,有关优化建议,请参阅下面的内存剖析器和垃圾回收器跟踪部分。`System` 表示花费在 goroutine 调度器、堆栈管理代码和其他辅助运行时代码上的时间。`ExternalCode` 表示花费在原生动态库上的时间。

以下是一些关于如何解释剖析结果的提示。

如果你看到大量时间花费在 `runtime.mallocgc` 函数上,则程序可能进行了过多的内存分配。剖析结果会告诉你分配的来源。有关优化此情况的建议,请参阅内存剖析器部分。

如果大量时间花费在通道操作、`sync.Mutex` 代码和其他同步原语或 `System` 组件上,则程序可能存在竞争。考虑重构程序以消除频繁访问的共享资源。常见的技术包括分片/分区、本地缓冲/批量处理和写时复制技术。

如果大量时间花费在 `syscall.Read/Write` 上,则程序可能进行了过多的少量读写操作。在这种情况下,可以使用 `bufio` 包装 `os.File` 或 `net.Conn`。

如果大量时间花费在 `GC` 组件上,则程序要么分配了太多临时对象,要么堆大小非常小,导致垃圾回收过于频繁。有关优化建议,请参阅垃圾回收器跟踪器和内存剖析器部分。

注意:对于 Darwin,CPU 剖析器目前仅在 El Capitan 或更新版本上可用([https://code.google.com/p/go/issues/detail?id=6047](https://code.google.com/p/go/issues/detail?id=6047))。

注意:在 Windows 上,你需要安装 Cygwin、Perl 和 Graphviz 才能生成 svg/web 剖析文件。

内存剖析器

内存剖析器显示哪些函数分配了堆内存。你可以通过与 CPU 剖析类似的方式收集它:使用 `go test --memprofile`([https://pkg.go.dev/cmd/go/#hdr-Description_of_testing_flags)`](https://pkg.go.dev/cmd/go/#hdr-Description_of_testing_flags))、通过 [`net/http/pprof`](https://pkg.go.dev/net/http/pprof) 访问 `http://myserver:6060:/debug/pprof/heap` 或调用 [`runtime/pprof.WriteHeapProfile`](https://pkg.go.dev/runtime/pprof/#WriteHeapProfile)。

你只能可视化剖析数据收集时存在的分配(`pprof` 的 `--inuse_space` 标志,默认),或者自程序启动以来发生的所有分配(`pprof` 的 `--alloc_space` 标志)。前者适用于使用 `net/http/pprof` 对正在运行的应用程序收集的剖析数据,后者适用于在程序结束时收集的剖析数据(否则你将看到几乎为空的剖析数据)。

注意:内存剖析器是采样式的,也就是说,它只收集部分内存分配的信息。对象被采样的概率与其大小成正比。你可以通过 `go test` 的 `--memprofilerate` 标志,或在程序启动时设置 `runtime.MemProfileRate` 变量来更改采样率。将速率设置为 1 将收集所有分配的信息,但这会减慢执行速度。默认采样率为每 512KB 分配内存进行一次采样。

你还可以可视化分配的字节数或分配的对象数(分别为 `--inuse/alloc_space` 和 `--inuse/alloc_objects` 标志)。剖析器在剖析时倾向于对较大的对象进行采样。但重要的是要理解,大对象会影响内存消耗和 GC 时间,而大量微小的分配会影响执行速度(在一定程度上也会影响 GC 时间)。因此,同时查看两者可能很有用。

对象可以是持久的或临时的。如果你在程序启动时分配了几个大的持久对象,它们很可能被剖析器采样(因为它们很大)。这些对象确实会影响内存消耗和 GC 时间,但它们不会影响正常的执行速度(不会对其进行内存管理操作)。另一方面,如果你有大量生命周期非常短的对象,它们可能在剖析中几乎没有体现(如果你使用默认的 `--inuse_space` 模式)。但它们确实会显著影响执行速度,因为它们在不断地分配和释放。所以,再次强调,同时查看这两种对象可能很有用。因此,通常情况下,如果你想减少内存消耗,你需要查看在正常程序运行期间收集的 `--inuse_space` 剖析数据。如果你想提高执行速度,请查看在显著运行一段时间后或程序结束时收集的 `--alloc_objects` 剖析数据。

有几个标志控制报告的粒度。`--functions` 使 pprof 在函数级别报告(默认)。`--lines` 使 pprof 在源代码行级别报告,这在热函数在不同行上分配时很有用。还有 `--addresses` 和 `--files` 分别用于精确的指令地址和文件级别。

内存剖析有一个有用的选项——你可以在浏览器中直接查看它(前提是你导入了 `net/http/pprof`)。如果你打开 `http://myserver:6060/debug/pprof/heap?debug=1`,你应该会看到类似以下的堆剖析数据:

heap profile: 4: 266528 [123: 11284472] @ heap/1048576
1: 262144 [4: 376832] @ 0x28d9f 0x2a201 0x2a28a 0x2624d 0x26188 0x94ca3 0x94a0b 0x17add6 0x17ae9f 0x1069d3 0xfe911 0xf0a3e 0xf0d22 0x21a70
#   0x2a201 cnew+0xc1   runtime/malloc.goc:718
#   0x2a28a runtime.cnewarray+0x3a          runtime/malloc.goc:731
#   0x2624d makeslice1+0x4d             runtime/slice.c:57
#   0x26188 runtime.makeslice+0x98          runtime/slice.c:38
#   0x94ca3 bytes.makeSlice+0x63            bytes/buffer.go:191
#   0x94a0b bytes.(*Buffer).ReadFrom+0xcb       bytes/buffer.go:163
#   0x17add6    io/ioutil.readAll+0x156         io/ioutil/ioutil.go:32
#   0x17ae9f    io/ioutil.ReadAll+0x3f          io/ioutil/ioutil.go:41
#   0x1069d3    godoc/vfs.ReadFile+0x133            godoc/vfs/vfs.go:44
#   0xfe911 godoc.func·023+0x471            godoc/meta.go:80
#   0xf0a3e godoc.(*Corpus).updateMetadata+0x9e     godoc/meta.go:101
#   0xf0d22 godoc.(*Corpus).refreshMetadataLoop+0x42    godoc/meta.go:141

2: 4096 [2: 4096] @ 0x28d9f 0x29059 0x1d252 0x1d450 0x106993 0xf1225 0xe1489 0xfbcad 0x21a70
#   0x1d252 newdefer+0x112              runtime/panic.c:49
#   0x1d450 runtime.deferproc+0x10          runtime/panic.c:132
#   0x106993    godoc/vfs.ReadFile+0xf3         godoc/vfs/vfs.go:43
#   0xf1225 godoc.(*Corpus).parseFile+0x75      godoc/parser.go:20
#   0xe1489 godoc.(*treeBuilder).newDirTree+0x8e9   godoc/dirtrees.go:108
#   0xfbcad godoc.func·002+0x15d            godoc/dirtrees.go:100

每个条目开头的数字(“1: 262144 [4: 376832]”)分别表示当前活动的对象的数量、活动对象占用的内存量、分配的总数以及所有分配占用的内存量。

优化通常是应用程序特定的,但这里有一些常见的建议。

  1. 将对象合并成更大的对象。例如,将 `*bytes.Buffer` 结构体成员替换为 `bytes.Buffer`(你可以稍后通过调用 `bytes.Buffer.Grow` 来预分配写入缓冲区)。这将减少内存分配的数量(更快),并减轻垃圾回收器的压力(更快的垃圾回收)。

  2. 局部变量如果逃逸出其声明的范围,将被提升到堆分配。编译器通常无法证明几个变量具有相同的生命周期,因此它会单独分配每个这样的变量。所以你也可以对局部变量应用上述建议。例如,将

    for k, v := range m {
        k, v := k, v   // copy for capturing by the goroutine
        go func() {
            // use k and v
        }()
    }
    

    替换为

    for k, v := range m {
        x := struct{ k, v string }{k, v}   // copy for capturing by the goroutine
        go func() {
            // use x.k and x.v
        }()
    }
    

    这将用一次内存分配替换两次内存分配。但是,这种优化通常会降低代码的可读性,所以要合理使用。

  3. 分配合并的一个特例是切片数组预分配。如果你知道切片的典型大小,你可以如下预分配其底层数组:

    type X struct {
        buf      []byte
        bufArray [16]byte // Buf usually does not grow beyond 16 bytes.
    }
    
    func MakeX() *X {
        x := &X{}
        // Preinitialize buf with the backing array.
        x.buf = x.bufArray[:0]
        return x
    }
    
  4. 如果可能,使用较小的数据类型。例如,使用 `int8` 而不是 `int`。

  5. 不包含任何指针的对象(注意字符串、切片、映射和通道包含隐式指针),不会被垃圾回收器扫描。例如,一个 1GB 的字节切片对垃圾回收时间几乎没有影响。所以,如果你从活动使用的对象中移除指针,可以对垃圾回收时间产生积极影响。一些可能性是:用索引替换指针,将对象拆分成两部分,其中一部分不包含指针。

  6. 使用空闲列表重用临时对象并减少分配数量。标准库包含 [`sync.Pool`](http://tip.golang.org/pkg/sync/#Pool) 类型,它允许在垃圾回收之间重用同一个对象多次。但是,请注意,与任何手动内存管理方案一样,错误使用 `sync.Pool` 可能导致使用后释放错误。

你也可以使用垃圾回收器跟踪(见下文)来了解内存问题。

TODO(dvyukov): 提及统计数据是延迟更新的:“Memprof 统计数据是延迟更新的。这是为了在分配持续不断而释放分批进行的情况下呈现一致的画面。几次连续的 GC 会推动更新管道前进。这就是你观察到的。因此,如果你对一个正在运行的服务器进行剖析,任何样本都会给你一个一致的快照。但是,如果程序完成了某项活动,并且你想在活动之后收集快照,那么你需要执行 2 或 3 次 GC 才能收集。”

阻塞剖析器

阻塞剖析器显示 goroutine 在哪些地方阻塞等待同步原语(包括定时器通道)。你可以通过与 CPU 剖析类似的方式收集它:使用 `go test --blockprofile`([https://pkg.go.dev/cmd/go/#hdr-Description_of_testing_flags)`](https://pkg.go.dev/cmd/go/#hdr-Description_of_testing_flags))、通过 [`net/http/pprof`](https://pkg.go.dev/net/http/pprof) 访问 `http://myserver:6060:/debug/pprof/block` 或调用 [`runtime/pprof.Lookup(“block”).WriteTo`](https://pkg.go.dev/runtime/pprof/#Profile.WriteTo)。

但是有一个重要的注意事项——阻塞剖析器默认情况下是禁用的。`go test --blockprofile` 会自动为你启用它。但是,如果你使用 `net/http/pprof` 或 `runtime/pprof`,你需要手动启用它(否则剖析数据将为空)。要启用阻塞剖析器,请调用 [`runtime.SetBlockProfileRate`](https://pkg.go.dev/runtime/#SetBlockProfileRate)。`SetBlockProfileRate` 控制阻塞剖析中报告的 goroutine 阻塞事件的比例。剖析器旨在对指定的阻塞纳秒数进行平均采样。要包含剖析中的每一个阻塞事件,请将速率设置为 1。

如果一个函数包含多个阻塞操作,并且不清楚哪个操作导致了阻塞,请使用 `pprof` 的 `--lines` 标志。

请注意,并非所有阻塞都是坏事。当一个 goroutine 阻塞时,底层的 worker 线程会切换到另一个 goroutine。因此,在协作式的 Go 环境中阻塞与在非协作式系统(例如典型的 C++ 或 Java 线程库,其中阻塞会导致线程空闲和昂贵的线程上下文切换)中阻塞互斥锁非常不同。为了让你有一些感觉,让我们看一些例子。

`time.Ticker` 上的阻塞通常是没问题的。如果一个 goroutine 在 `Ticker` 上阻塞 10 秒钟,你会在剖析中看到 10 秒钟的阻塞,这完全没问题。`sync.WaitGroup` 上的阻塞通常也是没问题的。例如,如果一项任务花费 10 秒钟,等待 WaitGroup 完成的 goroutine 将在剖析中计入 10 秒钟的阻塞。`sync.Cond` 上的阻塞可能还好,也可能不好,取决于具体情况。消费者阻塞在通道上表明生产者很慢或没有工作。生产者阻塞在通道上表明消费者更慢,但这通常是可以接受的。基于通道的信号量上的阻塞显示有多少 goroutine 在信号量上受限。`sync.Mutex` 或 `sync.RWMutex` 上的阻塞通常是糟糕的。你可以使用 `pprof` 的 `--ignore` 标志来排除剖析中已知的不感兴趣的阻塞。

goroutine 的阻塞可能导致两个负面后果:

  1. 由于缺乏工作,程序无法与处理器扩展。调度器跟踪器可以帮助识别这种情况。

  2. 过度的 goroutine 阻塞/取消阻塞会消耗 CPU 时间。CPU 剖析器可以帮助识别这种情况(查看 `System` 组件)。

以下是一些有助于减少 goroutine 阻塞的常见建议:

  1. 在生产者-消费者场景中使用足够缓冲的通道。无缓冲通道会严重限制程序的可用并行性。

  2. 对于以读取为主的工作负载,使用 `sync.RWMutex` 而不是 `sync.Mutex`。在 `sync.RWMutex` 中,读取者永远不会阻塞其他读取者,即使在实现层面也是如此。

  3. 在某些情况下,可以通过使用写时复制技术来完全移除互斥锁。如果受保护的数据结构不经常修改,并且可以创建其副本,那么可以按如下方式更新:

    type Config struct {
        Routes   map[string]net.Addr
        Backends []net.Addr
    }
    
    var config atomic.Value  // actual type is *Config
    
    // Worker goroutines use this function to obtain the current config.
      // UpdateConfig must be called at least once before this func.
    func CurrentConfig() *Config {
        return config.Load().(*Config)
    }
    
    // Background goroutine periodically creates a new Config object
    // as sets it as current using this function.
    func UpdateConfig(cfg *Config) {
      config.Store(cfg)
    }
    

    此模式可以防止写入者在更新期间阻塞读取者。

  4. 分区是另一种减少对共享可变数据结构进行争用/阻塞的通用技术。下面是如何分区一个哈希表的示例:

    type Partition struct {
        sync.RWMutex
        m map[string]string
    }
    
    const partCount = 64
    var m [partCount]Partition
    
    func Find(k string) string {
        idx := hash(k) % partCount
        part := &m[idx]
        part.RLock()
        v := part.m[k]
        part.RUnlock()
        return v
    }
    
  5. 本地缓存和批量更新可以帮助减少对不可分区数据结构的争用。下面展示了如何批量发送到通道:

    const CacheSize = 16
    
    type Cache struct {
        buf [CacheSize]int
        pos int
    }
    
    func Send(c chan [CacheSize]int, cache *Cache, value int) {
        cache.buf[cache.pos] = value
        cache.pos++
        if cache.pos == CacheSize {
            c <- cache.buf
            cache.pos = 0
        }
    }
    

    此技术不限于通道。它可以用于批量更新映射、批量分配等。

  6. 使用 [`sync.Pool`](http://tip.golang.org/pkg/sync/#Pool) 作为空闲列表,而不是基于通道或受互斥锁保护的空闲列表。`sync.Pool` 在内部使用智能技术来减少阻塞。

goroutine 剖析器

goroutine 剖析器仅提供进程中所有活动 goroutine 的当前堆栈。它可以方便地调试负载均衡问题(请参阅下面的调度器跟踪部分)或调试死锁。剖析数据仅对正在运行的应用程序有意义,因此 `go test` 不支持它。你可以通过 [`net/http/pprof`](https://pkg.go.dev/net/http/pprof) 访问 `http://myserver:6060:/debug/pprof/goroutine` 来收集剖析数据,并将其可视化为 svg/pdf,或通过调用 [`runtime/pprof.Lookup(“goroutine”).WriteTo`](https://pkg.go.dev/runtime/pprof/#Profile.WriteTo) 进行可视化。但最有用的是在浏览器中输入 `http://myserver:6060:/debug/pprof/goroutine?debug=2`,这将提供符号化的堆栈,类似于程序崩溃时看到的。请注意,处于“syscall”状态的 goroutine 会消耗一个 OS 线程,而其他 goroutine 则不会(除了那些调用了 `runtime.LockOSThread` 的 goroutine,不幸的是,这在剖析中是不可见的)。请注意,处于“IO wait”状态的 goroutine 也不会消耗线程,它们被停在非阻塞网络轮询器上(该轮询器使用 epoll/kqueue/GetQueuedCompletionStatus 稍后唤醒 goroutine)。

垃圾回收器跟踪

除了剖析工具,还有另一类工具——跟踪器。它们允许跟踪垃圾回收、内存分配器和 goroutine 调度器的状态。要启用垃圾回收器 (GC) 跟踪,请使用 `GODEBUG=gctrace=1` 环境变量运行程序:

$ GODEBUG=gctrace=1 ./myserver

然后程序在执行过程中会输出类似以下的内容:

gc9(2): 12+1+744+8 us, 2 -> 10 MB, 108615 (593983-485368) objects, 4825/3620/0 sweeps, 0(0) handoff, 6(91) steal, 16/1/0 yields
gc10(2): 12+6769+767+3 us, 1 -> 1 MB, 4222 (593983-589761) objects, 4825/0/1898 sweeps, 0(0) handoff, 6(93) steal, 16/10/2 yields
gc11(2): 799+3+2050+3 us, 1 -> 69 MB, 831819 (1484009-652190) objects, 4825/691/0 sweeps, 0(0) handoff, 5(105) steal, 16/1/0 yields

让我们看一下这些数字的含义。每行代表一次 GC。第一个数字 (“gc9”) 是 GC 的次数(这是自程序启动以来的第 9 次 GC)。括号中的数字 (“(2)”) 是参与 GC 的工作线程数。接下来的 4 个数字 (“12+1+744+8 us”) 分别是以微秒为单位的 stop-the-world、sweeping、marking 和等待工作线程完成的时间。接下来的 2 个数字 (“2 -> 10 MB”) 是上一次 GC 后活动堆的大小和当前 GC 之前的完整堆大小(包括垃圾)。接下来的 3 个数字 (“108615 (593983-485368) objects”) 是堆中对象的总数(包括垃圾)以及内存分配和释放操作的总数。接下来的 3 个数字 (“4825/3620/0 sweeps”) 描述了 sweep 阶段(上一次 GC 的):总共有 4825 个内存 span,3620 个是按需或在后台 sweep 的,0 个是在 stop-the-world 阶段 sweep 的(其余是未使用 span)。接下来的 4 个数字 (“0(0) handoff, 6(91) steal”) 描述了并行 marking 阶段的负载均衡:有 0 次对象交接操作(0 个对象被交接),以及 6 次 steal 操作(91 个对象被 steal)。最后的 3 个数字 (“16/1/0 yields”) 描述了并行 marking 阶段的有效性:总共有 17 次 yield 操作,用于等待其他线程。

GC 是 标记-清除类型。总 GC 时间可以表示为:

Tgc = Tseq + Tmark + Tsweep

其中 Tseq 是停止用户 goroutine 和一些准备活动的时间(通常很小);Tmark 是堆标记时间,标记发生在所有用户 goroutine 都停止时,因此会显著影响处理的延迟;Tsweep 是堆 sweep 时间,sweep 通常与正常程序执行并发进行,因此对延迟的影响不大。

标记时间可以近似表示为:

Tmark = C1*Nlive + C2*MEMlive_ptr + C3*Nlive_ptr

其中 Nlive 是 GC 期间堆中活动对象的数量,MEMlive_ptr 是包含指针的活动对象占用的内存量,Nlive_ptr 是活动对象中指针的数量。

Sweep 时间可以近似表示为:

Tsweep = C4*MEMtotal + C5*MEMgarbage

其中 MEMtotal 是堆的总内存量,MEMgarbage 是堆中垃圾的量。

下次 GC 发生的时间是,程序分配的内存量超过当前使用量的一定比例后。这个比例由 GOGC 环境变量控制(默认为 100)。如果 GOGC=100 且程序使用了 4MB 的堆内存,那么当程序达到 8MB 时,运行时将再次触发 GC。这使得 GC 的成本与分配成本成线性比例关系。调整 GOGC 会改变线性常数以及使用的额外内存量。

只有 sweep 受堆总大小的影响,并且 sweep 与正常程序执行并发进行。因此,如果你能承受额外的内存消耗,将 GOGC 设置为更高的值(200、300、500 等)是明智的。例如,GOGC=300 可以在保持延迟相同的情况下,将垃圾回收开销减少最多 2 倍(但代价是堆大小增加 2 倍)。

GC 是并行的,并且通常能很好地与硬件并行性扩展。因此,即使对于顺序程序,将 GOMAXPROCS 设置为更高的值以加快垃圾回收速度也是有意义的。但是,请注意,目前垃圾回收线程的数量限制为 8。

内存分配器跟踪

内存分配器跟踪只是将所有内存分配和释放操作转储到控制台。它通过 `GODEBUG=allocfreetrace=1` 环境变量启用。输出类似如下:

tracealloc(0xc208062500, 0x100, array of parse.Node)
goroutine 16 [running]:
runtime.mallocgc(0x100, 0x3eb7c1, 0x0)
    runtime/malloc.goc:190 +0x145 fp=0xc2080b39f8
runtime.growslice(0x31f840, 0xc208060700, 0x8, 0x8, 0x1, 0x0, 0x0, 0x0)
    runtime/slice.goc:76 +0xbb fp=0xc2080b3a90
text/template/parse.(*Tree).parse(0xc2080820e0, 0xc208023620, 0x0, 0x0)
    text/template/parse/parse.go:289 +0x549 fp=0xc2080b3c50
...

tracefree(0xc208002d80, 0x120)
goroutine 16 [running]:
runtime.MSpan_Sweep(0x73b080)
        runtime/mgc0.c:1880 +0x514 fp=0xc20804b8f0
runtime.MCentral_CacheSpan(0x69c858)
        runtime/mcentral.c:48 +0x2b5 fp=0xc20804b920
runtime.MCache_Refill(0x737000, 0xc200000012)
        runtime/mcache.c:78 +0x119 fp=0xc20804b950
...

跟踪包含内存块的地址、大小、类型、goroutine ID 和堆栈跟踪。它可能对调试更有用,但也可以为分配优化提供非常细粒度的信息。

调度器跟踪

调度器跟踪可以深入了解 goroutine 调度器的动态行为,并允许调试负载均衡和可伸缩性问题。要启用调度器跟踪,请使用 `GODEBUG=schedtrace=1000` 环境变量运行程序(该值表示输出周期,单位为毫秒,在本例中为每秒一次):

$ GODEBUG=schedtrace=1000 ./myserver

然后程序在执行过程中会输出类似以下的内容:

SCHED 1004ms: gomaxprocs=4 idleprocs=0 threads=11 idlethreads=4 runqueue=8 [0 1 0 3]
SCHED 2005ms: gomaxprocs=4 idleprocs=0 threads=11 idlethreads=5 runqueue=6 [1 5 4 0]
SCHED 3008ms: gomaxprocs=4 idleprocs=0 threads=11 idlethreads=4 runqueue=10 [2 2 2 1]

第一个数字 (“1004ms”) 是程序启动以来的时间。Gomaxprocs 是 GOMAXPROCS 的当前值。Idleprocs 是空闲处理器的数量(其余的正在执行 Go 代码)。Threads 是调度器创建的总 worker 线程数(线程有 3 种状态:执行 Go 代码 (gomaxprocs-idleprocs)、执行 syscall/cgocalls 或空闲)。Idlethreads 是空闲 worker 线程的数量。Runqueue 是可运行 goroutine 的全局队列的长度。全局队列和处理器本地队列的长度之和代表了可执行 goroutine 的总数。

注意:你可以组合使用任何跟踪器,例如 `GODEBUG=gctrace=1,allocfreetrace=1,schedtrace=1000`。

注意:还有一个详细的调度器跟踪,你可以通过 `GODEBUG=schedtrace=1000,scheddetail=1` 启用。它会打印有关每个 goroutine、worker 线程和处理器的详细信息。我们在这里不描述其格式,因为它主要对调度器开发人员有用;但你可以在 [`src/pkg/runtime/proc.c`](https://code.google.com/p/go/source/browse/src/pkg/runtime/proc.c) 中找到详细信息。

当程序未能与 GOMAXPROCS 线性扩展和/或未能 100% 消耗 CPU 时间时,调度器跟踪非常有用。理想情况是所有处理器都在忙于执行 Go 代码,线程数量合理,所有队列都有大量工作,并且工作分配相对均匀。

gomaxprocs=8 idleprocs=0 threads=40 idlethreads=5 runqueue=10 [20 20 20 20 20 20 20 20]

不良情况是以上某项不成立。例如,以下示例演示了工作量不足以使所有处理器保持忙碌:

gomaxprocs=8 idleprocs=6 threads=40 idlethreads=30 runqueue=0 [0 2 0 0 0 1 0 0]

注意:使用操作系统提供的工具来衡量实际 CPU 利用率作为最终指标。在类 Unix 操作系统上是 `top` 命令;在 Windows 上是任务管理器。

你可以在工作量不足的情况下使用 goroutine 剖析器来了解 goroutine 在哪里阻塞。请注意,负载不平衡并非绝对有害,只要所有处理器都保持忙碌,只会产生一些适度的负载均衡开销。

内存统计

Go 运行时通过 [`runtime.ReadMemStats`](https://pkg.go.dev/runtime/#ReadMemStats) 函数暴露粗粒度的内存统计信息。统计信息也可通过 `net/http/pprof` 在 `http://myserver:6060/debug/pprof/heap?debug=1` 的底部找到。统计信息在此处 [https://pkg.go.dev/runtime/#MemStats](https://pkg.go.dev/runtime/#MemStats) 描述。一些有趣的字段是:

  1. HeapAlloc - 当前堆大小。
  2. HeapSys - 总堆大小。
  3. HeapObjects - 堆中对象的总数。
  4. HeapReleased - 释放给操作系统的内存量;运行时会将 5 分钟内未使用的内存释放给操作系统,你可以通过 `runtime/debug.FreeOSMemory` 强制执行此过程。
  5. Sys - 从操作系统分配的总内存量。
  6. Sys-HeapReleased - 程序的有效内存消耗。
  7. StackSys - goroutine 堆栈消耗的内存(请注意,一些堆栈是从堆中分配的,并且不在此处计算,不幸的是,无法获得堆栈的总大小([https://code.google.com/p/go/issues/detail?id=7468](https://code.google.com/p/go/issues/detail?id=7468))。
  8. MSpanSys/MCacheSys/BuckHashSys/GCSys/OtherSys - 运行时为各种辅助目的分配的内存量;它们通常不那么重要,除非它们太高。
  9. PauseNs - 最近几次垃圾回收的持续时间。

堆转储器

最后一个可用的工具是堆转储器,它可以将整个堆的状态写入文件以供将来探索。它有助于识别内存泄漏并了解程序的内存消耗。

首先,你需要使用 [`runtime/debug.WriteHeapDump`](http://tip.golang.org/pkg/runtime/debug/#WriteHeapDump) 函数来写入转储:

    f, err := os.Create("heapdump")
    if err != nil { ... }
    debug.WriteHeapDump(f.Fd())

然后,你可以将其渲染为具有堆图形表示的 dot 文件,或将其转换为 hprof 格式。将其渲染为 dot 文件:

$ go get github.com/randall77/hprof/dumptodot
$ dumptodot heapdump mybinary > heap.dot

并使用 Graphviz 打开 `heap.dot`。

将其转换为 `hprof` 格式:

$ go get github.com/randall77/hprof/dumptohprof
$ dumptohprof heapdump heap.hprof
$ jhat heap.hprof

并将你的浏览器导航到 `http://myserver:7000`。

总结

优化是一个开放性问题,有一些简单的技巧可以用来提高性能。有时优化需要对程序进行彻底的重新架构。但我们希望这些工具能成为你工具箱中的宝贵补充,你可以用它们来至少分析和理解发生了什么。 [Profiling Go Programs](https://go-lang.org.cn/blog/profiling-go-programs) 是一个关于如何使用 CPU 和内存剖析器优化简单程序的优秀教程。


此内容是 Go Wiki 的一部分。