我為什么放棄Go語言?
你在什么時候會產(chǎn)生“想要放棄用 Go 語言”的念頭?也許是在用 Go 開發(fā)過程中,接連不斷踩坑的時候。本文作者提煉和總結(jié)《100 Go Mistakes and How to Avoid Them》里的精華內(nèi)容,并結(jié)合自身的工作經(jīng)驗,盤點了 Go 的常見典型錯誤,撰寫了這篇超全避坑指南。讓我們跟隨文章,一起重拾用 Go 的信心~
目錄
1 注意 shadow 變量
2 慎用 init 函數(shù)
3 embed types 優(yōu)缺點
4 Functional Options Pattern 傳遞參數(shù)
5 小心八進制整數(shù)
6 float 的精度問題
7 slice 相關(guān)注意點 slice 相關(guān)注意點
8 注意 range
9 注意 break 作用域
10 defer
11 string 相關(guān)
12 interface 類型返回的非 nil 問題
13 Error
14 happens before 保證
15 Context Values
16 應(yīng)多關(guān)注 goroutine 何時停止
17 Channel
18 string format 帶來的 dead lock
19 錯誤使用 sync.WaitGroup
20 不要拷貝 sync 類型
21 time.After 內(nèi)存泄露
22 HTTP body 忘記 Close 導(dǎo)致的泄露
23 Cache line
24 關(guān)于 False Sharing 造成的性能問題
25 內(nèi)存對齊
26 逃逸分析
27 byte slice 和 string 的轉(zhuǎn)換優(yōu)化
28 容器中的 GOMAXPROCS
29 總結(jié)
01、注意 shadow 變量
go復(fù)制代碼var client *http.Client ??if tracing { ?? ?client, err := createClientWithTracing() ?? ?if err != nil { ?? ? ?return err ?? ?} ?? ?log.Println(client) ??} else { ?? ?client, err := createDefaultClient() ?? ?if err != nil { ?? ? ?return err ?? ?} ?? ?log.Println(client) ?}
在上面這段代碼中,聲明了一個 client 變量,然后使用 tracing 控制變量的初始化,可能是因為沒有聲明 err 的緣故,使用的是 := 進行初始化,那么會導(dǎo)致外層的 client 變量永遠(yuǎn)是 nil。這個例子實際上是很容易發(fā)生在我們實際的開發(fā)中,尤其需要注意。
如果是因為 err 沒有初始化的緣故,我們在初始化的時候可以這么做:
go復(fù)制代碼var client *http.Client ??var err error ??if tracing { ?? ?client, err = createClientWithTracing() ? ?} else { ?? ?... ??} ?? ?if err != nil { // 防止重復(fù)代碼 ?? ? ? ?return err ? ?}
或者內(nèi)層的變量聲明換一個變量名字,這樣就不容易出錯了。
我們也可以使用工具分析代碼是否有 shadow,先安裝一下工具:
go復(fù)制代碼go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow
然后使用 shadow 命令:
go復(fù)制代碼go vet -vettool=C:\Users\luozhiyun\go\bin\shadow.exe .\main.go # command-line-arguments .\main.go:15:3: declaration of "client" shadows declaration at line 13 .\main.go:21:3: declaration of "client" shadows declaration at line 13
02、慎用 init 函數(shù)
使用 init 函數(shù)之前需要注意下面幾件事:
2.1 init 函數(shù)會在全局變量之后被執(zhí)行
init 函數(shù)并不是最先被執(zhí)行的,如果聲明了 const 或全局變量,那么 init 函數(shù)會在它們之后執(zhí)行:
go復(fù)制代碼package main ?import "fmt" ?var a = func() int { ??fmt.Println("a") ??return 0 }() ?func init() { ??fmt.Println("init") } ?func main() { ??fmt.Println("main") } ?// output a initmain
2.2 init 初始化按解析的依賴關(guān)系順序執(zhí)行
比如 main 包里面有 init 函數(shù),依賴了 redis 包,main 函數(shù)執(zhí)行了 redis 包的 Store 函數(shù),恰好 redis 包里面也有 init 函數(shù),那么執(zhí)行順序會是:
還有一種情況,如果是使用 "import _ foo" 這種方式引入的,也是會先調(diào)用 foo 包中的 init 函數(shù)。
2.3 擾亂單元測試
比如我們在 init 函數(shù)中初始了一個全局的變量,但是單測中并不需要,那么實際上會增加單測得復(fù)雜度,比如:
go復(fù)制代碼var db *sql.DB func init(){ ??dataSourceName := os.Getenv("MYSQL_DATA_SOURCE_NAME") ?? ?d, err := sql.Open("mysql", dataSourceName) ?? ?if err != nil { ?? ? ? ?log.Panic(err) ?? ?} ?? ?db = d}
在上面這個例子中 init 函數(shù)初始化了一個 db 全局變量,那么在單測的時候也會初始化一個這樣的變量,但是很多單測其實是很簡單的,并不需要依賴這個東西。
03、embed types 優(yōu)缺點
embed types 指的是我們在 struct 里面定義的匿名的字段,如:
go復(fù)制代碼type Foo struct { ??Bar } type Bar struct { ??Baz int}
那么在上面這個例子中,我們可以通過 Foo.Baz 直接訪問到成員變量,當(dāng)然也可以通過 Foo.Bar.Baz 訪問。
這樣在很多時候可以增加我們使用的便捷性,如果沒有使用 embed types 那么可能需要很多代碼,如下:
go復(fù)制代碼type Logger struct { ?? ? ? ?writeCloser io.WriteCloser } ?func (l Logger) Write(p []byte) (int, error) { ?? ? ? ?return l.writeCloser.Write(p) } ?func (l Logger) Close() error { ?? ? ? ?return l.writeCloser.Close() } ?func main() { ?? ? ? ?l := Logger{writeCloser: os.Stdout} ?? ? ? ?_, _ = l.Write([]byte("foo")) ?? ? ? ?_ = l.Close()}
如果使用了?embed types 我們的代碼可以變得很簡潔:
go復(fù)制代碼type Logger struct { ?? ? ? ?io.WriteCloser } ?func main() { ?? ? ? ?l := Logger{WriteCloser: os.Stdout} ?? ? ? ?_, _ = l.Write([]byte("foo")) ?? ? ? ?_ = l.Close()}
但是同樣它也有缺點,有些字段我們并不想 export ,但是 embed types 可能給我們帶出去,例如:
go復(fù)制代碼type InMem struct { ??sync.Mutex ??m map[string]int } ?func New() *InMem { ?? return &InMem{m: make(map[string]int)}}
Mutex 一般并不想 export, 只想在 InMem 自己的函數(shù)中使用,如:
go復(fù)制代碼func (i *InMem) Get(key string) (int, bool) { ??i.Lock() ??v, contains := i.m[key] ??i.Unlock() ??return v, contains}
但是這么寫卻可以讓拿到 InMem 類型的變量都可以使用它里面的 Lock 方法:
css復(fù)制代碼m := inmem.New() m.Lock() // ??
04、Functional Options Pattern傳遞參數(shù)
這種方法在很多 Go 開源庫都有看到過使用,比如 zap、GRPC 等。
它經(jīng)常用在需要傳遞和初始化校驗參數(shù)列表的時候使用,比如我們現(xiàn)在需要初始化一個 HTTP server,里面可能包含了 port、timeout 等等信息,但是參數(shù)列表很多,不能直接寫在函數(shù)上,并且我們要滿足靈活配置的要求,畢竟不是每個 server 都需要很多參數(shù)。那么我們可以:
設(shè)置一個不導(dǎo)出的 struct 叫 options,用來存放配置參數(shù); 創(chuàng)建一個類型 type Option func(options *options) error,用這個類型來作為返回值;
比如我們現(xiàn)在要給 HTTP server 里面設(shè)置一個 port 參數(shù),那么我們可以這么聲明一個 WithPort 函數(shù),返回 Option 類型的閉包,當(dāng)這個閉包執(zhí)行的時候會將 options 的 port 填充進去:
go復(fù)制代碼type options struct { ?? ? ? ?port *int } ?type Option func(options *options) error ?func WithPort(port int) Option { ?? ? ? ? // 所有的類型校驗,賦值,初始化啥的都可以放到這個閉包里面做 ?? ? ? ?return func(options *options) error { ?? ? ? ? ? ? ? ?if port < 0 { ?? ? ? ? ? ? ? ? ? ? ? ?return errors.New("port should be positive") ?? ? ? ? ? ? ? ?} ?? ? ? ? ? ? ? ?options.port = &port ?? ? ? ? ? ? ? ?return nil ?? ? ? ?}}
假如我們現(xiàn)在有一個這樣的 Option 函數(shù)集,除了上面的 port 以外,還可以填充 timeout 等。然后我們可以利用 NewServer 創(chuàng)建我們的 server:
go復(fù)制代碼func NewServer(addr string, opts ...Option) (*http.Server, error) { ?? ? ? ?var options options ?? ? ? ?// 遍歷所有的 Option ?? ? ? ?for _, opt := range opts { ?? ? ? ? ? ? ? ?// 執(zhí)行閉包 ?? ? ? ? ? ? ? ?err := opt(&options) ?? ? ? ? ? ? ? ?if err != nil { ?? ? ? ? ? ? ? ? ? ? ? ?return nil, err ?? ? ? ? ? ? ? ?} ?? ? ? ?} ? ? ? ? ?// 接下來可以填充我們的業(yè)務(wù)邏輯,比如這里設(shè)置默認(rèn)的port 等等 ?? ? ? ?var port int ?? ? ? ?if options.port == nil { ?? ? ? ? ? ? ? ?port = defaultHTTPPort ?? ? ? ?} else { ?? ? ? ? ? ? ? ?if *options.port == 0 { ?? ? ? ? ? ? ? ? ? ? ? ?port = randomPort() ?? ? ? ? ? ? ? ?} else { ?? ? ? ? ? ? ? ? ? ? ? ?port = *options.port ?? ? ? ? ? ? ? ?} ?? ? ? ?} ? ? ? ? ?// ...}
初始化 server:
css復(fù)制代碼server, err := httplib.NewServer("localhost", ? ? ? ? ? ? ? ? httplib.WithPort(8080), ? ? ? ? ? ? ? ?httplib.WithTimeout(time.Second))
這樣寫的話就比較靈活,如果只想生成一個簡單的 server,我們的代碼可以變得很簡單:
css復(fù)制代碼server, err := httplib.NewServer("localhost")
05、小心八進制整數(shù)
比如下面例子:
bash復(fù)制代碼sum := 100 + 010 ?fmt.Println(sum)
你以為要輸出110,其實輸出的是 108,因為在 Go 中以 0 開頭的整數(shù)表示八進制。
它經(jīng)常用在處理 Linux 權(quán)限相關(guān)的代碼上,如下面打開一個文件:
lua復(fù)制代碼file, err := os.OpenFile("foo", os.O_RDONLY, 0644)
所以為了可讀性,我們在用八進制的時候最好使用 "0o" 的方式表示,比如上面這段代碼可以表示為:
lua復(fù)制代碼file, err := os.OpenFile("foo", os.O_RDONLY, 0o644)
06、float 的精度問題
在 Go 中浮點數(shù)表示方式和其他語言一樣,都是通過科學(xué)計數(shù)法表示,float 在存儲中分為三部分:
符號位(Sign): 0代表正,1代表為負(fù) 指數(shù)位(Exponent):用于存儲科學(xué)計數(shù)法中的指數(shù)數(shù)據(jù),并且采用移位存儲 尾數(shù)部分(Mantissa):尾數(shù)部分
計算規(guī)則我就不在這里展示了,感興趣的可以自己去查查,我這里說說這種計數(shù)法在 Go 里面會有哪些問題。
go復(fù)制代碼func f1(n int) float64 { ??result := 10_000. ??for i := 0; i < n; i++ { ?? ?result += 1.0001 ??} ??return result } ?func f2(n int) float64 { ??result := 0. ??for i := 0; i < n; i++ { ?? ?result += 1.0001 ??} ??return result + 10_000.}
在上面這段代碼中,我們簡單地做了一下加法:
nExact resultf1f21010010.00110010.00110010.0011k11000.111000.111000.11m1.01E+061.01E+061.01E+06
可以看到 n 越大,誤差就越大,并且 f2 的誤差是小于 f1的。
對于乘法我們可以做下面的實驗:
css復(fù)制代碼a := 100000.001 b := 1.0001 c := 1.0002 ?fmt.Println(a * (b + c))fmt.Println(a*b + a*c)
輸出:
復(fù)制代碼200030.00200030004 200030.0020003
正確輸出應(yīng)該是 200030.0020003,所以它們實際上都有一定的誤差,但是可以看到先乘再加精度丟失會更小。
如果想要準(zhǔn)確計算浮點的話,可以嘗試 "github.com/shopspring/…" 庫,換成這個庫我們再來計算一下:
css復(fù)制代碼a := decimal.NewFromFloat(100000.001) b := decimal.NewFromFloat(1.0001) c := decimal.NewFromFloat(1.0002) ?fmt.Println(a.Mul(b.Add(c))) //200030.0020003
07、slice 相關(guān)注意點
7.1 區(qū)分 slice 的 length 和 capacity
首先讓我們初始化一個帶有 length 和 capacity 的 slice :
go復(fù)制代碼s := make([]int, 3, 6)
在 make 函數(shù)里面,capacity 是可選的參數(shù)。上面這段代碼我們創(chuàng)建了一個 length 是 3,capacity 是 6 的 slice,那么底層的數(shù)據(jù)結(jié)構(gòu)是這樣的:
slice 的底層實際上指向了一個數(shù)組。當(dāng)然,由于我們的 length 是 3,所以這樣設(shè)置 s[4] = 0 會 panic 的。需要使用 append 才能添加新元素。
go復(fù)制代碼panic: runtime error: index out of range [4] with length 3
當(dāng) appned 超過 cap 大小的時候,slice 會自動幫我們擴容,在元素數(shù)量小于 1024 的時候每次會擴大一倍,當(dāng)超過了 1024 個元素每次擴大 25% 。
有時候我們會使用 :操作符從另一個 slice 上面創(chuàng)建一個新切片:
go復(fù)制代碼s1 := make([]int, 3, 6) s2 := s1[1:3]
實際上這兩個 slice 還是指向了底層同樣的數(shù)組,構(gòu)如下:
由于指向了同一個數(shù)組,那么當(dāng)我們改變第一個槽位的時候,比如 s1[1]=2,實際上兩個 slice 的數(shù)據(jù)都會發(fā)生改變:
但是當(dāng)我們使用 append 的時候情況會有所不同:
scss復(fù)制代碼s2 = append(s2, 3) ?fmt.Println(s1) // [0 2 0] fmt.Println(s2) // [2 0 3]
s1 的 len 并沒有被改變,所以看到的還是3元素。
還有一件比較有趣的細(xì)節(jié)是,如果再接著 append s1 那么第四個元素會被覆蓋掉:
scss復(fù)制代碼s1 = append(s1, 4) ??fmt.Println(s1) // [0 2 0 4] ??fmt.Println(s2) // [2 0 4]
我們再繼續(xù) append s2 直到 s2 發(fā)生擴容,這個時候會發(fā)現(xiàn) s2 實際上和 s1 指向的不是同一個數(shù)組了:
scss復(fù)制代碼s2 = append(s2, 5, 6, 7) fmt.Println(s1) //[0 2 0 4] fmt.Println(s2) //[2 0 4 5 6 7]
除了上面這種情況,還有一種情況 append 會產(chǎn)生意想不到的效果:
go復(fù)制代碼s1 := []int{1, 2, 3} s2 := s1[1:2] s3 := append(s2, 10)
如果 print 它們應(yīng)該是這樣:
css復(fù)制代碼s1=[1 2 10], s2=[2], s3=[2 10]
7.2 slice 初始化
對于 slice 的初始化實際上有很多種方式:
scss復(fù)制代碼func main() { ?? ? ? ?var s []string ?? ? ? ?log(1, s) ? ? ? ? ?s = []string(nil) ?? ? ? ?log(2, s) ? ? ? ? ?s = []string{} ?? ? ? ?log(3, s) ? ? ? ? ?s = make([]string, 0) ?? ? ? ?log(4, s) } ?func log(i int, s []string) { ?? ? ? ?fmt.Printf("%d: empty=%t\tnil=%t\n", i, len(s) == 0, s == nil)}
輸出:
ini復(fù)制代碼1: empty=true ? nil=true 2: empty=true ? nil=true 3: empty=true ? nil=false 4: empty=true ? nil=false
前兩種方式會創(chuàng)建一個 nil 的 slice,后兩種會進行初始化,并且這些 slice 的大小都為 0 。
對于 var s []string 這種方式來說,好處就是不用做任何的內(nèi)存分配。比如下面場景可能可以節(jié)省一次內(nèi)存分配:
csharp復(fù)制代碼func f() []string { ?? ? ? ?var s []string ?? ? ? ?if foo() { ?? ? ? ? ? ? ? ?s = append(s, "foo") ?? ? ? ?} ?? ? ? ?if bar() { ?? ? ? ? ? ? ? ?s = append(s, "bar") ?? ? ? ?} ?? ? ? ?return s}
對于 s := []string{} 這種方式來說,它比較適合初始化一個已知元素的 slice:
go復(fù)制代碼s := []string{"foo", "bar", "baz"}
如果沒有這個需求其實用 var s []string 比較好,反正在使用的適合都是通過 append 添加元素, var s []string 還能節(jié)省一次內(nèi)存分配。
如果我們初始化了一個空的 slice, 那么最好是使用 len(xxx) == 0來判斷 slice 是不是空的,如果使用 nil 來判斷可能會永遠(yuǎn)非空的情況,因為對于 s := []string{} 和 s = make([]string, 0) 這兩種初始化都是非 nil 的。
對于 []string(nil) 這種初始化的方式,使用場景很少,一種比較方便地使用場景是用它來進行 slice 的 copy:
go復(fù)制代碼src := []int{0, 1, 2} dst := append([]int(nil), src...)
對于 make 來說,它可以初始化 slice 的 length 和 capacity,如果我們能確定 slice 里面會存放多少元素,從性能的角度考慮最好使用 make 初始化好,因為對于一個空的 slice append 元素進去每次達(dá)到閾值都需要進行擴容,下面是填充 100 萬元素的 benchmark:
bash復(fù)制代碼BenchmarkConvert_EmptySlice-4 22 49739882 ns/op BenchmarkConvert_GivenCapacity-4 86 13438544 ns/op BenchmarkConvert_GivenLength-4 91 12800411 ns/op
可以看到,如果我們提前填充好 slice 的容量大小,性能是空 slice 的四倍,因為少了擴容時元素復(fù)制以及重新申請新數(shù)組的開銷。
7.3 copy slice
css復(fù)制代碼src := []int{0, 1, 2} var dst []int copy(dst, src) fmt.Println(dst) // []
使用 copy 函數(shù) copy slice 的時候需要注意,上面這種情況實際上會 copy 失敗,因為對 slice 來說是由 length 來控制可用數(shù)據(jù),copy 并沒有復(fù)制這個字段,要想 copy 我們可以這么做:
go復(fù)制代碼src := []int{0, 1, 2} dst := make([]int, len(src)) copy(dst, src) fmt.Println(dst) //[0 1 2]
除此之外也可以用上面提到的:
go復(fù)制代碼src := []int{0, 1, 2} dst := append([]int(nil), src...)
7.4 slice capacity內(nèi)存釋放問題
先來看個例子:
css復(fù)制代碼type Foo struct { ??v []byte } ?func keepFirstTwoElementsOnly(foos []Foo) []Foo { ??return foos[:2] } ?func main() { ??foos := make([]Foo, 1_000) ??printAlloc() ? ?for i := 0; i < len(foos); i++ { ?? ?foos[i] = Foo{ ?? ? ?v: make([]byte, 1024*1024), ?? ?} ??} ??printAlloc() ? ?two := keepFirstTwoElementsOnly(foos) ??runtime.GC() ??printAlloc() ??runtime.KeepAlive(two)}
上面這個例子中使用 printAlloc 函數(shù)來打印內(nèi)存占用:
swift復(fù)制代碼func printAlloc() { ??var m runtime.MemStats ??runtime.ReadMemStats(&m) ??fmt.Printf("%d KB\n", m.Alloc/1024)}
上面 foos 初始化了 1000 個容量的 slice ,里面 Foo struct 每個都持有 1M 內(nèi)存的 slice,然后通過 keepFirstTwoElementsOnly 返回持有前兩個元素的 Foo 切片,我們的想法是手動執(zhí)行 GC 之后其他的 998 個 Foo 會被 GC 銷毀,但是輸出結(jié)果如下:
復(fù)制代碼387 KB 1024315 KB1024319 KB
實際上并沒有,原因就是實際上 keepFirstTwoElementsOnly 返回的 slice 底層持有的數(shù)組是和 foos 持有的同一個:
所以我們真的要只返回 slice 的前2個元素的話應(yīng)該這樣做:
go復(fù)制代碼func keepFirstTwoElementsOnly(foos []Foo) []Foo { ?? ? ? ?res := make([]Foo, 2) ?? ? ? ?copy(res, foos) ?? ? ? ?return res}
不過上面這種方法會初始化一個新的 slice,然后將兩個元素 copy 過去。不想進行多余的分配可以這么做:
css復(fù)制代碼func keepFirstTwoElementsOnly(foos []Foo) []Foo { ?? ? ? ?for i := 2; i < len(foos); i++ { ?? ? ? ? ? ? ? ?foos[i].v = nil ?? ? ? ?} ?? ? ? ?return foos[:2]}