1. 程式人生 > >一道有趣的golang排錯題

一道有趣的golang排錯題

很久沒寫部落格了,不得不說go語言愛好者週刊是個寶貝,本來想隨便看看打發時間的,沒想到一下子給了我久違的靈感。 [go語言愛好者週刊78期](https://zhuanlan.zhihu.com/p/344888294)出了一道非常有意思的題目。 我們來看看題目。先給出如下的程式碼: ```golang package main import ( "fmt" "time" ) func main() { ch1 := make(chan int) go fmt.Println(<-ch1) ch1 <- 5 time.Sleep(1 * time.Second) } ``` 請問這串程式碼的輸出是什麼。 我最先想到的是5,畢竟程式碼很簡單,反應比較快的話程式碼看完結果也就推斷出來了。 然而題目給出的其中一個選項是輸出死鎖報錯,這個選項引起了我的好奇,於是我運行了一下: ```bash $ go run a.go fatal error: all goroutines are asleep - deadlock! goroutine 1 [chan receive]: main.main() /tmp/a.go:10 +0x65 exit status 2 ``` 啊這。真的死鎖了。那麼我猜會不會和執行順序有關呢?於是我寫了個指令碼執行1000次看看: ```bash #!/bin/bash for i in {0..1000} do go run a.go &> /dev/null if [ $? -eq 0 ] then echo 'success!' break fi done ``` 結果自然是一次也沒成功,即使你改成10000哪怕是1000000也是一樣的。執行順序帶來的影響我們可以排除了。 如果你仔細觀察的話,所有的報錯也都是一樣的:`goroutine 1 [chan receive]:`,在這裡死鎖了。 那麼會不會是因為使用了無緩衝chan的原因呢?golang的記憶體模型規定了無緩衝chan的接受happens before傳送操作,這會不會帶來影響呢(其實仔細想想就很快排除了,happens before確定的是記憶體的可見性,而不是指令執行的時間順序),所以我改了下程式碼: ```golang func main() { ch1 := make(chan int, 100) go fmt.Println(<-ch1) ch1 <- 5 time.Sleep(1 * time.Second) } ``` 這次我們使用了一個有容納100個元素的buff的channel,然而結果還是沒有一點改變。 到這裡我的思路中斷了。 不過我還有google啊,所以我用“golang channel deadlock”為關鍵詞搜尋了一下,然後發現了一些有意思的結果。 那就是所有的chan的死鎖的程式碼基本都能抽象成下面的形式: ```golang func main() { ch1 := make(chan int) // 是否有buff無影響 _ = <-chan ch1 <- 5 } ``` 這個程式碼毫無疑問是會死鎖的,因為從chan接收值而chan裡是空的會導致當前goroutine進入等待,而當前goroutine不能繼續執行的話就永遠沒辦法向chan裡寫入值,死鎖就在這裡產生了。 在仔細觀察一下,你就會發現題目的程式碼和這很像: ```golang func main() { ch1 := make(chan int) go fmt.Println(<-ch1) ch1 <- 5 // sleep是為了main routine不會過早退出 } ``` 答案只有一個,`<-ch1`發生在main goroutine裡了。 為了佐證這一觀點,我有查閱了golang language spec,關於[go語句](https://golang.org/ref/spec#Go_statements)有如下的描述: >
The function value and parameters are evaluated as usual in the calling goroutine, but unlike with a regular call, program execution does not wait for the invoked function to complete. > 函式和它的引數會像通常那樣在使用go語句的那個goroutine裡被執行,但不像常規的函式呼叫,程式不會同步等待這個函式執行完畢。 如果在看看有關[求值](https://golang.org/ref/spec#Calls)的部分: >
calls f with arguments a1, a2, … an. Except for one special case, arguments must be single-valued expressions assignable to the parameter types of F and are evaluated before the function is called. > 用引數a1, a2等呼叫函式f,出了一個特例之外他們都必須是單值表示式,並且在函式執行前被求值。 上面說的特例是方法呼叫,方法的receiver會用特定的位置傳給method。 這樣事情的來龍去脈就清晰明瞭了,我們來梳理一下。 假設我們在main goroutine裡啟動一個子goroutine叫b,那麼實際上在main goroutine裡發生的事情是這樣的: 1. main goroutine執行到go語句 2. go語句發現後面的函式表示式需要傳遞引數 3. 於是被傳遞的引數在main goroutine裡求值 4. 新的goroutine b被建立,剛求值的引數傳遞給需要執行的函式(假設叫f),f在goroutine b中開始執行 5. go語句結束,控制流程回到main goroutine 所以`go fmt.Println(<-ch1)`裡的chan接收操作是在main goroutine裡執行的,因此死鎖是板上釘釘的事情。 如果改成下面這樣,死鎖就不會發生: ```golang package main import ( "fmt" "time" ) func main() { ch1 := make(chan int) go func() { fmt.Println(<-ch1) }() ch1 <- 5 time.Sleep(1 * time.Second) } ``` 這是因為`<-ch1`這回貨真價實地發生在了不同的goroutine裡,死鎖自然也不存在了。 這題很壞,壞就壞在`fmt.Println(...)`這樣的形式容易讓人迷惑,以為這個呼叫本身在新的goroutine裡執行,然而真正在新goroutine裡執行的卻是`fmt.Println`內部的函式實現程式碼,而不是`fmt.Println(...)`這句,引數會在這之前就被求值。 那麼這能讓我們學到什麼呢?答案是永遠也不要寫出題目裡那樣的程式碼,對於chan的操作應該確保是在和執行go語句的goroutine不同的routine中執行的。 不過萬事不絕對,帶buff的chan會有些例外,當然這些以後有機會再