Go工程管理 19 | 效能優化:Go 語言如何進行程式碼檢查和優化?

Swenson1992發表於2021-03-19

在專案開發中,保證程式碼質量和效能的手段不只有單元測試和基準測試,還有程式碼規範檢查和效能優化。

  • 程式碼規範檢查是對單元測試的一種補充,它可以從非業務的層面檢查程式碼是否還有優化的空間,比如變數是否被使用、是否是死程式碼等等。
  • 效能優化是通過基準測試來衡量的,這樣才知道優化部分是否真的提升了程式的效能。

程式碼規範檢查

什麼是程式碼規範檢查

程式碼規範檢查,顧名思義,是從 Go 語言層面出發,依據 Go 語言的規範,對程式碼進行的靜態掃描檢查,這種檢查和業務無關。

比如定義了個常量,從未使用過,雖然對程式碼執行並沒有造成什麼影響,但是這個常量是可以刪除的,程式碼如下所示:

const name = "Golang"
func main() {
}

示例中的常量 name 其實並沒有使用,所以為了節省記憶體可以刪除它,這種未使用常量的情況就可以通過程式碼規範檢查檢測出來。

再比如,呼叫了一個函式,該函式返回了一個 error,但是你並沒有對該 error 做判斷,這種情況下,程式也可以正常編譯執行。但是程式碼寫得不嚴謹,因為返回的 error 被忽略了。程式碼如下所示:

func main() {
   os.Mkdir("tmp",0666)
}

示例程式碼中,Mkdir 函式是有返回 error 的,但是並沒有對返回的 error 做判斷,這種情況下,哪怕建立目錄失敗,你也不知道,因為錯誤被忽略了。如果使用程式碼規範檢查,這類潛在的問題也會被檢測出來。

以上兩個例子可以理解什麼是程式碼規範檢查、它有什麼用。除了這兩種情況,還有拼寫問題、死程式碼、程式碼簡化檢測、命名中帶下劃線、冗餘程式碼等,都可以使用程式碼規範檢查檢測出來。

golangci-lint

要想對程式碼進行檢查,則需要對程式碼進行掃描,靜態分析寫的程式碼是否存在規範問題。

小提示:靜態程式碼分析是不會執行程式碼的。

可用於 Go 語言程式碼分析的工具有很多,比如 golint、gofmt、misspell 等,如果一一引用配置,就會比較煩瑣,所以通常不會單獨地使用它們,而是使用 golangci-lint。

golangci-lint 是一個整合工具,它整合了很多靜態程式碼分析工具,便於使用。通過配置這一工具,可以很靈活地啟用需要的程式碼規範檢查。

如果要使用 golangci-lint,首先需要安裝。因為 golangci-lint 本身就是 Go 語言編寫的,所以可以從原始碼安裝它,開啟終端,輸入如下命令即可安裝。

➜ go get github.com/golangci/golangci-lint/cmd/lint@v1.37.1"">golangci-lint@v1.37.1

使用這一命令安裝的是 v1.37.1 版本的 golangci-lint,安裝完成後,在終端輸入如下命令,檢測是否安裝成功。

➜ golangci-lint version
golangci-lint has version 1.37.1 built from b39dbcd6 on 2021-02-20T11:48:06Z

小提示:在 MacOS 下也可以使用 brew 來安裝 golangci-lint。

好了,安裝成功 golangci-lint 後,就可以使用它進行程式碼檢查了,以上面示例中的常量 name 和 Mkdir 函式為例,演示 golangci-lint 的使用。在終端輸入如下命令回車:

➜ golangci-lint run lint/

這一示例表示要檢測目錄中 base 下的程式碼,執行後可以看到如下輸出結果。

lint/main.go:5:7: `name` is unused (deadcode)
const name = "Golang"
      ^
lint/main.go:7:6: `main` is unused (deadcode)
func main() {
     ^
lint/main.go:8:10: Error return value of `os.Mkdir` is not checked (errcheck)
        os.Mkdir("tmp", 0666)
                ^

通過程式碼檢測結果可以看到,上面提到的兩個程式碼規範問題都被檢測出來了。檢測出問題後,就可以修復它們,讓程式碼更加符合規範。

golangci-lint 配置

golangci-lint 的配置比較靈活,比如可以自定義要啟用哪些 linter。golangci-lint 預設啟用的 linter,包括這些:

deadcode - 死程式碼檢查
errcheck - 返回錯誤是否使用檢查
gosimple - 檢查程式碼是否可以簡化
govet - 程式碼可疑檢查,比如格式化字串和型別不一致
ineffassign - 檢查是否有未使用的程式碼
staticcheck - 靜態分析檢查
structcheck - 查詢未使用的結構體欄位
typecheck - 型別檢查
unused - 未使用程式碼檢查
varcheck - 未使用的全域性變數和常量檢查

小提示:golangci-lint 支援的更多 linter,可以在終端中輸入 golangci-lint linters 命令檢視,並且可以看到每個 linter 的說明。

如果要修改預設啟用的 linter,就需要對 golangci-lint 進行配置。即在專案根目錄下新建一個名字為 .golangci.yml 的檔案,這就是 golangci-lint 的配置檔案。在執行程式碼規範檢查的時候,golangci-lint 會自動使用它。假設只啟用 unused 檢查,可以這樣配置:

linters:
  disable-all: true
  enable:
    - unused

在團隊多人協作開發中,有一個固定的 golangci-lint 版本是非常重要的,這樣大家就可以基於同樣的標準檢查程式碼。要配置 golangci-lint 使用的版本也比較簡單,在配置檔案中新增如下程式碼即可:

service:
  golangci-lint-version: 1.32.2 # use the fixed version to not introduce new linters unexpectedly

此外,還可以針對每個啟用的 linter 進行配置,比如要設定拼寫檢測的語言為 US,可以使用如下程式碼設定:

linters-settings:
  misspell:
    locale: US

golangci-lint 的配置比較多,可以靈活配置。關於 golangci-lint 的更多配置可以參考官方文件,這裡我給出一個常用的配置,程式碼如下:

linters-settings:
  golint:
    min-confidence: 0
  misspell:
    locale: US
linters:
  disable-all: true
  enable:
    - typecheck
    - goimports
    - misspell
    - govet
    - golint
    - ineffassign
    - gosimple
    - deadcode
    - structcheck
    - unused
    - errcheck
service:
  golangci-lint-version: 1.32.2 # use the fixed version to not introduce new linters unexpectedly

整合 golangci-lint 到 CI

程式碼檢查一定要整合到 CI 流程中,效果才會更好,這樣開發者提交程式碼的時候,CI 就會自動檢查程式碼,及時發現問題並進行修正。

不管是使用 Jenkins,還是 Gitlab CI,或者 Github Action,都可以通過Makefile的方式執行 golangci-lint。現在在專案根目錄下建立一個 Makefile 檔案,並新增如下程式碼:

getdeps:
   @mkdir -p ${GOPATH}/bin
   @which golangci-lint 1>/dev/null || (echo "Installing golangci-lint" && go get github.com/golangci/golangci-lint/cmd/golangci-lint@v1.32.2)
lint:
   @echo "Running $@ check"
   @GO111MODULE=on ${GOPATH}/bin/golangci-lint cache clean
   @GO111MODULE=on ${GOPATH}/bin/golangci-lint run --timeout=5m --config ./.golangci.yml
verifiers: getdeps lint

小提示:關於 Makefile 的知識可以網上搜尋學習一下,比較簡單,這裡不再進行講述。

好了,現在就可以把如下命令新增到你的 CI 中了,它可以自動安裝 golangci-lint,並檢查程式碼。

make verifiers

效能優化

效能優化的目的是讓程式更好、更快地執行,但是它不是必要的,這一點一定要記住。所以在程式開始的時候,不必刻意追求效能優化,先大膽地寫程式碼就好了,寫正確的程式碼是效能優化的前提

堆分配還是棧

在比較古老的 C 語言中,記憶體分配是手動申請的,記憶體釋放也需要手動完成。

  • 手動控制有一個很大的好處就是需要多少就申請多少,可以最大化地利用記憶體

  • 但是這種方式也有一個明顯的缺點,就是如果忘記釋放記憶體,就會導致記憶體洩漏

所以,為了讓程式設計師更好地專注於業務程式碼的實現,Go 語言增加了垃圾回收機制,自動地回收不再使用的記憶體。

Go 語言有兩部分記憶體空間:棧記憶體堆記憶體

  • 棧記憶體由編譯器自動分配和釋放,開發者無法控制。棧記憶體一般儲存函式中的區域性變數、引數等,函式建立的時候,這些記憶體會被自動建立;函式返回的時候,這些記憶體會被自動釋放。

  • 堆記憶體的生命週期比棧記憶體要長,如果函式返回的值還會在其他地方使用,那麼這個值就會被編譯器自動分配到堆上。堆記憶體相比棧記憶體來說,不能自動被編譯器釋放,只能通過垃圾回收器才能釋放,所以棧記憶體效率會很高。

    逃逸分析

    既然棧記憶體的效率更高,肯定是優先使用棧記憶體。那麼 Go 語言是如何判斷一個變數應該分配到堆上還是棧上的呢?這就需要逃逸分析了。下面通過一個示例來講解逃逸分析,程式碼如下:

    func newString() *string{
    s:=new(string)
    *s = "Golang"
    return s
    }

    在這個示例中:

  • 通過 new 函式申請了一塊記憶體;

  • 然後把它賦值給了指標變數 s;

  • 最後通過 return 關鍵字返回。

小提示:以上 newString 函式是沒有意義的,這裡只是為了方便演示。

現在通過逃逸分析來看下是否發生了逃逸,命令如下:

➜ go build -gcflags=”-m -l” ./lint/main.go
# command-line-arguments
lint/main.go: new(string) escapes to heap

在這一命令中,-m 表示列印出逃逸分析資訊,-l 表示禁止內聯,可以更好地觀察逃逸。從以上輸出結果可以看到,發生了逃逸,也就是說指標作為函式返回值的時候一定會發生逃逸

逃逸到堆記憶體的變數不能馬上被回收,只能通過垃圾回收標記清除,增加了垃圾回收的壓力,所以要儘可能地避免逃逸,讓變數分配在棧記憶體上,這樣函式返回時就可以回收資源,提升效率。

下面對 newString 函式進行了避免逃逸的優化,優化後的函式程式碼如下:

func newString() string{
   s:=new(string)
   *s = "Golang"
   return *s
}

再次通過命令檢視以上程式碼的逃逸分析,命令如下:

➜ go build -gcflags=”-m -l” ./lint/main.go
# command-line-arguments
lint/main.go: new(string) does not escape

通過分析結果可以看到,雖然還是宣告瞭指標變數 s,但是函式返回的並不是指標,所以沒有發生逃逸。

這就是關於指標作為函式返回逃逸的例子,那麼是不是不使用指標就不會發生逃逸了呢?下面看個例子,程式碼如下:

fmt.Println("Golang")

同樣執行逃逸分析,會看到如下結果:

➜ go build -gcflags=”-m -l” ./lint/main.go
# command-line-arguments
lint/main.go: … argument does not escape
lint/main.go: “Golang” escapes to heap
lint/main.go: new(string) does not escape

觀察這一結果,你會發現「Golang」這個字串逃逸到了堆上,這是因為「Golang」這個字串被已經逃逸的指標變數引用,所以它也跟著逃逸了,引用程式碼如下:

func (p *pp) printArg(arg interface{}, verb rune) {
   p.arg = arg
   //省略其他無關程式碼
}

所以被已經逃逸的指標引用的變數也會發生逃逸。

Go 語言中有 3 個比較特殊的型別,它們是 slice、map 和 chan,被這三種型別引用的指標也會發生逃逸,看個這樣的例子:

func main() {
   m:=map[int]*string{}
   s:="Golang"
   m[0] = &s
}

同樣執行逃逸分析,看到的結果是:

➜ go build -gcflags=”-m -l” ./lint/main.go
# command-line-arguments
lint/main.go: moved to heap: s
lint/main.go: map[int]*string literal does not escape

從這一結果可以看到,變數 m 沒有逃逸,反而被變數 m 引用的變數 s 逃逸到了堆上。所以被mapslice 和 chan 這三種型別引用的指標一定會發生逃逸的

逃逸分析是判斷變數是分配在堆上還是棧上的一種方法,在實際的專案中要儘可能避免逃逸,這樣就不會被 GC 拖慢速度,從而提升效率。

小技巧:從逃逸分析來看,指標雖然可以減少記憶體的拷貝,但它同樣會引起逃逸,所以要根據實際情況選擇是否使用指標。

優化技巧

下面總結幾個優化的小技巧:

第 1 個技巧是儘可能避免逃逸,因為棧記憶體效率更高,還不用 GC。比如小物件的傳參,array 要比 slice 效果好。

第 2 個技巧如果避免不了逃逸,還是在堆上分配了記憶體,那麼對於頻繁的記憶體申請操作,要學會重用記憶體,比如使用 sync.Pool。

第 3 個技巧就是選用合適的演算法,達到高效能的目的,比如空間換時間。

小提示:效能優化的時候,要結合基準測試,來驗證自己的優化是否有提升。

以上是基於 GO 語言的記憶體管理機制總結出的 3 個方向的技巧,基於這 3 個大方向基本上可以優化出想要的效果。除此之外,還有一些小技巧,比如要儘可能避免使用鎖、併發加鎖的範圍要儘可能小、使用 StringBuilder 做 string 和 [ ] byte 之間的轉換、defer 巢狀不要太多等等。
最後推薦一個 Go 語言自帶的效能剖析的工具 pprof,通過它可以檢視 CPU 分析、記憶體分析、阻塞分析、互斥鎖分析,它的使用不是太複雜,可以搜尋下它的使用教程,這裡就不展開介紹。

總結

程式碼規範檢查是從工具使用的角度講解,而效能優化可能涉及的點太多,所以是從原理的角度講解,明白了原理,就能更好地優化程式碼。

是否進行效能優化取決於兩點:業務需求和自我驅動。所以不要刻意地去做效能優化,尤其是不要提前做,先保證程式碼正確並上線,然後再根據業務需要,決定是否進行優化以及花多少時間優化。自我驅動其實是一種編碼能力的體現,比如有經驗的開發者在編碼的時候,潛意識地就避免了逃逸,減少了記憶體拷貝,在高併發的場景中設計了低延遲的架構。

本作品採用《CC 協議》,轉載必須註明作者和本文連結
golang

相關文章