在專案開發中,保證程式碼質量和效能的手段不只有單元測試和基準測試,還有程式碼規範檢查和效能優化。
- 程式碼規範檢查是對單元測試的一種補充,它可以從非業務的層面檢查程式碼是否還有優化的空間,比如變數是否被使用、是否是死程式碼等等。
- 效能優化是通過基準測試來衡量的,這樣才知道優化部分是否真的提升了程式的效能。
程式碼規範檢查
什麼是程式碼規範檢查
程式碼規範檢查,顧名思義,是從 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 逃逸到了堆上。所以被map、slice 和 chan 這三種型別引用的指標一定會發生逃逸的。
逃逸分析是判斷變數是分配在堆上還是棧上的一種方法,在實際的專案中要儘可能避免逃逸,這樣就不會被 GC 拖慢速度,從而提升效率。
小技巧:從逃逸分析來看,指標雖然可以減少記憶體的拷貝,但它同樣會引起逃逸,所以要根據實際情況選擇是否使用指標。
優化技巧
下面總結幾個優化的小技巧:
第 1 個技巧是儘可能避免逃逸,因為棧記憶體效率更高,還不用 GC。比如小物件的傳參,array 要比 slice 效果好。
第 2 個技巧如果避免不了逃逸,還是在堆上分配了記憶體,那麼對於頻繁的記憶體申請操作,要學會重用記憶體,比如使用 sync.Pool。
第 3 個技巧就是選用合適的演算法,達到高效能的目的,比如空間換時間。
小提示:效能優化的時候,要結合基準測試,來驗證自己的優化是否有提升。
以上是基於 GO 語言的記憶體管理機制總結出的 3 個方向的技巧,基於這 3 個大方向基本上可以優化出想要的效果。除此之外,還有一些小技巧,比如要儘可能避免使用鎖、併發加鎖的範圍要儘可能小、使用 StringBuilder 做 string 和 [ ] byte 之間的轉換、defer 巢狀不要太多等等。
最後推薦一個 Go 語言自帶的效能剖析的工具 pprof,通過它可以檢視 CPU 分析、記憶體分析、阻塞分析、互斥鎖分析,它的使用不是太複雜,可以搜尋下它的使用教程,這裡就不展開介紹。
總結
程式碼規範檢查是從工具使用的角度講解,而效能優化可能涉及的點太多,所以是從原理的角度講解,明白了原理,就能更好地優化程式碼。
是否進行效能優化取決於兩點:業務需求和自我驅動。所以不要刻意地去做效能優化,尤其是不要提前做,先保證程式碼正確並上線,然後再根據業務需要,決定是否進行優化以及花多少時間優化。自我驅動其實是一種編碼能力的體現,比如有經驗的開發者在編碼的時候,潛意識地就避免了逃逸,減少了記憶體拷貝,在高併發的場景中設計了低延遲的架構。
本作品採用《CC 協議》,轉載必須註明作者和本文連結