Skip to content

CPU 性能与优化

上一篇我们掌握了 pprof 的全套工具,本篇聚焦其中的 CPU profile:从采样原理讲起,到读 top、list、web 输出,再到常见 CPU 瓶颈(热循环、字符串、正则、JSON、反射)的优化手法,最后讲编译器层面的内联与逃逸对 CPU 的影响。所有结论均配 benchmark 量化。

一、CPU profile 原理:采样分析

Go 的 CPU profile 不是「全量统计」,而是「采样统计」:运行时每 10ms 中断一次所有正在运行的 goroutine,记录它们的调用栈。一段时间内某个函数出现在栈顶的次数越多,说明它消耗的 CPU 越多。

1. 采样的本质

时间轴 ─────────────────────────────────────────►
         │     │     │     │     │     │
采样点   ▼     ▼     ▼     ▼     ▼     ▼
栈A:    func1 func1 func2 func1 func1 func1
栈B:    func2       func2       func2

统计:
  func1 出现 4 次 → 占比 4/6 = 67%
  func2 出现 2 次 → 占比 2/6 = 33%

关键结论:

  • profile 反映的是「这段时间内」的 CPU 分布,不是单次调用耗时。
  • 采样间隔 10ms 意味着精度有限:执行时间 < 10ms 的函数可能完全采不到。
  • 想测单次调用耗时,用 benchmark(go test -bench),不要用 profile。

2. flat vs cum

pprof 输出里有两个核心指标:

  • flat:函数自身(不含子调用)消耗的 CPU。
  • cum(cumulative):函数及其所有子调用消耗的 CPU。
main ──► foo ──► bar
        flat=10ms  flat=30ms
        cum =40ms  cum =30ms

foo 的 flat=10ms 是它自己代码的耗时,cum=40ms 包含了 bar 的 30ms。优化时优先看 flat 高的函数(自己的代码慢),再看 cum 高但 flat 低的(调用链路慢,需下钻到子函数)。

二、采样率设置

CPU 采样率由 runtime.SetCPUProfileRate(hz) 控制,默认 100Hz(每秒 100 次,即 10ms 一次)。

go
package main

import (
	"fmt"
	"log"
	"os"
	"runtime"
	"runtime/pprof"
	"time"
)

func workload(n int) int {
	total := 0
	for i := 0; i < n; i++ {
		total += i * i
	}
	return total
}

func main() {
	// 提高采样率到 1000Hz(1ms 一次),精度更高但开销更大
	// 必须在 StartCPUProfile 之前调用
	runtime.SetCPUProfileRate(1000)

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

	start := time.Now()
	result := workload(100_000_000)
	fmt.Printf("result=%d, cost=%v\n", result, time.Since(start))
}

警告:采样率越高,profile 自身的开销越大,会扭曲结果。生产环境几乎不建议改默认值;只在某些极短任务需要更高精度时短暂调高。

三、分析 CPU 热点

1. top 命令:消耗最多的函数

进入 go tool pprof cpu.prof 后,top 列出 flat 最高的函数:

(pprof) top10
Showing nodes accounting for 9.20s, 92.00% of 10.00s total
      flat  flat%   sum%        cum   cum%
     4.50s 45.00% 45.00%      4.50s 45.00%  math/rand.(*Rand).Intn
     2.80s 28.00% 73.00%      2.80s 28.00%  main.primeFactors
     1.20s 12.00% 85.00%      5.70s 57.00%  main.generate
     0.70s  7.00% 92.00%      0.70s  7.00%  runtime.memmove
  • flat=4.50s math/rand.(*Rand).Intn:随机数生成是头号热点。
  • cum=5.70s main.generate:generate 调用链累计占 57%,但自身 flat 只有 12%,问题在它调用的子函数。

-cum 按累积排序,能快速定位「调用链最重的入口」:

(pprof) top10 -cum
      flat  flat%   sum%        cum   cum%
     0.10s  1.00%  1.00%     10.00s   100%  main.main
     0.00s     0%  1.00%      9.20s 92.00%  main.run
     1.20s 12.00% 13.00%      5.70s 57.00%  main.generate

2. list 命令:查看代码级耗时

list 函数名 把函数源码逐行显示,并标注每行的采样次数,是定位「哪一行慢」的最直接工具。

(pprof) list primeFactors
Total: 10.00s
ROUTINE ======================== main.primeFactors
     2.80s      2.80s (flat, cum) 28.00% of Total
         .          .   18:func primeFactors(n int) []int {
         .          .   19:   factors := []int{}
     0.20s     0.20s   20:   for i := 2; i <= n; i++ {
     1.50s     1.50s   21:       for n%i == 0 {
     0.40s     0.40s   22:           factors = append(factors, i)
         .          .   23:           n /= i
         .          .   24:       }
     0.70s     0.70s   25:   }
         .          .   26:   return factors
         .          .   27:}

第 21 行 for n%i == 0 占了 1.50s,循环条件本身耗时高,提示需要减少循环次数。

3. web 命令:调用关系图

web 在浏览器打开 SVG 调用图,节点大小反映 flat,边粗细反映调用频次。配合 -focus=函数名 可只看与某函数相关的子图:

bash
go tool pprof -web -focus=generate cpu.prof

调用图擅长发现「意外的调用链」:你以为某函数只被 A 调用,图上却显示 B 也在调,且占比不低。

四、常见 CPU 瓶颈与优化

1. 热循环优化

CPU 瓶颈 90% 出在循环里。优化思路:减少循环次数、减少每次迭代的工作量、把不变计算提出循环。

go
package main

import "testing"

// Bad: 每次迭代都调用 len、做类型断言
func sumBad(s []interface{}) int {
	total := 0
	for i := 0; i < len(s); i++ {
		total += s[i].(int)
	}
	return total
}

// Good: 缓存长度、用 range、避免类型断言
func sumGood(s []int) int {
	total := 0
	for _, v := range s {
		total += v
	}
	return total
}

var sink int

func BenchmarkSumBad(b *testing.B) {
	s := make([]interface{}, 1000)
	for i := range s {
		s[i] = i
	}
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		sink = sumBad(s)
	}
}

func BenchmarkSumGood(b *testing.B) {
	s := make([]int, 1000)
	for i := range s {
		s[i] = i
	}
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		sink = sumGood(s)
	}
}

sumGood 既避免了 interface{} 装箱(触发逃逸和类型断言开销),又用 range 让编译器更好地优化迭代,通常快 3-5 倍。

2. 算法复杂度

算法复杂度是 CPU 优化的「天花板」。O(n²) 优化得再极致,也比不过 O(n log n) 的朴素实现。

go
package main

import (
	"fmt"
	"sort"
)

// containsDup O(n²):双重循环
func containsDupBad(nums []int) bool {
	for i := 0; i < len(nums); i++ {
		for j := i + 1; j < len(nums); j++ {
			if nums[i] == nums[j] {
				return true
			}
		}
	}
	return false
}

// containsDup O(n):用 map
func containsDupMap(nums []int) bool {
	seen := make(map[int]bool, len(nums))
	for _, n := range nums {
		if seen[n] {
			return true
		}
		seen[n] = true
	}
	return false
}

// containsDupSort O(n log n):排序后线性扫描,无额外内存
func containsDupSort(nums []int) bool {
	sort.Ints(nums)
	for i := 1; i < len(nums); i++ {
		if nums[i] == nums[i-1] {
			return true
		}
	}
	return false
}

func main() {
	nums := []int{1, 3, 5, 7, 9, 2, 4, 6, 8, 3}
	fmt.Println("bad:", containsDupBad(nums))
	fmt.Println("map:", containsDupMap(nums))
	fmt.Println("sort:", containsDupSort(nums))
}

n=10 时差异不明显;n=10000 时 containsDupBadcontainsDupMap 慢数百倍。永远先选对算法。

3. 字符串操作:strings.Builder

字符串在 Go 里是不可变的,s += "x" 每次都分配新内存并拷贝。在循环拼接时务必用 strings.Builder

go
package main

import (
	"fmt"
	"strings"
	"testing"
)

// Bad: 循环 += 拼接
func concatBad(parts []string) string {
	s := ""
	for _, p := range parts {
		s += p
	}
	return s
}

// Good: strings.Builder + 预分配
func concatGood(parts []string) string {
	var b strings.Builder
	// 预估总长度,避免扩容
	total := 0
	for _, p := range parts {
		total += len(p)
	}
	b.Grow(total)
	for _, p := range parts {
		b.WriteString(p)
	}
	return b.String()
}

var parts = func() []string {
	p := make([]string, 1000)
	for i := range p {
		p[i] = fmt.Sprintf("part_%d_", i)
	}
	return p
}()

var sink string

func BenchmarkConcatBad(b *testing.B) {
	for i := 0; i < b.N; i++ {
		sink = concatBad(parts)
	}
}

func BenchmarkConcatGood(b *testing.B) {
	for i := 0; i < b.N; i++ {
		sink = concatGood(parts)
	}
}

1000 段拼接,concatGood 通常快 10 倍以上且分配次数从 1000+ 降到 1。b.Grow 是关键:提前分配能避免 Builder 内部 []byte 的多次扩容拷贝。

4. 正则表达式编译缓存

regexp.MustCompile 在每次调用时重新编译正则是常见陷阱。编译很贵,应编译一次复用。

go
package main

import (
	"fmt"
	"regexp"
	"testing"
)

var emailInput = "user_123@example.com"

// Bad: 每次调用都编译
func isEmailBad(s string) bool {
	re := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
	return re.MatchString(s)
}

// Good: 包级变量只编译一次
var emailRe = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)

func isEmailGood(s string) bool {
	return emailRe.MatchString(s)
}

func BenchmarkEmailBad(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_ = isEmailBad(emailInput)
	}
}

func BenchmarkEmailGood(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_ = isEmailGood(emailInput)
	}
}

func main() {
	fmt.Println("valid:", isEmailGood(emailInput))
}

编译一次复用通常快 50-100 倍。规则:正则、JSON schema、模板对象等「编译型」资源都应在包级或 init 里构造一次。

5. JSON 序列化优化

encoding/json 用反射,性能不佳。优化路径:预编译 encoder、用 jsoniter/sonic、或手写 marshal。

go
package main

import (
	"encoding/json"
	"testing"
)

type User struct {
	ID    int      `json:"id"`
	Name  string   `json:"name"`
	Email string   `json:"email"`
	Tags  []string `json:"tags"`
}

var user = User{
	ID: 1, Name: "alice", Email: "alice@example.com",
	Tags: []string{"a", "b", "c"},
}

// Bad: 每次都走 json.Marshal 的反射路径
func marshalStd(u User) []byte {
	b, _ := json.Marshal(u)
	return b
}

// Good: 用 json.NewEncoder + 预分配,或用第三方 sonic/jsoniter
var userBuf []byte

func marshalEncoder(u User) []byte {
	userBuf = userBuf[:0] // 复用 buffer
	enc := json.NewEncoder(byteAppender{&userBuf})
	_ = enc.Encode(u)
	return userBuf
}

// byteAppender 把 []byte 当作 io.Writer
type byteAppender struct{ b *[]byte }

func (w byteAppender) Write(p []byte) (int, error) {
	*w.b = append(*w.b, p...)
	return len(p), nil
}

func BenchmarkMarshalStd(b *testing.B) {
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		_ = marshalStd(user)
	}
}

func BenchmarkMarshalEncoder(b *testing.B) {
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		_ = marshalEncoder(user)
	}
}

进一步可用 github.com/bytedance/sonic(基于 JIT,比标准库快 3-5 倍)或 github.com/goccy/go-json

6. 反射开销:避免或缓存

反射是 Go 里最慢的操作之一。能不用就不用;必须用时,把 reflect.Type / reflect.Value 缓存复用。

go
package main

import (
	"fmt"
	"reflect"
	"testing"
)

type Point struct{ X, Y int }

// Bad: 每次反射取字段
func sumXBad(points []Point) int {
	total := 0
	t := reflect.TypeOf(Point{})
	for _, p := range points {
		v := reflect.ValueOf(p)
		total += v.FieldByName("X").Interface().(int)
		_ = t
	}
	return total
}

// Good: 直接字段访问
func sumXGood(points []Point) int {
	total := 0
	for _, p := range points {
		total += p.X
	}
	return total
}

func BenchmarkReflectBad(b *testing.B) {
	ps := make([]Point, 1000)
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		_ = sumXBad(ps)
	}
}

func BenchmarkReflectGood(b *testing.B) {
	ps := make([]Point, 1000)
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		_ = sumXGood(ps)
	}
}

func main() {
	ps := []Point{{1, 2}, {3, 4}}
	fmt.Println(sumXBad(ps), sumXGood(ps))
}

反射版本通常慢 10-50 倍。如果业务必须用反射(如 ORM、序列化框架),应建立「类型 → 预编译 accessor」的缓存表,避免热路径里反复反射。

五、编译优化

1. 内联://go:inline、//go:noinline

内联(inlining)把小函数的代码直接展开到调用处,省去函数调用开销,还能让编译器做更激进的优化。Go 编译器默认对「叶子、短小、无复杂控制流」的函数内联。

go
package main

import (
	"fmt"
	"testing"
)

// 小函数,默认会被内联
func addInline(a, b int) int {
	return a + b
}

//go:noinline 强制不内联(用于对比或调试)
//go:noinline
func addNoInline(a, b int) int {
	return a + b
}

//go:inline 提示编译器内联(Go 1.18+)
//go:inline
func addHint(a, b int) int {
	return a + b
}

var sink int

func BenchmarkAddInline(b *testing.B) {
	for i := 0; i < b.N; i++ {
		sink = addInline(i, i)
	}
}

func BenchmarkAddNoInline(b *testing.B) {
	for i := 0; i < b.N; i++ {
		sink = addNoInline(i, i)
	}
}

func main() {
	var x, y int = 1, 2
	fmt.Println(addInline(x, y), addNoInline(x, y), addHint(x, y))
}

查看内联决策:

bash
go build -gcflags="-m" main.go
# 输出: inlining call to addInline ...

实践中不要随意加 //go:inline:编译器通常比人更懂内联收益。//go:noinline 主要用于 benchmark 对比和避免内联导致栈膨胀。

2. 逃逸分析对 CPU 的影响

逃逸分析决定变量分配在栈还是堆。堆分配不仅增加 GC 压力,还会因间接寻址(指针解引用)拖慢 CPU。

go
package main

import "testing"

// Bad: 返回局部切片指针,切片逃逸到堆
func makeSlicePtr(n int) *[]int {
	s := make([]int, n) // 逃逸到堆
	for i := range s {
		s[i] = i
	}
	return &s
}

// Good: 返回值类型,编译器可能栈分配
func makeSlice(n int) []int {
	s := make([]int, n)
	for i := range s {
		s[i] = i
	}
	return s
}

// Best: 让调用方传入 buffer,零分配
func fillSlice(s []int) {
	for i := range s {
		s[i] = i
	}
}

var n = 64
var sink []int

func BenchmarkMakeSlicePtr(b *testing.B) {
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		sink = *makeSlicePtr(n)
	}
}

func BenchmarkMakeSlice(b *testing.B) {
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		sink = makeSlice(n)
	}
}

func BenchmarkFillSlice(b *testing.B) {
	buf := make([]int, n)
	b.ReportAllocs()
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		fillSlice(buf)
		sink = buf
	}
}
bash
go build -gcflags="-m" main.go
# makeSlicePtr 中的 s escapes to heap

fillSlice 版本在循环外预分配 buffer,循环内零分配,CPU 和 GC 都受益。这是 Go 性能优化最常见的模式之一。

六、完整示例:优化一个数据处理函数

下面用一个完整的例子演示「发现热点 → 优化 → 验证」全流程。需求:统计一段文本中每个单词的出现次数,返回 Top K。

1. 优化前

go
package main

import (
	"fmt"
	"sort"
	"strings"
	"unicode"
)

func wordCountBad(text string, k int) []string {
	// 用 strings.Fields 分词(每次分配)
	words := strings.FieldsFunc(text, func(r rune) bool {
		return !unicode.IsLetter(r) && !unicode.IsNumber(r)
	})
	// map 统计,无预分配
	counts := map[string]int{}
	for _, w := range words {
		counts[strings.ToLower(w)]++
	}
	// 排序取 Top K
	type pair struct {
		word  string
		count int
	}
	pairs := make([]pair, 0, len(counts))
	for w, c := range counts {
		pairs = append(pairs, pair{w, c})
	}
	sort.Slice(pairs, func(i, j int) bool {
		return pairs[i].count > pairs[j].count
	})
	result := make([]string, 0, k)
	for i := 0; i < k && i < len(pairs); i++ {
		result = append(result, pairs[i].word)
	}
	return result
}

func main() {
	text := "Go go GO is is fast fast fast and and concise concise concise concise"
	fmt.Println(wordCountBad(text, 3))
}

pprof 发现:strings.ToLower 每次分配新字符串、strings.FieldsFunc 分配切片、sort.Slice 闭包逃逸。

2. 优化后

go
package main

import (
	"container/heap"
	"fmt"
	"strings"
	"unicode"
)

// 小顶堆,用于 O(n log k) 取 Top K
type pair struct {
	word  string
	count int
}
type minHeap []pair

func (h minHeap) Len() int            { return len(h) }
func (h minHeap) Less(i, j int) bool  { return h[i].count < h[j].count }
func (h minHeap) Swap(i, j int)       { h[i], h[j] = h[j], h[i] }
func (h *minHeap) Push(x interface{}) { *h = append(*h, x.(pair)) }
func (h *minHeap) Pop() interface{} {
	old := *h
	n := len(old)
	x := old[n-1]
	*h = old[:n-1]
	return x
}

func wordCountGood(text string, k int) []string {
	// 手写分词,复用 buffer
	words := make([]string, 0, len(text)/4)
	var b strings.Builder
	flush := func() {
		if b.Len() > 0 {
			words = append(words, b.String())
			b.Reset()
		}
	}
	for _, r := range text {
		if unicode.IsLetter(r) || unicode.IsNumber(r) {
			// 小写化写入 builder,避免 ToLower 分配
			if r >= 'A' && r <= 'Z' {
				r += 'a' - 'A'
			}
			b.WriteRune(r)
		} else {
			flush()
		}
	}
	flush()

	// 预分配 map
	counts := make(map[string]int, len(words))
	for _, w := range words {
		counts[w]++
	}

	// 小顶堆取 Top K,避免全排序
	h := &minHeap{}
	heap.Init(h)
	for w, c := range counts {
		if h.Len() < k {
			heap.Push(h, pair{w, c})
		} else if c > (*h)[0].count {
			heap.Pop(h)
			heap.Push(h, pair{w, c})
		}
	}

	result := make([]string, 0, h.Len())
	for h.Len() > 0 {
		result = append(result, heap.Pop(h).(pair).word)
	}
	// 反转使降序
	for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
		result[i], result[j] = result[j], result[i]
	}
	return result
}

func main() {
	text := "Go go GO is is fast fast fast and and concise concise concise concise"
	fmt.Println(wordCountGood(text, 3))
}

3. benchmark 对比

go
package main

import "testing"

var sampleText = strings.Repeat("go is fast and concise ", 5000)

func BenchmarkWordCountBad(b *testing.B) {
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		_ = wordCountBad(sampleText, 10)
	}
}

func BenchmarkWordCountGood(b *testing.B) {
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		_ = wordCountGood(sampleText, 10)
	}
}
bash
go test -bench=. -benchmem
# BenchmarkWordCountBad-8     300   4200000 ns/op   1800000 B/op   60000 allocs/op
# BenchmarkWordCountGood-8    800   1500000 ns/op    320000 B/op    2100 allocs/op

优化版快约 2.8 倍,内存分配从 6 万次降到 2100 次。收益主要来自:手写分词避免 FieldsFunc 分配、内联小写化、小顶堆替代全排序、map 预分配。

七、小结

  • 先看 flat 再看 cum:flat 高是自身慢,cum 高 flat 低是子调用慢,下钻处理。
  • list 是行级定位神器list 函数名 直接显示每行采样,定位到具体代码行。
  • 循环是热点主战场:减少循环次数、提出不变计算、避免循环内分配。
  • 字符串用 Builder:循环拼接必须 strings.Builder + Grow 预分配。
  • 正则、模板编译一次复用:包级变量持有,避免热路径重编译。
  • JSON / 反射能避则避:预编译 encoder、缓存 reflect.Type、或换 sonic/jsoniter。
  • 内联和逃逸是编译期杠杆-gcflags="-m" 看决策,让编译器做对的事,必要时用 //go:inline
  • 永远用 benchmark 量化:优化前后必须有数据支撑,避免「感觉变快了」。

下一篇我们进入内存分配与逃逸分析,从堆栈模型讲起,系统讲解如何减少 Go 程序的堆分配。