《快學 Go 語言》第 11 課 —— 千軍萬馬跑協程

碼洞發表於2018-12-03

協程和通道是 Go 語言作為併發程式語言最為重要的特色之一,初學者可以完全將協程理解為執行緒,但是用起來比執行緒更加簡單,佔用的資源也更少。通常在一個程式裡啟動上萬個執行緒就已經不堪重負,但是 Go 語言允許你啟動百萬協程也可以輕鬆應付。如果把協程比喻成小島,那通道就是島嶼之間的交流橋樑,資料搭乘通道從一個協程流轉到另一個協程。通道是併發安全的資料結構,它類似於記憶體訊息佇列,允許很多的協程併發對通道進行讀寫。

《快學 Go 語言》第 11 課 —— 千軍萬馬跑協程

Go 語言裡面的協程稱之為 goroutine,通道稱之為 channel。

協程的啟動

Go 語言裡建立一個協程非常簡單,使用 go 關鍵詞加上一個函式呼叫就可以了。Go 語言會啟動一個新的協程,函式呼叫將成為這個協程的入口。

package main

import "fmt"
import "time"

func main() {
	fmt.Println("run in main goroutine")
	go func() {
		fmt.Println("run in child goroutine")
		go func() {
			fmt.Println("run in grand child goroutine")
			go func() {
				fmt.Println("run in grand grand child goroutine")
			}()
		}()
	}()
	time.Sleep(time.Second)
	fmt.Println("main goroutine will quit")
}

-------
run in main goroutine
run in child goroutine
run in grand child goroutine
run in grand grand child goroutine
main goroutine will quit
複製程式碼

main 函式執行在主協程(main goroutine)裡面,上面的例子中我們在主協程裡面啟動了一個子協程,子協程又啟動了一個孫子協程,孫子協程又啟動了一個曾孫子協程。這些協程之間似乎形成了父子、子孫、關係,但是實際上協程之間並不存在這麼多的層級關係,在 Go 語言裡只有一個主協程,其它都是它的子協程,子協程之間是平行關係。

值得注意的是這裡的 go 關鍵字語法和前面的 defer 關鍵字語法是一樣的,它後面跟了一個匿名函式,然後還要帶上一對(),表示對匿名函式的呼叫。

上面的程式碼中主協程睡眠了 1s,等待子協程們執行完畢。如果將睡眠的這行程式碼去掉,將會看不到子協程執行的痕跡

-------------
run in main goroutine
main goroutine will quit
複製程式碼

這是因為主協程執行結束,其它協程就會立即消亡,不管它們是否已經開始執行。

子協程異常退出

在使用子協程時一定要特別注意保護好每個子協程,確保它們正常安全的執行。因為子協程的異常退出會將異常傳播到主協程,直接會導致主協程也跟著掛掉,然後整個程式就崩潰了。

package main

import "fmt"
import "time"

func main() {
	fmt.Println("run in main goroutine")
	go func() {
		fmt.Println("run in child goroutine")
		go func() {
			fmt.Println("run in grand child goroutine")
			go func() {
				fmt.Println("run in grand grand child goroutine")
				panic("wtf")
			}()
		}()
	}()
	time.Sleep(time.Second)
	fmt.Println("main goroutine will quit")
}

---------
run in main goroutine
run in child goroutine
run in grand child goroutine
run in grand grand child goroutine
panic: wtf

goroutine 34 [running]:
main.main.func1.1.1()
	/Users/qianwp/go/src/github.com/pyloque/practice/main.go:14 +0x79
created by main.main.func1.1
	/Users/qianwp/go/src/github.com/pyloque/practice/main.go:12 +0x75
exit status 2
複製程式碼

我們看到主協程最後一句列印語句沒能執行就掛掉了,主協程在異常退出時會列印堆疊資訊。從堆疊資訊中可以瞭解到是哪行程式碼引發了程式崩潰。

為了保護子協程的安全,通常我們會在協程的入口函式開頭增加 recover() 語句來恢復協程內部發生的異常,阻斷它傳播到主協程導致程式崩潰。recover 語句必須寫在 defer 語句裡面。

go func() {
  defer func() {
    if err := recover(); err != nil {
      // log error
    }
  }()
  // do something
}()
複製程式碼

啟動百萬協程

Go 語言能同時管理上百萬的協程,這不是吹牛,下面我們就來編寫程式碼跑一跑這百萬協程,讀者們請想象一下這百萬大軍同時奔跑的感覺。

package main

import "fmt"
import "time"

func main() {
	fmt.Println("run in main goroutine")
	i := 1
	for {
		go func() {
			for {
				time.Sleep(time.Second)
			}
		}()
		if i % 10000 == 0 {
			fmt.Printf("%d goroutine started\n", i)
		}
		i++
	}
}
複製程式碼

上面的程式碼將會無休止地建立協程,每個協程都在睡眠,為了確保它們都是活的,協程會 1s 鍾醒過來一次。在我的個人電腦上,這個程式建立了千萬個協程還沒有到上限,觀察記憶體發現佔用還不到 1G,這意味著每個協程的記憶體佔用還不到 100 位元組。

協程死迴圈

前面我們通過 recover() 函式可以防止個別協程的崩潰波及整體程式。但是如果有個別協程死迴圈了會導致其它協程飢餓得到不執行麼?下面我們來做一個實驗

package main

import "fmt"
import "time"

func main() {
	fmt.Println("run in main goroutine")
	n := 3
	for i:=0; i<n; i++ {
		go func() {
			fmt.Println("dead loop goroutine start")
			for {}  // 死迴圈
		}()
	}
	for {
		time.Sleep(time.Second)
		fmt.Println("main goroutine running")
	}
}
複製程式碼

通過調整上面程式碼中的變數 n 的值可以發現一個有趣的現象,當 n 值大於 3 時,主協程將沒有機會得到執行,而如果 n 值為 3、2、1,主協程依然可以每秒輸出一次。要解釋這個現象就必須深入瞭解協程的執行原理

協程的本質

一個程式內部可以執行多個執行緒,而每個執行緒又可以執行很多協程。執行緒要負責對協程進行排程,保證每個協程都有機會得到執行。當一個協程睡眠時,它要將執行緒的執行權讓給其它的協程來執行,而不能持續霸佔這個執行緒。同一個執行緒內部最多隻會有一個協程正在執行。

《快學 Go 語言》第 11 課 —— 千軍萬馬跑協程

執行緒的排程是由作業系統負責的,排程演算法執行在核心態,而協程的呼叫是由 Go 語言的執行時負責的,排程演算法執行在使用者態。

協程可以簡化為三個狀態,執行態、就緒態和休眠態。同一個執行緒中最多隻會存在一個處於執行態的協程,就緒態的協程是指那些具備了執行能力但是還沒有得到執行機會的協程,它們隨時會被排程到執行態,休眠態的協程還不具備執行能力,它們是在等待某些條件的發生,比如 IO 操作的完成、睡眠時間的結束等。

《快學 Go 語言》第 11 課 —— 千軍萬馬跑協程

作業系統對執行緒的排程是搶佔式的,也就是說單個執行緒的死迴圈不會影響其它執行緒的執行,每個執行緒的連續執行受到時間片的限制。

Go 語言執行時對協程的排程並不是搶佔式的。如果單個協程通過死迴圈霸佔了執行緒的執行權,那這個執行緒就沒有機會去執行其它協程了,你可以說這個執行緒假死了。不過一個程式內部往往有多個執行緒,假死了一個執行緒沒事,全部假死了才會導致整個程式卡死。

每個執行緒都會包含多個就緒態的協程形成了一個就緒佇列,如果這個執行緒因為某個別協程死迴圈導致假死,那這個佇列上所有的就緒態協程是不是就沒有機會得到執行了呢?Go 語言執行時排程器採用了 work-stealing 演算法,當某個執行緒空閒時,也就是該執行緒上所有的協程都在休眠(或者一個協程都沒有),它就會去其它執行緒的就緒佇列上去偷一些協程來執行。也就是說這些執行緒會主動找活幹,在正常情況下,執行時會盡量平均分配工作任務。

設定執行緒數

預設情況下,Go 執行時會將執行緒數會被設定為機器 CPU 邏輯核心數。同時它內建的 runtime 包提供了 GOMAXPROCS(n int) 函式允許我們動態調整執行緒數,注意這個函式名字是全大寫,Go 語言的設計者就是這麼任性,該函式會返回修改前的執行緒數,如果引數 n <=0 ,就不會產生修改效果,等價於讀操作。

package main

import "fmt"
import "runtime"

func main() {
    // 讀取預設的執行緒數
    fmt.Println(runtime.GOMAXPROCS(0))
    // 設定執行緒數為 10
    runtime.GOMAXPROCS(10)
    // 讀取當前的執行緒數
    fmt.Println(runtime.GOMAXPROCS(0))
}

--------
4
10
複製程式碼

獲取當前的協程數量可以使用 runtime 包提供的 NumGoroutine() 方法

package main

import "fmt"
import "time"
import "runtime"

func main() {
	fmt.Println(runtime.NumGoroutine())
	for i:=0;i<10;i++ {
		go func(){
			for {
				time.Sleep(time.Second)
			}
		}()
	}
	fmt.Println(runtime.NumGoroutine())
}

------
1
11
複製程式碼

協程的應用

在日常網際網路應用中,Go 語言的協程主要應用在HTTP API 應用、訊息推送系統、聊天系統等。

在 HTTP API 應用中,每一個 HTTP 請求,伺服器都會單獨開闢一個協程來處理。在這個請求處理過程中,要進行很多 IO 呼叫,比如訪問資料庫、訪問快取、呼叫外部系統等,協程會休眠,IO 處理完成後協程又會再次被排程執行。待請求的響應回覆完畢後,連結斷開,這個協程的壽命也就到此結束。

在訊息推送系統中,客戶端的連結壽命很長,大部分時間這個連結都是空閒狀態,客戶端會每隔幾十秒週期性使用心跳來告知伺服器你不要斷開我。在伺服器端,每一個來自客戶端連結的維持都需要單獨一個協程。因為訊息推送系統維持的連結普遍很閒,單臺伺服器往往可以輕鬆撐起百萬連結,這些維持連結的協程只有在推送訊息或者心跳訊息到來時才會變成就緒態被排程執行。

聊天系統也是長連結系統,它內部來往的訊息要比訊息推送系統頻繁很多,限於 CPU 和 網路卡的壓力,它能撐住的連線數要比推送系統少很多。不過原理是類似的,都是一個連結由一個協程長期維持,連線斷開協程也就消亡。

在後面的高階內容部分,我將會教讀者使用協程來實現上面這三個系統。下一章節我們開講通道,因為通道的使用比較複雜,知識點較多,所以需要單獨一節來講。

《快學 Go 語言》第 11 課 —— 千軍萬馬跑協程

閱讀更多精品文章,微信掃一掃上面的二維碼關注公眾號「碼洞」

相關文章