停止 Goroutine 有幾種方法?

大家好,我是煎魚。

協程(goroutine)作爲 Go 語言的扛把子,經常在各種 Go 工程項目中頻繁露面,甚至有人會爲了用 goroutine 而強行用他。

在 Go 工程師的面試中,也繞不開他,會有人問 ” 如何停止一個 goroutine?”,一下子就把話題範圍擴大了,這是一個涉及多個知識點的話題,能進一步深入問。

爲此,今天煎魚就帶大家瞭解一下停止 goroutine 的方法!

goroutine 案例

在日常的工作中,我們常會有這樣的 Go 代碼,go 關鍵字一把搜起一個 goroutine:

func main() { 
 ch := make(chan string, 6)
 go func() {
  for {
   ch <- "腦子進煎魚了"
  }
 }()
}

初入 goroutine 大門的開發者可能就完事了,但跑一段時間後,他就可能會遇到一些問題,苦苦排查...

像是:當 goroutine 內的任務,運行的太久,又或是卡死了... 就會一直阻塞在系統中,變成 goroutine 泄露,或是間接造成資源暴漲,會帶來許多的問題。

如何在停止 goroutine,就成了一門必修技能了,不懂就沒法用好 goroutine。

關閉 channel

第一種方法,就是藉助 channel 的 close 機制來完成對 goroutine 的精確控制。

代碼如下:

func main() {
 ch := make(chan string, 6)
 go func() {
  for {
   v, ok := <-ch
   if !ok {
    fmt.Println("結束")
    return
   }
   fmt.Println(v)
  }
 }()

 ch <- "煎魚還沒進鍋裏..."
 ch <- "煎魚進腦子裏了!"
 close(ch)
 time.Sleep(time.Second)
}

在 Go 語言的 channel 中,channel 接受數據有兩種方法:

msg := <-ch
msg, ok := <-ch

這兩種方式對應着不同的 runtime 方法,我們可以利用其第二個參數進行判別,當關閉 channel 時,就根據其返回結果跳出。

另外我們也可以利用 for range 的特性:

 go func() {
  for {
   for v := range ch {
    fmt.Println(v)
   }
  }
 }()

其會一直循環遍歷通道 ch,直到其關閉爲止,是頗爲常見的一種用法。

定期輪詢 channel

第二種方法,是更爲精細的方法,其結合了第一種方法和類似信號量的處理方式。

代碼如下:

func main() {
 ch := make(chan string, 6)
 done := make(chan struct{})
 go func() {
  for {
   select {
   case ch <- "腦子進煎魚了":
   case <-done:
    close(ch)
    return
   }
   time.Sleep(100 * time.Millisecond)
  }
 }()

 go func() {
  time.Sleep(3 * time.Second)
  done <- struct{}{}
 }()

 for i := range ch {
  fmt.Println("接收到的值: ", i)
 }

 fmt.Println("結束")
}

在上述代碼中,我們聲明瞭變量 done,其類型爲 channel,用於作爲信號量處理 goroutine 的關閉。

而 goroutine 的關閉是不知道什麼時候發生的,因此在 Go 語言中會利用 for-loop 結合 select 關鍵字進行監聽,再進行完畢相關的業務處理後,再調用 close 方法正式關閉 channel。

若程序邏輯比較簡單結構化,也可以不調用 close 方法,因爲 goroutine 會自然結束,也就不需要手動關閉了。

使用 context

第三種方法,可以藉助 Go 語言的上下文(context)來做 goroutine 的控制和關閉。

代碼如下:

func main() {
 ch := make(chan struct{})
 ctx, cancel := context.WithCancel(context.Background())

 go func(ctx context.Context) {
  for {
   select {
   case <-ctx.Done():
    ch <- struct{}{}
    return
   default:
    fmt.Println("煎魚還沒到鍋裏...")
   }

   time.Sleep(500 * time.Millisecond)
  }
 }(ctx)

 go func() {
  time.Sleep(3 * time.Second)
  cancel()
 }()

 <-ch
 fmt.Println("結束")
}

在 context 中,我們可以藉助 ctx.Done 獲取一個只讀的 channel,類型爲結構體。可用於識別當前 channel 是否已經被關閉,其原因可能是到期,也可能是被取消了。

因此 context 對於跨 goroutine 控制有自己的靈活之處,可以調用 context.WithTimeout 來根據時間控制,也可以自己主動地調用 cancel 方法來手動關閉。

幹掉另外一個 goroutine

在瞭解了停止 goroutine 的 3 種經典方法後,又有小夥伴提出了新的想法。就是 “我想在 goroutineA 裏去停止 goroutineB,有辦法嗎?

答案是不能,因爲在 Go 語言中,goroutine 只能自己主動退出,一般通過 channel 來控制,不能被外界的其他 goroutine 關閉或幹掉,也沒有 goroutine 句柄的顯式概念。

go/issues/32610

在 Go issues 中也有人提過類似問題,Dave Cheney 給出了一些思考:

這都是值得深思的,另外一旦放開這種限制。作爲程序員,你維護代碼。很有可能就不知道 goroutine 的句柄被傳到了哪裏,又是在何時何地被人莫名其妙關閉,非常糟糕...

總結

在今天這篇文章中,我們介紹了在 Go 語言中停止 goroutine 的三大經典方法(channel、context,channel+context)和其背後的使用原理。

同時針對 goroutine 不可以跨 goroutine 強制停止的原因進行了分析。其實 goroutine 的設計就是這樣的,包括像 goroutine+panic+recover 的設計也是遵循這個原理,因此也有的 Go 開發者總是會誤以爲跨 goroutine 能有 recover 接住...

記住,在 Go 語言中每一個 goroutine 都需要自己承擔自己的任何責任,這是基本原則。

(你已經是個成熟的 goroutine 了...)

關注煎魚,吸取他的知識 👆

你好,我是煎魚。高一折騰過前端,參加過國賽拿了獎,大學搞過 PHP。現在整 Go,在公司負責微服務架構等相關工作推進和研發。

從大學開始靠自己賺生活費和學費,到出版 Go 暢銷書《Go 語言編程之旅》,再到獲得 GOP(Go 領域最有觀點專家)榮譽,點擊藍字查看我的出書之路

日常分享高質量文章,輸出 Go 面試、工作經驗、架構設計,加微信拉讀者交流羣,記得點贊!

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/tN8Q1GRmphZyAuaHrkYFEg