今天为大家分享一篇关于Go优化的文章,文章中会介绍一些技巧,通过这些技巧,我们可以事半功倍的提升程序性能。这些技巧只需要我们对程序稍加调整,不需要大的改动。希望能对大家有所帮助。

在本篇文章中,我们将会介绍一些技巧,通过这些技巧,我们可以事半功倍的提升程序性能。这些技巧只需要我们对程序稍加调整,那些需要对程序进行大量改动的优化,我们都会忽略掉,没有提及相应的技术。

0

开始之前

在对程序进行任何修改之前,我们需要花一些时间来做一个对比基准,以方便我们后面做比较。如果没有对比基准,我们的优化也变得没有意义,因为我们不确定到底是提升了还是降低了性能,是否有所改善。我们可以写一些基准测试,然后使用 pprof 进行一下测试。

1

使用sync.Pool复用已分配对象

sync.Pool 实现了一个 free list。这使我们可以重用先前分配的结构。这样可以缓冲对象的多次分配,减少内存回收的工作。这个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.
bp := bufpool.Get().(*[]byte)
b := *bp
defer func() {
*bp = b
bufpool.Put(bp)
}()


// Now, go do interesting things with your byte buffer.
buf := bytes.NewBuffer(b)

注意,在 Go1.13之前,每次进行垃圾收集时,都会清除池子,可能会影响程序的性能。

在把对象放回池子之前,必须要将数据结构中各字段数据进行清零。否则可能从池中获取到带有先前使用数据的脏对象。有可能带来严重安全风险。

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

2

避免使用指针为键的大map

在垃圾回收期间,运行时扫描包含指针的对象并对其进行追踪。如果有一个非常大的 map[string]int,则GC必须检查map中的每一个字符串,每次GC,因为字符串包含指针。

在示例中,我们将一千万个元素写入 map[sting]int,并进行来讲回收计时。我们在包范围内使用 map 确保是在堆内存中分配。

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

执行程序,得到下面结果。

→ 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%。在生成用例中,插入之前可以将字符串进行hash,转为整数。

可以通过很多操作来逃避GC。如果要分配大量的无指针结构,整数或字节的巨型数组,GC将不会对其扫描,这意味着无需GC的开销。这种技术通常需要对程序进行实质性的改造,因此本篇文章中不对其深入研究。

3

生成编组代码避免运行时反射

将结构与JSON等各种序列化格式进行编组和解组是比较常见的一种操作。特别是在构建微服务时。实际上,你经常会发现大多数微服务实际上唯一要做的就是序列化。诸如json.Marshal 和 json.Unmarshal之类的函数依赖于运行时反射,将struct字段序列化为字节,反之亦然。这可能就会很慢,反射的性能远不如显式代码高。

但是,不必一定是这种方式。编组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接口,因此将调用这些函数,而不是基于反射的默认函数。

4

使用strings.Builder构建字符串

在Go中,字符串是不可变的,我们视为只读字符片段。这意味着每次创建字符串时,都将分配新的内存,并有可能为垃圾收集器创建更多工作。

Go1.10中,strings.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 < b.N; i++ {
str = buildStrNaive()
}
}
func BenchmarkStringBuildBuilder(b *testing.B) {
for i := 0; i < b.N; i++ {
str = buildStrBuilder()
}
}

得到下面结果:

→ 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。

5

使用strconv代替fmt

fmt是Go中最常用的包之一。但是,当涉及到将整数转换为浮点数并转换为字符串时,它的性能不如其较低级的表兄弟 strconv。对于API的一些很小的更改,该包提供了很好的性能。

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

测试结果:

→ 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 3 allocs/op

可以看到,strconv版本的速度快了3.5倍,分配次数的1/3,分配内存的一半。

6

在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:重新分配之前切片可以增长到的元素数。

在内部,切片是定长数组。当达到切片的上限时,会分配一个新数组,其大小是前一个切片的上限的两倍,将内存从旧切片复制到新切片,并将旧数组丢弃

经常可以看到类似下面的代码,当预先知道分片的容量时,该分片会分配容量为零的分片。

var userIDs []string
for _, bar := range rsp.Users {
userIDs = append(userIDs, bar.ID)
}

在这种情况下,切片长度和容量从零开始。收到请求后,我们将用户附加到切片。这样做时,我们达到了分片的容量:分配了一个新的基础数组,该数组是前一个分片的容量的两倍,并将分片中的数据复制到其中。如果我们在响应中有8个用户,这将导致5个分配。

一种更有效的方法是将其更改为以下格式:

userIDs := make([]string, 0, len(rsp.Users)

for _, bar := range rsp.Users {
userIDs = append(userIDs, bar.ID)
}

通过使用make,我们已将容量明确分配给切片。现在,我们可以追加到切片,我们不会触发其他分配和副本。

如果由于容量是动态的或在程序的稍后阶段计算出的容量而又不知道应该分配多少,可以测量程序运行时最终得到的切片大小的分布。我通常采用90%或99%的百分比,并在程序中对值进行硬编码。如果你需要在RAM和CPU之间进行权衡,请将此值设置为高于你所需的值。

7

使用可以传递字节片的方法

当使用包时,应该使用允许传递字节片的方法:这些方法通常可以更好地控制分配。

以 time.Format 与 time.AppendFormat 为例。time.Format 返回一个字符串。在底层分配了一个新的字节片并在其上调用 time.AppendFormat。time.AppendFormat 获取一个字节缓冲区,写入时间的格式表示,然后返回扩展的字节片。这在标准库的其他包中很常见。

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

总结

通过本篇文章,我们应该能够采用这些技术并将其应用于代码库中。时间长了之后,我们会构建一个心理模型来推理Go程序中的性能。这可以大大有助于前期设计。

 

简单几招优化你的Go程序_Go程序