協程和通道是 Go 語言做爲併發編程語言最爲重要的特點之一,初學者能夠徹底將協程理解爲線程,可是用起來比線程更加簡單,佔用的資源也更少。一般在一個進程裏啓動上萬個線程就已經不堪重負,可是 Go 語言容許你啓動百萬協程也能夠輕鬆應付。若是把協程比喻成小島,那通道就是島嶼之間的交流橋樑,數據搭乘通道從一個協程流轉到另外一個協程。通道是併發安全的數據結構,它相似於內存消息隊列,容許不少的協程併發對通道進行讀寫。git
Go 語言裏面的協程稱之爲 goroutine,通道稱之爲 channel。github
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
複製代碼
這是由於主協程運行結束,其它協程就會當即消亡,無論它們是否已經開始運行。安全
在使用子協程時必定要特別注意保護好每一個子協程,確保它們正常安全的運行。由於子協程的異常退出會將異常傳播到主協程,直接會致使主協程也跟着掛掉,而後整個程序就崩潰了。bash
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 值爲 三、二、1,主協程依然能夠每秒輸出一次。要解釋這個現象就必須深刻了解協程的運行原理
一個進程內部能夠運行多個線程,而每一個線程又能夠運行不少協程。線程要負責對協程進行調度,保證每一個協程都有機會獲得執行。當一個協程睡眠時,它要將線程的運行權讓給其它的協程來運行,而不能持續霸佔這個線程。同一個線程內部最多隻會有一個協程正在運行。
線程的調度是由操做系統負責的,調度算法運行在內核態,而協程的調用是由 Go 語言的運行時負責的,調度算法運行在用戶態。
協程能夠簡化爲三個狀態,運行態、就緒態和休眠態。同一個線程中最多隻會存在一個處於運行態的協程,就緒態的協程是指那些具有了運行能力可是尚未獲得運行機會的協程,它們隨時會被調度到運行態,休眠態的協程還不具有運行能力,它們是在等待某些條件的發生,好比 IO 操做的完成、睡眠時間的結束等。
操做系統對線程的調度是搶佔式的,也就是說單個線程的死循環不會影響其它線程的執行,每一個線程的連續運行受到時間片的限制。
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 和 網卡的壓力,它能撐住的鏈接數要比推送系統少不少。不過原理是相似的,都是一個連接由一個協程長期維持,鏈接斷開協程也就消亡。
在後面的高級內容部分,我將會教讀者使用協程來實現上面這三個系統。下一章節咱們開講通道,由於通道的使用比較複雜,知識點較多,因此須要單獨一節來說。
閱讀更多精品文章,微信掃一掃上面的二維碼關注公衆號「碼洞」