golang 性能優化實戰

調優基本思路

  1. 對外接口協議不能改變
  2. 了解需求和代碼演進過程
  3. 確定資源消耗類型
  4. 控制運算數據輸入量
  5. 提高 CPU 利用率
  6. 提高緩存命中率

項目概況

  1. gin-swagger 解析使用 gin 的代碼,生成 swagger2.0 的文檔,以保證文檔和代碼的一致性。
  2. 使用 golang.org/x/tools/go/loader 將源碼解析成 go/types go/ast 相關結構化數據。
  3. 通過遍歷 package 找到目標代碼塊及其相關數據,構建 github.com/go-openapi/spec,序列化成 JSON 格式,完成所有操作。

性能現狀

service-card 項目為例:

$ system_profiler SPHardwareDataType
Hardware:

    Hardware Overview:

      Model Name: MacBook Pro
      Model Identifier: MacBookPro12,1
      Processor Name: Intel Core i5
      Processor Speed: 2.7 GHz
      Number of Processors: 1
      Total Number of Cores: 2
      L2 Cache (per Core): 256 KB
      L3 Cache: 3 MB
      Memory: 8 GB
      Boot ROM Version: MBP121.0167.B17
      SMC Version (system): 2.28f7
      Serial Number (system): C02Q560DFVH5
      Hardware UUID: 9BAB7C1A-0C07-5567-808A-0694D7C2C1B6

$ cd $GOPATH/src/demo/service-card
$ time gin-swagger 

gin-swagger-old -t  158.54s user 7.45s system 101% cpu 2:42.85 total

1. debugger 工具分步調試,梳理業務流程

  1. IDE 如 Golang/VSCode 都有相關工具或插件
  2. 命令行工具如 delve
  3. 梳理出程序運行的主要步驟:
    1. loader.Load(): 掃描 service-card 代碼包括所有依賴
    2. HttpErrorScanner.Scan(): 遍歷所有 package 找到代碼里定義的 HTTP 錯誤類型及其相關信息
    3. RoutesScanner.Scan(): 遍歷所有 package 找到用 gin 定義的 HTTP 路由及其相關信息
    4. 循環調用 collectOperation(): 找到請求和響應類型,構建 spec.Sawgger 的 Operation
    5. 將 spec.Swagger 序列化成 JSON 格式寫入文件

使用 trace 梳理資源消耗概況

  1. 標準庫中的 runtime/trace 包,用于追蹤程序運行各個階段的指標,官方使用范例

  2. 查看結果:$ go tool trace service-card.trace

origin_trace_01.png
origin_trace_02.png
  1. 初步分析:

    1. 大部分運行過程只使用了一個線程
    2. 內存開始階段陡增,中后期增速較小
    3. 沒有網絡請求
    4. 同步等待、系統調用、runtime調度的耗時操作都是 loader 庫相關
    5. 資源消耗特點: CPU 密集、內存容量需求穩定。
  2. 各主要步驟耗時情況:

    1. loader.Load(): 7.8s
    2. HttpErrorScanner.Scan(): 7s
    3. RoutesScanner.Scan(): 0.5s
    4. 122 * collectOperation(): 146.6s
    5. json.Marshal(): 0.1s

pprof 查看各方法耗時

  1. 標準庫中的 runtime/pprof 包,用于整體統計運行過程,各個方法的總的資源消耗情況,官方使用范例

  2. 手動安裝最新版本 pprof 工具:$ go get -u github.com/google/pprof

  3. 用 web 方式查看 pprof CPU 分析結果:$ pprof -http=":8091" ./cpu.prof

  4. 先看 Top
    origin_cpu_top10.png
    1. 排名第一的 go/types.(*Scope).Contains 這個方法耗時占比近 25.98%,代碼來自 go1.10.8 標準庫 go/types/scope.go:121
    // Contains returns true if pos is within the scope's extent.
    // The result is guaranteed to be valid only if the type-checked
    // AST has complete position information.
    func (s *Scope) Contains(pos token.Pos) bool {
      return s.pos <= pos && pos < s.end
    }
    

    就是簡單的 int 比較,所以不是方法耗時多,而是調用次數多。

    1. 排名第二的 runtime.mapiternext 也是標準庫遍歷 map 的方法,耗時多的原因也是調用次數多
    2. 依次看下來,沒有明顯的耗時過高的業務方法
  5. 初步判斷:業務方法沒有明顯缺陷,業務層面需要調用的次數過多導致整體耗時高

優化第零步:持續 Diff

首先使用原始版本 gin-swagger 生成 swagger 文檔,在優化的過程中每一次修改都要確保結果和原始版本一致。

優化第一步:提高 CPU 利用率

  1. 從 trace 結果發現,122 次調用 collectOperation(),耗時占比 90%,卻是單核執行,如果能利用多核,將有相當可觀的性能提升。
  2. 利用多核需要確保并發安全和兼容亂序,通過調試 collectOperation() 發現:
    1. 被競爭的資源是 Swagger.Paths.PathsSwagger.Definitions,都是插入操作
    2. 由于 Swagger.Paths.PathsSwagger.Definitions是 map 類型,所以沒有亂序的問題
  3. 給競爭資源上鎖 sync.RWMutex,保證并發安全
  4. 啟多個 goroutine 執行 collectOperation()
  5. 重新編譯執行,文檔結果沒有 diff,耗時: 162.85s => 76s
  6. trace 顯示 collectOperation 階段確實是啟動了多個 Processor
  7. top 發生了變化,program.Program.WithFuncprogram.Program.WhereDecl兩個方法耗達到 8.5%
step_01_top10.png

優化第二步:提供緩存命中率

分析 WitchFunc

func (program *Program) WitchFunc(pos token.Pos) *types.Func {
  for _, pkgInfo := range program.AllPackages {
    for _, obj := range pkgInfo.Defs {
      if tpeFunc, ok := obj.(*types.Func); ok {
        scope := tpeFunc.Scope()
        if scope != nil && scope.Contains(pos) {
          return tpeFunc
        }
      }
    }
  }
  return nil
}

  1. 業務邏輯:遍歷所有的 package,找到 pos 所在的 *types.Func

  2. 看到熟悉身影:scope.Contains(pos),確定是上文出現的 go/types.(*Scope).Contains

  3. 結論:大量 WitchFunc 調用,導致過多 go/types.(*Scope).Contains 調用,拖慢了執行速度

  4. 分析業務邏輯,做緩存映射 pos => go/types.Func,即做一個 go/types.Func 數組,按照 pos 排序,withFunc(pos token.Pos) 邏輯轉化為:二分搜索 pos,進而確定是哪個 tyeps.Func,時間復雜度:O(log2n)

    type fn struct {
      pkg     *types.Package
      pkgInfo *loader.PackageInfo
      tfn     *types.Func
      pos     token.Pos
    }
    
    type fns []*fn
    
    func (f fns) Len() int           { return len(f) }
    func (f fns) Less(i, j int) bool { return f[i].pos < f[j].pos }
    func (f fns) Swap(i, j int)      { f[i], f[j] = f[j], f[i] }
    
    
  5. 重新編譯執行,文檔結果沒有 diff,耗時: 76s => 61s

  6. 使用相同的思路構建其他緩存 pos => ast.File, types.Func => ast.Expr

  7. 重新編譯執行,文檔結果沒有 diff,耗時縮短到 61s => 20s

  8. 通過 trace 發現原來 122 * collectOperation() 步驟耗時已經縮短到 7.5s,但 HttpErrorScanner.Scan()步驟還是有 6.5s 的耗時,可見已有緩存對其影響不大

優化第三步:單步驟邏輯調優

針對 HttpErrorScanner.Scan() 我們來分析下其火焰圖

step_03_torch_httperr_scan.png

可以看到耗時的大頭依然是 go/types.(*Scope).Containsruntime.mapiternext,看業務邏輯:

 1  func (scanner *HttpErrorScanner) Scan(prog *program.Program) {
 2    // ... initialization
 3    for pkg, pkgInfo := range prog.AllPackages {
 4      for id, obj := range pkgInfo.Defs {
 5        // ... do something
 6          for pkgDefHttpError, httpErrorMap := range scanner.HttpErrors {
 7            if pkg == pkgDefHttpError || program.PkgContains(pkg.Imports(), pkgDefHttpError) {
 8              for id, obj := range pkgInfo.Uses {
 9                if tpeFunc.Scope() != nil && tpeFunc.Scope().Contains(id.Pos()) {
10                  if constObj, ok := obj.(*types.Const); ok {
11                    if http_error_code.IsHttpCode(obj.Type()) {
12                      code := constObj.Val().String()
13                      if httpErrorValue, ok := httpErrorMap[code]; ok {
14                        if scanner.ErrorType == nil {
15                          // ... do something
16                        }
17                        // ... do something

  1. 第9行 tpeFunc.Scope().Contains(id.Pos()) 上有四層 for 循環,估計調用次數很多
  2. 第 9、10、11 行連續 3 個 if 判斷,相互獨立,顯然可以調換順序。
  3. Scan 方法為的是找到個別類型,且數量很少,推斷第三個條件 http_error_code.IsHttpCode(obj.Type()) 的范圍最小,將第三個條件放到最前面,重新編譯執行,130s,尷尬了,看來 http_error_code.IsHttpCode(obj.Type())tpeFunc.Scope().Contains(id.Pos()) 耗時要多得多。

http_error_code.IsHttpCode 業務代碼:

var HttpErrorVarName = "HttpErrorCode"
var StatusErrorVarName = "StatusErrorCode"

func IsHttpCode(tpe types.Type) bool {
  return program.IsTypeName(tpe, HttpErrorVarName) || program.IsTypeName(tpe, StatusErrorVarName)
}

// package program
func IsTypeName(tpe types.Type, typeName string) bool {
  pkgPaths := strings.Split(tpe.String(), ".")
  return pkgPaths[len(pkgPaths)-1] == typeName
}

  1. IsTypeName 的邏輯可以簡化為

    tpe.String() == typeName || strings.HasSuffix(tpe.String(), "."+typeName)
    
    
  2. types.Type 可以做緩存

  3. 重新編譯運行,27s,看來 http_error_code.IsHttpCode(obj.Type()) 雖然過濾度高,但是消耗也大,看到三個 if 之一的第10行,只是一個類型判斷,消耗不大,放在第一個試試。

  4. 重新編譯運行,20s => 16s

更多優化可能

  1. 掃描中代碼中,原則上講,只需要參與 HTTP 接口定義的 package,目前的方案會對所有依賴庫建緩存掃描。
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容