快速提升 Go 程序性能的实用技巧,你值得了解一下。
作者 | Stephen Whitworth
译者 | 弯月,责编 | 屠敏
出品 | CSDN(ID:CSDNnews)
我对软件性能的话题十分感兴趣。虽然我说不清究竟是为什么。我忍受不了慢吞吞的服务和程序,而且似乎有此种感受的人不止我一个,比如还有Greg Linden:
我们尝试过在A/B测试中,将页面的延迟增加100毫秒,结果发现如此微小的延迟也会导致整体性能的大幅下降。
——亚马逊,Greg Linden
根据我的经验,糟糕的性能通常来自两个方面:
小规模时性能还不错,但用户数增长后就无法使用的操作。这类操作通常是O(N)或O(N²)。当用户基数小时,它们的性能还可以接受,所以经常在将产品推向市场阶段使用。随着用户基数的增长,这类操作会出现越来越多意想不到的异常,服务也变得不稳定。
出于不同人之手的大量小型优化——即“千疮百孔”的复杂代码。
我的职业生涯主要做两项工作,第一是用Python写数据科学脚本,第二是用Go写服务。关于后者我有更多的优化经验。Go通常不会是服务的瓶颈,因为这些程序通常需要访问数据库,因此更偏重于IO。相反,批处理的机器学习管线(前者)的程序通常偏重于CPU。如果Go语言使用了过多CPU,而且造成了负面影响,那么你可以采用几种策略来应对。
我在这篇文章中列出了一些不需要太多精力就能显著提高性能的技巧,并不包含那些需要太多精力或需要大幅度修改程序结构的技巧。
开始优化之前
开始优化之前,首先应该花些时间找出一个合适的基准线,以便稍后比较。如果没有基准,那就等于摸着石头过河,根本不知道自己的优化有没有效果。首先要编写性能测试程序,然后生成能用于pprof的profile文件。最好可以编写Go的性能测试脚本(https://dave.cheney.net/2013/06/30/how-to-write-benchmarks-in-go),这样可以很容易地使用pprof,还可以评测内存分配情况。还可以使用benchcmp,这个工具可以帮助比较两次性能测试之间的性能差异。
如果代码很难做性能测试,那就从你能测量时间的部分开始。可以利用runtime/pprof手工测量代码。
现在开始吧!
使用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)
不过要注意一些陷阱。在Go 1.13之前,每次发生垃圾回收时该池都会被清空。对于需要分配大量对象的程序来说,这可能会造成性能的影响。在1.13版本中似乎GC后能保留更多对象了(https://go-review.googlesource.com/c/go/+/162919/)。
你可能需要在将对象放回池中之前将其结构的字段清空。
如果不这样做,就可能从池中获得一个“脏”的对象,它包含之前使用过的数据。这可能会造成严重的安全问题!
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])
}
在大map中避免使用包含指针的结构作为map的键
关于Go中大型堆的性能问题已经有很多人讨论过了。在垃圾回收过程中,运行时会扫描包含指针的对象并遍历其指针。如果你有非常大的map[string]int,那么垃圾回收器就不得不在每次垃圾回收过程中检查map中的每个字符串,因为字符串包含指针。
这个例子中我们向一个map[string]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)
}
}
运行后可以得到以下结果:
???? 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)
}
}
重新运行程序,结果如下:
???? inthash → 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之后再插入到map中。
还有许多技巧可以避免垃圾回收。如果为一个不含指针的结构(如int或byte)分配了巨大的数组,那么垃圾回收器就不会扫描它,意味着没有任何垃圾回收的额外开销。这种技巧通常需要重写大量程序,所以这里就不再细谈了。
与任何优化一样,该技巧的效果因人而异。Damian Gryski的一系列推特(https://twitter.com/dgryski/status/1140685755578118144)介绍了一个有趣的例子,从一个大型map中去掉字符串而使用智能数据结构,实际上会增加内存开销。我建议你阅读下他的文章。
生成marshalling代码以避免运行时反射
将数据结构marshalh或unmarshal成JSON等各种序列化格式是个很常见的操作,特别是在构建微服务的时候。实际上,大部分微服务做的唯一工作就是序列化。像json.Marshal和json.Unmarshal需要依赖运行时反射才能将结构体的字段序列化成字节,反之亦然。这个操作很慢,反射的性能完全无法与显式的代码相比。
但我们不必这么做。marshalling 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)
}
如果我们知道怎样将对象marshal成JSON,就应该避免运行时反射。但我们不想手工marshal所有代码,怎么办呢?可以让计算机替我们写程序!像easyjson等代码生成器会检查结构体,然后生成高度优化且与json.Marshaller等接口完全兼容的代码。
下载这个包,然后在包含结构体的$file.go上运行下面的命令:
easyjson -all $file.go
这个命令会生成$file_easyjson.go。由于easyjson为我们实现了json.Marshaller接口,因此序列化时不会调用默认的反射,而是会使用生成的函数。祝贺你!你已经将JSON marshalling的代码的速度提高了三倍。还有许多其他技巧可以进一步提升性能。
我推荐这个包,是因为我之前用过,而且效果非常不错。不过请不要以此为契机跟我争论哪个JSON marshal的包最快。
需要确保在结构体改变后重新生成marshalling的代码。如果忘记,那么新的字段就不会被序列化和反序列化,会给编程造成困扰!可以调用go generate来处理代码生成。为了保证代码与结构体同步,我会在包的根目录下放一个generate.go文件,它会针对保重的所有文件调用go generate,这样可以帮助我处理众多需要生成的文件。小提示:在CI过程中调用go generate,检查生成的代码与已提交的代码是否有区别,来确保结构体是最新的。
使用strings.Builder来构建字符串
Go语言的字符串是不可修改的,可以认为它们是只读的字节切片。这就是说,每次创建字符串都要分配新的内存,可能还会给垃圾回收器造成更多工作。
Go 1.10引入了strings.Builder作为高效率构建字符串的方式。它内部会将字符串写入到字节缓冲区。只有在builder上调用String()时才会真正生成字符串。它依赖一些unsafe的技巧将底层的字节作为字符串返回,而不实际进行内存非配。这篇文章(https://syslog.ravelin.com/byte-vs-string-in-go-d645b67ca7ff)介绍了更多其工作原理。
我们来比较下两种方式的性能:
// 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上的结果如下:
???? strbuild → 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。一般来说,除非是非常不重要的情况,否则我建议永远使用strings.Builder来构建字符串。
使用strconv代替fmt
fmt是Go中最著名的包之一。估计你从第一个Go程序——输出“hello, world”——的时候就开始用它了。但是,如果需要将整数和浮点数转换成字符串,它就不如更底层的strconv有效率了。strconv只需要在API中进行很小改动,就能带来不错的性能提升。
大多数情况下fmt会接受一个interface{}作为参数。这样做有两个弊端:
失去了类型安全。对于我来说这是个很大的问题。
会增加内存分配次数。将非指针类型作为interface{}传递通常会导致堆分配的问题。进一步的内容可以阅读这篇文章(https://www.darkcoding.net/software/go-the-price-of-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上的测试结果:
???? strfmt → 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,内存分配量是1/2。
在make中指定分配的容量来避免重新分配
在讨论性能改善之前,我们先来迅速看一下切片。切片是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:切片中指向底层数据的指针
len:切片中的当前元素数目
cap:在不重新分配内存的前提下,切片能够增长到的元素数目
在底层,切片是固定长度的数组。当长度增长到cap时,就会重新分配一个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时间,可以设置更高的值。
这条建议也适用于map:适用make(map[string]string, len(foo))可以分配足够的底层内存以避免内存重新分配。
关于切片的工作原理可以参见这篇文章“Go切片:用法和内部原理”(https://blog.golang.org/go-slices-usage-and-internals)。
使用可以接受字节切片的方法
在使用包时,寻找那些接受字节切片作为参数的方法,这些方法通常给你更多控制内存分配的自由。
一个很好的例子就是time.Format和time.AppendFormat。time.Format返回字符串。内部会分配一个新的字节切片,然后在其上调用time.AppendFormat。而time.AppendFormat接受一个字节缓冲区,将格式化后的时间写入缓冲区,然后返回扩展后的字节切片。标准库中这种做法非常常见,如strconv.AppendFloat或bytes.NewBuffer。
为什么这样能提高性能?因为你可以传递从sync.Poolh获得的字节切片,而不需要每次都分配新的缓冲区。或者可以初始化一个足够大的缓冲区,来减少切片拷贝。
总结
读完这篇文章后,你应该可以在代码中应用这些技巧了。长期坚持这种做法,你就会习惯于考虑Go程序的性能。这会极大地影响你的设计能力。
作为结语,我需要提醒一点。我的这些建议只是某些具体情况下的建议,而不是真理。一定要自己测量性能。
要知道何时该停止优化。提高系统性能会让工程师感觉非常满足:问题本身很有趣,也有立竿见影的效果。但是,提高性能带来的效果非常依赖于具体情况。如果服务的响应时间只有10毫秒,而网络访问需要90毫秒,那么将10毫秒优化到5毫秒就完全不值得,因为你依然需要95毫秒。就算你将响应时间优化到1毫秒,最后结果还是91毫秒。你应该去做其他更有价值的事情。
用心优化!
原文:https://stephen.sh/posts/quick-go-performance-improvements
作者:Stephen Whitworth,软件工程师@monzo,https://www.ravelin.com/的合伙创始人。
本文为 CSDN 翻译,转载请注明来源出处。
【END】
有疑问加站长微信联系(非本文作者)