Go語言核心36講(Go語言進階技術十一)--學習筆記

MingsonZheng發表於2021-10-31

17 | go語句及其執行規則(下)

知識擴充套件

問題 1:怎樣才能讓主 goroutine 等待其他 goroutine?

我剛才說過,一旦主 goroutine 中的程式碼執行完畢,當前的 Go 程式就會結束執行,無論其他的 goroutine 是否已經在執行了。那麼,怎樣才能做到等其他的 goroutine 執行完畢之後,再讓主 goroutine 結束執行呢?

其實有很多辦法可以做到這一點。其中,最簡單粗暴的辦法就是讓主 goroutine“小睡”一會兒。

for i := 0; i < 10; i++ {
  go func() {
    fmt.Println(i)
  }()
}
time.Sleep(time.Millisecond * 500)

在for語句的後邊,我呼叫了time包的Sleep函式,並把time.Millisecond * 500的結果作為引數值傳給了它。time.Sleep函式的功能就是讓當前的 goroutine(在這裡就是主 goroutine)暫停執行一段時間,直到到達指定的恢復執行時間。

我們可以把一個相對的時間傳給該函式,就像我在這裡傳入的“500 毫秒”那樣。time.Sleep函式會在被呼叫時用當前的絕對時間,再加上相對時間計算出在未來的恢復執行時間。顯然,一旦到達恢復執行時間,當前的 goroutine 就會從“睡眠”中醒來,並開始繼續執行後邊的程式碼。

這個辦法是可行的,只要“睡眠”的時間不要太短就好。不過,問題恰恰就在這裡,我們讓主 goroutine“睡眠”多長時間才是合適的呢?如果“睡眠”太短,則很可能不足以讓其他的 goroutine 執行完畢,而若“睡眠”太長則純屬浪費時間,這個時間就太難把握了。

你可能會想到,既然不容易預估時間,那我們就讓其他的 goroutine 在執行完畢的時候告訴我們好了。這個思路很好,但怎麼做呢?

你是否想到了通道呢?我們先建立一個通道,它的長度應該與我們手動啟用的 goroutine 的數量一致。在每個手動啟用的 goroutine 即將執行完畢的時候,我們都要向該通道傳送一個值。

注意,這些傳送表示式應該被放在它們的go函式體的最後面。對應的,我們還需要在main函式的最後從通道接收元素值,接收的次數也應該與手動啟用的 goroutine 的數量保持一致。關於這些你可以到 demo39.go 檔案中,去檢視具體的寫法。

package main

import (
	"fmt"
	//"time"
)

func main() {
	num := 10
	sign := make(chan struct{}, num)

	for i := 0; i < num; i++ {
		go func() {
			fmt.Println(i)
			sign <- struct{}{}
		}()
	}

	// 辦法1。
	//time.Sleep(time.Millisecond * 500)

	// 辦法2。
	for j := 0; j < num; j++ {
		<-sign
	}
}

其中有一個細節你需要注意。我在宣告通道sign的時候是以chan struct{}作為其型別的。其中的型別字面量struct{}有些類似於空介面型別interface{},它代表了既不包含任何欄位也不擁有任何方法的空結構體型別。

注意,struct{}型別值的表示法只有一個,即:struct{}{}。並且,它佔用的記憶體空間是0位元組。確切地說,這個值在整個 Go 程式中永遠都只會存在一份。雖然我們可以無數次地使用這個值字面量,但是用到的卻都是同一個值。

當我們僅僅把通道當作傳遞某種簡單訊號的介質的時候,用struct{}作為其元素型別是再好不過的了。順便說一句,我在講“結構體及其方法的使用法門”的時候留過一道與此相關的思考題,你可以返回去看一看。

再說回當下的問題,有沒有比使用通道更好的方法?如果你知道標準庫中的程式碼包sync的話,那麼可能會想到sync.WaitGroup型別。沒錯,這是一個更好的答案。不過具體的使用方式我在後邊講sync包的時候再說。

問題 2:怎樣讓我們啟用的多個 goroutine 按照既定的順序執行?

在很多時候,當我沿著上面的主問題以及第一個擴充套件問題一路問下來的時候,應聘者往往會被這第二個擴充套件問題難住。

所以基於上一篇主問題中的程式碼,怎樣做到讓從0到9這幾個整數按照自然數的順序列印出來?你可能會說,我不用 goroutine 不就可以了嘛。沒錯,這樣是可以,但是如果我不考慮這樣做呢。你應該怎麼解決這個問題?

當然了,眾多應聘者回答的其他答案也是五花八門的,有的可行,有的不可行,還有的把原來的程式碼改得面目全非。我下面就來說說我的思路,以及心目中的答案吧。這個答案並不一定是最佳的,也許你在看完之後還可以想到更優的答案。

首先,我們需要稍微改造一下for語句中的那個go函式,要讓它接受一個int型別的引數,並在呼叫它的時候把變數i的值傳進去。為了不改動這個go函式中的其他程式碼,我們可以把它的這個引數也命名為i。

for i := 0; i < 10; i++ {
  go func(i int) {
    fmt.Println(i)
  }(i)
}

只有這樣,Go 語言才能保證每個 goroutine 都可以拿到一個唯一的整數。其原因與go函式的執行時機有關。

我在前面已經講過了。在go語句被執行時,我們傳給go函式的引數i會先被求值,如此就得到了當次迭代的序號。之後,無論go函式會在什麼時候執行,這個引數值都不會變。也就是說,go函式中呼叫的fmt.Println函式列印的一定會是那個當次迭代的序號。

然後,我們在著手改造for語句中的go函式。

for i := uint32(0); i < 10; i++ {
  go func(i uint32) {
    fn := func() {
      fmt.Println(i)
    }
    trigger(i, fn)
  }(i)
}

我在go函式中先宣告瞭一個匿名的函式,並把它賦給了變數fn。這個匿名函式做的事情很簡單,只是呼叫fmt.Println函式以列印go函式的引數i的值。

在這之後,我呼叫了一個名叫trigger的函式,並把go函式的引數i和剛剛宣告的變數fn作為引數傳給了它。注意,for語句宣告的區域性變數i和go函式的引數i的型別都變了,都由int變為了uint32。至於為什麼,我一會兒再說。

再來說trigger函式。該函式接受兩個引數,一個是uint32型別的引數i, 另一個是func()型別的引數fn。你應該記得,func()代表的是既無引數宣告也無結果宣告的函式型別。

trigger := func(i uint32, fn func()) {
  for {
    if n := atomic.LoadUint32(&count); n == i {
      fn()
      atomic.AddUint32(&count, 1)
      break
    }
    time.Sleep(time.Nanosecond)
  }
}

trigger函式會不斷地獲取一個名叫count的變數的值,並判斷該值是否與引數i的值相同。如果相同,那麼就立即呼叫fn代表的函式,然後把count變數的值加1,最後顯式地退出當前的迴圈。否則,我們就先讓當前的 goroutine“睡眠”一個納秒再進入下一個迭代。

注意,我操作變數count的時候使用的都是原子操作。這是由於trigger函式會被多個 goroutine 併發地呼叫,所以它用到的非本地變數count,就被多個使用者級執行緒共用了。因此,對它的操作就產生了競態條件(race condition),破壞了程式的併發安全性。

所以,我們總是應該對這樣的操作加以保護,在sync/atomic包中宣告瞭很多用於原子操作的函式。

另外,由於我選用的原子操作函式對被操作的數值的型別有約束,所以我才對count以及相關的變數和引數的型別進行了統一的變更(由int變為了uint32)。

縱觀count變數、trigger函式以及改造後的for語句和go函式,我要做的是,讓count變數成為一個訊號,它的值總是下一個可以呼叫列印函式的go函式的序號。

這個序號其實就是啟用 goroutine 時,那個當次迭代的序號。也正因為如此,go函式實際的執行順序才會與go語句的執行順序完全一致。此外,這裡的trigger函式實現了一種自旋(spinning)。除非發現條件已滿足,否則它會不斷地進行檢查。

最後要說的是,因為我依然想讓主 goroutine 最後一個執行完畢,所以還需要加一行程式碼。不過既然有了trigger函式,我就沒有再使用通道。

trigger(10, func(){})

呼叫trigger函式完全可以達到相同的效果。由於當所有我手動啟用的 goroutine 都執行完畢之後,count的值一定會是10,所以我就把10作為了第一個引數值。又由於我並不想列印這個10,所以我把一個什麼都不做的函式作為了第二個引數值。

總之,通過上述的改造,我使得非同步發起的go函式得到了同步地(或者說按照既定順序地)執行,你也可以動手自己試一試,感受一下。

package main

import (
	"fmt"
	"sync/atomic"
	"time"
)

func main() {
	var count uint32
	trigger := func(i uint32, fn func()) {
		for {
			if n := atomic.LoadUint32(&count); n == i {
				fn()
				atomic.AddUint32(&count, 1)
				break
			}
			time.Sleep(time.Nanosecond)
		}
	}
	for i := uint32(0); i < 10; i++ {
		go func(i uint32) {
			fn := func() {
				fmt.Println(i)
			}
			trigger(i, fn)
		}(i)
	}
	trigger(10, func() {})
}

總結

主 goroutine 的執行若過早結束,那麼我們的併發程式的功能就很可能無法全部完成。所以我們往往需要通過一些手段去進行干涉,比如呼叫time.Sleep函式或者使用通道。

另外,go函式的實際執行順序往往與其所屬的go語句的執行順序(或者說 goroutine 的啟用順序)不同,而且預設情況下的執行順序是不可預知的。那怎樣才能讓這兩個順序一致呢?其實複雜的實現方式有不少,但是可能會把原來的程式碼改得面目全非。

總之,我希望通過上述基礎知識以及三個連貫的問題幫你串起一條主線。這應該會讓你更快地深入理解 goroutine 及其背後的併發程式設計模型,從而更加遊刃有餘地使用go語句。

思考題

runtime包中提供了哪些與模型三要素 G、P 和 M 相關的函式?(模型三要素內容在上一篇)

知識共享許可協議

本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。

歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含連結: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。

相關文章