(2)千軍萬馬跑協程goroutine

參考:https://cloud.tencent.com/developer/article/1375753

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

       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,等待子協程們執行完畢。如果將睡眠的這行代碼去掉,將會看不到子協程運行的痕跡。

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
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")
}

輸出結果:

panic: wtf

goroutine 3 [running]:
main.main.func1.1.1()
	/Users/rongsong/Develop/go/my_test/go-func/go_func.go:14 +0x96
created by main.main.func1.1
	/Users/rongsong/Develop/go/my_test/go-func/go_func.go:12 +0x92
run in main goroutine
run in child goroutine
run in grand child goroutine
run in grand grand child goroutine

我們看到主協程最後一句打印語句沒能運行就掛掉了,主協程在異常退出時會打印堆棧信息。從堆棧信息中可以瞭解到是哪行代碼引發了程序崩潰。

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

go func() {
  if err := recover(); err != nil {
    // log error
  }
  // do something
}()

比如:

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() {
				if err := recover(); err != nil {
					// log error
					panic("wtf")
					fmt.Println("error happend in grand child")
				}
				// do something
				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

啓動百萬協程

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 鍾醒過來一次。在我的個人電腦上,這個程序瞬間創建了 200w 個協程,觀察發現內存佔用在 4G 多,這意味着每個協程的內存佔用大概 2000 多字節。協程還在繼續創建,電腦開始變的卡頓,應該是程序開始使用交換分區,CPU 佔用率持續走高。再繼續壓榨下去已經沒有了意義。

協程死循環

前面我們通過 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 語言的運行時負責的,調度算法運行在用戶態。

協程可以簡化爲三個狀態,運行態、就緒態和休眠態。同一個線程中最多隻會存在一個處於運行態的協程,就緒態的協程是指那些具備了運行能力但是還沒有得到運行機會的協程,它們隨時會被調度到運行態,休眠態的協程還不具備運行能力,它們是在等待某些條件的發生,比如 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))
}

獲取當前的協程數量可以使用 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())
}

協程的應用

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

在 HTTP API 應用中,每一個 HTTP 請求,服務器都會單獨開闢一個協程來處理。在這個請求處理過程中,要進行很多 IO 調用,比如訪問數據庫、訪問緩存、調用外部系統等,協程會休眠,IO 處理完成後協程又會再次被調度運行。待請求的響應回覆完畢後,鏈接斷開,這個協程的壽命也就到此結束。

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

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

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