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


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/ ),不得用於商業目的,基於本文修改后的作品務必以相同的許可發布。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM