『就要學習 Go 語言』系列 -- 第 33 篇分享好文
四哥翻譯水準有限,如有翻譯或了解錯誤,煩請幫忙指出,感謝!
原文如下:
這篇文章列舉了我在 Go 項目中遇見的最常見錯誤,排序先後不重要。
1.未知的枚舉值
來看個簡單的例子:
type Status uint32const ( StatusOpen Status = iota StatusClosed StatusUnknown)
我們使用 iota 建立了枚舉值,結果如下:
StatusOpen = 0StatusClosed = 1StatusUnknown = 2
現在,我們假設 Status 類型是 JSON 請求的一部分,将會被 marshalled/unmarshalled。設計的結構體如下:
type Request struct { ID int `json:"Id"` Timestamp int `json:"Timestamp"` Status Status `json:"Status"`}
接收到請求的結果如下:
{ "Id": 1234, "Timestamp": 1563362390, "Status": 0}
這沒什麼特殊的,Status 會被解析成 StatusOpen,沒錯吧?
好,我們再請求一次,得到的結果沒有設定 Status(不管什麼原因):
{ "Id": 1235, "Timestamp": 1563362390}
這個例子中,Request 結構體的 Status 字段會初始化為零值(對于 uint32 類型來說是 0)。是以,對應的是 StatusOpen 而不是 StatusUnknown。
最好的做法就是将未知的值設定為枚舉值 0:
type Status uint32const ( StatusUnknown Status = iota StatusOpen StatusClosed)
這樣,即使 Status 不是請求 JSON 的一部分,就像我們所期望的那樣,它也會初始化為 StatusUnknown。
2.基準測試
想要準确無誤地進行基準測試很難,因為有太多因素會對結果産生影響。
最常見的錯誤之一就是代碼會被編譯器優化,我們來看一個具體的例子,這個例子選自 teivah/bitvector[1] 庫:
func clear(n uint64, i, j uint8) uint64 { return (math.MaxUint64<}
在這個基準測試中,編譯器注意到 clear() 函數是一個葉子函數(沒有調用其他函數),會将它優化成内聯函數。一旦 clear() 函數被内聯,編譯器注意到它沒有任何的副作用,是以編譯器會将它移除,進而導緻錯誤的結果。
一種可行的方案是将結果作為全局變量,就像下面這樣:
var result uint64func BenchmarkCorrect(b *testing.B) { var r uint64 for i := 0; i < b.N; i++ { r = clear(1221892080809121, 10, 63) } result = r}
這樣,編譯器不知道調用是否會産生副作用。是以,基準測試結果是正确的。
拓展閱讀:High Performance Go Workshop[2]
3.指針!到處都是指針!
變量傳值會建立變量的副本;如果傳指針,複制的則是記憶體位址。
是以,傳指針通常會更快,是這樣嗎?
如果你相信這是真的,請看下這個例子[3]。這是一個基準測試,對一個 0.3 KB 的結構體分别做傳值和傳指針的對比。0.3 KB 不算大,與我們每天接觸的結構體的大小相差不遠。
當在我本地環境執行基準測試,傳值要比傳指針快 4 倍,是不是有點違反直覺?
這個答案的解釋與 Go 的記憶體管理有關。我無法像 William Kennedy 一樣出色地解釋,但不妨礙我們來總結一下:
一個變量可以配置設定在堆上或者棧上:
•棧中存儲目前 goroutine 正在使用的變量,一旦函數傳回,變量便會從棧中彈出;•堆存儲共享變量(全局變量等);
我們來看一個傳回值的例子:
func getFooValue() foo { var result foo // Do something return result}
示例中,目前 goroutine 建立了變量 result,被推入目前的棧中。函數傳回的時候,函數調用者将會接收到變量的副本。而變量 result 本身被目前 goroutine 彈出棧。但它依然存儲在記憶體裡(但再也不能通路),直到被其他變量覆寫。
來看一個傳指針的例子:
func getFooPointer() *foo { var result foo // Do something return &result}
目前 goroutine 建立了變量 result,但函數調用者将接收到一個指針(變量位址的副本),如果變量 result 被彈出棧,函數調用者将再也不能通路它。
在這種情況下,Go 編譯器會将變量 result 逃逸到一個變量可以共享的地方:堆。
來看下傳指針的另外一種情況:
func main() { p := &foo{} f(p)}
因為我們在同一個 goroutine 裡調用 f() 函數,變量 p 不會被逃逸。隻是被推入棧中,子函數任然可以通路它。
這也是 io.Reader 接口中 Read 方法接收切片而不是傳回切片的直接原因,如果傳回一個切片,它将會逃逸到堆中。
為什麼棧會如此之快?有兩個主要的原因:
•棧不需要垃圾回收機制。我們說過,變量建立的時候便會被推入棧中,函數傳回時會被彈出棧。不需要一個複雜的過程回收未使用的變量。•棧屬于一個 goroutine,與堆相比,将變量存儲在棧中不需要同步機制。這也可以提高性能。
總之,當我們建立函數時,預設應該是使用值而不是指針。隻有在我們想要共享變量時才應使用指針。
當我們遭遇性能問題時,在一些特定情況下,可能的優化政策是檢查下指針,這擷取對我們有所幫助。通過使用下面這條指令,可以知道編譯器何時将變量轉義到堆:
go build -gcflags "-m -m"
再次強調下,對于大多數日常用例來說,值傳遞是最合适的。
拓展閱讀:Language Mechanics On Stacks And Pointers[4]
4.使用 break 跳出 for/switch 或者 for/select
下面的例子中,如果函數 f() 傳回 true 将會發生什麼:
for { switch f() { case true: break case false: // Do something }}
将會走 break 語句,跳出 switch 語句而不是終止 for 循環。
下面是同樣的問題:
for { select { case // Do something case break }}
break 隻會跳出 select 語句,而不是終止 for 循環。
上述問題的一種可行方法是使用标記,就像下面這樣:
loop: for { select { case // Do something case break loop } }
5.錯誤管理
Go 在錯誤處理方面仍然有待提高,這也是 Go2.0 最令人期待的特性之一。
目前标準庫(Go1.13 之前)隻提供建構錯誤的功能,如果檢視 pkg/errors[5] 包,會發現對于錯誤處理的規則,它并不完全遵守:
一個錯誤隻被處理一次,記錄錯誤就是在處理錯誤。是以,錯誤要麼被記錄要麼被收集。
使用目前的标準庫,很難遵守以上規則,因為總是希望對錯誤加一些上下文資訊或者讓其具有某種層次結構。
讓我們來看一個例子,希望能通過 REST 調用檢視一個資料庫的問題:
unable to serve HTTP POST request for customer 1234 |_ unable to insert customer contract abcd |_ unable to commit transaction
如果我們使用 pkg/errors 包,可以這樣做:
func postHandler(customer Customer) Status { err := insert(customer.Contract) if err != nil { log.WithError(err).Errorf("unable to serve HTTP POST request for customer %s", customer.ID) return Status{ok: false} } return Status{ok: true}}func insert(contract Contract) error { err := dbQuery(contract) if err != nil { return errors.Wrapf(err, "unable to insert customer contract %s", contract.ID) } return nil}func dbQuery(contract Contract) error { // Do something then fail return errors.New("unable to commit transaction")}
剛開始的時候,錯誤由 errors.New 函數建立;并在函數 insert() 中,通過 errors.Wrapf() 添加一些上下文資訊包裝次錯誤;最後,在父級函數中通過 log 包來記錄錯誤。每個層級都會傳回或處理錯誤。
有時候我們想通過檢查錯誤原因來判斷是否應該重試。假設有一個來自外部庫的 db 包,用來處理資料庫通路。該庫可能會傳回一個名為 db.DBError 的臨時性錯誤。我們需要檢查錯誤原因來決定是否需要重試:
func postHandler(customer Customer) Status { err := insert(customer.Contract) if err != nil { switch errors.Cause(err).(type) { default: log.WithError(err).Errorf("unable to serve HTTP POST request for customer %s", customer.ID) return Status{ok: false} case *db.DBError: return retry(customer) } } return Status{ok: true}}func insert(contract Contract) error { err := db.dbQuery(contract) if err != nil { return errors.Wrapf(err, "unable to insert customer contract %s", contract.ID) } return nil}
這樣,通過使用 pkg/errors 包的 errors.Cause() 函數實作重試。
我見過的使用 pkg/errors 包處理錯誤的常見錯誤方式如下:
switch err.(type) {default: log.WithError(err).Errorf("unable to serve HTTP POST request for customer %s", customer.ID) return Status{ok: false}case *db.DBError: return retry(customer)}
在這個例子中,如果 db.DBError 被包裝過,将永遠不會觸發 retry() 函數。
拓展閱讀:Don’t just check errors, handle them gracefully[6]
6.初始化切片
一些情況下,我們知道一個切片的最終長度。例如,假設我們将一個切片 轉化成一個切片 Bar,很明顯我們知道這兩個切片有相同的長度。
我經常看到使用下面這種方式對切片進行初始化:
var bars []Barbars := make([]Bar, 0)
切片并不是一個神奇的結構。在底層,切片實作了自動增長政策,如果目前切片沒有足夠容量可用時候會自動增長。這種情況下,會自動建立一個新的更大長度[7]的數組,原有的數組元素會拷貝到新數組。
現在,讓我們想象下,當 []Foo 有成千上萬個元素時,是不是需要多次重複自增長操作?插入操作的時間複雜度仍然是 O(1),但實際上這個會嚴重的影響程式的性能。
是以,如果我們知道切片的最終長度,可以選擇下面兩種做法之一:
•使用預定義長度初始化;
func convert(foos []Foo) []Bar { bars := make([]Bar, len(foos)) for i, foo := range foos { bars[i] = fooToBar(foo) } return bars}
•使用 0 長度和預定義的容量對其進行初始化;
func convert(foos []Foo) []Bar { bars := make([]Bar, 0, len(foos)) for _, foo := range foos { bars = append(bars, fooToBar(foo)) } return bars}
到底哪種才是最佳的方式呢?第一種會稍稍快一點。然而,你可以更喜歡第二種,因為它更加有一緻性:不管我們是否知道初始大小,都可以使用 append 在切片的末尾添加元素。
7.Context 管理
context.Context 經常被開發者誤解,根據官方文檔描述:
A Context carries a deadline, a cancelation signal, and other values across API boundaries.
這個描述非常通用,通用的足以讓很多人對為什麼要使用和如何去使用 context 感到非常疑惑。
讓我們較長的描述下,一個 context 可以包含:
•一個截止時間。意味着,一個時間段(例如 250 ms)結束之後或者到了某一個時間點(例如 2019-01-08 01:00:00),我們必須取消正在進行的操作(例如:I/O 請求、等待一個協程輸入等)。•一個取消信号(基本上都是 •一個 key/value 對的清單(都是 interface{}類型)。
補充兩點:第一,上下文是可組合的。是以,我們可以有一個包含截止時間和 key/value 清單的上下文;第二,多個 goroutine 可以共享同一 context,是以取消信号可能會終止多個活動。
言歸正傳,下面是我見過的一個具體錯誤。
一個 Go 應用基于 urfave/cli[8](一個非常好用的可建立指令行引用的第三方包)。一旦引用啟動,開發人員将繼承一種應用的 context,這意味着當應用停止之後,庫将使用次 context 發送取消信号。
我遇到的情況是,在我調用 gRPC 服務的時候,這個 context 被直接傳遞過去了。這并不是我們想要的。
相反,我們希望訓示 gRPC庫:請在程式停止或者 100ms 以後取消這個請求。為此,我們可以簡單的建立一個組合的 context,如果 parent 是應用 context 的名字,我們可以簡單的這樣做:
ctx, cancel := context.WithTimeout(parent, 100 * time.Millisecond)response, err := grpcClient.Send(ctx, request)
context 了解起來并不複雜,而且我認為它是 Go 語言的最佳特性之一。
拓展閱讀:
1 Understanding the context package in golang[9]
2 gRPC and Deadlines[10]
8.不使用 -race 選項
測試 Go 應用的時候,我經常碰到的一個錯誤是沒有 -race 選項。正如在這份報告[11]裡描述的一樣,盡管 Go 語言的設計旨在“使并發程式設計變得更容易且不容易出錯”,我們依然會遇到各種并發問題。
顯然,Go 的競争檢查(race detector)無法解決每一個并發問題。不過,它依然是有使用價值的工具,在測試 Go 應用的時候,應當開啟它。
拓展閱讀:Does the Go race detector catch all data race bugs?[12]
9.使用檔案名作為輸入參數
另一個常見的錯誤是将一個檔案名作為參數傳遞。
假設我們要編寫一個函數,實作計算檔案裡空行的數目的功能。最常見的做法就是像下面這樣:
func count(filename string) (int, error) { file, err := os.Open(filename) if err != nil { return 0, errors.Wrapf(err, "unable to open %s", filename) } defer file.Close() scanner := bufio.NewScanner(file) count := 0 for scanner.Scan() { if scanner.Text() == "" { count++ } } return count, nil}
檔案名作為參數傳遞,然後在函數裡打開它,接着實作其他的邏輯,應當是這樣嗎?
現在,假設我們想要基于這個函數實作單元測試,測試普通檔案、空檔案或者不同編碼類型的檔案等。這會變得難以管理。
再比如,如果是從一個 HTTP Body 接收内容去實作相同的邏輯(計算空行數目), 那就不得不為此再建立一個函數。
Go 語言裡面有兩個非常棒的抽象函數:io.Reader 和 io.Writer。我們可以傳遞一個抽象方法 io.Reader 代替傳檔案名,這就可以使得函數更具通用性了。
不管輸入源是檔案、HTTP 包體還是一個位元組 Buffer,都不重要,我們都可以使用同一個讀取方法實作相同功能。
在我們的例子中,我們甚至可以通過逐行讀取的方式将輸入緩存起來,是以,我們可以使用 bufio.Reader 和 ReadLine 方法:
func count(reader *bufio.Reader) (int, error) { count := 0 for { line, _, err := reader.ReadLine() if err != nil { switch err { default: return 0, errors.Wrapf(err, "unable to read") case io.EOF: return count, nil } } if len(line) == 0 { count++ } }}
至于打開檔案的操作就可以交給 count() 函數調用者去實作:
file, err := os.Open(filename)if err != nil { return errors.Wrapf(err, "unable to open %s", filename)}defer file.Close()count, err := count(bufio.NewReader(file))
通過第二種方法,不管實際的資料來源是什麼,都可以通過調用該函數來實作相同功能。與此同時,這也有助于我們的單元測試,因為我們可以簡單地從字元串建立 bufio.Reader 即可:
count, err := count(bufio.NewReader(strings.NewReader("input")))
10.協程和循環變量
最後一個常見的錯誤是使用循環變量的方式建立 goroutine。
下面的例子輸出什麼?
ints := []int{1, 2, 3}for _, i := range ints { go func() { fmt.Printf("%v\n", i) }()}
正如所希望的一樣,按順序輸出 1 2 3,不過真的是這樣嗎?
在這個例子中,每一個 goroutine 共享相同的變量,是以會輸出 3 3 3(最有可能)。
這個問題有兩個解決辦法,第一種是将變量 i 的值傳遞給閉包(内部的函數):
ints := []int{1, 2, 3}for _, i := range ints { go func(i int) { fmt.Printf("%v\n", i) }(i)}
第二種解決辦法是在 for-range 循環體内建立一個臨時變量:
ints := []int{1, 2, 3}for _, i := range ints { i := i go func() { fmt.Printf("%v\n", i) }()}
這行代碼 i := i 看起來有些奇怪,但完全是有效的。進入循環體裡就是進入另一個變量作用域,是以 i:=i 建立了一個新的、名稱相同的變量 i。當然為了提高可讀性,我們也可以使用其他的變量名。
拓展閱讀:Common Mistakes:Using goroutines on loop iterator variables[13]
References
[1]
teivah/bitvector: https://github.com/teivah/bitvector/
[2]
High Performance Go Workshop: https://dave.cheney.net/high-performance-go-workshop/dotgo-paris.html?source=post_page-----4b79d4f6cd65----------------------#watch_out_for_compiler_optimisations
[3]
例子: https://gist.github.com/teivah/a32a8e9039314a48f03538f3f9535537
[4]
Language Mechanics On Stacks And Pointers: https://www.ardanlabs.com/blog/2017/05/language-mechanics-on-stacks-and-pointers.html
[5]
pkg/errors: https://github.com/pkg/errors
[6]
Don’t just check errors, handle them gracefully: https://dave.cheney.net/2016/04/27/dont-just-check-errors-handle-them-gracefully?source=post_page-----4b79d4f6cd65----------------------
[7]
更大長度: https://www.darkcoding.net/software/go-how-slices-grow/
[8]
urfave/cli: https://github.com/urfave/cli
[9]
Understanding the context package in golang: http://p.agnihotry.com/post/understanding_the_context_package_in_golang/?source=post_page-----4b79d4f6cd65----------------------
[10]
gRPC and Deadlines: https://grpc.io/blog/deadlines/?source=post_page-----4b79d4f6cd65----------------------
[11]
這份報告: https://blog.acolyer.org/2019/05/17/understanding-real-world-concurrency-bugs-in-go/
[12]
Does the Go race detector catch all data race bugs?: https://medium.com/@val_deleplace/does-the-race-detector-catch-all-data-races-1afed51d57fb
[13]
Common Mistakes:Using goroutines on loop iterator variables: https://github.com/golang/go/wiki/CommonMistakes?source=post_page-----4b79d4f6cd65----------------------#using-goroutines-on-loop-iterator-variables
推薦閱讀:
阿裡雲進階技術專家探讨 Go 的錯誤處理
Gin架構系列 - 自定義錯誤處理
如果我的文章對你有所幫助,點贊、轉發都是一種支援!

給個[在看],是對我最大的支援