首页 > 代码库 > golang 核心开发者 Dmitry Vyukov(1.1 调度器作者) 关于性能剖析

golang 核心开发者 Dmitry Vyukov(1.1 调度器作者) 关于性能剖析

让我们假设你有一golang 程序,想改善其性能。有几种工具可以帮我们完成这个任务。这些工具可以帮我们识别程序中的热点(cpu,io,memory), 热点即是那些需要我们集中精力于其上,能显著改善改善性能的地方。然而,另外一种结果也是可能的,工具帮我们识别出程序里的多种性能缺陷。比如,每次查询数据库,你都准备sql 语句,然而,你可以在程序启动时,只准备一次。另一个例子,一个O(n^2)的算法莫名其妙的溜进,某些存在O(n) 算法的地方。为了识别出这些情况,你需要合理检查程序剖析所看到的结果。比如第一个例子中,有显著的时间花费在sql 语句准备阶段,这就是一个危险的信号。

了解多种关于性能的边界因素也是重要的。比如,你的程序使用100Mbps网络链路通信,它已经使用了链路90Mbps以上的带宽,那你的程序就没有太多性能改善空间。对于磁盘io,内存消耗,计算性任务也存在类似的情况。这些情况,铭记在心,接着我们可以查看可用的工具。

注意:工具之间会相互干扰,比如,精准的内存剖析会偏差cpu剖析,goroutine阻塞剖析影响调度器的追踪等,隔离地使用工具能够获得更精确的信息。以下阐述基于golang 1.3。

cpu 剖析器

go运行时内置cpu profiler,它可以显示哪些函数消耗了多少的百分比cpu时间,有三种方式可以访问它:

1.最简单的是go test 命令-cpuprofile标志,例如,以下命令:

$ go test -run=none -bench=ClientServerParallel4 -cpuprofile=cprof net/http

剖析给定的benchmark,把cpu profile 信息写入‘cprof‘ 文件

接着:

$ go tool pprof --text http.test cprof

打印最热点的函数列表,有几种可用输出格式,最有用的几个:--text,--web,--list.运行 ‘go tool pprof‘ 获取完整列表

2.net/http/pprof 包,这个方案对于网络服务应用十分理想,你仅需导入net/http/pprof,就可使用下面命令profile:

go tool pprof --text mybin http://myserver:6060:/debug/pprof/profile

3.手动profile 采集,需要引入runtime/pprof,在main函数添加下述代码:

if *flagCpuprofile != "" {
   f, err := os.Create(*flagCpuprofile)
   if err != nil {
      log.Fatal(err)
   }
   pprof.StartCPUProfile(f)
   defer pprof.StopCPUProfile()
}

剖析信息会写入指定的文件,与第一个选项同样方式可视化它。这里有一个使用--web 选项可视化的例子:

技术分享

你可以使用--list=funcname 选项查阅单个函数,列如以下profile 显示时间append 函数时间花费:

.      .   93: func (bp *buffer) WriteRune(r rune) error {
.      .   94:     if r < utf8.RuneSelf {
5      5   95:         *bp = append(*bp, byte(r))
.      .   96:         return nil
.      .   97:     }
.      .   98: 
.      .   99:     b := *bp
.      .  100:     n := len(b)
.      .  101:     for n+utf8.UTFMax > cap(b) {
.      .  102:         b = append(b, 0)
.      .  103:     }
.      .  104:     w := utf8.EncodeRune(b[n:n+utf8.UTFMax], r)
.      .  105:     *bp = b[:n+w]
.      .  106:     return nil
.      .  107: }

当剖析器不能解开调用栈时,使用三个特殊条目使用:GC,System,ExternalCode。GC 表示花费在垃圾回收的时间;System表示花费在goroutine 调度器,栈管理及其他辅助运行时代码的时间;ExternalCode,表示花费在调用本地动态库时间。这里有一些关于如何解释profile结果的提示:

如果你看到大量时间花费在runtime.mallocgc 函数,程序可能做了过多的小内存分配。profile能告诉你这些分配来自哪里。

如果大量时间花费在channel,sync.Mutex,其他的同步原语,或者系统组件,程序可能遭受资源争用。考虑以下从新组织代码结构,消除最常访问的共享资源。常用的技术包括分片/分区, 局部缓冲/聚集, 写时拷贝等。

如果大量时间花费在syscall.Read/Write, 程序可能产生了太多小数据量读写操作,可考虑使用bufio 包裹os.File or net.Conn。

如果大量时间花费在GC 组件,要么是程序分配了太多的临时对象,要么是堆内存太小,导致垃圾收集操作运行频繁。

注意:在当前的darwin 平台,cpu profiler 不能正确工作  darwin 不能正常工作;window 平台需要安装cygwin,perl,graphviz,用来生成svg/web profile;在linux平台,你也可使用perf system profiler,它不能解开go stack,但可剖析解开cgo/swig 代码和内核代码。

memory profiler

内存剖析器显示哪些函数分配堆内存,你可以采集它,使用’go test  --memprofile‘, 或者net/http/ppro经由  http://myserver:6060:/debug/pprof/heap 或者调用  runtime/pprof.WriteHeapProfile

你可以仅仅可视化profile 收集过程中的活跃分配(传递 --inuse_space 标志,默认),或者自程序启动以来的所有分配(--alloc_space )。

你可以显示分配了多少字节或者多少个对象(--inuse/alloc_space or --inuse/alloc_objects )。多次剖析过程中,profiler 趋向于采样更大的对象。了解大对象影响内存消耗和gc 时间,大量小分配影响执行速度也在某种程度影响gc时间。对象可以是持久或临时的生命周期。如果在程序启动时,你有几个大的持久对象分配,它们很可能被采集到。这些对象影响内存消耗和gc时间,但不影响正常的执行速度。另一方面,如果你有大量短生命周期对象,那么profile过程中,它们几乎不能被呈现。但它们对执行速度有显著影响,因为它们被分配释放很频繁。

一般情况是,如果你要减少内存消耗,考虑使用--inuse_space 选项的profile;如果是想要改善执行速度,使用--alloc_objects 选项profile。有几个选项可以用来控制报告的粒度,--function函数级别(默认),--lines,--files,--adrresses,行级,文件级,指令地址级。

优化通常是应用特定的,以下是一些常用建议:

1.合并对象进入更大的对象。例如,使用bytes.Buffer替代*bytes.Buffer,作为结构体成员,这个能减少内存分配次数,减轻gc压力。

2.那些脱离它们声明作用域的局部变量,被提升的堆内存分配。编译器通常不能判定几个这样变量有相同的生命周期,所以要单独分配它们。可以把下面代码:

for k, v := range m {
   k, v := k, v   // copy for capturing by the goroutine
   go func() {
       // use k and v
   }()
}

替换为: 

for k, v := range m {
   x := struct{ k, v string }{k, v}   // copy for capturing by the goroutine
   go func() {
       // use x.k and x.v
   }()
}

使用一次分配代替两次,但对代码可读性有消极影响,所以适度使用。

3.一个合并分配的特例,如果你了解使用中slice典型大小,slice的底层数组可以使用预分配。

type X struct {
    buf      []byte
    bufArray [16]byte // Buf usually does not grow beyond 16 bytes.
}

func MakeX() *X {
    x := &X{}
    // Preinitialize buf with the backing array.
    x.buf = x.bufArray[:0]
    return x
}

4.使用占用空间小的数据类型, 比如,使用 int8 替代 int。

5.那些不包含任何指针的对象(注意:string,slice,map,chan 包含隐式指针),不会被垃圾收集器扫描。比如 1Gb byte slice 不会影响gc time,从活跃使用的对象中移除指针,能对gc time 产生正面影响。一些可能方式:使用索引替代指针,分割对象成两部分,其中一部分没有任何指针。

6.使用freelist 重用临时对象,减少分配次数。标准库包含的sync.Pool 类型允许在gc 之间多次重用同一个对象。不过要意识到,就像任何手动内存管理模式一样,不正确地使用sync.Pool 可以导致 use-after-free bug。

Blocking  Profile 

goroutine 阻塞 profiler展示goroutine 阻塞等待同步原语(包括定时器channel),出现在代码中哪些地方。你可以使用‘go test --blockprofile‘,  net/http/pprof 经由 http://myserver:6060:/debug/pprof/block,或者调用    runtime/pprof.Lookup("block").WriteTo 

阻塞剖析器默认没有被开启,’go test --blockprofile‘ 会为你自动开启,但是使用net/http/pprof, runtime/pprof,需要你手动开启。调用runtime.SetBlockProfileRate,开启阻塞剖析器,SetBlockProfileRate 控制blocking profile 中阻塞时间的报告粒度。

如果一个函数包含多个阻塞操作,了解到那个操作导致阻塞,就变得不太明晰,如此可以使用--lines 标志辨别。

注意并不是所有的阻塞都是不好的。当一个goroutine阻塞,底层工作线程会切换到另一个goroutine。这样以来,协作的go环境的阻塞与非协作系统互斥器上的阻塞,就有着显著差异。(c++, java 线程库里,阻塞会导致线程空闲,和昂贵的线程上下文切换)

在time.Ticker 上阻塞通常没什么问题。如果一个goroutine在一个ticker上阻塞10 s,阻塞剖析中也会看到10s阻塞。在sync.WaitGroup 上阻塞,大多也没什么问题。例如,一个任务花费10s,goroutine在waitgroup等待,记账10s。在sync.Cond 上阻塞是好是坏,取决于具体情况。消费者阻塞在channel 上,暗示生产者的慢速,或者缺少可以做的工作。生产者阻塞在channel 上,暗示消费者的慢速,通常这也不是个问题。阻塞于channel基于的信号量,显示有多少goroutine被卡在信号量上。阻塞于sync.Mutex, sync,RWMutex, 一般不太好。你可以使用--ignore  排除那些不感兴趣的阻塞事件。

goroutine的阻塞产生两种消极后果:

1. 程序不能与处理器线性比例伸缩。

2. 过多的goroutine阻塞与解阻塞,消耗太多cpu时间。

以下是一些提示帮助减少goroutine阻塞:

1. 在匹配生产者,消费者模型代码中,使用充足缓冲的 buffer channel,无缓冲channel实质上限制了程序的并行度。

2. 在有很多读取操作, 很少修改数据操作的场景,使用sync.RWMutex 代替 sync.Mutex。读者之间不会相互阻塞。

3. 某些场景甚至可以通过使用写时拷贝技术完全移除mutex。如果被保护的数据结构修改的 不太频繁,制造一份拷贝是可行的:

   type Config struct {

    Routes   map[string]net.Addr
    Backends []net.Addr
}

var config unsafe.Pointer  // actual type is *Config

// Worker goroutines use this function to obtain the current config.
func CurrentConfig() *Config {
    return (*Config)(atomic.LoadPointer(&config))
}

// Background goroutine periodically creates a new Config object
// as sets it as current using this function.
func UpdateConfig(cfg *Config) {
    atomic.StorePointer(&config, unsafe.Pointer(cfg))
}

这个模式防止写者在更新操作时阻塞了读者的活动。

4.分区是另外一种常用的在易变数据结构上减少争用/阻塞的技术。下面是一个怎么分区一个hashmap 的示例:

type Partition struct {
    sync.RWMutex
    m map[string]string
}

const partCount = 64
var m [partCount]Partition

func Find(k string) string {
    idx := hash(k) % partCount
    part := &m[idx]
    part.RLock()
    v := part.m[k]
    part.RUnlock()
    return v
}

5. 局部缓冲和批量更新可以帮助减少不可分区的数据结构上争用。

const CacheSize = 16

type Cache struct {
    buf [CacheSize]int
    pos int
}

func Send(c chan [CacheSize]int, cache *Cache, value int) {
    cache.buf[cache.pos] = value
    cache.pos++
    if cache.pos == CacheSize {
        c <- cache.buf
        cache.pos = 0
    }
}

这个技术并不限于channel上,可以用在批量更新map, 批量分配内存。

6. 使用sync.Pool 的freelist ,而非基于channel,或mutex 保护的 freelist。sync.Pool 内部用了一些聪明技术减少阻塞。

Goroutine Profiler

goroutine 剖析器仅是让你看到进程所有活跃goroutine的当前堆栈,这个对于调试负载均衡及死锁问题,十分方便。

goroutine profile 仅对运行中的应用程序,显得合理,所以go test 命令做不到这点。你可以使用 net/http/pprof 经由 http://myserver:6060:/debug/pprof/goroutine 或者调用  runtime/pprof.Lookup("goroutine").WriteTo 。但是最有用的方法是在浏览器里键入 http://myserver:6060:/debug/pprof/goroutine?debug=2, 你会看到类似程序崩溃时的堆栈追踪。注意显示 “syscall" 状态的goroutine 消费os 线程,其他goroutine不会。”io wait“ 状态的goroutine 也不消费os 线程,它们停靠在非阻塞的网络轮询器上。 

golang 核心开发者 Dmitry Vyukov(1.1 调度器作者) 关于性能剖析