内容简介:根据我的经验,性能不佳表现为以下两种方式之一:我的职业生涯大部分时间都是用Python做数据科学,或者用Go构建服务; 我有更多优化后者的经验。Go通常不是我编写的服务的瓶颈 - 程序通常在与数据库通信时受到IO限制。但是,在批处理机器学习管道中 - 就像我在之前的角色中构建的那样 - 您的程序通常受CPU限制。当您的Go程序使用过多的CPU,并且过度使用会产生负面影响时,您可以使用各种策略来缓解这种情况。这篇文章解释了一些可以用来显着提高程序性能的技巧。我故意忽略需要付出巨大努力的技术,或者对程序结构进行
根据我的经验,性能不佳表现为以下两种方式之一:
- 在小规模上表现良好的运营,但随着用户数量的增长而变得不可行。这些通常是O(N)或O(N²)操作。当您的用户群很小时,这些表现很好,通常是为了将产品推向市场。随着您的使用基础的增长,您会看到更多您不期望的 病态示例 ,并且您的服务将停止运行。
- 许多个别小优化的来源 - AKA'千人死亡'。
我的职业生涯大部分时间都是用 Python 做数据科学,或者用 Go 构建服务; 我有更多优化后者的经验。Go通常不是我编写的服务的瓶颈 - 程序通常在与数据库通信时受到IO限制。但是,在批处理机器学习管道中 - 就像我在之前的角色中构建的那样 - 您的程序通常受CPU限制。当您的Go程序使用过多的CPU,并且过度使用会产生负面影响时,您可以使用各种策略来缓解这种情况。
这篇文章解释了一些可以用来显着提高程序性能的技巧。我故意忽略需要付出巨大努力的技术,或者对程序结构进行大量更改。
在你开始之前
在对程序进行任何更改之前,请花时间创建适当的基线进行比较。如果你不这样做,你会在黑暗中四处搜寻,想知道你的改变是否有任何改善。首先编写基准测试,并获取在pprof中使用的 配置文件 。在最好的情况下,这将是一个 Go基准 :这允许轻松使用pprof和内存分配分析。您还应该使用 benchcmp :一个有用的工具,用于比较两个基准测试之间的性能差异。
如果您的代码不容易进行基准测试,那么请从您可以计算的时间开始。您可以使用手动配置代码 runtime/pprof 。
让我们开始吧!
使用sync.Pool重新使用以前分配的对象
sync.Pool 实现一个 free-list .。这允许您重新使用先前分配的struct 。这会在多个用法中分配对象的分配,从而减少垃圾收集器必须完成的工作。API非常简单:实现一个分配对象新实例的函数。它应该返回一个指针类型。
<b>var</b> bufpool = sync.Pool{ New: func() <b>interface</b>{} { buf := make([]byte, 512) <b>return</b> &buf }}
在此之后,您可以Get()从池中获取对象,在Put()完成后将它们返回。
<font><i>// sync.Pool returns a interface{}: you must cast it to the underlying type</i></font><font> </font><font><i>// before you use it.</i></font><font> b := *bufpool.Get().(*[]byte) defer bufpool.Put(&b) </font><font><i>// Now, go do interesting things with your byte buffer.</i></font><font> buf := bytes.NewBuffer(b) </font>
在Go 1.13之前,每次发生垃圾收集时,池都被清除。这可能会对分配很多的程序的性能产生不利影响。在1.13中, 似乎更多的对象将在GC中存活下来 。
在将对象放回池中之前,必须将 struct 的字段清零。
如果不这样做,则可以从池中获取包含先前使用数据的“脏”对象。这可能是一个严重的安全风险!
type AuthenticationResponse { Token string UserID string } rsp := authPool.Get().(*AuthenticationResponse) defer authPool.Put(rsp) <font><i>// If we don't hit this if statement, we might return data from other users! </i></font><font> <b>if</b> blah { rsp.UserID = </font><font>"user-1"</font><font> rsp.Token = </font><font>"<b>super</b>-secret } <b>return</b> rsp </font>
确保始终保持零内存的安全方法是明确地这样做:
// 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)
}()
其中,这不是一个问题的唯一情况是当您使用正是你写的内存。例如:
<b>var</b> ( r io.Reader w io.Writer ) <font><i>// Obtain a buffer from the pool.</i></font><font> buf := *bufPool.Get().(*[]byte) defer bufPool.Put(&buf) </font><font><i>// We only write to w exactly what we read from r, and no more. </i></font><font> nr, er := r.Read(buf) <b>if</b> nr > 0 { nw, ew := w.Write(buf[0:nr]) } </font>
避免使用包含指针的struct作为大型Map的Key
在垃圾收集期间,运行时扫描包含指针的对象,并追踪它们。如果你有一个非常大的map[string]int,GC必须检查地图中的每个字符串,每个GC,因为字符串包含指针。
在这个例子中,我们向a写入1000万个元素map[string]int,并为垃圾收集计时。我们在包范围内分配映射以确保它是堆分配的。
<b>package</b> main <b>import</b> ( <font>"fmt"</font><font> </font><font>"runtime"</font><font> </font><font>"strconv"</font><font> </font><font>"time"</font><font> ) <b>const</b> ( numElements = 10000000 ) <b>var</b> foo = map[string]<b>int</b>{} func timeGC() { t := time.Now() runtime.GC() fmt.Printf(</font><font>"gc took: %s\n"</font><font>, time.Since(t)) } func main() { <b>for</b> i := 0; i < numElements; i++ { foo[strconv.Itoa(i)] = i } <b>for</b> { timeGC() time.Sleep(1 * time.Second) } } </font>
运行此程序,我们看到以下内容:
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。
<b>package</b> main <b>import</b> ( <font>"fmt"</font><font> </font><font>"runtime"</font><font> </font><font>"time"</font><font> ) <b>const</b> ( numElements = 10000000 ) <b>var</b> foo = map[<b>int</b>]<b>int</b>{} func timeGC() { t := time.Now() runtime.GC() fmt.Printf(</font><font>"gc took: %s\n"</font><font>, time.Since(t)) } func main() { <b>for</b> i := 0; i < numElements; i++ { foo[i] = i } <b>for</b> { timeGC() time.Sleep(1 * time.Second) } } </font>
再次运行程序,我们得到以下内容:
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的机制有点像这样:
<b>package</b> json <font><i>// Marshal take an object and returns its representation in JSON.</i></font><font> func Marshal(obj <b>interface</b>{}) ([]byte, error) { </font><font><i>// Check if this object knows how to marshal itself to JSON</i></font><font> </font><font><i>// by satisfying the Marshaller interface.</i></font><font> <b>if</b> m, is := obj.(json.Marshaller); is { <b>return</b> m.MarshalJSON() } </font><font><i>// It doesn't know how to marshal itself. Do default reflection based marshallling.</i></font><font> <b>return</b> marshal(obj) } </font>
如果我们知道如何编组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技巧来将基础字节作为具有零分配的字符串返回:请参阅 此博客 以进一步了解其工作原理。
让我们进行性能比较以验证两种方法:
<font><i>// main.go</i></font><font> <b>package</b> main <b>import</b> </font><font>"strings"</font><font> <b>var</b> strs = []string{ </font><font>"here's"</font><font>, </font><font>"a"</font><font>, </font><font>"some"</font><font>, </font><font>"long"</font><font>, </font><font>"list"</font><font>, </font><font>"of"</font><font>, </font><font>"strings"</font><font>, </font><font>"for"</font><font>, </font><font>"you"</font><font>, } func buildStrNaive() string { <b>var</b> s string <b>for</b> _, v := range strs { s += v } <b>return</b> s } func buildStrBuilder() string { b := strings.Builder{} </font><font><i>// Grow the buffer to a decent length, so we don't have to continually</i></font><font> </font><font><i>// re-allocate.</i></font><font> b.Grow(60) <b>for</b> _, v := range strs { b.WriteString(v) } <b>return</b> b.String() } </font>
<font><i>// main_test.go</i></font><font> <b>package</b> main <b>import</b> ( </font><font>"testing"</font><font> ) <b>var</b> str string func BenchmarkStringBuildNaive(b *testing.B) { <b>for</b> i := 0; i < b.N; i++ { str = buildStrNaive() } } func BenchmarkStringBuildBuilder(b *testing.B) { <b>for</b> i := 0; i < b.N; i++ { str = buildStrBuilder() } </font>
在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{}作为函数的参数。这有两个缺点:
- 你失去了类型安全。对我来说这是一个很大的问题。
- 它可以增加所需的分配数量。传递非指针类型interface{}通常会导致堆分配。阅读 此博客 ,找出原因。
以下程序显示了性能差异:
<font><i>// main.go</i></font><font> <b>package</b> main <b>import</b> ( </font><font>"fmt"</font><font> </font><font>"strconv"</font><font> ) func strconvFmt(a string, b <b>int</b>) string { <b>return</b> a + </font><font>":"</font><font> + strconv.Itoa(b) } func fmtFmt(a string, b <b>int</b>) string { <b>return</b> fmt.Sprintf(</font><font>"%s:%d"</font><font>, a, b) } func main() {} </font><font><i>// main_test.go</i></font><font> <b>package</b> main <b>import</b> ( </font><font>"testing"</font><font> ) <b>var</b> ( a = </font><font>"boo"</font><font> blah = 42 box = </font><font>""</font><font> ) func BenchmarkStrconv(b *testing.B) { <b>for</b> i := 0; i < b.N; i++ { box = strconvFmt(a, blah) } a = box } func BenchmarkFmt(b *testing.B) { <b>for</b> i := 0; i < b.N; i++ { box = fmtFmt(a, blah) } a = box } </font>
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 { <font><i>// pointer to underlying data in the slice.</i></font><font> data uintptr </font><font><i>// the number of elements in the slice.</i></font><font> len <b>int</b> </font><font><i>// the number of elements that the slice can </i></font><font> </font><font><i>// grow to before a new underlying array</i></font><font> </font><font><i>// is allocated.</i></font><font> cap <b>int</b> } </font>
说明:
- data:指向slice 中基础数据的指针
- len:slice 中当前的元素数。
- cap:slice 在重新分配之前可以增长的元素数。
在引擎盖下,slice 是固定长度的阵列数组。当你到达cap一个slice 时,会分配一个前一个slice 上限加倍的新数组,将内存从旧切片复制到新slice ,旧数组被丢弃
我经常看到类似下面的代码,当预先知道slice 的容量时,会分配零容量的slice 。
<b>var</b> userIDs []string <b>for</b> _, bar := range rsp.Users { userIDs = append(userIDs, bar.ID) }
在这种情况下,切片以零长度和零容量开始。收到响应后,我们将用户附加到slice 。当我们这样做时,我们达到了slice 的容量:需要分配了一个新的底层数组,它是前一个slice 容量的两倍,并且slice 中的数据被复制到其中。如果响应中有8个用户,则会产生5个分配。
一种更有效的方法是将其更改为以下内容:
userIDs := make([]string, 0, len(rsp.Users) <b>for</b> _, 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,而不是每次都分配一个新的缓冲区。或者,您可以将初始缓冲区大小增加到您认为更适合您的程序的值,以减少切片重新复制。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:- 程序常用的设计技巧
- Laravel 5 程序优化技巧
- 十个 Laravel 5 程序优化技巧
- 美女程序员观点:程序员最重要的非编程技巧
- 老鸟程序员才知道的40个小技巧
- @程序员,不容错过的 Vim 实用技巧请查收!
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。