新聞中心
1. 前言
本文來梳理一下使用 channel 中常見的三大坑:panic、死鎖、內(nèi)存泄漏,做到防患于未然。

峽江網(wǎng)站建設(shè)公司成都創(chuàng)新互聯(lián)公司,峽江網(wǎng)站設(shè)計(jì)制作,有大型網(wǎng)站制作公司豐富經(jīng)驗(yàn)。已為峽江1000多家提供企業(yè)網(wǎng)站建設(shè)服務(wù)。企業(yè)網(wǎng)站搭建\外貿(mào)網(wǎng)站建設(shè)要多少錢,請(qǐng)找那個(gè)售后服務(wù)好的峽江做網(wǎng)站的公司定做!
2. 死鎖
go 語言新手在編譯時(shí)很容易碰到這個(gè)死鎖的問題:
fatal error: all goroutines are asleep - deadlock!
這個(gè)就是喜聞樂見的「死鎖」了…… 在操作系統(tǒng)中,我們學(xué)過,「死鎖」就是兩個(gè)線程互相等待,耗在那里,最后程序不得不終止。go 語言中的「死鎖」也是類似的,兩個(gè) goroutine 互相等待,導(dǎo)致程序耗在那里,無法繼續(xù)跑下去。看了很多死鎖的案例后,channel 導(dǎo)致的死鎖可以歸納為以下幾類案例(先討論 unbuffered channel 的情況):
2.1 只有生產(chǎn)者,沒有消費(fèi)者,或者反過來
channel 的生產(chǎn)者和消費(fèi)者必須成對(duì)出現(xiàn),如果缺乏一個(gè),就會(huì)造成死鎖,例如:
// 只有生產(chǎn)者,沒有消費(fèi)者
func f1() {
ch := make(chan int)
ch <- 1
}
或是:
// 只有消費(fèi)者,沒有生產(chǎn)者
func f2() {
ch := make(chan int)
<-ch
}
2.2 生產(chǎn)者和消費(fèi)者出現(xiàn)在同一個(gè) goroutine 中
除了需要成對(duì)出現(xiàn),還需要出現(xiàn)在不同的 goroutine 中,例如:
// 同一個(gè) goroutine 中同時(shí)出現(xiàn)生產(chǎn)者和消費(fèi)者
func f3() {
ch := make(chan int)
ch <- 1 // 由于消費(fèi)者還沒執(zhí)行到,這里會(huì)一直阻塞住
<-ch
}
對(duì)于 buffered channel 則是:
2.3 buffered channel 已滿,且出現(xiàn)上述情況
buffered channel 會(huì)將收到的元素先存在 hchan 結(jié)構(gòu)體的 ringbuffer 中,繼而才會(huì)發(fā)生阻塞。而當(dāng)發(fā)生阻塞時(shí),如果阻塞了主 goroutine ,則也會(huì)出現(xiàn)死鎖。
所以實(shí)際使用中,推薦盡量使用 buffered channel ,使用起來會(huì)更安全,在下文的「內(nèi)存泄漏」相關(guān)內(nèi)容也會(huì)提及。
3. 內(nèi)存泄漏
內(nèi)存泄漏一般都是通過 OOM(Out of Memory) 告警或者發(fā)布過程中對(duì)內(nèi)存的觀察發(fā)現(xiàn)的,服務(wù)內(nèi)存往往都是緩慢上升,直到被系統(tǒng) OOM 掉清空內(nèi)存再周而復(fù)始。
在 go 語言中,錯(cuò)誤地使用 channel 會(huì)導(dǎo)致 goroutine 泄漏,進(jìn)而導(dǎo)致內(nèi)存泄漏。
3.1 如何實(shí)現(xiàn) goroutine 泄漏呢?
不會(huì)修 bug,我還不會(huì)寫 bug 嗎?讓 goroutine 泄漏的核心就是:
生產(chǎn)者/消費(fèi)者 所在的 goroutine 已經(jīng)退出,而其對(duì)應(yīng)的 消費(fèi)者/生產(chǎn)者 所在的 goroutine 會(huì)永遠(yuǎn)阻塞住,直到進(jìn)程退出。
3.2 生產(chǎn)者阻塞導(dǎo)致泄漏
我們一般會(huì)用 channel 來做一些超時(shí)控制,例如下面這個(gè)例子:
func leak1() {
ch := make(chan int)
// g1
go func() {
time.Sleep(2 * time.Second) // 模擬 io 操作
ch <- 100 // 模擬返回結(jié)果
}()
// g2
// 阻塞住,直到超時(shí)或返回
select {
case <-time.After(500 * time.Millisecond):
fmt.Println("timeout! exit...")
case result := <-ch:
fmt.Printf("result: %d\n", result)
}
}這里我們用 goroutine g1 來模擬 io 操作,主 goroutine g2 來模擬客戶端的處理邏輯。
- 假設(shè)客戶端超時(shí)為 500ms,而實(shí)際請(qǐng)求耗時(shí)為 2s,則 select 會(huì)走到 timeout 的邏輯,這時(shí)g2? 退出,channelch 沒有消費(fèi)者,會(huì)一直在等待狀態(tài),輸出如下:
Goroutine num: 1
timeout! exit...
Goroutine num: 2
如果這是在 server 代碼中,這個(gè)請(qǐng)求處理完后,g1 就會(huì)掛起、發(fā)生泄漏了,就等著 OOM 吧 =。=。
- 假設(shè)客戶端超時(shí)調(diào)整為 5000ms,實(shí)際請(qǐng)求耗時(shí) 2s,則 select 會(huì)進(jìn)入獲取 result 的分支,輸出如下:
Goroutine num: 1
timeout! exit...
Goroutine num: 2
3.3 消費(fèi)者阻塞導(dǎo)致泄漏
如果生產(chǎn)者不繼續(xù)生產(chǎn),消費(fèi)者所在的 goroutine 也會(huì)阻塞住,不會(huì)退出,例如:
func leak2() {
ch := make(chan int)
// 消費(fèi)者 g1
go func() {
for result := range ch {
fmt.Printf("result: %d\n", result)
}
}()
// 生產(chǎn)者 g2
ch <- 1
ch <- 2
time.Sleep(time.Second) // 模擬耗時(shí)
fmt.Println("main goroutine g2 done...")
}這種情況下,只需要增加 close(ch) 的操作即可,for-range 操作在收到 close 的信號(hào)后會(huì)退出、goroutine 不再阻塞,能夠被回收。
3.4 如何預(yù)防內(nèi)存泄漏?
預(yù)防 goroutine 泄漏的核心就是:
- 創(chuàng)建 goroutine 時(shí)就要想清楚它什么時(shí)候被回收。
具體到執(zhí)行層面,包括:
- 當(dāng) goroutine 退出時(shí),需要考慮它使用的 channel 有沒有可能阻塞對(duì)應(yīng)的生產(chǎn)者、消費(fèi)者的 goroutine。
- 盡量使用buffered channel?使用buffered channel 能減少阻塞發(fā)生、即使疏忽了一些極端情況,也能降低 goroutine 泄漏的概率。
4. panic
panic 就更刺激了,一般是測(cè)試的時(shí)候沒發(fā)現(xiàn),上線之后偶現(xiàn),程序掛掉,服務(wù)出現(xiàn)一個(gè)超時(shí)毛刺后觸發(fā)告警。channel 導(dǎo)致的 panic 一般是以下幾個(gè)原因:
4.1 向已經(jīng) close 掉的 channel 繼續(xù)發(fā)送數(shù)據(jù)
先舉一個(gè)簡(jiǎn)單的栗子:
func p1() {
ch := make(chan int, 1)
close(ch)
ch <- 1
}
// panic: send on closed channel在實(shí)際開發(fā)過程中,處理多個(gè) goroutine 之間協(xié)作時(shí),可能存在一個(gè) goroutine 已經(jīng) close 掉 channel 了,另外一個(gè)不知道,也去 close 一下,就會(huì) panic 掉,例如:
func p1() {
ch := make(chan int, 1)
done := make(chan struct{}, 1)
go func() {
<- time.After(2*time.Second)
println("close2")
close(ch)
close(done)
}()
go func() {
<- time.After(1*time.Second)
println("close1")
ch <- 1
close(ch)
}()
<-done
}萬惡之源就是在 go 語言里,你是無法知道一個(gè) channel 是否已經(jīng)被 close 掉的,所以在嘗試做 close 操作的時(shí)候,就應(yīng)該做好會(huì) panic 的準(zhǔn)備……
4.2 多次 close 同一個(gè) channel
同上,在嘗試往 channel 里發(fā)送數(shù)據(jù)時(shí),就應(yīng)該考慮。
這個(gè) channel 已經(jīng)關(guān)了嗎?
這個(gè) channel 什么時(shí)候、在哪個(gè) goroutine 里關(guān)呢?
誰來關(guān)呢?還是干脆不關(guān)?
5. 如何優(yōu)雅地 close channel
5.1 我們需要檢查 channel 是否關(guān)閉嗎?
剛遇到上面說的 panic 問題時(shí),我也試過去找一個(gè)內(nèi)置的 closed 函數(shù)來檢查關(guān)閉狀態(tài),結(jié)果發(fā)現(xiàn),并沒有這樣一個(gè)函數(shù)……
那么,如果有這樣的函數(shù),真能徹底解決 panic 的問題么?答案是不能。因?yàn)?channel 是在一個(gè)并發(fā)的環(huán)境下去做收發(fā)操作,就算當(dāng)前執(zhí)行 closed(ch) 得到的結(jié)果是 false,還是不能直接去關(guān),例如如下 yy 出來的代碼:
if !closed(ch) { // 返回 false
// 在這中間出了幺蛾子!
close(ch) // 還是 panic 了……
}遵循 less is more 的原則,這個(gè) closed 函數(shù)是要不得了
5.2 需要 close 嗎?為什么?
結(jié)論:除非必須關(guān)閉 chan,否則不要主動(dòng)關(guān)閉。關(guān)閉 chan 最優(yōu)雅的方式,就是不要關(guān)閉 chan~。
當(dāng)一個(gè) chan 沒有 sender 和 receiver 時(shí),即不再被使用時(shí),GC 會(huì)在一段時(shí)間后標(biāo)記、清理掉這個(gè) chan。那么什么時(shí)候必須關(guān)閉 chan 呢?比較常見的是將 close 作為一種通知機(jī)制,尤其是生產(chǎn)者與消費(fèi)者之間是 1:M 的關(guān)系時(shí),通過 close 告訴下游:我收工了,你們別讀了。
5.3 誰來關(guān)?
chan 關(guān)閉的原則:
- Don't close a channel from the receiver side 不要在消費(fèi)者端關(guān)閉 chan。
- Don't close a channel if the channel has multiple concurrent senders 有多個(gè)并發(fā)寫的生產(chǎn)者時(shí)也別關(guān)。
只要我們遵循這兩條原則,就能避免兩種 panic 的場(chǎng)景,即:向 closed chan 發(fā)送數(shù)據(jù),或者是 close 一個(gè) closed chan。
按照生產(chǎn)者和消費(fèi)者的關(guān)系可以拆解成以下幾類情況:
- 一寫一讀:生產(chǎn)者關(guān)閉即可。
- 一寫多讀:生產(chǎn)者關(guān)閉即可,關(guān)閉時(shí)下游全部消費(fèi)者都能收到通知。
- 多寫一讀:多個(gè)生產(chǎn)者之間需要引入一個(gè)協(xié)調(diào) channel 來處理信號(hào)。
- 多寫多讀:與 3 類似,核心思路是引入一個(gè)中間層以及使用try-send 的套路來處理非阻塞的寫入,例如:
func main() {
rand.Seed(time.Now().UnixNano())
log.SetFlags(0)
const Max = 100000
const NumReceivers = 10
const NumSenders = 1000
wgReceivers := sync.WaitGroup{}
wgReceivers.Add(NumReceivers)
dataCh := make(chan int)
stopCh := make(chan struct{})
// stopCh 是額外引入的一個(gè)信號(hào) channel.
// 它的生產(chǎn)者是下面的 toStop channel,
// 消費(fèi)者是上面 dataCh 的生產(chǎn)者和消費(fèi)者
toStop := make(chan string, 1)
// toStop 是拿來關(guān)閉 stopCh 用的,由 dataCh 的生產(chǎn)者和消費(fèi)者寫入
// 由下面的匿名中介函數(shù)(moderator)消費(fèi)
// 要注意,這個(gè)一定要是 buffered channel (否則沒法用 try-send 來處理了)
var stoppedBy string
// moderator
go func() {
stoppedBy = <-toStop
close(stopCh)
}()
// senders
for i := 0; i < NumSenders; i++ {
go func(id string) {
for {
value := rand.Intn(Max)
if value == 0 {
// try-send 操作
// 如果 toStop 滿了,就會(huì)走 default 分支啥也不干,也不會(huì)阻塞
select {
case toStop <- "sender#" + id:
default:
}
return
}
// try-receive 操作,盡快退出
// 如果沒有這一步,下面的 select 操作可能造成 panic
select {
case <- stopCh:
return
default:
}
// 如果嘗試從 stopCh 取數(shù)據(jù)的同時(shí),也嘗試向 dataCh
// 寫數(shù)據(jù),則會(huì)命中 select 的偽隨機(jī)邏輯,可能會(huì)寫入數(shù)據(jù)
select {
case <- stopCh:
return
case dataCh <- value:
}
}
}(strconv.Itoa(i))
}
// receivers
for i := 0; i < NumReceivers; i++ {
go func(id string) {
defer wgReceivers.Done()
for {
// 同上
select {
case <- stopCh:
return
default:
}
// 嘗試讀數(shù)據(jù)
select {
case <- stopCh:
return
case value := <-dataCh:
if value == Max-1 {
select {
case toStop <- "receiver#" + id:
default:
}
return
}
log.Println(value)
}
}
}(strconv.Itoa(i))
}
wgReceivers.Wait()
log.Println("stopped by", stoppedBy)
}參考資料
- Golang channel 死鎖的幾種情況以及例子
- 老手也常誤用!詳解 Go channel 內(nèi)存泄漏問題
- 深入解析 Goroutine 泄露的場(chǎng)景:channel 發(fā)送者
- How to Gracefully Close Channels
本文轉(zhuǎn)載自微信公眾號(hào)「 翔叔架構(gòu)筆記」,可以通過以下二維碼關(guān)注。轉(zhuǎn)載本文請(qǐng)聯(lián)系翔叔架構(gòu)筆記公眾號(hào)。
網(wǎng)頁名稱:Golang Channel 三大坑,你踩過了嘛?
鏈接地址:http://m.fisionsoft.com.cn/article/cccojsc.html


咨詢
建站咨詢
