优化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,而不是每次都分配一个新的缓冲区。或者,您可以将初始缓冲区大小增加到您认为更适合您的程序的值,以减少切片重新复制。


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

查看所有标签

猜你喜欢:

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

程序员面试宝典

程序员面试宝典

欧立奇、刘洋、段韬 / 电子工业出版社 / 2006-7 / 39.00元

本书取材于各大IT公司历年面试真题(包括笔试题、口试题、电话面试、英语面试,以及逻辑测试和智商测试)。通过精确详细的分类,把在应聘程序员(含网络、测试等)过程中所遇见的常见考点分为21章。不仅对传统的C系语言考点做了详尽的解说,包括面向对象问题、sizeof问题、const问题、数据结构问题等。还根据外企出题最新特点,针对设计模式问题、C#问题、网络问题、数据库问题、NET问题等,做了深入的说明。......一起来看看 《程序员面试宝典》 这本书的介绍吧!

HTML 编码/解码
HTML 编码/解码

HTML 编码/解码

Base64 编码/解码
Base64 编码/解码

Base64 编码/解码

HEX HSV 转换工具
HEX HSV 转换工具

HEX HSV 互换工具