golang 深入淺出之 goroutine 理解

目錄

一、前言

二、goroutine 的理解與使用

 (一)goroutine 入門

 (二)sync 包同步 goroutine

三、併發與並行

 (一)併發與並行的區別

 (二)runtime 包對 goroutine 控制

四、思考題

五、參考文獻


一、前言

       goroutine 是 Go 並行設計的核心,其本質就是協程,協程比線程小,也叫輕量級線程,它可以輕鬆創建上萬個而不會導致系統資源的枯竭。十幾個 goroutine 可能體現在底層就是五六個線程,一個線程可以有任意多個協程,但是某一個時刻只能有一個協程在運行,多個協程共享該線程分配到的計算機資源。創建 goroutine 只需在函數調用前加上 go 語句,就可以創建併發執行單元,開發人員無需瞭解任何執行細節,調度器會自動安排其到合適的系統線程上執行。

二、goroutine 的理解與使用

 (一)goroutine 入門

       首先如何創建一個 goroutine ,在調用的函數前加上 go 語句,該函數除了匿名函數也可以是 golang 包中自帶的方法。

go func() {
    // to do something
}()
//比如:
go fmt.Println(1)  // 輸出 1

        在併發的程序中,通常是將一個過程分爲幾塊,然後讓每個 goroutine 各自負責一塊工作,當程序啓動時,主函數在一個單獨的 goroutine 中運行,我們叫他 main goroutine , 啓動程序時 main goroutine 則立刻運行,其他 goroutine 會用 go 語句來創建。下面定義了 3 個函數,前兩個函數加上 go 創建兩個 goroutine,分別爲 goroutine_1, goroutine_2, 第 3 個 func_1 爲普通函數,作爲對比用。

package main

import (
	"fmt"
	"time"
)

func main() {
	go func() {
		fmt.Println("goroutine_1")
	}()
	go func() {
		fmt.Println("goroutine_2")
	}()
	func() {
		fmt.Println("func_1")
	}()
}

       剛開始運行的時候只有 output1 這種輸出,多運行幾次之後出現了 output2 的輸出。

       

       兩個輸出結果不一樣,第一個輸出結果 geroutine_1 , goroutine_2都沒有輸出,第二個輸出也只是出現 goroutine_1。造成以上問題是因爲 goroutine 的運行時需要獲得時間片,獲取 CPU 時間片才能運行 goroutine,在運行隊列中等待調用,並不會馬上執行, 在 main goroutine 中的 fun_1 則立刻運行。現在再看上面的程序,一共定義了兩個 goroutine ,執行完兩個定義語句之後,goroutine 不會馬上運行,而是等待獲取時間片執行,然而這段代碼還沒等到兩個 goroutine 被調度執行 main goroutine 就結束運行並退出,main goroutine 退出後,其他的工作 goroutine 也會自動退出,所以就看不到兩個 goroutine 的輸出。爲了看到兩個 goroutine 的執行可以暫時在程序的末尾加上休眠時間。

package main

import (
	"fmt"
	"time"
)

func main() {
	go func() {
		fmt.Println("goroutine_1")
	}()
	go func() {
		fmt.Println("goroutine_2")
	}()
	func() {
		fmt.Println("func_1")
	}()
	time.Sleep(time.Second) // 程序休眠1s
}

       這時候就看到了兩個 goroutine 輸出結果。因爲 goroutinue 的調度執行是隨機的,所以每次運行的結果可能不一樣,以下是兩種輸出的結果。fun_1因爲是馬上運行的,所以第一個輸出。

     

       但是我們總不能每次在運行程序的時候都要休眠,畢竟我們不能自己算出所有的 goroutine 什麼時候被調用執行,以及他們什麼時候才能結束,所以 sync 包中的 WaitGroup 就可以用來判斷 goroutine 是否運行結束的方法。

 (二)sync 包同步 goroutine

sync 包

sync.WaitGroup : 一個計數信號量,用來記錄並維護 goroutine。

wg.Add(n) : 代表有 n 個正在運行的 goroutine。

wg.Done() :  其源碼是 wg.Add(-1),說明有一個 goroutine 運行結束。

wg.Wait() :  如果 WaitGroup 的值大於 0 ,Wait 方法就會阻塞,直到等待隊列的值到 0 並最終釋放 main 函數。

package main

import (
	"fmt"
	"sync"
)

func main() {
	// WaitGroup 是一個計數信號量,被
	// 用來記錄並維護 goroutine
	var wg sync.WaitGroup
	wg.Add(2)  // 共有兩個 goroutine
	go func(){
		fmt.Println("goroutine 1")
		wg.Done() // goroutine 運行結束
	}()
	go func(){
		fmt.Println("goroutine 2")
		wg.Done()
	}()
	func() {
		fmt.Println("func_1")
	}()
	wg.Wait() // 阻塞等待所有 goroutine 運行完畢
	//time.Sleep(time.Second)  // 程序休眠1s
}

 

        這樣就可以保證所有的 goroutine 都被運行。 以下代碼可以顯示了添加 WaitGroup 阻塞判斷和沒有阻塞判斷程序所耗時間。首先是沒有加 sync.WaitGroup 的代碼。

package main

import (
	"fmt"
	"time"
)

func main() {
	startTime := time.Now().UnixNano()
	go func() {
		fmt.Println("goroutine_1")
	}()
	go func() {
		fmt.Println("goroutine_2")
	}()
	func() {
		fmt.Println("func_1")
	}()
	endTime := time.Now().UnixNano()
	nanoSeconds := float64((endTime - startTime))
	fmt.Println(nanoSeconds)
}

       輸出結果爲:

       在這 27000 納秒內,main goroutine 程序就已經運行完畢,但是 goroutine_1, goroutine_2 沒有被調用執行。然後我們將阻塞等待機制加入以上代碼中,來對比兩段程序的運行時間。

package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	startTime := time.Now().UnixNano()
	var wg sync.WaitGroup
	wg.Add(2)
	go func() {
		fmt.Println("goroutine_1")
		wg.Done()
	}()
	go func() {
		fmt.Println("goroutine_2")
		wg.Done()
	}()
	func() {
		fmt.Println("func_1")
	}()
	wg.Wait()
	endTime := time.Now().UnixNano()
	nanoSeconds := float64((endTime - startTime))
	fmt.Println(nanoSeconds)
}

       得到的輸出爲:

       這段程序運行了 goroutine_1, goroutine_2 ,一共花費了 65000 納秒的時間。 很顯然加了阻塞判斷機制的程序爲了等待 goroutine_1, goroutine_2 被調用執行,花費了更多的時間,當然這個時間並不是唯一的,goroutine 的調用時隨機的,可能兩個 goroutine 調用的晚,程序花費較多的時間,goroutine 調用得早,花費較少的時間,這個視系統的環境而定。

三、併發與並行

 (一)併發與並行的區別

       Golang在運行時候會在邏輯處理器上調度 goroutine 來運行。每個邏輯處理器分別綁定綁定到單個操作系統線程。golang 運行時會默認爲每個可用的物理處理器分配一個處理器,所以我們在運行代碼的時候有時候會用  runtime.GOMAXPROCS(N) 來設置邏輯處理器的數量,其中 N 代表 N 個邏輯處理器,這些處理器會被用於執行所有的 goroutine。

       關於併發與並行的區別,知乎上很讚的回答,可以幫助理解兩者的不同。       

你吃飯吃到一半,電話來了,你一直到吃完了以後纔去接,這就說明你不支持併發也不支持並行。
你吃飯吃到一半,電話來了,你停了下來接了電話,接完後繼續吃飯,這說明你支持併發。
你吃飯吃到一半,電話來了,你一邊打電話一邊吃飯,這說明你支持並行。

併發的關鍵是你有處理多個任務的能力,不一定要同時。
並行的關鍵是你有同時處理多個任務的能力。

所以我認爲它們最關鍵的點就是:是否是『同時』。

來源:知乎
 

       併發是當正在運行的 goroutine 發生阻塞時,比如讀取io,或者打開一個文件,通常都需要花費一定的時間等待。這類調用會讓線程和 goroutine 從邏輯處理器分離,該線程會阻塞,並等待系統調用返回。調度器會重新創建一個新的線程,然後再繼續從本地執行隊列中選擇一個 goroutine 運行。一旦被阻塞的 goroutine 執行完成並返回是,對應的 goroutine 會被放回本地運行隊列,而之前的線程會被保存,以備之後繼續使用,調度情況如圖1.

                                                                               圖1 Go 調度器如何管理 goroutine

       併發(concurrency)與並行(parallelism)是不同的。並行是讓不同的代碼片段同時在不同的物理處理器上執行。並行的關鍵是同時做很多事情,併發是指同時管理很多事情,這些事情可能只做了一半就被暫停去做別的事情。在很多情況下,併發比並行的效果好,因爲操作系統的和硬件的總資源一般很少,但能支持系統做很多事情。這種"使用較少資源做更多事情”的哲學也是知道 Go 語言設計的哲學。圖2 展示了併發與並行的區別。

                                                                               圖2 併發與並行的區別

 (二)runtime 包對 goroutine 控制

runtime 包

runtime.GOMAXPROCS(N)  設置 N 個邏輯處理器給調度器使用。當 N值爲 runtime.NumCPU(),代表給每個可用的核心分配一個邏輯處理器。當 N 爲 1,最多同時只能有一個 goroutine 被執行,當 N 爲 2 時,兩個 goroutine 可以被一起執行。

runtime.Gosched() 用於讓出 CPU 時間片,讓出當前 goroutine 的執行權限,調度器安排其他等待的任務先執行,並在下次再次獲得 CPU 時間輪片的時候,從讓出該 CPU 的位置恢復執行。

runtime.Goexit() 終止當前 goroutine 執行。

       現在可以在剛纔的代碼上加上 go 語句,看看邏輯處理器的調度。先分配邏輯處理器,同時將兩個 goroutine 方法改爲輸出 1 ~ 10。

// 輸出 1 ~ n 的數字
func printNum(goroutine string, n int) {
	// 設置隨機種子,隨機種子不同,生成的隨機數不同。
	rand.Seed(time.Now().Unix())
	for i := 1; i <= n; i++ {
		fmt.Printf("%s: %d\n", goroutine, i)
		// 程序隨機休眠 0~1 秒,方便觀察 goroutine 之間的切換
		time.Sleep(time.Duration(rand.Intn(2)) * time.Second)
	}
	// 輸出結束標誌
	fmt.Printf("%s: completed\n", goroutine)
}

       輸出 1~n 之間的數字,然後通過隨機長度休眠模擬阻塞,阻塞時間爲 [0,n) 秒,方便觀察 goroutine 之間切換執行的併發效果。將以上代碼整合到前面的程序中。

package main

import (
	"fmt"
	"math/rand"
	"runtime"
	"sync"
	"time"
)

// 輸出 1 ~ n 的數字
func printNum(goroutine string, n int) {
	// 設置隨機種子,隨機種子不同,生成的隨機數不同。
	rand.Seed(time.Now().Unix())
	for i := 1; i <= n; i++ {
		fmt.Printf("%s: %d\n", goroutine, i)
		// 程序隨機休眠 0~1 秒,方便觀察 goroutine 之間的切換
		time.Sleep(time.Duration(rand.Intn(2)) * time.Second)
	}
	// 輸出結束標誌
	fmt.Printf("%s: completed\n", goroutine)
}

func main() {
	// WaitGroup 是一個計數信號量,被
	// 用來記錄並維護 goroutine
	runtime.GOMAXPROCS(1)
	var wg sync.WaitGroup
	wg.Add(2)  // 共有兩個 goroutine
	go func(){
		printNum("1", 10)
		wg.Done() // goroutine 運行結束
	}()
	go func(){
		printNum("2", 10)
		wg.Done()
	}()
	func() {
		fmt.Println("func_1")
	}()
	wg.Wait() // 阻塞等待所有 goroutine 運行完畢
	//time.Sleep(time.Second)  // 程序休眠1s
}

       運行程序後可以發現,當一個程序阻塞,調度器會切換到另外一個程序執行。以下爲該程序的輸出結果。

func_1
goroutine_2: 1
goroutine_1: 1          // 切換到 goroutine_1
goroutine_1: 2
goroutine_2: 2          // 切換到 goroutine_2
goroutine_2: 3
goroutine_2: 4
goroutine_2: 5
goroutine_2: 6
goroutine_2: 7
goroutine_2: 8
goroutine_2: 9
goroutine_1: 3          // 切換到 goroutine_1
goroutine_1: 4
goroutine_2: 10         // 切換到 goroutine_2
goroutine_2: completed
goroutine_1: 5          // 切換到 goroutine_1
goroutine_1: 6
goroutine_1: 7
goroutine_1: 8
goroutine_1: 9
goroutine_1: 10
goroutine_1: completed

Process finished with exit code 0

       上圖顯示先是 goroutine_2 在運行輸出,輸出到數字 1 的時候,調度器將正在運行的 goroutine_2 轉換爲 goroutine_1,之後 goroutine_1 輸出了數字 1~2,再次切換到 goroutine_2。通過這樣的切換調度使得兩個 goroutine 完成所有的工作。每次運行結果可能會不一樣。如果將上面的休眠阻塞時間去掉,一般 goroutine 就會執行完所有的工作再切換另外一個。即 goroutine_1 輸出完1~n,之後再切換 goroutine_2 輸出 1~ n。對於 goroutine 的執行順序,如果沒有利用同步加以控制,那麼 goroutine 的執行順序是無法預測的。如果看到相同的執行順序是因爲測試的次數太少。

四、思考題

        文章寫到這裏,我們對 goroutine 有了初步的瞭解,我在網上找了一個有趣的程序,用來加深理解。

        程序1:

package main

import (
	"runtime"
	"time"
)

func main() {
	runtime.GOMAXPROCS(1)
	for i := 0; i < 10; i++ {
		go func() {
			println(i)
		}()
	}
	time.Sleep(time.Second)
}

       結果輸出爲:10個10,爲什麼結果是這個?

       程序2:

package main

import (
	"runtime"
	"time"
)

func main() {
	runtime.GOMAXPROCS(1)
	for i := 0; i < 10; i++ {
		go println(i)
	}
	time.Sleep(time.Second)
}

       結果輸出:亂序輸出 0~9 。爲什麼和程序1 結果不一樣?

分析  

       首先程序1:我們在 for 循環裏面定義了一個匿名函數,匿名函數的功能是輸出 i 的值,結果輸出 10 個 10。前面講過了,除了 goroutine 會等待系統調用執行,其他代碼會立刻運行,所以 for 循環會很快就結束,同時爲了看到 goroutine 被調用執行,我們讓 main 函數休眠 1 秒。當程序運行到 go 語句的時候,編譯器就把運行 goroutine 所需的函數和參數都保存了,程序1 中編譯器保存的是{main.func_xxx, nil},第一個參數爲函數,第二個參數爲該函數接收的參數,此時應該注意到,該匿名函數是無參的,所以保存參數爲 nil。當系統中的 goroutine 被調用執行時, 匿名函數裏面的代碼開始執行,此時需要用到 i 的值,然而 for 循環已經執行完畢,當前的 i 值爲 10,所以後面輸出的所有值都是10。

      其次是程序2:相比於程序1 唯一不同的地方就是 go 語句後面的內容,代碼看起來沒什麼差別,但是結果卻不同。經過程序1 的分析,我們知道運行到 go 語句時,編譯器會保存方法和參數,程序2 中編譯器保存的是{println, current_i} ,不同的是,這次編譯器保存的方法爲 println 且是有參數的,參數值爲當前 i 的值,所以 for 循環執行了 10次,分別傳入 i 的值爲 0~9 ,因此當 goroutine 被調用執行的時候,輸出的值爲亂序的 0~9,之所以亂序,這是因爲 goroutine 的調用時隨機的,所以每次執行 0~9 序列都不盡相同。

五、參考文獻

[1]《 Go 語言實戰》

[2] 深入淺出Golang關鍵字"go" 

[3] 併發與並行的區別?

[4] go語言之行--golang核武器goroutine調度原理、channel詳解

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章