go程序优化 1、内存优化
A、将小对象合并成结构体一次分配,减少内存分配次数 Go runtime底层采用内存池机制,每个span大小为4k,同时维护一个cache。cache有一个0到n的list数组,list数组的每个单元挂载的是一个链表,链表的每个节点就是一块可用的内存块,同一链表中的所有节点内存块都是大小相等的;但是不同链表的内存大小是不等的,即list数组的一个单元存储的是一类固定大小的内存块,不同单元里存储的内存块大小是不等的。cache缓存的是不同类大小的内存对象,申请的内存大小最接近于哪类缓存内存块时,就分配哪类内存块。当cache不够时再向spanalloc中分配。
B、缓存区内容一次分配足够大小空间,并适当复用 在协议编解码时,需要频繁地操作[]byte,可以使用bytes.Buffer或其它byte缓存区对象。bytes.Buffer等通过预先分配足够大的内存,避免当增长时动态申请内存,减少内存分配次数。对于byte缓存区对象需要考虑适当地复用。
C、slice和map采make创建时,预估大小指定容量 slice和map与数组不一样,不存在固定空间大小,可以根据增加元素来动态扩容。 slice初始会指定一个数组,当对slice进行append等操作时,当容量不够时,会自动扩容: 如果新的大小是当前大小2倍以上,则容量增涨为新的大小;否则循环以下操作:如果当前容量小于1024,按2倍增加;否则每次按当前容量1/4增涨,直到增涨的容量超过或等新大小。 map的扩容比较复杂,每次扩容会增加到上次容量的2倍。map的结构体中有一个buckets和oldbuckets,用于实现增量扩容:正常情况下,直接使用buckets,oldbuckets为空;如果正在扩容,则oldbuckets不为空,buckets是oldbuckets的2倍,因此,建议初始化时预估大小指定容量
D、长调用栈避免申请较多的临时对象 Goroutine的调用栈默认大小是4K(1.7修改为2K),采用连续栈机制,当栈空间不够时,Go runtime会自动扩容: 当栈空间不够时,按2倍增加,原有栈的变量会直接copy到新的栈空间,变量指针指向新的空间地址;退栈会释放栈空间的占用,GC时发现栈空间占用不到1/4时,则栈空间减少一半。比如栈的最终大小2M,则极端情况下,就会有10次的扩栈操作,会带来性能下降。因此,建议控制调用栈和函数的复杂度,不要在一个goroutine做完所有逻辑;如的确需要长调用栈,而考虑goroutine池化,避免频繁创建goroutine带来栈空间的变化。
E、避免频繁创建临时对象 Go在GC时会引发stop the world,即整个情况暂停。Go1.8最坏情况下GC为100us。但暂停时间还是取决于临时对象的个数,临时对象数量越多,暂停时间可能越长,并消耗CPU。因此,建议GC优化方式是尽可能地减少临时对象的个数:尽量使用局部变量;所多个局部变量合并一个大的结构体或数组,减少扫描对象的次数,一次回尽可能多的内存。
2、并发优化
A、高并发的任务处理使用goroutine池 Goroutine虽然轻量,但对于高并发的轻量任务处理,频繁来创建goroutine来执行,执行效率并不会太高,因为:过多的goroutine创建,会影响go runtime对goroutine调度,以及GC消耗;高并发时若出现调用异常阻塞积压,大量的goroutine短时间积压可能导致程序崩溃。
B、避免高并发调用同步系统接口 goroutine的实现,是通过同步来模拟异步操作。 网络IO、锁、channel、Time.sleep、基于底层系统异步调用的Syscall操作并不会阻塞go runtime的线程调度。本地IO调用、基于底层系统同步调用的Syscall、CGo方式调用C语言动态库中的调用IO或其它阻塞会创建新的调度线程。网络IO可以基于epoll的异步机制(或kqueue等异步机制),但对于一些系统函数并没有提供异步机制。例如常见的posix api中,对文件的操作就是同步操作。虽有开源的fileepoll来模拟异步文件操作。但Go的Syscall还是依赖底层的操作系统的API。系统API没有异步,Go也做不了异步化处理。因此,建议:把涉及到同步调用的goroutine,隔离到可控的goroutine中,而不是直接高并的goroutine调用。
C、高并发时避免共享对象互斥 传统多线程编程时,当并发冲突在4~8线程时,性能可能会出现拐点。Go推荐不通过共享内存来通信,Go创建goroutine非常容易,当大量goroutine共享同一互斥对象时,也会在某一数量的goroutine出在拐点。因此,建议:goroutine尽量独立,无冲突地执行;若goroutine间存在冲突,则可以采分区来控制goroutine的并发个数,减少同一互斥对象冲突并发数。
3、其它优化
A、避免使用CGO或者减少CGO调用次数 GO可以调用C库函数,但Go带有垃圾收集器且Go的栈动态增涨,无法与C无缝地对接。Go的环境转入C代码执行前,必须为C创建一个新的调用栈,把栈变量赋值给C调用栈,调用结束现拷贝回来。调用开销较大,需要维护Go与C的调用上下文,两者调用栈的映射。相比直接的GO调用栈,单纯的调用栈可能有2个甚至3个数量级以上。 因此,建议:尽量避免使用CGO,无法避免时,要减少跨CGO的调用次数。
B、减少[]byte与string之间转换,尽量采用[]byte来字符串处理 GO里面的string类型是一个不可变类型,GO中[]byte与string底层是两个不同的结构,转换存在实实在在的值对象拷贝,所以尽量减少不必要的转化。 因此,建议:存在字符串拼接等处理,尽量采用[]byte。
C、字符串的拼接优先考虑bytes.Buffer string类型是一个不可变类型,但拼接会创建新的string。GO中字符串拼接常见有如下几种方式: string + 操作 :导致多次对象的分配与值拷贝 fmt.Sprintf :会动态解析参数,效率好不哪去 strings.Join :内部是[]byte的append bytes.Buffer :可以预先分配大小,减少对象分配与拷贝 因此,建议:对于高性能要求,优先考虑bytes.Buffer,预先分配大小。fmt.Sprintf可以简化不同类型转换与拼接。
开始之前
在对程序进行任何修改之前,我们需要花一些时间来做一个对比基准,以方便我们后面做比较。如果没有对比基准,我们的优化也变得没有意义,因为我们不确定到底是提升了还是降低了性能,是否有所改善。我们可以写一些基准测试,然后使用 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 获得的字节片,而不是每次都分配一个新的缓冲区。或者,我们可以将初始缓冲区大小增加到更适合程序的值,以减少切片重新复制