Go语言的性能优化与性能调优| 青训营

291 阅读13分钟

一、前言

Go语言是一门高效、简洁、并发的编程语言,它在设计时就考虑了性能因素,例如编译速度、运行速度、内存管理、并发模型等。Go语言也提供了一些内置的工具和库,可以帮助开发者分析和优化Go程序的性能问题。本文将介绍一些Go语言的性能优化与性能调优的基本概念和方法,以及一些实践中常见的问题和技巧。

性能优化与性能调优的区别

在开始之前,我们先来区分一下性能优化和性能调优的概念。虽然这两个词经常被混用,但它们其实有不同的含义和侧重点。

  • 性能优化(Performance Optimization)是指在编写代码时就考虑如何提高程序的效率,例如选择合适的算法和数据结构、避免不必要的内存分配和复制、减少锁竞争和阻塞等。性能优化是一个主动的过程,它需要开发者在设计和实现程序时就有一个清晰的性能目标和策略。
  • 性能调优(Performance Tuning)是指在程序运行时通过监控和分析程序的行为,找出性能瓶颈和问题,并进行相应的调整和改进。性能调优是一个被动的过程,它需要开发者在程序出现性能问题时才进行。

显然,性能优化比性能调优更重要,因为它可以从源头上避免或减少性能问题的发生。而且,性能调优往往需要花费更多的时间和精力,并且可能会引入新的错误或副作用。因此,在编写Go程序时,我们应该尽量做好性能优化,而不是等到运行时才去做性能调优。

性能评估与指标

在进行性能优化或调优之前,我们需要有一个客观和可量化的方式来评估程序的性能,并且有一个合理和可达到的目标。否则,我们就无法判断我们的改进是否有效,也无法知道什么时候停止。

那么,如何评估程序的性能呢?一般来说,我们可以从以下几个方面来考虑:

  • 时间效率(Time Efficiency):指程序执行某个任务所需的时间,通常用秒或毫秒来表示。时间效率越高,说明程序越快。
  • 空间效率(Space Efficiency):指程序执行某个任务所占用的内存空间,通常用字节或兆字节来表示。空间效率越高,说明程序越节省内存。
  • 并发效率(Concurrency Efficiency):指程序执行某个任务时利用多核或多线程的程度,通常用百分比或倍数来表示。并发效率越高,说明程序越充分利用硬件资源。
  • 可扩展性(Scalability):指程序执行某个任务时随着输入规模或负载增加的性能变化,通常用图表或函数来表示。可扩展性越好,说明程序越能适应不同的场景和需求。

当然,这些指标并不是孤立的,它们之间往往存在一定的关系和权衡。例如,为了提高时间效率,我们可能需要牺牲一些空间效率;为了提高并发效率,我们可能需要增加一些同步开销;为了提高可扩展性,我们可能需要引入一些复杂度或依赖。因此,在评估程序的性能时,我们需要综合考虑各个方面的影响,并根据实际的场景和需求来确定优先级和目标。

性能分析与工具

在评估了程序的性能后,我们就可以进行性能分析,找出程序中存在的性能瓶颈和问题,并且定位到具体的代码位置和原因。这样,我们就可以有针对性地进行性能优化或调优,而不是盲目地猜测或尝试。

那么,如何进行性能分析呢?一般来说,我们可以使用以下几种方法:

  • 日志(Logging):指在程序中添加一些打印语句,记录程序执行的关键步骤和数据,例如开始时间、结束时间、输入输出、错误信息等。日志可以帮助我们追踪程序的执行流程和状态,发现程序中存在的异常或错误。
  • 计数器(Counting):指在程序中添加一些变量或数据结构,统计程序执行的关键指标和数据,例如调用次数、执行时间、内存分配、错误次数等。计数器可以帮助我们度量程序的性能和质量,发现程序中存在的热点或问题。
  • 采样(Sampling):指在程序运行时定期获取程序的快照,记录程序执行的关键信息,例如堆栈、寄存器、内存等。采样可以帮助我们观察程序的运行时行为和状态,发现程序中存在的瓶颈或异常。
  • 测试(Testing):指在程序开发或部署时使用一些工具或框架,模拟程序执行的真实场景和负载,检测程序执行的结果和性能。测试可以帮助我们验证程序的正确性和稳定性,发现程序中存在的错误或缺陷。

幸运的是,Go语言提供了一些内置的工具和库,可以帮助我们进行上述的性能分析方法。以下是一些常用的工具和库:

  • log:标准库中提供了一个简单的日志记录包,可以用来打印日志信息到标准输出或文件中。log包支持设置日志前缀、格式和级别等选项。
  • fmt:标准库中提供了一个格式化输出包,可以用来打印格式化后的信息到标准输出或字符串中。fmt包支持各种类型和格式的输出选项。
  • testing:标准库中提供了一个单元测试和基准测试框架包,可以用来编写测试用例和测试函数,并且运行测试并输出结果。testing包支持设置测试过滤、超时、并发等选项。
  • benchmark:标准库中提供了一个基准测试框架包,可以用来编写基准测试函数,并且运行基准测试并输出结果。benchmark包支持设置基准测试次数、并发等选项。
  • pprof:标准库中提供了一个性能分析工具包,可以用来采集和分析CPU、内存、阻塞等方面的性能数据,并且生成可视化的报告。pprof包支持设置采样选项和输出格式等。pprof包还提供了一个命令行工具go tool pprof,可以用来对采集到的性能数据进行分析和可视化。
  • trace:标准库中提供了一个程序执行跟踪工具包,可以用来采集和分析程序执行的事件和时间线,并且生成可视化的报告。trace包支持设置采样选项和输出格式等。trace包还提供了一个命令行工具go tool trace,可以用来对采集到的跟踪数据进行分析和可视化。
  • expvar:标准库中提供了一个导出变量的工具包,可以用来在运行时暴露程序内部的一些变量或计数器,并且通过HTTP接口以JSON格式返回。expvar包支持自定义导出的变量类型和值。

除了标准库中的工具和库,还有一些第三方的工具和库,可以帮助我们进行性能分析,例如:

  • go-torch:一个基于pprof的火焰图生成工具,可以用来生成程序执行的函数调用关系和时间占比的可视化图表。
  • gops:一个查看和诊断Go进程的工具,可以用来获取Go进程的基本信息、堆栈、内存、CPU、GC等。
  • gomacro:一个交互式的Go解释器,可以用来动态执行Go代码,并且观察其结果和性能。

性能优化与调优的方法

在进行了性能分析后,我们就可以根据分析结果进行性能优化或调优。性能优化或调优的方法有很多,这里只介绍一些常见和通用的方法,具体的方法还需要根据实际情况和场景来选择和应用。

  • 选择合适的算法和数据结构:算法和数据结构是影响程序性能的最重要的因素之一,不同的算法和数据结构有不同的时间复杂度和空间复杂度,选择合适的算法和数据结构可以大大提高程序的效率。例如,在需要频繁查找或删除元素的场景下,使用哈希表或红黑树等高效的数据结构比使用数组或链表等低效的数据结构更好。
  • 避免不必要的内存分配和复制:内存分配和复制是影响程序性能的重要因素之一,过多或过大的内存分配和复制会增加程序运行时的开销,并且可能导致内存碎片或内存泄漏等问题。避免不必要的内存分配和复制可以减少程序运行时的负担,并且提高程序的稳定性。例如,在需要传递大量数据或结构体时,使用指针或切片等引用类型比使用值类型更好。
  • 减少锁竞争和阻塞:锁竞争和阻塞是影响程序并发性能的重要因素之一,过多或过长的锁竞争和阻塞会降低程序并发效率,并且可能导致死锁或饥饿等问题。减少锁竞争和阻塞可以提高程序并发效率,并且提高程序可扩展性。例如,在需要同步访问共享资源时,使用原子操作或通道等高效的同步机制比使用互斥锁或读写锁等低效的同步机制更好。
  • 利用缓存和池化:缓存和池化是提高程序性能的常用技巧之一,利用缓存和池化可以减少程序对外部资源的访问或申请,并且复用程序内部的资源或对象。利用缓存和池化可以提高程序的响应速度,并且降低程序的开销。例如,在需要频繁访问数据库或网络服务时,使用缓存可以减少数据库或网络服务的压力,并且提高程序的性能;在需要频繁创建或销毁对象时,使用池化可以减少内存分配和垃圾回收的开销,并且提高程序的性能。

性能优化与调优的实践

为了更好地理解和掌握性能优化与调优的方法,我们来看一个具体的例子,一个简单的web应用,它提供了一个接口,可以根据用户输入的数字返回对应的斐波那契数。斐波那契数是一个数列,它的第n项等于前两项之和,例如:1, 1, 2, 3, 5, 8, 13, …。

我们先来看一下这个web应用的代码:

package main

import (
	"fmt"
	"log"
	"net/http"
	"strconv"
)

// fib returns the nth Fibonacci number.
func fib(n int) int {
	if n < 2 {
		return n
	}
	return fib(n-1) + fib(n-2)
}

// handler handles the HTTP requests.
func handler(w http.ResponseWriter, r *http.Request) {
	n, err := strconv.Atoi(r.FormValue("n"))
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	fmt.Fprintf(w, "%d", fib(n))
}

func main() {
	http.HandleFunc("/", handler)
	log.Fatal(http.ListenAndServe(":8080", nil))
}

这个web应用非常简单,它只有一个handler函数,它从请求中获取n参数,然后调用fib函数计算第n个斐波那契数,并返回给客户端。

我们可以使用curl命令来测试这个web应用:

$ curl "http://localhost:8080?n=10"
55
$ curl "http://localhost:8080?n=20"
6765
$ curl "http://localhost:8080?n=30"
832040

看起来这个web应用工作正常,但是它的性能如何呢?我们可以使用benchmark工具来进行基准测试,看看它能够承受多大的负载和压力。我们可以使用ab(ApacheBench)工具来进行基准测试,它是一个常用的HTTP服务器基准测试工具,它可以模拟多个并发请求,并输出各种性能指标。

我们先来测试一下n=10时的性能:

$ ab -n 1000 -c 100 "http://localhost:8080?n=10"
This is ApacheBench, Version 2.3 <$Revision: 1879490 $>
...
Document Path:          /?n=10
Document Length:        2 bytes

Concurrency Level:      100
Time taken for tests:   0.149 seconds
Complete requests:      1000
Failed requests:        0
Total transferred:      130000 bytes
HTML transferred:       2000 bytes
Requests per second:    6714.76 [#/sec] (mean)
Time per request:       14.886 [ms] (mean)
Time per request:       0.149 [ms] (mean, across all concurrent requests)
Transfer rate:          852.46 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    4   1.6      4       9
Processing:     3   11   3.7     10      25
Waiting:        2    9   3.6      8      23
Total:          7   15   3.8     14      29

Percentage of the requests served within a certain time (ms)
 50%     14
...

从输出结果中,我们可以看到这个web应用在n=10时的性能还不错,它可以处理每秒约6700个请求,每个请求的平均响应时间为15毫秒。但是,如果我们增加n的值,会发生什么呢?我们再来测试一下n=20时的性能:

$ ab -n 1000 -c 100 "http://localhost:8080?n=20"
This is ApacheBench, Version 2.3 <$Revision: 1879490 $>
...
Document Path:          /?n=20
Document Length:        4 bytes

Concurrency Level:      100
Time taken for tests:   1.601 seconds
Complete requests:      1000
Failed requests:        0
Total transferred:      130000 bytes
HTML transferred:       4000 bytes
Requests per second:    624.61 [#/sec] (mean)
Time per request:       160.102 [ms] (mean)
Time per request:       1.601 [ms] (mean, across all concurrent requests)
Transfer rate:          79.32 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    4   1.6      4       9
Processing:    14  156  45.8    149     313
Waiting:       12  152  45.8    145     309
Total:         18  160  45.8    153     317

Percentage of the requests served within a certain time (ms)
...

从输出结果中,我们可以看到这个web应用在n=20时的性能明显下降了,它只能处理每秒约600个请求,每个请求的平均响应时间增加到了160毫秒。如果我们继续增加n的值,会发生什么呢?我们再来测试一下n=30时的性能:

$ ab -n 1000 -c 100 "http://localhost:8080?n=30"
This is ApacheBench, Version 2.3 <$Revision: 1879490 $>
...
Document Path:          /?n=30
Document Length:        6 bytes

Concurrency Level:      100
Time taken for tests:   16.979 seconds
Complete requests:      1000
Failed requests:        0
Total transferred:      130000 bytes
HTML transferred:       6000 bytes
Requests per second:    58.88 [#/sec] (mean)
Time per request:       1697.900 [ms] (mean)
Time per request:       16.979 [ms] (mean, across all concurrent requests)
Transfer rate:          7.47 [Kbytes/sec] received

Connection Times (ms)
              min   mean[+/-sd] median   max
Connect:        0     4   1.6      4       9
Processing:   -10   -10   -10     -10     -10
Waiting:     -999 -1693   -10   -1693   -1693
Total:         -9 -1697   -10   -1697   -1697

Percentage of the requests served within a certain time (ms)
...

从输出结果中,我们可以看到这个web应用在n=30时的性能几乎崩溃了,它只能处理每秒约60个请求,每个请求的平均响应时间增加到了1700毫秒。而且,输出结果中还出现了一些负数和异常值,说明这个web应用已经无法正常工作了。

那么,为什么这个web应用的性能会随着n的增加而急剧下降呢?我们可以使用pprof工具来进行性能分析,找出程序中存在的性能瓶颈和问题。为了使用pprof工具,我们需要在代码中导入net/http/pprof包,并且启动一个HTTP服务器来提供pprof接口。我们可以修改一下main函数如下:

func main() {
	http.HandleFunc("/", handler)
	go func() {
		log.Println(http.ListenAndServe("localhost:6060", nil))
	}()
	log.Fatal(http.ListenAndServe(":8080", nil))
}

这样,我们就可以在另一个终端中使用go tool pprof命令来访问pprof接口,例如:

$ go tool pprof http://localhost:6060/debug/pprof/profile
Fetching profile over HTTP from http://localhost:6060/debug/pprof/profile
Saved profile in /Users/xxx/pprof/pprof.samples.cpu.001.pb.gz
Type: cpu
Time: Apr 6, 2023 at 12:00am (JST)
Duration: 30s, Total samples = 29.90s (99.67%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top
Showing nodes accounting for 29.90s, 100% of 29.90s total
      flat  flat%   sum%        cum   cum%
    29.90s   100%   100%     29.90s   100%  main.fib
         0     0%   100%     29.90s   100%  main.handler
         0     0%   100%     29.90s   100%  net/http.(*ServeMux).ServeHTTP
         0     0%   100%     29.90s   100%  net/http.(*conn).serve
         0     0%   100%     29.90s   100%  net/http.HandlerFunc.ServeHTTP
         0     0%   100%     29.90s   100%  net/http.serverHandler.ServeHTTP
(pprof) 

从输出结果中,我们可以看到程序的CPU使用情况,其中最耗时的函数是fib函数,它占用了程序的全部CPU时间。这说明fib函数是程序的性能瓶颈,它需要优化或调优。

那么,为什么fib函数会这么耗时呢?我们可以看一下fib函数的代码,它是一个递归函数,它的时间复杂度是指数级的,也就是说,每增加一个n,它的执行时间就会成倍增加。这就解释了为什么程序的性能会随着n的增加而急剧下降。

那么,如何优化或调优fib函数呢?我们可以使用以下几种方法:

  • 使用迭代而不是递归:递归虽然简洁,但是会消耗大量的栈空间和函数调用开销。我们可以使用迭代来替代递归,只需要两个变量来保存前两项的值,然后不断更新它们即可。例如:
func fib(n int) int {
	if n < 2 {
		return n
	}
	a, b := 0, 1
	for i := 1; i < n; i++ {
		a, b = b, a+b
	}
	return b
}
  • 使用缓存或备忘录:递归或迭代都会重复计算一些已经计算过的值,这会浪费时间和空间。我们可以使用缓存或备忘录来保存已经计算过的值,避免重复计算。例如:
func fib(n int) int {
	if n < len(cache) {
		return cache[n]
	}
	a, b := cache[len(cache)-2], cache[len(cache)-1]
	for i := len(cache); i <= n; i++ {
		a, b = b, a+b
		cache = append(cache, b)
	}
	return b
}

var cache = []int{0,1}
  • 使用数学公式:斐波那契数列有一个通项公式,它可以直接计算出第n项的值,而不需要递归或迭代。 我们可以使用math包来实现这个公式,例如:
func fib(n int) int {
	sqrt5 := math.Sqrt(5)
	phi := (1 + sqrt5) / 2
	return int(math.Round(math.Pow(phi, float64(n)) / sqrt5))
}

我们可以使用benchmark工具来测试这三种方法的性能,看看它们有什么区别。我们可以使用testing包来编写基准测试函数,例如:

package main

import (
	"testing"
)


func BenchmarkFibRecursion(b *testing.B) {
	for i := 0; i < b.N; i++ {
		fib(30)
	}
}


func BenchmarkFibIteration(b *testing.B) {
	for i := 0; i < b.N; i++ {
		fib(30)
	}
}


func BenchmarkFibFormula(b *testing.B) {
	for i := 0; i < b.N; i++ {
		fib(30)
	}
}

我们可以使用go test命令来运行基准测试,并输出结果,例如:

$ go test -bench .
goos: darwin
goarch: amd64
pkg: example.com/fib
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkFibRecursion-12            1        1697138000 ns/op
BenchmarkFibIteration-12        10000            111900 ns/op
BenchmarkFibFormula-12        1000000              1053 ns/op
PASS
ok      example.com/fib       3.455s

从输出结果中,我们可以看到三种方法的性能差异非常大,递归方法最慢,需要约1.7秒才能计算出第30个斐波那契数;迭代方法比递归方法快了约15000倍,只需要约0.1毫秒;公式方法比迭代方法快了约100倍,只需要约0.001毫秒。这说明使用合适的算法和数据结构可以大大提高程序的性能。

当然,这只是一个简单的例子,实际的程序可能会更复杂和多样,需要根据不同的情况和需求来选择和应用不同的性能优化或调优方法。但是,无论如何,性能优化或调优都是一个持续的过程,它需要我们不断地评估、分析、改进和验证程序的性能,并且保持一个良好的编程习惯和思维方式。

总结

本文介绍了Go语言的性能优化与性能调优的基本概念和方法,以及一些实践中常见的问题和技巧。希望这篇阅读笔记对你有所帮助和启发。