优化Go程序的简单方法

我一直都很关心性能。 我不知道为什么。 但是我只是对缓慢的服务和程序感到生气。 看起来我并不孤单

在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()

 // 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之后似乎还有更多对象可以生存

!!! 在将对象返回池之前,请确保重置结构字段。

如果不这样做,则可以从包含先前使用数据的池中获取脏对象。 这是严重的安全风险!

 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]) } 

避免将包含指针的结构用作大型地图的键


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.Marshaljson.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()时才实际创建一个字符串。 他依靠一些不安全的技巧将基本字节作为分配为零的字符串返回:请参阅此博客,以进一步了解其工作方式。

比较两种方法的性能:

 // 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; i < bN; i++ { str = buildStrNaive() } } func BenchmarkStringBuildBuilder(b *testing.B) { for i := 0; i < bN; i++ { str = buildStrBuilder() } 

这是我的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{}传递通常会导致堆分配。 这篇博客文章解释了为什么会这样。
  • 以下程序显示了性能上的差异:

     // 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; i < bN; i++ { box = strconvFmt(a, blah) } a = box } func BenchmarkFmt(b *testing.B) { for i := 0; i < bN; i++ { box = fmtFmt(a, blah) } a = box } 

    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 { // 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 } 

    这些领域是什么?

    • 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.Formattime.AppendFormat 。 第一个返回一个字符串。 在time.AppendFormat ,这将选择一个新的字节片并在其上调用time.AppendFormat 。 第二个接收字节缓冲区,编写格式化的时间表示,然后返回扩展的字节片。 这通常在标准库的其他软件包中找到:请参见strconv.AppendFloatbytes.NewBuffer

    为什么这会提高生产率? 好了,现在您可以传递从sync.Pool接收到的字节片,而不是每次都分配一个新的缓冲区。 或者,可以将初始缓冲区大小增加到更适合您的程序的值,以减少切片的重复副本数。

    总结


    您可以将所有这些方法应用于代码库。 随着时间的流逝,您将构建一个心理模型来推理Go程序中的性能。 这将大大有助于他们的设计。

    但是根据情况使用它们。 这些只是建议,而不是福音。 使用基准测试并检查所有内容。

    并知道何时停止。 提高生产率是一个很好的练习:这项任务很有趣,并且结果立即可见。 但是,提高生产率的有用性取决于具体情况。 如果您的服务在10毫秒内给出了答案,并且网络延迟为90毫秒,则您可能不应该尝试将这10毫秒缩短为5毫秒:您仍有95毫秒。 即使您最多将服务最优化为1毫秒,总延迟仍将为91毫秒。 大概吃更大的鱼。

    明智地优化!

    参考文献


    如果您想了解更多信息,请参考以下伟大的灵感来源:

Source: https://habr.com/ru/post/zh-CN457004/


All Articles