1.前言
雖然在 go 中,並發編程十分簡單, 只需要使用 go func() 就能啟動一個 goroutine 去做一些事情,但是正是由於這種簡單我們要十分當心,不然很容易出現一些莫名其妙的 bug 或者是你的服務由於不知名的原因就重啟了。 而最常見的bug是關於線程安全方面的問題,比如對同一個map進行寫操作。
2.數據競爭
線程安全是否有什么辦法檢測到呢?
答案就是 data race tag
,go 官方早在 1.1 版本就引入了數據競爭的檢測工具,我們只需要在執行測試或者是編譯的時候加上 -race 的 flag 就可以開啟數據競爭的檢測
使用方式如下
go test -race main.go
go build -race
不建議在生產環境 build 的時候開啟數據競爭檢測,因為這會帶來一定的性能損失(一般內存5-10倍,執行時間2-20倍),當然 必須要 debug 的時候除外。
建議在執行單元測試時始終開啟數據競爭的檢測
2.1 示例一
執行如下代碼,查看每次執行的結果是否一樣
2.1.1 測試
-
代碼
package main import ( "fmt" "sync" ) var wg sync.WaitGroup var counter int func main() { // 多跑幾次來看結果 for i := 0; i < 100000; i++ { run() } fmt.Printf("Final Counter: %d\n", counter) } func run() { // 開啟兩個 協程,操作 for i := 1; i <= 2; i++ { wg.Add(1) go routine(i) } wg.Wait() } func routine(id int) { for i := 0; i < 2; i++ { value := counter value++ counter = value } wg.Done() }
-
執行三次查看結果,分別是
Final Counter: 399950 Final Counter: 399989 Final Counter: 400000
-
原因分析:每一次執行的時候,都使用 go routine(i) 啟動了兩個 goroutine,但是並沒有控制它的執行順序,並不能滿足順序一致性內存模型。
當然由於種種不確定性,所有肯定不止這兩種情況,
2.1.2 data race 檢測
上面問題的出現在上線后如果出現bug會非常難定位,因為不知道到底是哪里出現了問題,所以我們就要在測試階段就結合 data race 工具提前發現問題。
- 使用
go run -race ./main.go
- 輸出: 運行結果發現輸出記錄太長,調試的時候並不直觀,結果如下
main.main() D:/gopath/src/Go_base/daily_test/data_race/demo.go:14 +0x44 ================== Final Counter: 399987 Found 1 data race(s) exit status 66
2.1.3 data race 配置
在官方的文檔當中,可以通過設置 GORACE 環境變量,來控制 data race 的行為, 格式如下:
GORACE="option1=val1 option2=val2"
可選配置見下表
- 配置
GORACE="halt_on_error=1 strip_path_prefix=/mnt/d/gopath/src/Go_base/daily_test/data_race/01_data_race" go run -race ./demo.go
- 輸出:
================== WARNING: DATA RACE Read at 0x00000064d9c0 by goroutine 8: main.routine() /mnt/d/gopath/src/Go_base/daily_test/data_race/demo.go:31 +0x47 Previous write at 0x00000064d9c0 by goroutine 7: main.routine() /mnt/d/gopath/src/Go_base/daily_test/data_race/demo.go:33 +0x64 Goroutine 8 (running) created at: main.run() /mnt/d/gopath/src/Go_base/daily_test/data_race/demo.go:24 +0x75 main.main() /mnt/d/gopath/src/Go_base/daily_test/data_race/demo.go:14 +0x3c Goroutine 7 (finished) created at: main.run() /mnt/d/gopath/src/Go_base/daily_test/data_race/demo.go:24 +0x75 main.main() /mnt/d/gopath/src/Go_base/daily_test/data_race/demo.go:14 +0x3c ================== exit status 66
- 說明:結果告訴可以看出 31 行這個地方有一個 goroutine 在讀取數據,但是呢,在 33 行這個地方又有一個 goroutine 在寫入,所以產生了數據競爭。
然后下面分別說明這兩個 goroutine 是什么時候創建的,已經當前是否在運行當中。
2.2 循環中使用goroutine引用臨時變量
-
代碼如下:
func main() { var wg sync.WaitGroup wg.Add(5) for i := 0; i < 5; i++ { go func() { fmt.Println(i) wg.Done() }() } wg.Wait() }
-
輸出:常見的答案就是會輸出 5 個 5,因為在 for 循環的 i++ 會執行的快一些,所以在最后打印的結果都是 5
這個答案不能說不對,因為真的執行的話大概率也是這個結果,但是不全。因為這里本質上是有數據競爭,在新啟動的 goroutine 當中讀取 i 的值,在 main 中寫入,導致出現了 data race,這個結果應該是不可預知的,因為我們不能假定 goroutine 中 print 就一定比外面的 i++ 慢,習慣性的做這種假設在並發編程中是很有可能會出問題的 -
正確示例:將 i 作為參數傳入即可,這樣每個 goroutine 拿到的都是拷貝后的數據
func main() { var wg sync.WaitGroup wg.Add(5) for i := 0; i < 5; i++ { go func(i int) { fmt.Println(i) wg.Done() }(i) } wg.Wait() }
2.3 引起變量共享
-
代碼
package main import "os" func main() { ParallelWrite([]byte("xxx")) } // ParallelWrite writes data to file1 and file2, returns the errors. func ParallelWrite(data []byte) chan error { res := make(chan error, 2) // 創建/寫入第一個文件 f1, err := os.Create("/tmp/file1") if err != nil { res <- err } else { go func() { // 下面的這個函數在執行時,是使用err進行判斷,但是err的變量是個共享的變量 _, err = f1.Write(data) res <- err f1.Close() }() } // 創建寫入第二個文件n f2, err := os.Create("/tmp/file2") if err != nil { res <- err } else { go func() { _, err = f2.Write(data) res <- err f2.Close() }() } return res }
-
分析: 使用
go run -race main.go
執行,可以發現這里報錯的地方是,21 行和 28 行,有 data race,這里主要是因為共享了 err 這個變量root@failymao:/mnt/d/gopath/src/Go_base/daily_test/data_race# go run -race demo2.go ================== WARNING: DATA RACE Write at 0x00c0001121a0 by main goroutine: main.ParallelWrite() /mnt/d/gopath/src/Go_base/daily_test/data_race/demo2.go:28 +0x1dd main.main() /mnt/d/gopath/src/Go_base/daily_test/data_race/demo2.go:6 +0x84 Previous write at 0x00c0001121a0 by goroutine 7: main.ParallelWrite.func1() /mnt/d/gopath/src/Go_base/daily_test/data_race/demo2.go:21 +0x94 Goroutine 7 (finished) created at: main.ParallelWrite() /mnt/d/gopath/src/Go_base/daily_test/data_race/demo2.go:19 +0x336 main.main() /mnt/d/gopath/src/Go_base/daily_test/data_race/demo2.go:6 +0x84 ================== Found 1 data race(s) exit status 66
-
修正: 在兩個goroutine中使用新的臨時變量
_, err := f1.Write(data) ... _, err := f2.Write(data) ...
2.4 不受保護的全局變量
-
所謂全局變量是指,定義在多個函數的作用域之外,可以被多個函數或方法進行調用,常用的如 map數據類型
// 定義一個全局變量 map數據類型 var service = map[string]string{} // RegisterService RegisterService // 用於寫入或更新key-value func RegisterService(name, addr string) { service[name] = addr } // LookupService LookupService // 用於查詢某個key-value func LookupService(name string) string { return service[name] }
-
要寫出可測性比較高的代碼就要少用或者是盡量避免用全局變量,使用 map 作為全局變量比較常見的一種情況就是配置信息。關於全局變量的話一般的做法就是加鎖,或者也可以使用 sync.Map
var ( service map[string]string serviceMu sync.Mutex ) func RegisterService(name, addr string) { serviceMu.Lock() defer serviceMu.Unlock() service[name] = addr } func LookupService(name string) string { serviceMu.Lock() defer serviceMu.Unlock() return service[name] }
2.5 未受保護的成員變量
-
一般講
成員變量
指的是數據類型為結構體的某個字段。 如下一段代碼type Watchdog struct{ last int64 } func (w *Watchdog) KeepAlive() { // 第一次進行賦值操作 w.last = time.Now().UnixNano() } func (w *Watchdog) Start() { go func() { for { time.Sleep(time.Second) // 這里在進行判斷的時候,很可能w.last更新正在進行 if w.last < time.Now().Add(-10*time.Second).UnixNano() { fmt.Println("No keepalives for 10 seconds. Dying.") os.Exit(1) } } }() }
-
使用原子操作
atomiic
type Watchdog struct{ last int64 } func (w *Watchdog) KeepAlive() { // 修改或更新 atomic.StoreInt64(&w.last, time.Now().UnixNano()) } func (w *Watchdog) Start() { go func() { for { time.Sleep(time.Second) // 讀取 if atomic.LoadInt64(&w.last) < time.Now().Add(-10*time.Second).UnixNano() { fmt.Println("No keepalives for 10 seconds. Dying.") os.Exit(1) } } }() }
2.6 接口中存在的數據競爭
-
一個很有趣的例子 Ice cream makers and data races
package main import "fmt" type IceCreamMaker interface { // Great a customer. Hello() } type Ben struct { name string } func (b *Ben) Hello() { fmt.Printf("Ben says, \"Hello my name is %s\"\n", b.name) } type Jerry struct { name string } func (j *Jerry) Hello() { fmt.Printf("Jerry says, \"Hello my name is %s\"\n", j.name) } func main() { var ben = &Ben{name: "Ben"} var jerry = &Jerry{"Jerry"} var maker IceCreamMaker = ben var loop0, loop1 func() loop0 = func() { maker = ben go loop1() } loop1 = func() { maker = jerry go loop0() } go loop0() for { maker.Hello() } }
-
這個例子有趣的點在於,最后輸出的結果會有這種例子
Ben says, "Hello my name is Jerry" Ben says, "Hello my name is Jerry"
這是因為我們在
maker = jerry
這種賦值操作的時候並不是原子的,在上一篇文章中我們講到過,只有對 single machine word 進行賦值的時候才是原子的,雖然這個看上去只有一行,但是 interface 在 go 中其實是一個結構體,它包含了 type 和 data 兩個部分,所以它的復制也不是原子的,會出現問題type interface struct { Type uintptr // points to the type of the interface implementation Data uintptr // holds the data for the interface's receiver }
這個案例有趣的點還在於,這個案例的兩個結構體的內存布局一模一樣所以出現錯誤也不會 panic 退出,如果在里面再加入一個 string 的字段,去讀取就會導致 panic,但是這也恰恰說明這個案例很可怕,這種錯誤在線上實在太難發現了,而且很有可能會很致命。
3. 總結
- 使用
go build -race main.go
和go test -race ./
可以測試程序代碼中是否存在數據競爭問題- 善用 data race 這個工具幫助我們提前發現並發錯誤
- 不要對未定義的行為做任何假設,雖然有時候我們寫的只是一行代碼,但是 go 編譯器可能后面做了很多事情,並不是說一行寫完就一定是原子的
- 即使是原子的出現了 data race 也不能保證安全,因為我們還有可見性的問題,上篇我們講到了現代的 cpu 基本上都會有一些緩存的操作。
- 所有出現了 data race 的地方都需要進行處理
4 參考
- https://lailin.xyz/post/go-training-week3-data-race.html#典型案例
- https://dave.cheney.net/2014/06/27/ice-cream-makers-and-data-races
- http://blog.golang.org/race-detector
- https://golang.org/doc/articles/race_detector.html
- https://dave.cheney.net/2018/01/06/if-aligned-memory-writes-are-atomic-why-do-we-need-the-sync-atomic-package