Go開發中的十大常見陷阱[譯]
原文: The Top 10 Most Common Mistakes I’ve Seen in Go Projects
作者: Teiva Harsanyi
譯者: Simon Ma
我在Go開發中遇到的十大常見錯誤。順序無關緊要。
未知的列舉值
讓我們看一個簡單的例子:
type Status uint32
const (
StatusOpen Status = iota
StatusClosed
StatusUnknown
)
在這裡,我們使用iota建立了一個列舉,其結果如下:
StatusOpen = 0 StatusClosed = 1 StatusUnknown = 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
}
這裡沒有什麼特別的,狀態會被unmarshalled
為StatusOpen
然而,讓我們以另一個未設定狀態值的請求為例:
{
"Id": 1235,
"Timestamp": 1563362390
}
在這種情況下,請求結構的Status
欄位將初始化為它的零值(對於uint32
型別:0),因此結果將是StatusOpen
而不是StatusUnknown
。
那麼最好的做法是將列舉的未知值設定為0:
type Status uint32
const (
StatusUnknown Status = iota
StatusOpen
StatusClosed
)
如果狀態不是JSON請求的一部分,它將被初始化為StatusUnknown
,這才符合我們的期望。
自動優化的基準測試
基準測試需要考慮很多因素的,才能得到正確的測試結果。
一個常見的錯誤是測試程式碼無形間被編譯器所優化。
下面是teivah/bitvector
庫中的一個例子:
func clear(n uint64, i, j uint8) uint64 {
return (math.MaxUint64<<j | ((1 << i) - 1)) & n
}
此函式清除給定範圍內的位。為了測試它,可能如下這樣做:
func BenchmarkWrong(b *testing.B) {
for i := 0; i < b.N; i++ {
clear(1221892080809121, 10, 63)
}
}
在這個基準測試中,clear
不呼叫任何其他函式,沒有副作用。所以編譯器將會把clear
優化成行內函數。一旦內聯,將會導致不準確的測試結果。
一個解決方案是將函式結果設定為全域性變數,如下所示:
var result uint64
func BenchmarkCorrect(b *testing.B) {
var r uint64
for i := 0; i < b.N; i++ {
r = clear(1221892080809121, 10, 63)
}
result = r
}
如此一來,編譯器將不知道clear
是否會產生副作用。
因此,不會將clear
優化成行內函數。
延伸閱讀
High Performance Go Workshop
被轉移的指標
在函式呼叫中,按值傳遞的變數將建立該變數的副本,而通過指標傳遞只會傳遞該變數的記憶體地址。
那麼,指標傳遞會比按值傳遞更快嗎?請看一下這個例子。
我在本地環境上模擬了0.3KB
的資料,然後分別測試了按值傳遞和指標傳遞的速度。
結果顯示:按值傳遞比指標傳遞快4倍以上,這很違背直覺。
測試結果與Go中如何管理記憶體有關。我雖然不能像威廉·肯尼迪那樣出色地解釋它,但讓我試著總結一下。
譯者注開始
作者沒有說明Go記憶體的基本儲存方式,譯者補充一下。
下面是來自Go語言聖經的介紹:
一個goroutine會以一個很小的棧開始其生命週期,一般只需要2KB。
一個goroutine的棧,和作業系統執行緒一樣,會儲存其活躍或掛起的函式呼叫的本地變數,但是和OS執行緒不太一樣的是,一個goroutine的棧大小並不是固定的;棧的大小會根據需要動態地伸縮。
而goroutine的棧的最大值有1GB,比傳統的固定大小的執行緒棧要大得多,儘管一般情況下,大多goroutine都不需要這麼大的棧。
譯者自己的理解:
棧:每個Goruntine開始的時候都有獨立的棧來儲存資料。(Goruntine分為主Goruntine和其他Goruntine,差異就在於起始棧的大小)
堆: 而需要被多個Goruntine共享的資料,儲存在堆上面。
譯者注結束
眾所周知,可以在堆或棧上分配變數。
- 棧儲存當前
Goroutine
的正在使用的變數(譯者注: 可理解為區域性變數)。一旦函式返回,變數就會從棧中彈出。 - 堆儲存共享變數(全域性變數等)。
讓我們看一個簡單的例子,返回單一的值:
func getFooValue() foo {
var result foo
// Do something
return result
}
當呼叫函式時,result
變數會在當前Goruntine棧建立,當函式返回時,會傳遞給接收者一份值的拷貝。而result
變數自身會從當前Goruntine棧出棧。
雖然它仍然存在於記憶體中,但它不能再被訪問。並且還有可能被其他資料變數所擦除。
現在,在看一個返回指標的例子:
func getFooPointer() *foo {
var result foo
// Do something
return &result
}
當呼叫函式時,result
變數會在當前Goruntine棧建立,當函式返回時,會傳遞給接收者一個指標(變數地址的副本)。如果result
變數從當前Goruntine棧出棧,則接收者將無法再訪問它。(譯者注:此情況稱為“記憶體逃逸”)
在這個場景中,Go編譯器將把result
變數轉義到一個可以共享變數的地方:堆。
不過,傳遞指標是另一種情況。例如:
func main() {
p := &foo{}
f(p)
}
因為我們在同一個Goroutine中呼叫f
,所以p
變數不需要轉義。它只是被推送到堆疊,子功能可以訪問它。(譯者注:不需要其他Goruntine共享的變數就儲存在棧上即可)
比如,io.Reader
中的Read
方法簽名,接收切片引數,將內容讀取到切片中,返回讀取的位元組數。而不是返回讀取後的切片。(譯者注:如果返回切片,會將切片轉義到堆中。)
type Reader interface {
Read(p []byte) (n int, err error)
}
為什麼棧如此之快? 主要有兩個原因:
- 堆疊不需要垃圾收集器。就像我們說的,變數一旦建立就會被入棧,一旦函式返回就會從出棧。不需要一個複雜的程序來回收未使用的變數。
- 儲存變數不需要考慮同步。堆屬於一個Goroutine,因此與在堆上儲存變數相比,儲存變數不需要同步。
總之,當建立一個函式時,我們的預設行為應該是使用值而不是指標。只有在我們想要共享變數時才應使用指標。
如果我們遇到效能問題,可以使用go build -gcflags "-m -m"
命令,來顯示編譯器將變數轉義到堆的具體操作。
再次重申,對於大多數日常用例來說,值傳遞是最合適的。
延伸閱讀
Language Mechanics On Stacks And Pointers
Understanding Allocations: the Stack and the Heap - GopherCon SG 2019
出乎意料的break
如果f
返回true,下面的例子中會發生什麼?
for {
switch f() {
case true:
break
case false:
// Do something
}
}
我們將呼叫break
語句。然而,將會break
出switch
語句,而不是for
迴圈。
同樣的問題:
for {
select {
case <-ch:
// Do something
case <-ctx.Done():
break
}
}
break
與select
語句有關,與for
迴圈無關。
break
出for/switch或for/select
的一種解決方案是使用帶標籤的break,如下所示:
loop:
for {
select {
case <-ch:
// Do something
case <-ctx.Done():
break loop
}
}
缺失上下文的錯誤
Go在錯誤處理方面仍然有待提高,以至於現在錯誤處理是Go2中最令人期待的需求。
當前的標準庫(在Go 1.13之前)只提供error
的建構函式,自然而然就會缺失其他資訊。
讓我們看一下pkg/errors庫中錯誤處理的思想:
An error should be handled only once. Logging an error is handling an error. So an error should either be logged or propagated.
(譯:錯誤應該只處理一次。記錄log 錯誤就是在處理錯誤。所以,錯誤應該記錄或者傳播)
對於當前的標準庫,很難做到這一點,因為我們希望向錯誤中新增一些上下文資訊,使其具有層次結構。
例如: 所期望的REST
呼叫導致資料庫問題的示例:
unable to server 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 server 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")
}
如果不是由外部庫返回的初始error
可以使用error.New
建立。中間層insert
對此錯誤新增更多上下文資訊。最終通過log
錯誤來處理錯誤。每個級別要麼返回錯誤,要麼處理錯誤。
我們可能還想檢查錯誤原因來判讀是否應該重試。假設我們有一個來自外部庫的db
包來處理資料庫訪問。 該庫可能會返回一個名為db.DBError
的臨時錯誤。要確定是否需要重試,我們必須檢查錯誤原因:
使用pkg/errors
中提供的errors.Cause
可以判斷錯誤原因。
func postHandler(customer Customer) Status {
err := insert(customer.Contract)
if err != nil {
switch errors.Cause(err).(type) {
default:
log.WithError(err).Errorf("unable to server 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
。 例如,通過這種方式檢查錯誤:
switch err.(type) {
default:
log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID)
return Status{ok: false}
case *db.DBError:
return retry(customer)
}
在此示例中,如果db.DBError
被wrapped
,它將永遠不會執行retry
。
延伸閱讀
Don’t just check errors, handle them gracefully
正在擴容的切片
有時,我們知道切片的最終長度。假設我們想把Foo
切片轉換成Bar
切片,這意味著這兩個切片的長度是一樣的。
我經常看到切片以下面的方式初始化:
var bars []Bar
bars := make([]Bar, 0)
切片不是一個神奇的資料結構,如果沒有更多可用空間,它會進行雙倍擴容。在這種情況下,會自動建立一個切片(容量更大),並複製其中的元素。
如果想容納上千個元素,想象一下,我們需要擴容多少次。雖然插入的時間複雜度是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 }
毫無規範的Context
context.Context
經常被誤用。 根據官方文件:
A Context carries a deadline, a cancelation signal, and other values across API boundaries.
這種描述非常籠統,以至於讓一些人對使用它感到困惑。
讓我們試著詳細描述一下。Context
可以包含:
- A deadline(最後期限)。它意味著到期之後(250ms之後或者一個指定的日期),我們必須停止正在進行的操作(
I/O
請求,等待的channel
輸入,等等)。 - A cancelation signal(取消訊號)。一旦我們收到訊號,我們必須停止正在進行的活動。例如,假設我們收到兩個請求:一個用來插入一些資料,另一個用來取消第一個請求。這可以通過在第一個呼叫中使用
cancelable
上下文來實現,一旦我們獲得第二個請求,這個上下文就會被取消。 - A list of key/value (鍵/值列表)均基於
interface{}
型別。
值得一提的是,Context是可以組合的。例如,我們可以繼承一個帶有截止日期和鍵/值列表的Context
。此外,多個goroutines
可以共享相同的Context
,取消一個Context
可能會停止多個活動。
回到我們的主題,舉一個我經歷的例子。
一個基於urfave/cli (如果您不知道,這是一個很好的庫,可以在Go中建立命令列應用程式)建立的Go應用。一旦開始,程式就會繼承父級的Context
。這意味著當應用程式停止時,將使用此Context
傳送取消訊號。
我經歷的是,這個Context
是在呼叫gRPC
時直接傳遞的,這不是我想做的。相反,我想當應用程式停止時或無操作100毫秒後,傳送取消請求。
為此,可以簡單地建立一個組合的Context
。如果parent
是父級的Context
的名稱(由urfave/cli建立),那麼組合操作如下:
ctx, cancel := context.WithTimeout(parent, 100 * time.Millisecond)
response, err := grpcClient.Send(ctx, request)
Context
並不複雜,在我看來,可謂是 Go 的最佳特性之一。
延伸閱讀
- Understanding the context package in golang
- gRPC and Deadlines
被遺忘的-race引數
我經常看到的一個錯誤是在沒有-race
引數的情況下測試 Go 應用程式。
正如本報告所述,雖然Go“旨在使併發程式設計更容易,更不容易出錯”,但我們仍然遇到很多併發問題。
顯然,Go 競爭檢測器無法解決每一個併發問題。但是,它仍有很大價值,我們應該在測試應用程式時始終啟用它。
延伸閱讀
Does the Go race detector catch all data race bugs?
更完美的封裝
另一個常見錯誤是將檔名傳遞給函式。
假設我們實現一個函式來計算檔案中的空行數。最初的實現是這樣的:
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
}
filename
作為給定的引數,然後我們開啟該檔案,再實現讀空白行的邏輯,嗯,沒有問題。
假設我們希望在此函式之上實現單元測試,並使用普通檔案,空檔案,具有不同編碼型別的檔案等進行測試。程式碼很容易變得非常難以維護。
此外,如果我們想對於HTTP Body
實現相同的邏輯,將不得不為此建立另一個函式。
Go 設計了兩個很棒的介面:io.Reader
和 io.Writer
(譯者注:常見IO 命令列,檔案,網路等)
所以可以傳遞一個抽象資料來源的io.Reader
,而不是傳遞檔名。
仔細想一想統計的只是檔案嗎?一個HTTP正文?位元組緩衝區?
答案並不重要,重要的是無論Reader
讀取的是什麼型別的資料,我們都會使用相同的Read
方法。
在我們的例子中,甚至可以緩衝輸入以逐行讀取它(使用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))
無論資料來源如何,都可以呼叫count
。並且,還將促進單元測試,因為可以從字串建立一個bufio.Reader
,這大大提高了效率。
count, err := count(bufio.NewReader(strings.NewReader("input")))
Goruntines與迴圈變數
我見過的最後一個常見錯誤是使用 Goroutines 和迴圈變數。
以下示例將會輸出什麼?
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
迴圈範圍內建立另一個變數:
ints := []int{1, 2, 3}
for _, i := range ints {
i := i
go func() {
fmt.Printf("%v\n", i)
}()
}
i := i
可能看起來有點奇怪,但它完全有效。
因為處於迴圈中意味著處於另一個作用域內,所以i := i
相當於建立了另一個名為i
的變數例項。
當然,為了便於閱讀,最好使用不同的變數名稱。
延伸閱讀
Using goroutines on loop iterator variables
你還想提到其他常見的錯誤嗎?請隨意分享,繼續討論