協(xié)程和通道是 Go 語言作為并發(fā)編程語言最為重要的特色之一,初學(xué)者可以完全將協(xié)程理解為線程,但是用起來比線程更加簡單,占用的資源也更少。通常在一個進程里啟動上萬個線程就已經(jīng)不堪重負,但是 Go 語言允許你啟動百萬協(xié)程也可以輕松應(yīng)付。
如果把協(xié)程比喻成小島,那通道就是島嶼之間的交流橋梁,數(shù)據(jù)搭乘通道從一個協(xié)程流轉(zhuǎn)到另一個協(xié)程。通道是并發(fā)安全的數(shù)據(jù)結(jié)構(gòu),它類似于內(nèi)存消息隊列,允許很多的協(xié)程并發(fā)對通道進行讀寫。

Go 語言里面的協(xié)程稱之為 goroutine,通道稱之為 channel。
協(xié)程的啟動
Go 語言里創(chuàng)建一個協(xié)程非常簡單,使用 go 關(guān)鍵詞加上一個函數(shù)調(diào)用就可以了。Go 語言會啟動一個新的協(xié)程,函數(shù)調(diào)用將成為這個協(xié)程的入口。
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 函數(shù)運行在主協(xié)程(main goroutine)里面,上面的例子中我們在主協(xié)程里面啟動了一個子協(xié)程,子協(xié)程又啟動了一個孫子協(xié)程,孫子協(xié)程又啟動了一個曾孫子協(xié)程。這些協(xié)程之間似乎形成了父子、子孫、關(guān)系,但是實際上協(xié)程之間并不存在這么多的層級關(guān)系,在 Go 語言里只有一個主協(xié)程,其它都是它的子協(xié)程,子協(xié)程之間是平行關(guān)系。
值得注意的是這里的 go 關(guān)鍵字語法和前面的 defer 關(guān)鍵字語法是一樣的,它后面跟了一個匿名函數(shù),然后還要帶上一對(),表示對匿名函數(shù)的調(diào)用。
上面的代碼中主協(xié)程睡眠了 1s,等待子協(xié)程們執(zhí)行完畢。如果將睡眠的這行代碼去掉,將會看不到子協(xié)程運行的痕跡
-------------
run in main goroutine
main goroutine will quit
這是因為主協(xié)程運行結(jié)束,其它協(xié)程就會立即消亡,不管它們是否已經(jīng)開始運行。
子協(xié)程異常退出
在使用子協(xié)程時一定要特別注意保護好每個子協(xié)程,確保它們正常安全的運行。因為子協(xié)程的異常退出會將異常傳播到主協(xié)程,直接會導(dǎo)致主協(xié)程也跟著掛掉,然后整個程序就崩潰了。
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
我們看到主協(xié)程最后一句打印語句沒能運行就掛掉了,主協(xié)程在異常退出時會打印堆棧信息。從堆棧信息中可以了解到是哪行代碼引發(fā)了程序崩潰。
為了保護子協(xié)程的安全,通常我們會在協(xié)程的入口函數(shù)開頭增加 recover() 語句來恢復(fù)協(xié)程內(nèi)部發(fā)生的異常,阻斷它傳播到主協(xié)程導(dǎo)致程序崩潰。
go func() {
if err := recover(); err != nil {
// log error
}
// do something
}()
啟動百萬協(xié)程
Go 語言能同時管理上百萬的協(xié)程,這不是吹牛,下面我們就來編寫代碼跑一跑這百萬協(xié)程,讀者們請想象一下這百萬大軍同時奔跑的感覺。
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++
}
}
上面的代碼將會無休止地創(chuàng)建協(xié)程,每個協(xié)程都在睡眠,為了確保它們都是活的,協(xié)程會 1s 鐘醒過來一次。在我的個人電腦上,這個程序瞬間創(chuàng)建了 200w 個協(xié)程,觀察發(fā)現(xiàn)內(nèi)存占用在 4G 多,這意味著每個協(xié)程的內(nèi)存占用大概 2000 多字節(jié)。協(xié)程還在繼續(xù)創(chuàng)建,電腦開始變的卡頓,應(yīng)該是程序開始使用交換分區(qū),CPU 占用率持續(xù)走高。再繼續(xù)壓榨下去已經(jīng)沒有了意義。
協(xié)程死循環(huán)
前面我們通過 recover() 函數(shù)可以防止個別協(xié)程的崩潰波及整體進程。但是如果有個別協(xié)程死循環(huán)了會導(dǎo)致其它協(xié)程饑餓得到不運行么?下面我們來做一個實驗
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 {} // 死循環(huán)
}()
}
for {
time.Sleep(time.Second)
fmt.Println("main goroutine running")
}
}
通過調(diào)整上面代碼中的變量 n 的值可以發(fā)現(xiàn)一個有趣的現(xiàn)象,當 n 值大于 3 時,主協(xié)程將沒有機會得到運行,而如果 n 值為 3、2、1,主協(xié)程依然可以每秒輸出一次。要解釋這個現(xiàn)象就必須深入了解協(xié)程的運行原理
協(xié)程的本質(zhì)
一個進程內(nèi)部可以運行多個線程,而每個線程又可以運行很多協(xié)程。線程要負責對協(xié)程進行調(diào)度,保證每個協(xié)程都有機會得到執(zhí)行。當一個協(xié)程睡眠時,它要將線程的運行權(quán)讓給其它的協(xié)程來運行,而不能持續(xù)霸占這個線程。同一個線程內(nèi)部最多只會有一個協(xié)程正在運行。

線程的調(diào)度是由操作系統(tǒng)負責的,調(diào)度算法運行在內(nèi)核態(tài),而協(xié)程的調(diào)用是由 Go 語言的運行時負責的,調(diào)度算法運行在用戶態(tài)。
協(xié)程可以簡化為三個狀態(tài),運行態(tài)、就緒態(tài)和休眠態(tài)。同一個線程中最多只會存在一個處于運行態(tài)的協(xié)程,就緒態(tài)的協(xié)程是指那些具備了運行能力但是還沒有得到運行機會的協(xié)程,它們隨時會被調(diào)度到運行態(tài),休眠態(tài)的協(xié)程還不具備運行能力,它們是在等待某些條件的發(fā)生,比如 IO 操作的完成、睡眠時間的結(jié)束等。

操作系統(tǒng)對線程的調(diào)度是搶占式的,也就是說單個線程的死循環(huán)不會影響其它線程的執(zhí)行,每個線程的連續(xù)運行受到時間片的限制。
Go 語言運行時對協(xié)程的調(diào)度并不是搶占式的。如果單個協(xié)程通過死循環(huán)霸占了線程的執(zhí)行權(quán),那這個線程就沒有機會去運行其它協(xié)程了,你可以說這個線程假死了。不過一個進程內(nèi)部往往有多個線程,假死了一個線程沒事,全部假死了才會導(dǎo)致整個進程卡死。
每個線程都會包含多個就緒態(tài)的協(xié)程形成了一個就緒隊列,如果這個線程因為某個別協(xié)程死循環(huán)導(dǎo)致假死,那這個隊列上所有的就緒態(tài)協(xié)程是不是就沒有機會得到運行了呢?Go 語言運行時調(diào)度器采用了 work-stealing 算法,當某個線程空閑時,也就是該線程上所有的協(xié)程都在休眠(或者一個協(xié)程都沒有),它就會去其它線程的就緒隊列上去偷一些協(xié)程來運行。也就是說這些線程會主動找活干,在正常情況下,運行時會盡量平均分配工作任務(wù)。
設(shè)置線程數(shù)
默認情況下,Go 運行時會將線程數(shù)會被設(shè)置為機器 CPU 邏輯核心數(shù)。同時它內(nèi)置的 runtime 包提供了 GOMAXPROCS(n int) 函數(shù)允許我們動態(tài)調(diào)整線程數(shù),注意這個函數(shù)名字是全大寫,Go 語言的設(shè)計者就是這么任性,該函數(shù)會返回修改前的線程數(shù),如果參數(shù) n <=0 ,就不會產(chǎn)生修改效果,等價于讀操作。
package main
import "fmt"
import "runtime"
func main() {
// 讀取默認的線程數(shù)
fmt.Println(runtime.GOMAXPROCS(0))
// 設(shè)置線程數(shù)為 10
runtime.GOMAXPROCS(10)
// 讀取當前的線程數(shù)
fmt.Println(runtime.GOMAXPROCS(0))
}
--------
4
10
獲取當前的協(xié)程數(shù)量可以使用 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
協(xié)程的應(yīng)用
在日?;ヂ?lián)網(wǎng)應(yīng)用中,Go 語言的協(xié)程主要應(yīng)用在HTTP API 應(yīng)用、消息推送系統(tǒng)、聊天系統(tǒng)等。
在 HTTP API 應(yīng)用中,每一個 HTTP 請求,服務(wù)器都會單獨開辟一個協(xié)程來處理。在這個請求處理過程中,要進行很多 IO 調(diào)用,比如訪問數(shù)據(jù)庫、訪問緩存、調(diào)用外部系統(tǒng)等,協(xié)程會休眠,IO 處理完成后協(xié)程又會再次被調(diào)度運行。待請求的響應(yīng)回復(fù)完畢后,鏈接斷開,這個協(xié)程的壽命也就到此結(jié)束。
在消息推送系統(tǒng)中,客戶端的鏈接壽命很長,大部分時間這個鏈接都是空閑狀態(tài),客戶端會每隔幾十秒周期性使用心跳來告知服務(wù)器你不要斷開我。在服務(wù)器端,每一個來自客戶端鏈接的維持都需要單獨一個協(xié)程。因為消息推送系統(tǒng)維持的鏈接普遍很閑,單臺服務(wù)器往往可以輕松撐起百萬鏈接,這些維持鏈接的協(xié)程只有在推送消息或者心跳消息到來時才會變成就緒態(tài)被調(diào)度運行。
聊天系統(tǒng)也是長鏈接系統(tǒng),它內(nèi)部來往的消息要比消息推送系統(tǒng)頻繁很多,限于 CPU 和 網(wǎng)卡的壓力,它能撐住的連接數(shù)要比推送系統(tǒng)少很多。不過原理是類似的,都是一個鏈接由一個協(xié)程長期維持,連接斷開協(xié)程也就消亡。