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


根据我的经验,性能不佳表现为以下两种方式之一:

  • 在小规模上表现良好的运营,但随着用户数量的增长而变得不可行。这些通常是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非常简单:实现一个分配对象新实例的函数。它应该返回一个指针类型。

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; i < numElements; i++ {
        foo[strconv.Itoa(i)] = i
    }

    for {
        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; i < numElements; i++ {
        foo[i] = i
    }

    for {
        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 GryskiTwitter帖子,这是一个有趣的例子,从大型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; i < b.N; i++ {
        str = buildStrNaive()
    }
}
func BenchmarkStringBuildBuilder(b *testing.B) {
    for i := 0; i < b.N; i++ {
        str = buildStrBuilder()
    }

在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{}通常会导致堆分配。阅读此博客,找出原因。

以下程序显示了性能差异:
// 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 < b.N; i++ {
        box = strconvFmt(a, blah)
    }
    a = box
}

func BenchmarkFmt(b *testing.B) {
    for i := 0; i < b.N; i++ {
        box = fmtFmt(a, blah)
    }
    a = box
}

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     
}

说明:

  • data:指向slice 中基础数据的指针
  • len:slice 中当前的元素数。
  • cap:slice 在重新分配之前可以增长的元素数。

在引擎盖下,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.Formatvs. time.AppendFormat是一个很好的例子。time.Format返回一个字符串。在引擎盖下,这会分配一个新的字节slice 并对其进行调用time.AppendFormat。time.AppendFormat采用字节缓冲区,写入时间的格式化表示,并返回扩展字节slice 。这在标准库的其他包中很常见:请参阅strconv.AppendFloat(链接)或bytes.NewBuffer。

为什么这会增加性能呢?那么,您现在可以传递从您获得的字节slice sync.Pool,而不是每次都分配一个新的缓冲区。或者,您可以将初始缓冲区大小增加到您认为更适合您的程序的值,以减少切片重新复制。