1. 程式人生 > >go中的關鍵字-go(上)

go中的關鍵字-go(上)

1. goroutine的使用

  在Go語言中,表示式go f(x, y, z)會啟動一個新的goroutine執行函式f(x, y, z),建立一個併發任務單元。即go關鍵字可以用來開啟一個goroutine(協程))進行任務處理。

  建立單個goroutine

 1 package main
 2 
 3 import (
 4     "fmt"
 5 )
 6 
 7 func HelloWorld() {
 8     fmt.Println("Hello goroutine")
 9 }
10 
11 func main() {
12     go HelloWorld()      // 開啟一個新的併發執行
time.Sleep(1*time.Second) 13 fmt.Println("後輸出訊息!") 14 }

  輸出

1 Hello goroutine
2 後輸出訊息!

  這裡的sleep是必須的,否則你可能看不到goroutine裡頭的輸出,或者裡面的訊息後輸出。因為當main函式返回時,所有的gourutine都是暴力終結的,然後程式退出。

  建立多個goroutine時

 1 package main
 2 
 3 import (
 4     "fmt"
 5     "time"
 6 )
 7 
 8 func DelayPrint() {
 9     for i := 1; i <= 3; i++ {
10         time.Sleep(500 * time.Millisecond)
11         fmt.Println(i)
12     }
13 }
14 
15 func HelloWorld() {
16     fmt.Println("Hello goroutine")
17 }
18 
19 func main() {
20     go DelayPrint()     // 第一個goroutine
21     go HelloWorld()     // 第二個goroutine
22     time.Sleep(10*time.Second)
23     fmt.Println("main func")
24 }

  輸出

1 Hello  goroutine
2 1
3 2
4 3
5 4
6 
7 main func

  當去掉 DelayPrint() 函式裡的sleep之後,輸出為:

1 1
2 2
3 3
4 4
5 Hello goroutine
6 main function

  說明第二個goroutine不會因為第一個而堵塞或者等待。事實是當程式執行go FUNC()的時候,只是簡單的呼叫然後就立即返回了,並不關心函式裡頭髮生的故事情節,所以不同的goroutine直接不影響,main會繼續按順序執行語句。

goroutine阻塞

  場景一:

1 package main
2 
3 func main() {
4     ch := make(chan int)
5     <- ch // 阻塞main goroutine, 通道被鎖
6 }

  執行程式會報錯:

1 fatal error: all goroutines are asleep - deadlock!
2 
3 goroutine 1 [chan receive]:
4 main.main()

  場景二

 1 package main
 2 
 3 func main() {
 4     ch1, ch2 := make(chan int), make(chan int)
 5 
 6     go func() {
 7         ch1 <- 1 // ch1通道的資料沒有被其他goroutine讀取走,堵塞當前goroutine
 8         ch2 <- 0
 9     }()
10 
11     <- ch2 // ch2 等待資料的寫
12 }

  非緩衝通道上如果只有資料流入,而沒有流出,或者只流出無流入,都會引起阻塞。 goroutine的非緩衝通道里頭一定要一進一出,成對出現。 上面例子,一:流出無流入;二:流入無流出。

  處理方式:

  1. 讀取通道資料

 1 package main
 2 
 3 func main() {
 4     ch1, ch2 := make(chan int), make(chan int)
 5 
 6     go func() {
 7         ch1 <- 1 // ch1通道的資料沒有被其他goroutine讀取走,堵塞當前goroutine
 8         ch2 <- 0
 9     }()
10 
11     <- ch1 // 取走便是
12     <- ch2 // chb 等待資料的寫
13 }

  2. 建立緩衝通道

 1 package main
 2 
 3 func main() {
 4     ch1, ch2 := make(chan int, 3), make(chan int)
 5 
 6     go func() {
 7         ch1 <- 1 // cha通道的資料沒有被其他goroutine讀取走,堵塞當前goroutine
 8         ch2 <- 0
 9     }()
10 
11     <- ch2 // ch2 等待資料的寫
12 }

2. goroutine排程器相關結構

  goroutine的排程涉及到幾個重要的資料結構,我們先逐一介紹和分析這幾個資料結構。這些資料結構分別是結構體G,結構體M,結構體P,以及Sched結構體。前三個的定義在檔案runtime/runtime.h中,而Sched的定義在runtime/proc.c中。Go語言的排程相關實現也是在檔案proc.c中。

2.1 結構體G

  g是goroutine的縮寫,是goroutine的控制結構,是對goroutine的抽象。看下它內部主要的一些結構:

 1 type g struct {
 2    //堆疊引數。
 3      //堆疊描述了實際的堆疊記憶體:[stack.lo,stack.hi)。
 4      // stackguard0是在Go堆疊增長序言中比較的堆疊指標。
 5      //通常是stack.lo + StackGuard,但是可以通過StackPreempt觸發搶佔。
 6      // stackguard1是在C堆疊增長序言中比較的堆疊指標。
 7      //它是g0和gsignal堆疊上的stack.lo + StackGuard。
 8      //在其他goroutine堆疊上為〜0,以觸發對morestackc的呼叫(並崩潰)。
9 //當前g使用的棧空間,stack結構包括 [lo, hi]兩個成員 10 stack stack // offset known to runtime/cgo
11 // 用於檢測是否需要進行棧擴張,go程式碼使用 12 stackguard0 uintptr // offset known to liblink
13 // 用於檢測是否需要進行棧擴充套件,原生程式碼使用的 14 stackguard1 uintptr // offset known to liblink
15 // 當前g所繫結的m 16 m *m // current m; offset known to arm liblink
17 // 當前g的排程資料,當goroutine切換時,儲存當前g的上下文,用於恢復 18 sched gobuf
19 // goroutine執行的函式 20 fnstart *FuncVal 21 // g當前的狀態 22 atomicstatus uint32 23 // 當前g的id 24 goid int64
25 // 狀態Gidle,Grunnable,Grunning,Gsyscall,Gwaiting,Gdead 26 status int16
27 // 下一個g的地址,通過guintptr結構體的ptr set函式可以設定和獲取下一個g,通過這個欄位和sched.gfreeStack sched.gfreeNoStack 可以把 free g串成一個連結串列 28 schedlink guintptr
29 // 判斷g是否允許被搶佔 30 preempt bool // preemption signal, duplicates stackguard0 = stackpreempt
31 // g是否要求要回到這個M執行, 有的時候g中斷了恢復會要求使用原來的M執行 32 lockedm muintptr
33 // 用於傳遞引數,睡眠時其它goroutine設定param,喚醒時此goroutine可以獲取
param *void
34 // 建立這個goroutine的go表示式的pc 35 uintptr gopc 36 }

  其中包含了棧資訊stackbase和stackguard,有執行的函式資訊fnstart。這些就足夠成為一個可執行的單元了,只要得到CPU就可以執行。goroutine切換時,上下文資訊儲存在結構體的sched域中。goroutine切換時,上下文資訊儲存在結構體的sched域中。goroutine是輕量級的執行緒或者稱為協程,切換時並不必陷入到作業系統核心中,很輕量級。

  結構體G中的Gobuf,其實只儲存了當前棧指標,程式計數器,以及goroutine自身。

1 struct Gobuf
2 {
3     //這些欄位的偏移是libmach已知的(硬編碼的)。
4     sp   uintper;
5     pc   *byte;
6     g    *G;
7     ...
8 };

  記錄g是為了恢復當前goroutine的結構體G指標,執行時庫中使用了一個常駐的暫存器extern register G* g,這是當前goroutine的結構體G的指標。這種結構是為了快速地訪問goroutine中的資訊,比如,Go的棧的實現並沒有使用%ebp暫存器,不過這可以通過g->stackbase快速得到。"extern register"是由6c,8c等實現的一個特殊的儲存,在ARM上它是實際的暫存器。在linux系統中,對g和m使用的分別是0(GS)和4(GS)。連結器還會根據特定作業系統改變編譯器的輸出,每個連結到Go程式的C檔案都必須包含runtime.h標頭檔案,這樣C編譯器知道避免使用專用的暫存器。

2.2 結構體P

  P是Processor的縮寫。結構體P的加入是為了提高Go程式的併發度,實現更好的排程。M代表OS執行緒。P代表Go程式碼執行時需要的資源。

 1 type p struct {
 2    lock mutex
 3 
 4    id          int32
 5    // p的狀態,稍後介紹
 6    status      uint32 // one of pidle/prunning/...
 7 
 8    // 下一個p的地址,可參考 g.schedlink
 9    link        puintptr
10    // p所關聯的m
11    m           muintptr   // back-link to associated m (nil if idle)
12 
13    // 記憶體分配的時候用的,p所屬的m的mcache用的也是這個
14    mcache      *mcache
15   
16    // Cache of goroutine ids, amortizes accesses to runtime·sched.goidgen.
17    // 從sched中獲取並快取的id,避免每次分配goid都從sched分配
18      goidcache    uint64
19      goidcacheend uint64
20 
21    // Queue of runnable goroutines. Accessed without lock.
22    // p 本地的runnbale的goroutine形成的佇列
23    runqhead uint32
24    runqtail uint32
25    runq     [256]guintptr
26 
27    // runnext,如果不是nil,則是已準備好執行的G
28    //當前的G,並且應該在下一個而不是其中執行
29    // runq,如果執行G的時間還剩時間
30    //切片。它將繼承當前時間剩餘的時間
31    //切片。如果一組goroutine鎖定在
32    //交流等待模式,該計劃將其設定為
33    //單位並消除(可能很大)排程
34    //否則會由於新增就緒商品而引起的延遲
35    // goroutines到執行佇列的末尾。
36 
37    // 下一個執行的g,如果是nil,則從佇列中獲取下一個執行的g
38    runnext guintptr
39 
40    // Available G's (status == Gdead)
41    // 狀態為 Gdead的g的列表,可以進行復用
42    gfree    *g
43    gfreecnt int32
44 }

  跟G不同的是,P不存在waiting狀態。MCache被移到了P中,但是在結構體M中也還保留著。在P中有一個Grunnable的goroutine佇列,這是一個P的區域性佇列。當P執行Go程式碼時,它會優先從自己的這個區域性佇列中取,這時可以不用加鎖,提高了併發度。如果發現這個佇列空了,則去其它P的佇列中拿一半過來,這樣實現工作流竊取的排程。這種情況下是需要給呼叫器加鎖的。

2.3 結構體M

  M是machine的縮寫,是對機器的抽象,每個m都是對應到一條作業系統的物理執行緒。

 1 type m struct {
 2      // g0是用於排程和執行系統呼叫的特殊g
 3    g0      *g             // goroutine with scheduling stack
 4      // m當前執行的g
 5    curg    *g             // current running goroutine
 6    // 當前擁有的p
 7    p        puintptr      // attached p for executing go code (nil if not executing go code)
8 // 執行緒的 local storage 9 tls [6]uintptr // thread-local storage 10 // 喚醒m時,m會擁有這個p 11 nextp puintptr 12 id int64 13 // 如果 !="", 繼續執行curg 14 preemptoff string // if != "", keep curg running on this m
15 // 自旋狀態,用於判斷m是否工作已結束,並尋找g進行工作 16 spinning bool // m is out of work and is actively looking for work
17 // 用於判斷m是否進行休眠狀態 18 blocked bool // m is blocked on a note 19 // m休眠和喚醒通過這個,note裡面有一個成員key,對這個key所指向的地址進行值的修改,進而達到喚醒和休眠的目的 20 park note
21 // 所有m組成的一個連結串列 22 alllink *m // on allm 23 // 下一個m,通過這個欄位和sched.midle 可以串成一個m的空閒連結串列 24 schedlink muintptr 25 // mcache,m擁有p的時候,會把自己的mcache給p 26 mcache *mcache 27 // lockedm的對應值 28 lockedg guintptr 29 // 待釋放的m的list,通過sched.freem 串成一個連結串列 30 freelink *m // on sched.freem 31 }

  和G類似,M中也有alllink域將所有的M放在allm連結串列中。lockedg是某些情況下,G鎖定在這個M中執行而不會切換到其它M中去。M中還有一個MCache,是當前M的記憶體的快取。M也和G一樣有一個常駐暫存器變數,代表當前的M。同時存在多個M,表示同時存在多個物理執行緒。

2.4 Sched結構體

  Sched是排程實現中使用的資料結構,該結構體的定義在檔案proc.c中。

 1 type schedt struct {
 2    // 全域性的go id分配
 3    goidgen  uint64
 4    // 記錄的最後一次從i/o中查詢g的時間
 5    lastpoll uint64
 6 
 7    lock mutex
 8 
 9    //當增加nmidle,nmidlelocked,nmsys或nmfreed時,應
10    //確保呼叫checkdead()。
11 
12      // m的空閒連結串列,結合m.schedlink 就可以組成一個空閒連結串列了
13    midle        muintptr // idle m's waiting for work
14    nmidle       int32    // number of idle m's waiting for work
15    nmidlelocked int32    // number of locked m's waiting for work
16    // 下一個m的id,也用來記錄建立的m數量
17    mnext        int64    // number of m's that have been created and next M ID
18    // 最多允許的m的數量
19    maxmcount    int32    // maximum number of m's allowed (or die)
20    nmsys        int32    // number of system m's not counted for deadlock
21    // free掉的m的數量,exit的m的數量
22    nmfreed      int64    // cumulative number of freed m's
23 
24    ngsys uint32 // 系統goroutine的數量;原子更新
25 
26    pidle      puintptr // 閒置的
27    npidle     uint32
28    nmspinning uint32 // See "Worker thread parking/unparking" comment in proc.go.
29 
30    // Global runnable queue.
31    // 這個就是全域性的g的隊列了,如果p的本地佇列沒有g或者太多,會跟全域性佇列進行平衡
32    // 根據runqhead可以獲取佇列頭的g,然後根據g.schedlink 獲取下一個,從而形成了一個連結串列
33    runqhead guintptr
34    runqtail guintptr
35    runqsize int32
36 
37    // freem是m等待被釋放時的列表
38    //設定了m.exited。通過m.freelink連結。
39 
40    // 等待釋放的m的列表
41    freem *m
42 }

  大多數需要的資訊都已放在了結構體M、G和P中,Sched結構體只是一個殼。可以看到,其中有M的idle佇列,P的idle佇列,以及一個全域性的就緒的G佇列。Sched結構體中的Lock是非常必須的,如果M或P等做一些非區域性的操作,它們一般需要先鎖住排程器。

3. G、P、M相關狀態

g.status

  • _Gidle: goroutine剛剛建立還沒有初始化
  • _Grunnable: goroutine處於執行佇列中,但是還沒有執行,沒有自己的棧
  • _Grunning: 這個狀態的g可能處於執行使用者程式碼的過程中,擁有自己的m和p
  • _Gsyscall: 執行systemcall中
  • _Gwaiting: 這個狀態的goroutine正在阻塞中,類似於等待channel
  • _Gdead: 這個狀態的g沒有被使用,有可能是剛剛退出,也有可能是正在初始化中
  • _Gcopystack: 表示g當前的棧正在被移除,新棧分配中

p.status

  • _Pidle: 空閒狀態,此時p不繫結m
  • _Prunning: m獲取到p的時候,p的狀態就是這個狀態了,然後m可以使用這個p的資源執行g
  • _Psyscall: 當go呼叫原生程式碼,原生程式碼又反過來呼叫go的時候,使用的p就會變成此態
  • _Pdead: 當執行中,需要減少p的數量時,被減掉的p的狀態就是這個了

m.status

m的status沒有p、g的那麼明確,但是在執行流程的分析中,主要有以下幾個狀態

  • 執行中: 拿到p,執行g的過程中
  • 執行原生程式碼: 正在執行原聲程式碼或者阻塞的syscall
  • 休眠中: m發現無待執行的g時,進入休眠,並加入到空閒列表中
  • 自旋中(spining): 當前工作結束,正在尋找下一個待執行的g

4. G、P、M的排程關係

  一個G就是一個gorountine,儲存了協程的棧、程式計數器以及它所在M的資訊。P全稱是Processor,處理器,它的主要用途就是用來執行goroutine的。M代表核心級執行緒,一個M就是一個執行緒,goroutine就是跑在M之上的。程式啟動時,會建立一個主G,而每使用一次go關鍵字也建立一個G。go func()建立一個新的G後,放到P的本地佇列裡,或者平衡到全域性佇列,然後檢查是否有可用的M,然後喚醒或新建一個M,M獲取待執行的G和空閒的P,將呼叫引數儲存到g的棧,將sp,pc等上下文環境儲存在g的sched域,這樣整個goroutine就準備好了,只要等分配到CPU,它就可以繼續執行,之後再清理現場,重新進入排程迴圈。

 

4.1 排程實現

  圖中有兩個物理執行緒,M0、M1每一個M都擁有一個處理器P,每一個P都有一個正在執行的G。P的數量可以通過GOMAXPROCS()來設定,它其實也代表了真正的併發度,即有多少個goroutine可以同時執行。圖中灰色goroutine都是處於ready的就緒態,正在等待被排程。由P維護這個就緒佇列(runqueue),go function每啟動一個goroutine,runqueue佇列就在其末尾加入一個goroutine,在下一個排程點,就從runqueue中取出一個goroutine執行。

  當一個OS執行緒M0陷入阻塞時,P轉而在M1上執行G,圖中的M1可能是正被建立,或者從執行緒快取中取出。當MO返回時,它嘗試取得一個P來執行goroutine,一般情況下,它會從其他的OS執行緒那裡拿一個P過來執行,像M1獲取P一樣;如果沒有拿到的話,它就把goroutine放在一個global runqueue(全域性執行佇列)裡,然後自己睡眠(放入執行緒快取裡)。所有的P會週期性的檢查全域性佇列並執行其中的goroutine,否則其上的goroutine永遠無法執行。

  另一種情況是P上的任務G很快就執行完了(分配不均),這個處理器P很忙,但是其他的P還有任務,此時如果global runqueue也沒有G了,那麼P就會從其他的P裡拿一些G來執行。一般來說,如果一般就拿run queue的一半,這就確保了每個OS執行緒都能充分的使用。

  golang採用了m:n執行緒模型,即m個gorountine(簡稱為G)對映到n個使用者態程序(簡稱為P)上,多個G對應一個P,一個P對應一個核心執行緒(簡稱為M)。     P的數量:由啟動時環境變數$GOMAXPROCS或者是由runtime的方法GOMAXPROCS()決定(預設是1)。這意味著在程式執行的任意時刻都只有$GOMAXPROCS個goroutine在同時執行。在確定了P的最大數量n後,執行時系統會根據這個數量建立n個P。

   M的數量:go語言本身的限制:go程式啟動時,會設定M的最大數量,預設10000.但是核心很難支援這麼多的執行緒數,所以這個限制可以忽略。runtime/debug中的SetMaxThreads函式,設定M的最大數量。一個M阻塞了,會建立新的M。

M與P的數量沒有絕對關係,一個M阻塞,P就會去建立或者切換另一個M,所以,即使P的預設數量是1,也有可能會建立很多個M出來。

 

  P上G的排程:如果一個G不主動讓出cpu或被動block,所屬P中的其他G會一直等待順序執行。

  一個G執行IO時可能會進入waiting狀態,主動讓出CPU,此時會被移到所屬P中的其他G後面,等待下一次輪到執行。   一個G呼叫了runtime.Gosched()會進入runnable狀態,主動讓出CPU,並被放到全域性等待佇列中。   一個G呼叫了runtime.Goexit(),該G將會被立即終止,然後把已載入的defer(有點類似析構)依次執行完。   一個G呼叫了允許block的syscall,此時G及其對應的P、其他G和M都會被block起來,監控執行緒M會定時掃描所有P,一旦發現某個P處於block syscall狀態,則通知排程器讓另一個M來帶走P(這裡的另一個M可能是新建立的,因此隨著G被不斷block,M數量會不斷增加,最終M數量可能會超過P數量),這樣P及其餘下的G就不會被block了,等被block的M返回時發現自己的P沒有了,也就不能再處理G了,於是將G放入全域性等待佇列等待空閒P接管,然後M自己sleep。 通過實驗,當一個G運行了很久(比如進入死迴圈),會被自動切到其他CPU核,可能是因為超過時間片後G被移到全域性等待佇列中,後面被其他CPU核上的M處理。

  M上P和G的排程:每當一個G要開始執行時,排程器判斷當前M的數量是否可以很好處理完G:如果M少G多且有空閒P,則新建M或喚醒一個sleep M,並指定使用某個空閒P;如果M應付得來,G被負載均衡放入一個現有P+M中。

  當M處理完其身上的所有G後,會再去全域性等待佇列中找G,如果沒有就從其他P中分一半的G(以便保證各個M處理G的負載大致相等),如果還沒有,M就去sleep了,對應的P變為空閒P。 在M進入sleep期間,排程器可能會給其P不斷放入G,等M醒後(比如超時):如果G數量不多,則M直接處理這些G;如果M覺得G太多且有空閒P,會先主動喚醒其他sleep的M來分擔G,如果沒有其他sleep的M,排程器建立新M來分擔。

協程特點

  協程擁有自己的暫存器上下文和棧。協程排程切換時,將暫存器上下文和棧儲存到其他地方,在切回來的時候,恢復先前儲存的暫存器上下文和棧。因此,協程能保留上一次呼叫時的狀態(即所有區域性狀態的一個特定組合),每次過程重入時,就相當於進入上一次呼叫的狀態,換種說法:進入上一次離開時所處邏輯流的位置。執行緒和程序的操作是由程式觸發系統介面,最後的執行者是系統;協程的操作執行者則是使用者自身程式,goroutine也是協程。

&n