Go 併發編程 - goroutine 初體驗

說到 Go 語言,被人討論最多的就是 Go 很擅長做高併發,並且不需要依賴外部的庫,語言本身就支持高併發。

Go 實現這一能力的祕密是 goroutine,也經常被稱之爲協程,goroutine 是 Go 對協程的實現。在這篇文章中,會介紹協程的基本概念,以及 goroutine 的基本使用。

  1. 什麼是協程

協程(Coroutine),又被稱之爲微線程,這個概念出現的時間很早,在 1963 年就有相關的文獻發表,但協程真正被用起來的時間很短。

對於操作系統來說,線程是最小的調度單位,但對於一些高併發的環境,線程處理起來就比較喫力,一方面操作系統能夠分配的線程數量有限,另外線程之間的切換相對來說也比較大。

所以對於 Java 這類以線程爲調度單位的語言,一般會依靠外部的類庫來做到高併發,比如 Java 的 Netty 就是一個開始高併發應用必不可少的庫。

協程和線程非常類似,只是比線程更加輕量級,具體表現在協程之間的切換不需要涉及系統調用,也不需要互斥鎖或者信號量等同步手段,甚至都不需要操作系統的支持。

協程與線程的行爲基本一致,但是協程是在語言層面實現的,而線程是操作系統實現的。

  1. Go 語言的協程

在 Go 語言中,支持兩種併發編程的模式,一種就是以 goroutine 和 channel 爲主,這種方式稱之爲 CSP 模式,這種方式的核心是在 goroutine 之間傳遞值來來實現併發。

還有一種方式是傳統的共享內存式的模式,通過一些同步機制,比如鎖之類的機制來實現併發。

Go 程序通過 main 函數來啓動,main 函數啓動的時候也會啓動一個 goroutine,稱之爲主 goroutine。然後在主 goroutine 中通過 go 關鍵字創建新的 goroutine。go 語句是立馬返回的,不會阻塞當前的 goroutine。

一個 Go 程序中可以創建的 goroutine 數量可以比線程數量多很多,這也是 Go 程序可以做到高併發的原因,goroutine 的實現原理,我們後續的文章再詳細聊,下面來看看看 goroutine 的使用。

  1. goroutine 的基本使用

goroutine 的使用很簡單,只需要在調用的函數前面添加 go 關鍵字,就會創建一個新的 goroutine:

func goroutine1()  {
  fmt.Println("Hello goroutine")
}
func main() {
  go goroutine1()
  fmt.Println("Hello main")
}

但運行上面的代碼之後,輸出的結果爲:

Hello main

預想中的 Hello goroutine 並沒有出現,因爲 main 方法執行完成之後,main 方法 所在的 goroutine 就銷燬了,其他的 goroutine 都沒有機會執行完。

可以通過設置一個休眠時間來阻止主 goroutine 執行完成。

func goroutine1()  {
  fmt.Println("Hello goroutine")
}
func main() {
  go goroutine1()
  time.Sleep(1 * time.Second)
  fmt.Println("Hello main")
}

這樣,輸出結果就和我們預想的一樣了:

Hello goroutine
Hello main

但是這種方法也存在一些問題,這個休眠時間不太好設置,設置的過長,會浪費時間,設置的過短, goroutine 還沒運行完成,所以最好的方式是讓 goroutine 自己來決定。我們再改動一下代碼:

func goroutine2(isDone chan bool) {
  fmt.Println("child goroutine begin...")
  time.Sleep(2 * time.Second)
  fmt.Println("child goroutine end...")
  isDone <- true
}
func main() {
  isDone := make(chan bool)
  go goroutine2(isDone)
  <-isDone
  close(isDone)
  fmt.Println("main goroutine end..")
}

在上面的代碼中,我們使用了 chan 類型,這個類型我們後續會詳細講解,暫時只需要知道創建一個 chan 類型的變量,傳入到一個子 goroutine 之後,它就會阻塞當前的 goroutine,直到子 goroutine 執行完成。這種方式比上面設置休眠時間的方式要優雅很多,也不會產生一些意料之外的結果。

結果輸出爲:

child goroutine begin...
child goroutine end...
main goroutine end..

但這種方式還是不完美,現在只啓動了一個 goroutine,如果要啓動多個 goroutine,這種方式就不管用了。當然,肯定還是有解決辦法的,看下面的代碼:

func goroutine3(id int, wg *sync.WaitGroup) {
  defer wg.Done()
  fmt.Printf("child goroutine %d begin...\n", id)
  time.Sleep(time.Second)
  fmt.Printf("child goroutine %d end...\n", id)
}
func main() {
  var wg sync.WaitGroup
  for i := 0; i < 5; i++ {
    wg.Add(1)
    go goroutine3(i, &wg)
  }
  wg.Wait()
}

這個代碼看起來要複雜不少,其中 sync 包中包括了 Go 語言併發編程的所有工具,我們用到的 WaitGroup 就是其中的一個工具。

首先創建一個 WaitGroup 類型的變量 wg,每創建一個 goroutine,就向 wg 中加 1,每個 goroutine 執行完成之後,就調用 wg.Done,這樣 wg 就會減 1,wg.Wait() 會阻塞當前 goroutine,直到 wg 中的值清零。

如果熟悉其他語言同步機制的人就會想到,這不就是信號量麼,是的,這就是使用信號量來實現的。這個 WaitGroup 與 Java 語言中的 CountDownLatch 功能是一樣的。

輸出的結果也很漂亮:

child goroutine 4 begin...
child goroutine 0 begin...
child goroutine 3 begin...
child goroutine 2 begin...
child goroutine 1 begin...
child goroutine 1 end...
child goroutine 2 end...
child goroutine 3 end...
child goroutine 4 end...
child goroutine 0 end...

到這裏,我們瞭解了 goroutine 的基本使用,但很多情況下,goroutine 不是獨立運行的,而經常需要與其他的 goroutine 通信,在下一篇文章中,我們將詳細的聊一聊 goroutine 之間的通信方式。

  1. 小結

在這篇文章中,我們瞭解了協程的概念,並且知道了 goroutine 是 Go 語言對協程的實現。也知道了如何通過啓動一個新的 goroutine 併發的去做一些事情,同時也知道了如何讓 main  goroutine 來等待其他 goroutine 工作完成再退出的幾種方法。

文 / Rayjun

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