该程序基本上读取一个输入文件,并解析每一行以填充内存中的对象。
$ go test -bench =。
每次执行μs(越小越好)
因此,在我的机器上,“好代码”的速度提高了16%。我们能获得更多吗?
根据我的经验,代码质量和性能之间存在有趣的关联。当您成功地重构代码以使其更清晰且更加分离时,您通常最终会使其更快,因为它不会使之前执行的无关指令变得混乱,并且还因为一些可能的优化变得明显且易于实现。
另一方面,如果你进一步追求性能,你将不得不放弃简单并诉诸于黑客。你确实会刮掉几毫秒,但代码质量会受到影响,因为它会变得更难以阅读和推理,更脆弱,更不灵活。
攀登mount Simplicity,然后降序
这是一个权衡:你愿意走多远?
为了正确确定您的绩效工作的优先顺序,最有价值的策略是确定您的瓶颈并专注于它们。要实现这一点,请使用分析工具!Pprof和Trace是你的朋友:
$ go test -bench =。-cpuprofile cpu.prof
$ go tool pprof -svg cpu.prof> cpu.svg
一个相当大的CPU使用率图(点击SVG)
$ go test -bench =。-trace trace.out
$ go工具跟踪trace.out
彩虹追踪:许多小任务(点击打开,仅限Chrome)
跟踪证明使用了所有CPU内核(底线0,1等),这在一开始看起来是件好事。但它显示了数千个小的彩色计算切片,以及一些空闲插槽,其中一些核心处于空闲状态。我们放大:
一个3毫秒的窗口(点击打开,仅限Chrome)
每个核心实际上花费大量时间闲置,并在微任务之间保持切换。看起来任务的粒度不是最优的,导致许多上下文切换以及由于同步而导致的争用。
让我们检查一下竞争检测器是否同步是正确的(如果没有,那么我们的问题比性能更大):
$ go test -race
PASS
是!!看起来是正确的,没有遇到数据争用情况。测试函数和基准函数是不同的(参见文档),但在这里他们调用相同的函数ParseAdexpMessage,我们可以使用-race
。
“好”版本中的并发策略包括在其自己的goroutine中处理每行输入,以利用多个核心。这是一种合法的直觉,因为goroutines的声誉是轻量级和廉价的。我们多少得益于并发性?让我们与单个顺序goroutine中的相同代码进行比较(只需删除行解析函数调用之前的go关键字)
每次执行μs(越小越好)
哎呀,没有任何并行性,它实际上更快。这意味着启动goroutine的(非零)开销超过了同时使用多个核心所节省的时间。
自然的下一步,因为我们现在顺序而不是同时处理行,是为了避免使用结果通道的(非零)开销:让我们用裸片替换它。

每次执行μs(越小越好)
我们现在从“好”版本获得了大约40%的加速,只是简化了代码,删除了并发(差异)。
使用单个goroutine,在任何给定时间只有1个CPU内核正在工作。
现在让我们看一下Pprof图中的热函数调用:
发现瓶颈
我们当前版本的基准(顺序,带切片)花费86%的时间实际解析消息,这很好。我们很快注意到,总时间的43%用于将正则表达式与(* Regexp).FindAll匹配 。
虽然regexp是从原始文本中提取数据的一种方便灵活的方法,但它们存在缺陷,包括内存和运行时的成本。它们很强大,但对于许多用例来说可能有点过分。
在我们的程序中,模式
patternSubfield =“ - 。[^ - ] *”
主要用于识别以短划线“ - ” 开头的“ 命令 ”,并且一行可能有多个命令。通过一些调整,可以使用bytes.Split完成。让我们调整代码(commit,commit)以使用Split替换regexp:
每次执行μs(越小越好)
哇,这是40%的额外增益!
CPU图现在看起来像这样:
没有更多正则表达式的巨大成本。从5个不同的功能中分配内存花费了相当多的时间(40%)。有趣的是,总时间的21%现在由字节占.Trim 。
这个函数引起了我的兴趣:我们可以做得更好吗?
bytes.Trim期望一个“ cutset string”作为参数(对于要在左侧和右侧删除的字符),但我们仅使用单个空格字节作为cutset。这是一个例子,您可以通过引入一些复杂性来获得性能:实现您自己的自定义“trim”函数来代替标准库函数。在自定义的“微调”的交易,只有一个割集字节。
每次执行μs(越小越好)
是的,另外20%被削减了。当前版本的速度是原始“坏”速度的4倍,而机器只使用1个CPU内核。相当实质!