Golang 課程 – 錯誤處理與測試
主題:基準測試(Benchmark)
簡介
在軟體開發的全生命週期中,效能往往是決定系統能否成功的關鍵因素之一。Go 語言內建的測試框架不只支援單元測試 (Test*),還提供了 基準測試(benchmark),讓開發者能以科學、可重複的方式測量程式碼的執行速度與資源使用情形。
基準測試的價值不只在於找出「哪段程式碼太慢」;它同時也是 性能回歸測試 的重要工具,能在功能調整或重構後即時驗證效能是否維持在預期範圍。對於初學者而言,掌握基準測試的寫法與最佳實踐,能在日後開發大型服務或高併發系統時,避免因效能問題導致的瓶頸與維護成本。
本篇文章將從概念說明、實作範例、常見陷阱到實務應用,完整介紹 Go 的基準測試,讓你能 快速上手、正確使用,並在程式碼品質上更上一層樓。
核心概念
1. 基準測試的基本結構
Go 的基準測試函式必須以 Benchmark 為前綴,接受 *testing.B 參數:
func BenchmarkXXX(b *testing.B) {
// b.N 代表測試框架自動決定的迭代次數
for i := 0; i < b.N; i++ {
// 被測試的程式碼
}
}
b.N:測試框架會根據前一次執行的時間自動調整N,以確保測試時間足夠(通常在 1 秒以上),從而得到較穩定的測量結果。b.ResetTimer():在需要排除前置作業(如資料初始化)的時間時,可呼叫此方法重置計時器。b.StopTimer()/b.StartTimer():可在基準測試中暫停與重新啟動計時,適用於需要額外清理或設定的情境。
2. 設定基準測試的參數
testing 套件支援 -bench、-benchmem、-run 等旗標:
| 旗標 | 功能 |
|---|---|
-bench=. |
執行所有基準測試 |
-bench=BenchmarkName |
只跑指定名稱的基準測試 |
-benchmem |
顯示每次迭代的記憶體配置(alloc、bytes) |
-benchtime=5s |
設定每個基準測試的執行時間上限(預設 1s) |
範例指令:
go test -bench=. -benchmem -benchtime=3s
3. 基準測試與平行執行
若要測試 併發安全 或 多核效能,可使用 b.RunParallel:
func BenchmarkParallelSort(b *testing.B) {
data := generateRandomSlice(1000)
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
sort.Ints(data) // 只要確保每個 goroutine 使用自己的副本
}
})
}
RunParallel 會自動根據 GOMAXPROCS 建立適當數量的 goroutine,讓每個 goroutine 執行 pb.Next() 迴圈,模擬高併發情境。
4. 基準測試的結果解讀
執行 go test -bench=. -benchmem 後,會得到類似以下的輸出:
BenchmarkSum-8 10000000 123 ns/op 0 B/op 0 allocs/op
BenchmarkConcat-8 2000000 567 ns/op 112 B/op 2 allocs/op
ns/op:每次迭代所耗費的納秒數。B/op:每次迭代分配的位元組數(僅在使用-benchmem時顯示)。allocs/op:每次迭代的記憶體分配次數。
5. 基準測試與自訂計時
有時候想測量 整段程式碼的執行時間(包含前置與後置作業),可自行使用 time.Now(),但要注意 不要混用 b.N 與自行計時,否則會失去框架自動調整迭代次數的優勢。
func BenchmarkManualTimer(b *testing.B) {
start := time.Now()
for i := 0; i < b.N; i++ {
heavyComputation()
}
elapsed := time.Since(start)
b.ReportMetric(float64(elapsed.Nanoseconds())/float64(b.N), "ns/custom")
}
程式碼範例
以下提供 5 個實用範例,涵蓋基礎、記憶體、平行與自訂計時等情境。
範例 1:簡單的加總函式
// sum.go
package demo
func Sum(nums []int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
// sum_test.go
package demo
import "testing"
func BenchmarkSum(b *testing.B) {
// 建立固定長度的測試資料
data := make([]int, 1000)
for i := range data {
data[i] = i
}
b.ResetTimer() // 排除資料建立時間
for i := 0; i < b.N; i++ {
Sum(data)
}
}
重點:使用
b.ResetTimer()讓基準測試只計算Sum本身的執行時間。
範例 2:字串拼接 vs. bytes.Buffer
package demo
import (
"bytes"
"strings"
)
func ConcatPlus(a, b string) string {
return a + b
}
func ConcatBuffer(a, b string) string {
var buf bytes.Buffer
buf.WriteString(a)
buf.WriteString(b)
return buf.String()
}
func BenchmarkConcatPlus(b *testing.B) {
a, c := "hello", "world"
b.ResetTimer()
for i := 0; i < b.N; i++ {
ConcatPlus(a, c)
}
}
func BenchmarkConcatBuffer(b *testing.B) {
a, c := "hello", "world"
b.ResetTimer()
for i := 0; i < b.N; i++ {
ConcatBuffer(a, c)
}
}
觀察:執行
go test -bench=. -benchmem後,可比較兩種實作的 分配次數 與 記憶體使用量,選擇最適合的實作方式。
範例 3:使用 RunParallel 測試併發安全的 map
package demo
import "sync"
func ParallelWrite(m *sync.Map, key, value int) {
m.Store(key, value)
}
func BenchmarkParallelMap(b *testing.B) {
var m sync.Map
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
ParallelWrite(&m, 1, 2)
}
})
}
說明:
sync.Map本身支援併發寫入,透過RunParallel可驗證在高併發下的效能表現。
範例 4:測量大量資料排序的效能
package demo
import (
"math/rand"
"sort"
"time"
)
func RandomInts(n int) []int {
r := rand.New(rand.NewSource(time.Now().UnixNano()))
s := make([]int, n)
for i := range s {
s[i] = r.Intn(1000000)
}
return s
}
func BenchmarkSort1000(b *testing.B) {
data := RandomInts(1000) // 只產生一次
b.ResetTimer()
for i := 0; i < b.N; i++ {
// 必須複製資料,避免前一次排序影響結果
tmp := make([]int, len(data))
copy(tmp, data)
sort.Ints(tmp)
}
}
技巧:在基準測試中 避免共享可變資料,否則會產生不一致的測量結果。
範例 5:自訂計時與報告額外指標
func BenchmarkCustomMetric(b *testing.B) {
start := time.Now()
for i := 0; i < b.N; i++ {
heavyComputation()
}
elapsed := time.Since(start)
// 每次迭代的平均耗時(自訂指標)
b.ReportMetric(float64(elapsed.Nanoseconds())/float64(b.N), "ns/custom")
}
應用:當需要追蹤 非標準指標(例如 I/O 次數、網路請求數)時,可利用
ReportMetric手動上報。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解法 |
|---|---|---|
忘記使用 b.ResetTimer() |
前置資料產生會被算入基準時間,導致結果失真。 | 在所有一次性初始化完成後立即呼叫 b.ResetTimer()。 |
| 在迴圈內部分配大量記憶體 | 每次迭代都會產生 GC 壓力,測試結果會被記憶體分配干擾。 | 盡量在迭代外部建立緩衝區,或使用 sync.Pool。 |
| 共享可變資料 | 多個迭代同時使用同一 slice/map,會產生競爭條件或排序結果不一致。 | 為每次迭代建立獨立的副本(copy、append([]T{}, src...))。 |
| 基準測試過於簡單 | 測試只跑了極少次數,誤差大。 | 讓 go test 自動調整 b.N,或使用 -benchtime 增加測試時間。 |
忽略 -benchmem |
只看時間指標,卻忽略了記憶體分配的成本。 | 加上 -benchmem,同時關注 ns/op、B/op、allocs/op。 |
最佳實踐
- 保持基準測試的獨立性:每個
Benchmark*應只測試單一功能或單一變化,避免混雜多個因素。 - 使用
testing.B的輔助方法:b.StopTimer()、b.StartTimer()、b.ReportAllocs()能幫助精細控制測試範圍。 - 在 CI/CD 中加入基準測試:將基準測試納入自動化流程,配合閾值(threshold)檢查,防止效能倒退。
- 以真實工作負載為基礎:測試資料大小、結構應盡可能模擬實際生產環境,才能得到具參考價值的結果。
- 記錄與追蹤結果:將基準測試的輸出寫入檔案或 Grafana 等監控系統,方便長期趨勢分析。
實際應用場景
| 場景 | 為何需要基準測試 | 可能的測試項目 |
|---|---|---|
| Web API 回傳 JSON | 高併發時 JSON 序列化可能成為瓶頸 | json.Marshal、json.Encoder 的效能比較 |
| 資料庫批次寫入 | 大量寫入時每筆資料的轉換成本會累積 | sql.Tx、pgx.CopyFrom 的基準測試 |
| 影像處理或壓縮 | CPU 密集型演算法需確保在可接受的延遲內 | image/jpeg.Encode、自訂演算法的時間測量 |
| Cache 讀寫 | 讀取快取與寫入快取的效能直接影響整體響應時間 | sync.Map、ristretto.Cache、bigcache 的基準測試 |
| 微服務間 RPC | 序列化、壓縮與網路傳輸的總體延遲需要量測 | grpc、protobuf、msgpack 的編解碼時間 |
透過上述基準測試,開發團隊可以在 設計階段 立即發現效能瓶頸,或在 版本升級 時快速驗證新實作是否維持或提升效能。
總結
- 基準測試是 Go 測試套件中 不可或缺 的功能,能以科學方法量測程式碼的執行時間與記憶體分配。
- 正確使用
b.N、b.ResetTimer()、b.RunParallel()等 API,能避免常見的測試失真問題。 - 透過
-benchmem、ReportMetric等技巧,我們不只看時間,還能掌握 記憶體與資源使用 的全貌。 - 在實務開發中,將基準測試納入 CI/CD、效能門檻 與 長期監控,才能確保系統在不斷演進的同時,保持良好的效能表現。
最後的建議:從現在開始,為每個重要的演算法或 I/O 操作寫下基準測試,並把測試結果當作 性能指標 來管理。只要養成這個習慣,未來面對效能優化時,你將不再手忙腳亂,而是有據可依、一步步提升系統的表現。祝你在 Go 的世界裡,寫出既 正確 又 高效 的程式碼!