根据我的经验,性能不佳表现为以下两种方式之一:
我的职业生涯大部分时间都是用 Python 做数据科学,或者用 Go 构建服务; 我有更多优化后者的经验。Go通常不是我编写的服务的瓶颈 - 程序通常在与数据库通信时受到IO限制。但是,在批处理机器学习管道中 - 就像我在之前的角色中构建的那样 - 您的程序通常受CPU限制。当您的Go程序使用过多的CPU,并且过度使用会产生负面影响时,您可以使用各种策略来缓解这种情况。
这篇文章解释了一些可以用来显着提高程序性能的技巧。我故意忽略需要付出巨大努力的技术,或者对程序结构进行大量更改。
在你开始之前
在对程序进行任何更改之前,请花时间创建适当的基线进行比较。如果你不这样做,你会在黑暗中四处搜寻,想知道你的改变是否有任何改善。首先编写基准测试,并获取在pprof中使用的 配置文件 。在最好的情况下,这将是一个 Go基准 :这允许轻松使用pprof和内存分配分析。您还应该使用 benchcmp :一个有用的工具,用于比较两个基准测试之间的性能差异。
如果您的代码不容易进行基准测试,那么请从您可以计算的时间开始。您可以使用手动配置代码 runtime/pprof 。
让我们开始吧!
使用sync.Pool重新使用以前分配的对象
sync.Pool 实现一个 free-list .。这允许您重新使用先前分配的struct 。这会在多个用法中分配对象的分配,从而减少垃圾收集器必须完成的工作。API非常简单:实现一个分配对象新实例的函数。它应该返回一个指针类型。
var bufpool = sync.Pool{ New: func() interface{} { buf := make([]byte, 512) return &buf }}
在此之后,您可以Get()从池中获取对象,在Put()完成后将它们返回。
// sync.Pool returns a interface{}: you must cast it to the underlying type // before you use it. b := *bufpool.Get().(*[]byte) defer bufpool.Put(&b) // Now, go do interesting things with your byte buffer. buf := bytes.NewBuffer(b)
在Go 1.13之前,每次发生垃圾收集时,池都被清除。这可能会对分配很多的程序的性能产生不利影响。在1.13中, 似乎更多的对象将在GC中存活下来 。
在将对象放回池中之前,必须将 struct 的字段清零。
如果不这样做,则可以从池中获取包含先前使用数据的“脏”对象。这可能是一个严重的安全风险!
type AuthenticationResponse { Token string UserID string } rsp := authPool.Get().(*AuthenticationResponse) defer authPool.Put(rsp) // If we don't hit this if statement, we might return data from other users! if blah { rsp.UserID = "user-1" rsp.Token = "super-secret } return rsp
确保始终保持零内存的安全方法是明确地这样做:
// reset resets all fields of the AuthenticationResponse before pooling it.
func (a* AuthenticationResponse) reset() {
a.Token = ""
a.UserID = ""
}
rsp := authPool.Get().(*AuthenticationResponse)
defer func() {
rsp.reset()
authPool.Put(rsp)
}()
其中,这不是一个问题的唯一情况是当您使用正是你写的内存。例如:
var ( r io.Reader w io.Writer ) // Obtain a buffer from the pool. buf := *bufPool.Get().(*[]byte) defer bufPool.Put(&buf) // We only write to w exactly what we read from r, and no more. nr, er := r.Read(buf) if nr > 0 { nw, ew := w.Write(buf[0:nr]) }
避免使用包含指针的struct作为大型Map的Key
在垃圾收集期间,运行时扫描包含指针的对象,并追踪它们。如果你有一个非常大的map[string]int,GC必须检查地图中的每个字符串,每个GC,因为字符串包含指针。
在这个例子中,我们向a写入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; ifor { timeGC() time.Sleep(1 * time.Second) } }
运行此程序,我们看到以下内容:
inthash → go install && inthash gc took: 98.726321ms gc took: 105.524633ms gc took: 102.829451ms gc took: 102.71908ms gc took: 103.084104ms gc took: 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; ifor { timeGC() time.Sleep(1 * time.Second) } }
再次运行程序,我们得到以下内容:
go install && inthash gc took: 3.608993ms gc took: 3.926913ms gc took: 3.955706ms gc took: 4.063795ms gc took: 3.91519ms gc took: 3.75226ms
好多了。我们已经将垃圾收集时间缩短了97%。在生产用例中,在插入Map之前,您需要将字符串哈希为整数。
你可以做更多的事情来逃避GC。如果您分配无指针结构,整数或字节的巨型数组, GC将不会扫描它 :这意味着您不需要支付GC开销。这些技术通常需要对程序进行大量的重新设计,因此我们今天不会深入研究它们。
与所有优化一样,您的里程可能会有所不同。查看 来自Damian Gryski 的 Twitter帖子,这 是一个有趣的例子,从大型Map中删除字符串以支持更智能的数据结构实际上增加了内存。一般来说,你应该阅读他所提出的一切。
代码生成编组代码以避免运行时反射
将struct编组和解组为各种序列化格式(如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 这样的代码生成器查看struct,并生成高度优化的代码,该代码与现有的编组接口json.Marshaller完全兼容。
下载该包,并在$file.go包含要为其生成代码的结构上运行以下命令。
easyjson -all $file.go
您应该找到$file_easyjson.go已生成的文件。由于easyjson已经为您实现了json.Marshaller接口,因此将调用这些函数而不是基于反射的默认值。恭喜: 您刚刚将JSON编组代码加速了3倍 。你可以通过很多东西来提高性能。
更改struct时,您需要确保重新生成编组代码。如果您忘记了,您添加的新字段将不会被序列化和反序列化,这可能会令人困惑!您可以使用它go generate来为您处理此代码生成。为了使这些与结构保持同步,我喜欢generate.go在包的根目录中调用包中go generate的所有文件:当有许多需要生成的文件时,这可以帮助维护。热门提示:go generate在CI中调用并检查它没有带有签入代码的差异,以确保结构是最新的。
使用strings.Builder建立字符串
在Go中,字符串是不可变的:将它们视为只读字节片。这意味着每次创建字符串时,都会分配新内存,并可能为垃圾收集器创建更多工作。
在Go 1.10中, strings.Builder 作为构建字符串的有效方式被引入。在内部,它写入一个字节缓冲区。只有在调用String()构建器时,才会实际创建字符串。它依赖于一些unsafe技巧来将基础字节作为具有零分配的字符串返回:请参阅 此博客 以进一步了解其工作原理。
让我们进行性能比较以验证两种方法:
// main.go package main import "strings" var strs = []string{ "here's", "a", "some", "long", "list", "of", "strings", "for", "you", } func buildStrNaive() string { var s string for _, v := range strs { s += v } return s } func buildStrBuilder() string { b := strings.Builder{} // Grow the buffer to a decent length, so we don't have to continually // re-allocate. b.Grow(60) for _, v := range strs { b.WriteString(v) } return b.String() }
// main_test.go package main import ( "testing" ) var str string func BenchmarkStringBuildNaive(b *testing.B) { for i := 0; ifor i := 0; i
在Macbook Pro上得到以下结果:
go test -bench=. -benchmem goos: darwin goarch: amd64 pkg: github.com/sjwhitworth/perfblog/strbuild BenchmarkStringBuildNaive-8 5000000 255 ns/op 216 B/op 8 allocs/op BenchmarkStringBuildBuilder-8 20000000 54.9 ns/op 64 B/op 1 allocs/op
我们可以看到, strings.Builder速度提高了4.7倍 ,导致分配数量的1/8,以及分配的内存的1/4。
如果性能很重要,请使用strings.Builder。一般来说,我建议除了最简单的构建字符串之外的所有情况都使用它。
使用strconv而不是fmt
fmt 是Go中最知名的软件包之一。您可能已经在第一个Go程序中使用它来向屏幕打印“hello,world”。然而,当涉及将整数和浮点数转换为字符串时,它的性能不如它的低级表兄: strconv 。对于API中的一些非常小的变化,这个软件包可以为您提供更好的性能。
fmt主要是interface{}作为函数的参数。这有两个缺点:
以下程序显示了性能差异:
// main.go package main import ( "fmt" "strconv" ) func strconvFmt(a string, b int) string { return a + ":" + strconv.Itoa(b) } func fmtFmt(a string, b int) string { return fmt.Sprintf("%s:%d", a, b) } func main() {} // main_test.go package main import ( "testing" ) var ( a = "boo" blah = 42 box = "" ) func BenchmarkStrconv(b *testing.B) { for i := 0; ifor i := 0; i
Macbook Pro上的基准测试结果:
go test -bench=. -benchmem goos: darwin goarch: amd64 pkg: github.com/sjwhitworth/perfblog/strfmt BenchmarkStrconv-8 30000000 39.5 ns/op 32 B/op 1 allocs/op BenchmarkFmt-8 10000000 143 ns/op 72 B/op
我们可以看到 strconv版本快3.5倍 ,分配数量的1/3,分配的内存的一半。
分配make中的容量以避免重新分配
在我们进行性能改进之前,让我们快速回顾一下slice 。slice 是Go中非常有用的构造。它提供了一个可调整大小的数组,能够在不重新分配的情况下在相同的底层内存上获取不同的视图。如果你偷看引擎盖下,slice 由三个元素组成:
type slice struct { // pointer to underlying data in the slice. data uintptr // the number of elements in the slice. len int // the number of elements that the slice can // grow to before a new underlying array // is allocated. cap int }
说明:
在引擎盖下,slice 是固定长度的阵列数组。当你到达cap一个slice 时,会分配一个前一个slice 上限加倍的新数组,将内存从旧切片复制到新slice ,旧数组被丢弃
我经常看到类似下面的代码,当预先知道slice 的容量时,会分配零容量的slice 。
var userIDs []string for _, bar := range rsp.Users { userIDs = append(userIDs, bar.ID) }
在这种情况下,切片以零长度和零容量开始。收到响应后,我们将用户附加到slice 。当我们这样做时,我们达到了slice 的容量:需要分配了一个新的底层数组,它是前一个slice 容量的两倍,并且slice 中的数据被复制到其中。如果响应中有8个用户,则会产生5个分配。
一种更有效的方法是将其更改为以下内容:
userIDs := make([]string, 0, len(rsp.Users) for _, bar := range rsp.Users { userIDs = append(userIDs, bar.ID) }
我们已经使用make明确地将容量分配给slice 。现在,我们可以附加到slice ,知道我们不会触发额外的分配和复制。
如果您不知道应分配多少因为容量是动态的或稍后在程序中计算的,请测量在程序运行时最终得到的切片大小的分布。我通常采用第90或第99百分位数,并对程序中的值进行硬编码。如果您有RAM来换取CPU,请将此值设置为高于您认为需要的值。
此建议也适用于map:使用make(map[string]string, len(foo))将在引擎盖下分配足够的容量以避免重新分配。
使用允许您传递字节slice 的方法
使用包时,请查看使用允许传递字节slice 的方法:这些方法通常可以让您更好地控制分配。
time.Format vs. time.AppendFormat 是一个很好的例子。time.Format返回一个字符串。在引擎盖下,这会分配一个新的字节slice 并对其进行调用time.AppendFormat。time.AppendFormat采用字节缓冲区,写入时间的格式化表示,并返回扩展字节slice 。这在标准库的其他包中很常见:请参阅strconv.AppendFloat(链接)或bytes.NewBuffer。
为什么这会增加性能呢?那么,您现在可以传递从您获得的字节slice sync.Pool,而不是每次都分配一个新的缓冲区。或者,您可以将初始缓冲区大小增加到您认为更适合您的程序的值,以减少切片重新复制。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 我们