我一直都很关心性能。 我不知道为什么。 但是我只是对缓慢的服务和程序感到生气。 看起来
我并不孤单 。
在A / B测试中,我们试图以100毫秒为增量来减慢页面的输出速度,发现即使很小的延迟也会导致收入的大幅下降。 -格雷格·林登(Greg Linden),Amazon.com
根据经验,低生产率以两种方式之一体现:
- 随着用户数量的增加,小规模执行良好的操作变得不可行。 通常这些是O(N)或O(N²)操作。 当用户群较小时,一切正常。 该产品急于推向市场。 随着基地的扩大,越来越多的意外病理情况出现-服务停止了。
- 次优工作的许多单独来源是“一千次裁员死亡”。
在我的职业生涯的大部分时间里,我要么使用Python研究数据科学,要么在Go上创建服务。 在第二种情况下,我在优化方面拥有更多经验。 Go通常不是我编写的服务的瓶颈-数据库程序通常受I / O限制。 但是,在我开发的机器学习批处理管道中,该程序通常受CPU限制。 如果Go过多使用处理器,则有多种策略。
本文介绍了一些无需费力即可用于显着提高生产率的方法。 我故意忽略那些需要大量精力或对程序结构进行较大更改的方法。
开始之前
在对程序进行任何更改之前,请花时间创建一个合适的基线进行比较。 如果您不这样做,那么您将在黑暗中徘徊,想知道所做的更改是否会带来任何好处。 首先,编写基准并获取用于pprof的
配置文件 。 最好
在Go上也写基准测试 :这使使用pprof和内存配置文件更加容易。 还可以使用Benchcmp:一个有用的工具,用于比较测试之间的性能差异。
如果该代码与基准测试不太兼容,则从可以衡量的内容开始。 您可以使用
runtime / pprof手动分析代码。
因此,让我们开始吧!
使用sync.Pool重用以前选择的对象
sync.Pool实现
发布列表 。 这使您可以重用先前分配的结构,并在许多用途上摊销对象的分配,从而减少了垃圾收集器的工作。 该API非常简单。 实现分配对象的新实例的函数。 API将返回指针的类型。
var bufpool = sync.Pool{ New: func() interface{} { buf := make([]byte, 512) return &buf }}
之后,您可以从池中执行
Get()
对象,并在完成后将它们放回
Put()
。
有细微差别。 在Go 1.13之前,每次垃圾回收都会清除该池。 这可能会对分配大量内存的程序的性能产生不利影响。 从1.13版开始,
GC之后似乎还有更多对象可以生存 。
!!! 在将对象返回池之前,请确保重置结构字段。如果不这样做,则可以从包含先前使用数据的池中获取脏对象。 这是严重的安全风险!
type AuthenticationResponse { Token string UserID string } rsp := authPool.Get().(*AuthenticationResponse) defer authPool.Put(rsp)
始终保证零内存的安全方法是显式执行此操作:
这不是唯一问题的情况是使用写入的确切内存。 例如:
var ( r io.Reader w io.Writer )
避免将包含指针的结构用作大型地图的键
h,我太冗长了。 对不起 他们经常谈论(包括我的前同事
Phil Pearl )有关
堆大的 Go性能的讨论。 在垃圾回收期间,运行时使用指针扫描对象并跟踪它们。 如果您的地图
map[string]int
非常大,则GC应该检查每一行。 每次垃圾回收都会发生这种情况,因为这些行包含指针。
在此示例中,我们编写了1000万个元素以
map[string]int
并测量垃圾回收的持续时间。 我们在包区域分配我们的映射,以确保从堆分配内存。
package main import ( "fmt" "runtime" "strconv" "time" ) const ( numElements = 10000000 ) var foo = map[string]int{} func timeGC() { t := time.Now() runtime.GC() fmt.Printf("gc took: %s\n", time.Since(t)) } func main() { for i := 0; i < numElements; i++ { foo[strconv.Itoa(i)] = i } for { timeGC() time.Sleep(1 * time.Second) } }
运行该程序,我们将看到以下内容:
inthash→转到安装&& inthash
gc时间:98.726321ms
gc花费:105.524633ms
gc需要:102.829451ms
gc需要:102.71908ms
gc花费:103.084104ms
gc需要:104.821989ms
在计算机国家,这已经是相当长的时间了!
可以做些什么来优化? 最好在所有地方删除指针,以免加载垃圾收集器。
行中有指针 ; 因此,让我们将其实现为
map[int]int
。
package main import ( "fmt" "runtime" "time" ) const ( numElements = 10000000 ) var foo = map[int]int{} func timeGC() { t := time.Now() runtime.GC() fmt.Printf("gc took: %s\n", time.Since(t)) } func main() { for i := 0; i < numElements; i++ { foo[i] = i } for { timeGC() time.Sleep(1 * time.Second) } }
再次运行该程序,我们看到:
inthash→转到安装&& inthash
gc花费了:3.608993ms
gc拍摄时间:3.926913ms
gc拍摄时间:3.955706ms
gc:4.063795ms
gc拍摄时间:3.91519ms
gc拍摄时间:3.75226ms
好多了。 我们已将垃圾收集速度提高了35倍。 在生产中使用时,必须在插入卡之前将字符串散列为整数。
顺便说一下,还有许多避免GC的方法。 如果您分配了无意义的结构,整数或字节的巨大数组,则
GC将不会进行扫描 :也就是说,您可以节省GC时间。 此类方法通常需要对程序进行实质性修改,因此,今天我们将不再深入探讨该主题。
与任何优化一样,效果可能会有所不同。 有关如何从大型地图删除行以支持更智能的数据结构实际上
增加内存消耗的有趣示例,请参见
Damian Gryski的推文主题 。 通常,请阅读他发布的所有内容。
封送处理代码生成以避免运行时反射
将结构编组和解组为各种序列化格式(例如JSON)是一种典型的操作,尤其是在创建微服务时。 对于许多微服务,这通常是唯一的工作。 诸如
json.Marshal
和
json.Unmarshal
类的函数依靠
运行时中的反射将结构字段序列化为字节,反之亦然。 这可能工作缓慢:反射不如显式代码有效。
但是,有一些优化选项。 JSON封送处理机制如下所示:
package json // Marshal take an object and returns its representation in JSON. func Marshal(obj interface{}) ([]byte, error) { // Check if this object knows how to marshal itself to JSON // by satisfying the Marshaller interface. if m, is := obj.(json.Marshaller); is { return m.MarshalJSON() } // It doesn't know how to marshal itself. Do default reflection based marshallling. return marshal(obj) }
如果我们知道JSON中的封送处理过程,那么就有一条线索可以避免在运行时进行反思。 但是我们不想手动编写所有封送处理代码,那么我们该怎么办? 让计算机生成此代码! 像
easyjson这样的代码生成器
会查看结构并生成高度优化的代码,这些代码与
json.Marshaller
等现有的编组接口完全兼容。
下载该软件包,并在
$file.go
写入以下命令,其中包含要为其生成代码的结构。
easyjson -all $ file.go
应该生成文件
$file_easyjson.go
。 由于
easyjson
为您实现了
json.Marshaller
接口,因此默认情况下将调用这些函数,而不是反射。 恭喜:您刚刚将JSON代码加速了三倍。 有许多技巧可以进一步提高生产率。
我建议使用此软件包,因为我以前曾成功使用过它。 但是要小心。 请不要以此为邀请与我开始就最快的JSON包展开激烈辩论。
确保在结构更改时重新生成封送处理代码。 如果您忘记执行此操作,那么新添加的字段将不会被序列化,这将导致混乱! 您可以将
go generate
用于这些任务。 为了保持与结构的同步,我更喜欢将
generate.go
放在包的根目录,这会导致为所有包文件
go generate
文件:当您有许多需要生成此类代码的文件时,这会有所帮助。 主要技巧:为确保结构得到更新,请在CI中调用
go generate
并检查与注册代码是否没有差异。
使用strings.Builder构建字符串
在Go中,字符串是不可变的:将其视为只读字节。 这意味着每次创建字符串时,都会分配内存,并有可能为垃圾收集器创建更多工作。
1.10实现的
字符串.Builder是创建字符串的有效方法。 在内部,它写入字节缓冲区。 仅当在构建器中调用
String()
时才实际创建一个字符串。 他依靠一些不安全的技巧将基本字节作为分配为零的字符串返回:请参阅
此博客,以进一步了解其工作方式。
比较两种方法的性能:
这是我的Macbook Pro上的结果:
strbuild->去测试-bench =。 -长椅
goos:达尔文
goarch:amd64
pkg:github.com/sjwhitworth/perfblog/strbuild
BenchmarkStringBuildNaive-8 5,000,000 255 ns / op 216 B / op 8分配/ op
BenchmarkStringBuildBuilder-8 20,000,000 54.9 ns / op 64 B / op 1分配/ op
如您所见,
strings.Builder
速度提高了4.7倍,分配量减少了八倍,占用的内存减少了四倍。
当性能很重要时,请使用
strings.Builder
。 通常,我建议在所有地方都使用它,除了最简单的构建字符串情况。
使用strconv而不是fmt
fmt是Go中最著名的软件包之一。 您可能在第一个程序中使用了它来显示“你好,世界”。 但是,当涉及到将整数转换为浮点数并转换为字符串时,效率不如其弟弟
strconv高效 。 该软件包通过很少的API更改即可显示出不错的性能。
fmt
基本上将
interface{}
作为函数参数。 有两个缺点:
- 您正在失去类型安全性。 对我来说,这很重要。
- 这可以增加所需的分泌量。 将没有指针的类型作为
interface{}
传递通常会导致堆分配。 这篇博客文章解释了为什么会这样。 - 以下程序显示了性能上的差异:
Macbook Pro基准测试:
strfmt→进行测试-bench =。 -长椅
goos:达尔文
goarch:amd64
pkg:github.com/sjwhitworth/perfblog/strfmt
基准Strconv-8 30,000,000 39.5 ns / op 32 B / op 1 allocs / op
基准Fmt-8 10,000,000 143 ns / op 72 B / op 3 allocs / op
如您所见,strconv选项的速度提高了3.5倍,分配量减少了三倍,占用的内存只有原来的一半。
用make分配切片罐,以避免重新分配
在继续提高性能之前,让我们快速更新内存中的切片信息。 切片是Go中非常有用的构造。 它提供了可伸缩的阵列,能够在不重新分配的情况下接受同一基本内存中的不同视图。 如果您在引擎盖下看,则切片包含三个元素:
type slice struct {
这些领域是什么?
data
:指向切片中基础数据的指针
len
:切片中的当前元素数
cap
:切片在重新分配之前可以增长到的元素数
引擎盖下的部分是固定长度的阵列。 当达到最大值( cap
)时,将分配一个具有双精度值的新数组,将内存从旧切片复制到新切片,并丢弃旧数组。
我经常看到这样的代码,如果事先知道切片容量,则会分配边界容量为零的切片:
var userIDs []string for _, bar := range rsp.Users { userIDs = append(userIDs, bar.ID) }
在这种情况下,切片以零尺寸len
和零边界电容cap
开头。 收到答案后,我们将元素添加到切片中,同时达到边界容量:选择一个新的基本数组,将cap
加倍,然后将数据复制到该数组。 如果答案中有8个元素,则会导致5个重新分布。
以下方法效率更高:
userIDs := make([]string, 0, len(rsp.Users)) for _, bar := range rsp.Users { userIDs = append(userIDs, bar.ID) }
在这里,我们使用make显式分配了切片的容量。 现在,我们可以安全地在其中添加数据,而无需进行其他重新分发和复制。
如果您不知道要分配多少内存,因为容量是动态的或以后会在程序中计算出来,请在程序运行后测量切片大小的最终分布。 我通常采用90%或99%的百分比,并对程序中的值进行硬编码。 如果CPU对您来说比RAM贵,请将该值设置为您认为必要的值。
该技巧也适用于地图: make(map[string]string, len(foo))
将分配足够的内存以避免重新分配。
有关切片实际如何工作的信息,请参阅本文 。
使用方法传输字节片
使用数据包时,请使用允许传输字节片的方法:这些方法通常可以更好地控制分发。
一个很好的例子是比较time.Format和time.AppendFormat 。 第一个返回一个字符串。 在time.AppendFormat
,这将选择一个新的字节片并在其上调用time.AppendFormat
。 第二个接收字节缓冲区,编写格式化的时间表示,然后返回扩展的字节片。 这通常在标准库的其他软件包中找到:请参见strconv.AppendFloat或bytes.NewBuffer 。
为什么这会提高生产率? 好了,现在您可以传递从sync.Pool
接收到的字节片,而不是每次都分配一个新的缓冲区。 或者,可以将初始缓冲区大小增加到更适合您的程序的值,以减少切片的重复副本数。
总结
您可以将所有这些方法应用于代码库。 随着时间的流逝,您将构建一个心理模型来推理Go程序中的性能。 这将大大有助于他们的设计。
但是根据情况使用它们。 这些只是建议,而不是福音。 使用基准测试并检查所有内容。
并知道何时停止。 提高生产率是一个很好的练习:这项任务很有趣,并且结果立即可见。 但是,提高生产率的有用性取决于具体情况。 如果您的服务在10毫秒内给出了答案,并且网络延迟为90毫秒,则您可能不应该尝试将这10毫秒缩短为5毫秒:您仍有95毫秒。 即使您最多将服务最优化为1毫秒,总延迟仍将为91毫秒。 大概吃更大的鱼。
明智地优化!
参考文献
如果您想了解更多信息,请参考以下伟大的灵感来源: