优化Go程序的简单技巧 - stephen.sh

栏目: Go · 发布时间: 5年前

内容简介:根据我的经验,性能不佳表现为以下两种方式之一:我的职业生涯大部分时间都是用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 GryskiTwitter帖子,这 是一个有趣的例子,从大型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,而不是每次都分配一个新的缓冲区。或者,您可以将初始缓冲区大小增加到您认为更适合您的程序的值,以减少切片重新复制。


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Practical JavaScript, DOM Scripting and Ajax Projects

Practical JavaScript, DOM Scripting and Ajax Projects

Frank Zammetti / Apress / April 16, 2007 / $44.99

http://www.amazon.com/exec/obidos/tg/detail/-/1590598164/ Book Description Practical JavaScript, DOM, and Ajax Projects is ideal for web developers already experienced in JavaScript who want to ......一起来看看 《Practical JavaScript, DOM Scripting and Ajax Projects》 这本书的介绍吧!

RGB转16进制工具
RGB转16进制工具

RGB HEX 互转工具

正则表达式在线测试
正则表达式在线测试

正则表达式在线测试

RGB CMYK 转换工具
RGB CMYK 转换工具

RGB CMYK 互转工具