Go 併發程式設計:Goroutine 如何排程的?
本文從巨集觀角度介紹了一下Go排程器的排程過程。
上篇文章回顧: ofollow,noindex">Etcd+confd通過Nginx對後端服務的註冊發現
前言
隨著伺服器硬體迭代升級,配置也越來越高。為充分利用伺服器資源,併發程式設計也變的越來越重要。在開始之前,需要了解一下併發(concurrency)和並行(parallesim)的區別。
併發: 邏輯上具有處理多個同時性任務的能力。
並行: 物理上同一時刻執行多個併發任務。
通常所說的併發程式設計,也就是說它允許多個任務同時執行,但實際上並不一定在同一時刻被執行。在單核處理器上,通過多執行緒共享CPU時間片序列執行(併發非並行)。而並行則依賴於多核處理器等物理資源,讓多個任務可以實現並行執行(併發且並行)。
多執行緒或多程序是並行的基本條件,但單執行緒也可以用協程(coroutine)做到併發。簡單將Goroutine歸納為協程並不合適,因為它執行時會建立多個執行緒來執行併發任務,且任務單元可被排程到其它執行緒執行。這更像是多執行緒和協程的結合體,能最大限度提升執行效率,發揮多核處理器能力。
Go編寫一個併發程式設計程式很簡單,只需要在函式之前使用一個Go關鍵字就可以實現併發程式設計。
func main() {go func(){ fmt.Println("Hello,World!") }() }
Go排程器組成
Go語言雖然使用一個 Go 關鍵字即可實現併發程式設計,但Goroutine被排程到後端之後,具體的實現比較複雜。先看看排程器有哪幾部分組成。
1、G
G是 Go routine的縮寫,相當於作業系統中的程序控制塊,在這裡就是Goroutine的控制結構,是對Goroutine的抽象。其中包括執行的函式指令及引數;G儲存的任務物件;執行緒上下文切換,現場保護和現場恢復需要的暫存器(SP、IP)等資訊。
Go不同版本Goroutine預設棧大小不同。
// Go1.11版本預設stack大小為2KB _StackMin = 2048 // 建立一個g物件,然後放到g佇列 // 等待被執行 func newproc1(fn *funcval, argp *uint8, narg int32, callergp *g, callerpc uintptr) { _g_ := getg() _g_.m.locks++ siz := narg siz = (siz + 7) &^ 7 _p_ := _g_.m.p.ptr() newg := gfget(_p_) if newg == nil { // 初始化g stack大小 newg = malg(_StackMin) casgstatus(newg, _Gidle, _Gdead) allgadd(newg) } // 以下省略}
2、M
M是一個執行緒或稱為Machine,所有M是有執行緒棧的。如果不對該執行緒棧提供記憶體的話,系統會給該執行緒棧提供記憶體(不同作業系統提供的執行緒棧大小不同)。當指定了執行緒棧,則M.stack→G.stack,M的PC暫存器指向G提供的函式,然後去執行。
type m struct { /* 1.所有呼叫棧的Goroutine,這是一個比較特殊的Goroutine。 2.普通的Goroutine棧是在Heap分配的可增長的stack,而g0的stack是M對應的執行緒棧。 3.所有排程相關程式碼,會先切換到該Goroutine的棧再執行。 */ g0*g curg*g// M當前繫結的結構體G // SP、PC暫存器用於現場保護和現場恢復 vdsoSP uintptr vdsoPC uintptr // 省略…}
3、P
P(Processor)是一個抽象的概念,並不是真正的物理CPU。所以當P有任務時需要建立或者喚醒一個系統執行緒來執行它佇列裡的任務。所以P/M需要進行繫結,構成一個執行單元。
P決定了同時可以併發任務的數量,可通過GOMAXPROCS限制同時執行使用者級任務的作業系統執行緒。可以通過runtime.GOMAXPROCS進行指定。在Go1.5之後GOMAXPROCS被預設設定可用的核數,而之前則預設為1。
// 自定義設定GOMAXPROCS數量 func GOMAXPROCS(n int) int { /* 1.GOMAXPROCS設定可執行的CPU的最大數量,同時返回之前的設定。 2.如果n < 1,則不更改當前的值。 */ ret := int(gomaxprocs) stopTheWorld("GOMAXPROCS") // startTheWorld啟動時,使用newprocs。 newprocs = int32(n) startTheWorld() return ret }
// 預設P被繫結到所有CPU核上 // P == cpu.cores func getproccount() int32 { const maxCPUs = 64 * 1024 var buf [maxCPUs / 8]byte // 獲取CPU Core r := sched_getaffinity(0, unsafe.Sizeof(buf), &buf[0]) n := int32(0) for _, v := range buf[:r] { for v != 0 { n += int32(v & 1) v >>= 1 } } if n == 0 { n = 1 } return n } // 一個程序預設被繫結在所有CPU核上,返回所有CPU core。 // 獲取程序的CPU親和性掩碼系統呼叫 // rax 204; 系統呼叫碼 // system_call sys_sched_getaffinity; 系統呼叫名稱 // ridpid; 程序號 // rsi unsigned int len // rdx unsigned long *user_mask_ptr sys_linux_amd64.s: TEXT runtime·sched_getaffinity(SB),NOSPLIT,$0 MOVQpid+0(FP), DI MOVQlen+8(FP), SI MOVQbuf+16(FP), DX MOVL$SYS_sched_getaffinity, AX SYSCALL MOVLAX, ret+24(FP) RET
Go排程器排程過程
首先建立一個G物件,G物件儲存到P本地佇列或者是全域性佇列。P此時去喚醒一個M。P繼續執行它的執行序。M尋找是否有空閒的P,如果有則將該G物件移動到它本身。接下來M執行一個排程迴圈(呼叫G物件->執行->清理執行緒→繼續找新的Goroutine執行)。
M執行過程中,隨時會發生上下文切換。當發生上線文切換時,需要對執行現場進行保護,以便下次被排程執行時進行現場恢復。Go排程器M的棧儲存在G物件上,只需要將M所需要的暫存器(SP、PC等)儲存到G物件上就可以實現現場保護。當這些暫存器資料被保護起來,就隨時可以做上下文切換了,在中斷之前把現場儲存起來。如果此時G任務還沒有執行完,M可以將任務重新丟到P的任務佇列,等待下一次被排程執行。當再次被排程執行時,M通過訪問G的vdsoSP、vdsoPC暫存器進行現場恢復(從上次中斷位置繼續執行)。
1、P 佇列 通過上圖可以發現,P有兩種佇列:本地佇列和全域性佇列。
-
本地佇列: 當前P的佇列,本地佇列是Lock-Free,沒有資料競爭問題,無需加鎖處理,可以提升處理速度。
-
全域性佇列: 全域性佇列為了保證多個P之間任務的平衡。所有M共享P全域性佇列,為保證資料競爭問題,需要加鎖處理。相比本地佇列處理速度要低於全域性佇列。
2、 上線文切換
簡單理解為當時的環境即可,環境可以包括當時程式狀態以及變數狀態。例如執行緒切換的時候在核心會發生上下文切換,這裡的上下文就包括了當時暫存器的值,把暫存器的值儲存起來,等下次該執行緒又得到cpu時間的時候再恢復暫存器的值,這樣執行緒才能正確執行。
對於程式碼中某個值說,上下文是指這個值所在的區域性(全域性)作用域物件。相對於程序而言,上下文就是程序執行時的環境,具體來說就是各個變數和資料,包括所有的暫存器變數、程序開啟的檔案、記憶體(堆疊)資訊等。
3、執行緒清理 Goroutine被排程執行必須保證P/M進行繫結,所以執行緒清理只需要將P釋放就可以實現執行緒的清理。什麼時候P會釋放,保證其它G可以被執行。P被釋放主要有兩種情況。
-
主動釋放: 最典型的例子是,當執行G任務時有系統呼叫,當發生系統呼叫時M會處於Block狀態。排程器會設定一個超時時間,當超時時會將P釋放。
-
被動釋放: 如果發生系統呼叫,有一個專門監控程式,進行掃描當前處於阻塞的P/M組合。當超過系統程式設定的超時時間,會自動將P資源搶走。去執行佇列的其它G任務。
總結
本文從巨集觀角度介紹了一下Go排程器的排程過程。Go排程器也是Go語言最精華的部分,希望對大家有所幫助。