文档
一个 项目

Profiling Caddy

程序剖面(program profile)是在运行时对程序资源使用情况的快照。剖面对于识别问题区域、排查错误和崩溃以及优化代码非常有帮助。

Caddy 使用 Go 的剖面工具 pprof 来捕获剖面,它内置在 go 命令中。

剖面报告 CPU 和内存的使用情况,显示 goroutine 的堆栈跟踪,并有助于查找死锁或高争用的同步原语。

在报告某些 Caddy 的错误时,我们可能会要求提供剖面。本篇文章可以提供帮助。它既描述了如何从 Caddy 获取剖面,也介绍了如何使用和解释得到的 pprof 剖面文件的一般方法。

在开始之前要知道两点:

  1. Caddy 的剖面不是敏感的安全信息。它们包含的是良性技术读数,而不是内存内容。它们不会授予对系统的访问权限,安全上可以放心共享。
  2. 剖面开销小,可以在生产环境中收集。实际上,这对许多用户来说是推荐的最佳实践;本文稍后会提到。

获取剖面

剖面可通过 admin 界面/debug/pprof/ 端点获得。在运行 Caddy 的机器上,用浏览器打开:

http://localhost:2019/debug/pprof/

你会看到一个简单的计数表和链接,例如:

计数 剖面
79 allocs
0 block
0 cmdline
22 goroutine
79 heap
0 mutex
0 profile
29 threadcreate
0 trace
full goroutine stack dump

这些计数是快速识别泄漏的便捷方法。如果你怀疑有泄漏,反复刷新页面,你会看到其中一个或多个计数持续增加。如果 heap(堆)计数增长,可能是内存泄漏;如果 goroutine 计数增长,可能是 goroutine 泄漏。

点击这些剖面查看它们的样子。有些剖面可能为空,这是很常见的。最常用的剖面是 goroutine(函数栈)、heap(内存)和 profile(CPU)。其他剖面对于排查互斥锁争用或死锁也很有用。

在页面底部,有对每个剖面的简单说明:

  • allocs: 对过去所有内存分配的采样
  • block: 导致在同步原语上阻塞的堆栈跟踪
  • cmdline: 当前程序的命令行调用
  • goroutine: 所有当前 goroutine 的堆栈跟踪。使用 query 参数 debug=2 可以以未恢复 panic 的相同格式导出。
  • heap: 活跃对象内存分配的采样。可以为 heap 指定 gc GET 参数以在采样前运行 GC。
  • mutex: 被争用的互斥锁持有者的堆栈跟踪
  • profile: CPU 剖面。你可以在 seconds GET 参数中指定持续时间。获取 profile 文件后,使用 go tool pprof 命令分析剖面。
  • threadcreate: 导致新 OS 线程创建的堆栈跟踪
  • trace: 当前程序执行的 trace。可以在 seconds GET 参数中指定持续时间。获取 trace 文件后,使用 go tool trace 命令分析该 trace。

下载剖面

在 pprof 索引页面上点击链接会以文本格式给出剖面。这对于调试很有用,也是 Caddy 团队偏好的方式,因为我们可以扫描文本以查找明显线索,而无需额外工具。

但二进制实际上是默认格式。HTML 链接会在查询字符串中追加 ?debug= 参数以将其格式化为文本,除了(CPU)“profile”链接之外,该链接没有文本表示。

这些是你可以设置的查询字符串参数(摘自 Go 文档):

  • debug=N(除 cpu 以外的所有剖面): 响应格式:N = 0:二进制(默认),N > 0:明文
  • gc=N(heap 剖面): N > 0:在剖面采样前运行一次垃圾回收
  • seconds=N(allocs、block、goroutine、heap、mutex、threadcreate 剖面): 返回增量剖面
  • seconds=N(cpu、trace 剖面): 对给定持续时间进行剖面

因为这些是 HTTP 端点,你也可以使用任何 HTTP 客户端,例如 curl 或 wget 来下载剖面。

剖面下载后,你可以将它们上传到 GitHub issue 评论或使用像 pprof.me 这样的站点。针对 CPU 剖面,另一个选项是 flamegraph.com

远程访问

如果你已经可以在本地访问 admin API,可跳过本节。

默认情况下,Caddy 的 admin API 仅可通过环回接口访问。但至少有三种方法可以远程访问 Caddy 的 /debug/pprof 端点:

通过你的网站进行反向代理

一个简单的选项是直接从你的网站反向代理到它:

reverse_proxy /debug/pprof/* localhost:2019 {
	header_up Host {upstream_hostport}
}

这当然会使得能连接你网站的人访问到剖面。如果这不是你想要的,你可以使用任意 HTTP 认证模块添加一些认证。

(别忘了使用 /debug/pprof/* 匹配器,否则你会代理整个 admin API!)

SSH 隧道

另一种方式是使用 SSH 隧道。这是在你的电脑与服务器之间使用 SSH 协议的加密连接。在你的电脑上运行类似下面的命令:

ssh -N username@example.com -L 8123:localhost:2019

这会将 localhost:8123(在本地机器上)隧道到 example.com 上的 localhost:2019。请根据需要替换 usernameexample.com 以及端口。

然后在另一个终端,你可以这样运行 curl

curl -v http://localhost:8123/debug/pprof/ -H "Host: localhost:2019"

你可以通过在隧道两端都使用端口 2019 来避免需要 -H "Host: ..."(但这要求你本机上未占用 2019 端口,例如没有在本地运行 Caddy)。

当隧道处于活动状态时,你可以访问 admin API 的任意端点。对运行 ssh 的终端按 Ctrl+C 可关闭隧道。

长期运行的隧道

用上面的命令运行隧道需要你保持终端打开。如果你想在后台运行隧道,可以像这样启动:

ssh -f -N -M -S /tmp/caddy-tunnel.sock username@example.com -L 8123:localhost:2019

这会在后台启动并在 /tmp/caddy-tunnel.sock 创建一个控制套接字。完成后你可以使用该控制套接字来关闭隧道:

ssh -S /tmp/caddy-tunnel.sock -O exit e

远程 admin API

你也可以配置 admin API 接受授权客户端的远程连接。

(TODO:撰写关于此的文章。)

Goroutine 剖面

goroutine 转储用于了解存在哪些 goroutine 以及它们的调用栈。换句话说,它让我们了解正在执行或正在阻塞/等待的代码。

如果你点击 "goroutines" 或访问 /debug/pprof/goroutine?debug=1,你会看到 goroutine 列表及其调用栈。例如:

goroutine profile: total 88
23 @ 0x43e50e 0x436d37 0x46bda5 0x4e1327 0x4e261a 0x4e2608 0x545a65 0x5590c5 0x6b2e9b 0x50ddb8 0x6b307e 0x6b0650 0x6b6918 0x6b6921 0x4b8570 0xb11a05 0xb119d4 0xb12145 0xb1d087 0x4719c1
#	0x46bda4	internal/poll.runtime_pollWait+0x84			runtime/netpoll.go:343
#	0x4e1326	internal/poll.(*pollDesc).wait+0x26			internal/poll/fd_poll_runtime.go:84
#	0x4e2619	internal/poll.(*pollDesc).waitRead+0x279		internal/poll/fd_poll_runtime.go:89
#	0x4e2607	internal/poll.(*FD).Read+0x267				internal/poll/fd_unix.go:164
#	0x545a64	net.(*netFD).Read+0x24					net/fd_posix.go:55
#	0x5590c4	net.(*conn).Read+0x44					net/net.go:179
#	0x6b2e9a	crypto/tls.(*atLeastReader).Read+0x3a			crypto/tls/conn.go:805
#	0x50ddb7	bytes.(*Buffer).ReadFrom+0x97				bytes/buffer.go:211
#	0x6b307d	crypto/tls.(*Conn).readFromUntil+0xdd			crypto/tls/conn.go:827
#	0x6b064f	crypto/tls.(*Conn).readRecordOrCCS+0x24f		crypto/tls/conn.go:625
#	0x6b6917	crypto/tls.(*Conn).readRecord+0x157			crypto/tls/conn.go:587
#	0x6b6920	crypto/tls.(*Conn).Read+0x160				crypto/tls/conn.go:1369
#	0x4b856f	io.ReadAtLeast+0x8f					io/io.go:335
#	0xb11a04	io.ReadFull+0x64					io/io.go:354
#	0xb119d3	golang.org/x/net/http2.readFrameHeader+0x33		golang.org/x/net@v0.14.0/http2/frame.go:237
#	0xb12144	golang.org/x/net/http2.(*Framer).ReadFrame+0x84		golang.org/x/net@v0.14.0/http2/frame.go:498
#	0xb1d086	golang.org/x/net/http2.(*serverConn).readFrames+0x86	golang.org/x/net@v0.14.0/http2/server.go:818

1 @ 0x43e50e 0x44e286 0xafeeb3 0xb0af86 0x5c29fc 0x5c3225 0xb0365b 0xb03650 0x15cb6af 0x43e09b 0x4719c1
#	0xafeeb2	github.com/caddyserver/caddy/v2/cmd.cmdRun+0xcd2					github.com/caddyserver/caddy/v2@v2.7.4/cmd/commandfuncs.go:277
#	0xb0af85	github.com/caddyserver/caddy/v2/cmd.init.1.func2.WrapCommandFuncForCobra.func1+0x25	github.com/caddyserver/caddy/v2@v2.7.4/cmd/cobra.go:126
#	0x5c29fb	github.com/spf13/cobra.(*Command).execute+0x87b						github.com/spf13/cobra@v1.7.0/command.go:940
#	0x5c3224	github.com/spf13/cobra.(*Command).ExecuteC+0x3a4					github.com/spf13/cobra@v1.7.0/command.go:1068
#	0xb0365a	github.com/spf13/cobra.(*Command).Execute+0x5a						github.com/spf13/cobra@v1.7.0/command.go:992
#	0xb0364f	github.com/caddyserver/caddy/v2/cmd.Main+0x4f						github.com/caddyserver/caddy/v2@v2.7.4/cmd/main.go:65
#	0x15cb6ae	main.main+0xe										caddy/main.go:11
#	0x43e09a	runtime.main+0x2ba									runtime/proc.go:267

1 @ 0x43e50e 0x44e9c5 0x8ec085 0x4719c1
#	0x8ec084	github.com/caddyserver/certmagic.(*Cache).maintainAssets+0x304	github.com/caddyserver/certmagic@v0.19.2/maintain.go:67

...

第一行 goroutine profile: total 88 告诉我们这是 goroutine 剖面,以及总共有多少个 goroutine。

随后是 goroutine 列表。它们按调用栈分组,并按频率降序排列。

一行 goroutine 的语法是:<count> @ <addresses...>

该行以拥有该调用栈的 goroutine 数量开始。@ 符号表示调用指令地址(即函数指针)的开始,这些地址是 goroutine 的起源。每个指针代表一次函数调用,或调用帧。

你可能注意到许多 goroutine 共享相同的第一个调用地址。这通常是程序的 main 或入口点。有些 goroutine 不会在那里起始,因为程序有各种 init() 函数,Go 运行时也可能生成 goroutine。

随后以 # 开头的行实际上只是为读者提供的注释。它们包含 goroutine 的当前堆栈跟踪。顶部代表栈顶,即当前正在执行的代码行。底部代表栈底,或者 goroutine 最初开始运行的代码。

堆栈跟踪的格式如下:

<address> <package/func>+<offset> <filename>:<line>

地址是函数指针,然后你将看到 Go 包和函数名(如果是方法则带上类型名),以及函数内的指令偏移量。最后,可能最有用的信息是文件和行号,会出现在末尾。

完整的 goroutine 堆栈转储

如果我们将查询字符串参数改为 ?debug=2,就会得到完整转储。这包括每个 goroutine 的详细堆栈跟踪,并且相同的 goroutine 不会被合并。在繁忙服务器上此输出可能非常庞大,但信息非常有价值!

来看一个对应上述第一个调用栈的例子(已截断):

goroutine 61961905 [IO wait, 1 minutes]:
internal/poll.runtime_pollWait(0x7f9a9a059eb0, 0x72)
	runtime/netpoll.go:343 +0x85
...
golang.org/x/net/http2.(*serverConn).readFrames(0xc001756f00)
	golang.org/x/net@v0.14.0/http2/server.go:818 +0x87
created by golang.org/x/net/http2.(*serverConn).serve in goroutine 61961902
	golang.org/x/net@v0.14.0/http2/server.go:930 +0x56a

尽管输出很冗长,但该转储唯一提供的最有用信息是每个 goroutine 的第一行和最后一行。

第一行包含 goroutine 的编号(61961905)、状态("IO wait")和存在的持续时间("1 minutes"):

  • Goroutine 编号:是的,goroutine 有编号!但这些编号不会暴露给我们的代码。不过这些编号在堆栈跟踪中特别有帮助,因为我们可以看到哪个 goroutine 生成了当前的这个(见末尾:"created by ... in goroutine 61961902")。下面的工具可以帮助我们绘制可视化图表。
  • 状态:这告诉我们 goroutine 当前正在做什么。你可能会看到的一些状态包括:
    • running:正在执行代码——太棒了!
    • IO wait:等待网络。因为它被停在非阻塞网络轮询器上,所以不会占用 OS 线程。
    • sleep:我们都更需要它。
    • select:在 select 上阻塞;等待某个 case 可用。
    • select (no cases): 在空的 select select {} 上阻塞。Caddy 在其 main 中使用一个这样的语句来保持运行,因为关闭是由其他 goroutine 发起的。
    • chan receive:在通道接收上阻塞(<-ch)。
    • semacquire:等待获取信号量(底层同步原语)。
    • syscall:正在执行系统调用。会占用 OS 线程。
  • 持续时间:goroutine 存在了多长时间。对发现例如 goroutine 泄漏之类的 bug 很有用。例如,如果我们期望所有网络连接在几分钟内关闭,那么当我们发现大量网络连接的 goroutine 存活数小时,这说明可能出问题。

解释 goroutine 转储

在不查看代码的情况下,我们能从上面的 goroutine 学到什么?

它大约一分钟前创建,正在等待网络套接字上的数据,并且其 goroutine 编号很大(61961905)。

从第一个转储(debug=1)我们知道它的调用栈被频繁执行,并且较大的 goroutine 编号结合短的持续时间表明存在数千万个这些相对短寿命的 goroutine。它位于名为 pollWait 的函数中,调用历史包括从使用 TLS 的加密网络连接读取 HTTP/2 帧。

因此,我们可以推断该 goroutine 正在为某个 HTTP/2 请求提供服务!它在等待来自客户端的数据。更重要的是,我们知道生成它的 goroutine 不是进程的最初几个 goroutine 之一,因为它也有较大的编号;在转储中找到那个 goroutine 会显示它是在处理现有请求期间为一个新的 HTTP/2 流而生成的。相比之下,其他具有较大编号的 goroutine 可能是由一个编号较小的 goroutine(例如 32)生成的,这表明是一个新连接刚从套接字上 Accept() 出来被处理。

每个程序都不同,但在调试 Caddy 时,这些模式通常是成立的。

内存剖面

内存(或堆)剖面跟踪堆分配,这是系统上内存的主要消耗者。分配也常常是性能问题的嫌疑对象,因为分配内存需要系统调用,这可能较慢。

堆剖面在几乎所有方面都与 goroutine 剖面类似,除了顶部行的开头不同。示例如下:

0: 0 [1: 4096] @ 0xb1fc05 0xb1fc4d 0x48d8d1 0xb1fce6 0xb184c7 0xb1bc8e 0xb41653 0xb4105c 0xb4151d 0xb23b14 0x4719c1
#	0xb1fc04	bufio.NewWriterSize+0x24					bufio/bufio.go:599
#	0xb1fc4c	golang.org/x/net/http2.glob..func8+0x6c				golang.org/x/net@v0.17.0/http2/http2.go:263
#	0x48d8d0	sync.(*Pool).Get+0xb0						sync/pool.go:151
#	0xb1fce5	golang.org/x/net/http2.(*bufferedWriter).Write+0x45		golang.org/x/net@v0.17.0/http2/http2.go:276
#	0xb184c6	golang.org/x/net/http2.(*Framer).endWrite+0xc6			golang.org/x/net@v0.17.0/http2/frame.go:371
#	0xb1bc8d	golang.org/x/net/http2.(*Framer).WriteHeaders+0x48d		golang.org/x/net@v0.17.0/http2/frame.go:1131
#	0xb41652	golang.org/x/net/http2.(*writeResHeaders).writeHeaderBlock+0xd2	golang.org/x/net@v0.17.0/http2/write.go:239
#	0xb4105b	golang.org/x/net/http2.splitHeaderBlock+0xbb			golang.org/x/net@v0.17.0/http2/write.go:169
#	0xb4151c	golang.org/x/net/http2.(*writeResHeaders).writeFrame+0x1dc	golang.org/x/net@v0.17.0/http2/write.go:234
#	0xb23b13	golang.org/x/net/http2.(*serverConn).writeFrameAsync+0x73	golang.org/x/net@v0.17.0/http2/server.go:851

第一行格式如下:

<live objects> <live memory> [<allocations>: <allocation memory>] @ <addresses...>

在上面的例子中,我们看到一次由 bufio.NewWriterSize() 做出的分配,但当前没有来自该调用栈的存活对象。

有趣的是,我们可以从该调用栈推断出 http2 包使用了一个池化的 4 KB 来向客户端写 HTTP/2 帧。如果热点路径经过优化以重用分配,你常常会在 Go 的内存剖面中看到池化对象。这可以减少新分配,堆剖面可以帮助你了解池是否被正确使用!

CPU 剖面

CPU 剖面帮助你理解 Go 程序在处理器上把调度时间主要花在哪些地方。

但是,这类剖面没有明文形式,因此在下一节我们将使用 go tool pprof 命令来帮助阅读它们。

要下载 CPU 剖面,请请求 /debug/pprof/profile?seconds=N,其中 N 是你希望收集剖面的秒数。在收集 CPU 剖面期间,程序性能可能会受到轻微影响。(其他剖面对性能几乎没有影响。)

完成后,它会下载一个二进制文件,恰如其分地命名为 profile。然后我们需要查看它。

go tool pprof

我们将以 CPU 剖面为例使用 Go 的内置剖面分析器来读取剖面,但你可以用它来分析任何类型的剖面。

运行下面的命令(如果文件名不同,请替换 "profile" 为实际文件路径),它会打开一个交互提示符:

go tool pprof profile
File: caddy_master
Type: cpu
Time: Aug 29, 2022 at 8:47pm (MDT)
Duration: 30.02s, Total samples = 70.11s (233.55%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) 

这是一个你可以探索的交互工具。输入 help 会给出命令列表,o 会显示当前选项。如果你键入 help <command>,可以获得关于某个具体命令的信息。

命令很多,但一些常用命令包括:

  • top:显示消耗最多 CPU 的项。你可以附加一个数字,例如 top 20 来查看更多,或附加正则表达式来“聚焦”或忽略某些项。
  • web:在浏览器中打开调用图。这是可视化查看 CPU 使用情况的好方法。
  • svg:生成调用图的 SVG 图像。它与 web 相同,但不会打开浏览器,SVG 会保存在本地。
  • tree:调用栈的表格视图。

我们先从 top 开始。你会看到如下输出:

(pprof) top
Showing nodes accounting for 38.36s, 54.71% of 70.11s total
Dropped 785 nodes (cum <= 0.35s)
Showing top 10 nodes out of 196
      flat  flat%   sum%        cum   cum%
    10.97s 15.65% 15.65%     10.97s 15.65%  runtime/internal/syscall.Syscall6
     6.59s  9.40% 25.05%     36.65s 52.27%  runtime.gcDrain
     5.03s  7.17% 32.22%      5.34s  7.62%  runtime.(*lfstack).pop (inline)
     3.69s  5.26% 37.48%     11.02s 15.72%  runtime.scanobject
     2.42s  3.45% 40.94%      2.42s  3.45%  runtime.(*lfstack).push
     2.26s  3.22% 44.16%      2.30s  3.28%  runtime.pageIndexOf (inline)
     2.11s  3.01% 47.17%      2.56s  3.65%  runtime.findObject
     2.03s  2.90% 50.06%      2.03s  2.90%  runtime.markBits.isMarked (inline)
     1.69s  2.41% 52.47%      1.69s  2.41%  runtime.memclrNoHeapPointers
     1.57s  2.24% 54.71%      1.57s  2.24%  runtime.epollwait

前十名的 CPU 消耗全部集中在 Go 运行时——特别是大量的垃圾回收(记住 syscalls 被用于释放和分配内存)。这提示我们可以通过减少分配来改善性能,值得查看堆剖面。

好,但如果我们想查看自己代码的 CPU 使用情况呢?我们可以忽略包含 "runtime" 的模式,比如这样:

(pprof) top -runtime  
Active filters:
   ignore=runtime
Showing nodes accounting for 0.92s, 1.31% of 70.11s total
Dropped 160 nodes (cum <= 0.35s)
Showing top 10 nodes out of 243
      flat  flat%   sum%        cum   cum%
     0.17s  0.24%  0.24%      0.28s   0.4%  sync.(*Pool).getSlow
     0.11s  0.16%   0.4%      0.11s  0.16%  github.com/prometheus/client_golang/prometheus.(*histogram).observe (inline)
     0.10s  0.14%  0.54%      0.23s  0.33%  github.com/prometheus/client_golang/prometheus.(*MetricVec).hashLabels
     0.10s  0.14%  0.68%      0.12s  0.17%  net/textproto.CanonicalMIMEHeaderKey
     0.10s  0.14%  0.83%      0.10s  0.14%  sync.(*poolChain).popTail
     0.08s  0.11%  0.94%      0.26s  0.37%  github.com/prometheus/client_golang/prometheus.(*histogram).Observe
     0.07s   0.1%  1.04%      0.07s   0.1%  internal/poll.(*fdMutex).rwlock
     0.07s   0.1%  1.14%      0.10s  0.14%  path/filepath.Clean
     0.06s 0.086%  1.23%      0.06s 0.086%  context.value
     0.06s 0.086%  1.31%      0.06s 0.086%  go.uber.org/zap/buffer.(*Buffer).AppendByte

很明显,Prometheus 指标是另一个主要的消耗源,但你会注意到它们的累积时间远小于上面显示的 GC。明显的差异表明我们应该优先减少 GC。

我们用 q 退出该剖面,然后对堆剖面运行相同命令:

(pprof) top
Showing nodes accounting for 22259.07kB, 81.30% of 27380.04kB total
Showing top 10 nodes out of 102
      flat  flat%   sum%        cum   cum%
   12300kB 44.92% 44.92%    12300kB 44.92%  runtime.allocm
 2570.01kB  9.39% 54.31%  2570.01kB  9.39%  bufio.NewReaderSize
 2048.81kB  7.48% 61.79%  2048.81kB  7.48%  runtime.malg
 1542.01kB  5.63% 67.42%  1542.01kB  5.63%  bufio.NewWriterSize
 ...

正中要害。近一半的内存分配严格用于我们使用 bufio 包时的读写缓冲。因此,我们可以推断出优化代码以减少缓冲会很有收益。(对 Caddy 的相关补丁就实现了这一点)

可视化

如果我们改用 svgweb 命令,就会得到剖面的可视化:

CPU profile visualization

这是一个 CPU 剖面,但其他类型的剖面也可得到类似的图。

想了解如何阅读这些图,请参阅 pprof 文档

对比剖面(Diff)

在你做出代码更改后,可以使用差异分析(“diff”)来比较前后效果。下面是一个堆剖面的 diff:

go tool pprof -diff_base=before.prof after.prof
File: caddy
Type: inuse_space
Time: Aug 29, 2022 at 1:21am (MDT)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top
Showing nodes accounting for -26.97MB, 49.32% of 54.68MB total
Dropped 10 nodes (cum <= 0.27MB)
Showing top 10 nodes out of 137
      flat  flat%   sum%        cum   cum%
  -27.04MB 49.45% 49.45%   -27.04MB 49.45%  bufio.NewWriterSize
      -2MB  3.66% 53.11%       -2MB  3.66%  runtime.allocm
    1.06MB  1.93% 51.18%     1.06MB  1.93%  github.com/yuin/goldmark/util.init
    1.03MB  1.89% 49.29%     1.03MB  1.89%  github.com/caddyserver/caddy/v2/modules/caddyhttp/reverseproxy.glob..func2
       1MB  1.84% 47.46%        1MB  1.84%  bufio.NewReaderSize
      -1MB  1.83% 49.29%       -1MB  1.83%  runtime.malg
       1MB  1.83% 47.46%        1MB  1.83%  github.com/caddyserver/caddy/v2/modules/caddyhttp/reverseproxy.cloneRequest
      -1MB  1.83% 49.29%       -1MB  1.83%  net/http.(*Server).newConn
   -0.55MB  1.00% 50.29%    -0.55MB  1.00%  html.populateMaps
    0.53MB  0.97% 49.32%     0.53MB  0.97%  github.com/alecthomas/chroma.TypeRemappingLexer

如你所见,我们将内存分配减少了大约一半!

差异也可以可视化:

CPU profile visualization

这让更改如何影响程序各部分的性能一目了然。

延伸阅读

程序剖面有很多需要学习的内容,我们这里只涉及了皮毛。

要真正把“剖析”做到专业水平,考虑阅读以下资源: