Golang-調度器原理解析

Golang調度器原理解析

本文主要介紹調度器的由來以及golang調度器爲何要如此設計,以及GPM模型解析

一.調度器的由來

1.單進程時代

單進程時代不需要調度器,一切程序都是串行,所以單進程的操作系統會面臨這樣一個問題:

  • 程序只能串行執行,一個進程阻塞了,其他進程啥事也做不了,只能等待,會造成CPU時間的嚴重浪費

那麼能不能有多個進程一起來執行多個任務呢?
答案是可以的,後來操作系統就有了最早的併發能力:多進程併發
多進程併發:當一個進程阻塞的時候,切換到另外等待執行的進程,儘量將CPU利用起來。

2.多進程/多線程時代

多進程或多線程時代就有了調度器的需求,以多進程爲例,其會使用CPU調度器來當某個進程阻塞的時候,調度一個合適的進程給CPU。

這種方式解決了阻塞的問題,但也存在一個問題:

  • 如果進程數量很多,進程的調度會佔用CPU很多的時間(進程創建,銷燬,切換等),CPU利用率不高

對比線程,雖然其調度成本會比進程小很多,但實際上多線程程序的開發和設計也比較複雜,而且在當前互聯網業務環境下,爲每個任務都創建一個線程是不現實的,這會大量的消耗內存(進程佔用4G(32位),而線程大約也要4M)
所以,多線程/多進程時代,會面臨這樣兩個問題

  • 高內存佔用
  • 調度的高CPU消耗

但是,其實一個線程可以分爲內核態線程用戶態線程一個用戶態線程必須要綁定一個內核態線程,但是CPU並不知道用戶態線程的存在,它只知道它運行的是一個內核態線程(Linux的PCB進程控制塊)

3.協程時代

我們可以將內核線程依然叫做線程,把用戶態線程叫做協程

那麼協程和線程就有三種映射關係:

  • N : 1 : N個協程,一個線程
  • 1 : 1 : 一個協程,一個線程
  • N : M : N個協程,M個線程
    下面我們分別討論一下這三種映射關係的優點和缺點:

N : 1 關係

N個協程綁定一個線程

優點:

  • 協程在用戶態即完成切換,不會陷入到內核態,這種切換非常輕量快速

缺點:

  • 無法使用硬件的多核加速
  • 一旦協程阻塞,造成線程阻塞,本進程的其他協程就都無法執行了,根本沒有併發能力!

1 : 1 關係

一個協程綁定一個線程

優點:

  • 容易實現,不存在N比1的缺點

缺點:

  • 協程的創建,刪除,切換的代價都由CPU完成,代價有點昂貴

M : N 關係

M個協程,N個線程

克服了以上兩種模型的缺點,但是實現較爲複雜

協程和線程的調度是有區別的,線程是由CPU調度的,是搶佔式的,協程是由用戶態調度,是協作式的,一個協程讓出CPU後,才執行下一個協程

二. goroutine 和 go調度器

1. goroutine

go提供了goroutine

goroutine來自於協程的概念,讓一組可複用的函數運行在一組線程之上,即使有協程被阻塞,該線程的其他協程也可以被runtime調度,轉移到其他可運行的線程上,最關鍵的是,程序員看不到這些底層的細節,這就降低了編程的難度,提供了更容易的併發

goroutine非常的輕量,一個goroutine只佔幾KB,並且只幾KB就足夠goroutine運行完,這樣就能在有限的內存空間內,支持大量的goroutine,支持更多的併發,雖然一個goroutine的棧只有幾KB,但實際是可伸縮的,如果goroutine需要更多的空間,runtime會爲goroutine自動分配。

goroutine的特點:

  • 佔用內存很小(幾KB)
  • 調度更加靈活(由runtime調度)

2. go調度器的演變歷史

go目前使用的調度器是2012年重新設計的,因爲之前的調度器存在性能問題,我們先來研究一下廢棄的調度器,這樣才能更好的瞭解現有的調度器爲何如此設計

先行約定一下,我們採用G來表示goroutine,M來表示線程

廢棄的調度器僅有一個全局的go協程隊列,所以多個M如果要訪問此全局的G隊列,都需要加鎖,鎖的粒度會非常的大,極度的影響調度器的性能,所以我們可以總結一下,老調度器的幾個缺點:

  • 鎖競爭激烈: 每個M要需要加鎖訪問全局的G隊列
  • 延遲和額外的系統負載:比如G中創建新的協程的時候,最好是新建的協程能給當前M,而不是其他M,局部性很差
  • 系統調用(CPU在M之間切換),導致頻繁的系統阻塞和取消阻塞的操作,都增加了系統的開銷
    正是基於以上缺點的改進,GPM模型的go調度器,誕生了!

三.GPM模型的Go調度器及其設計思想

在GPM模型的go調度器中,除了M和G,又引進了P

  • G : 協程
  • P : 邏輯處理器,包含了運行goroutine的資源和可運行的G隊列
  • M : 內核線程,負責運行G

  • 全局隊列:存放等待運行的G
  • P的本地隊列:和全局隊列類似,存放的也是等待運行的G,但是存放的數量有限,不會超過256個,在G中新建G時,新建的G優先加入P的本地隊列,如果隊列滿了,則把本地隊列中一半的G移動到全局隊列
  • P列表:所有的P都在程序啓動時創建,並保證的數組中,最多有GOMAXPROCS
  • M:線程想運行G就得獲得P,從P的本地隊列獲取G,P隊列爲空時,M會嘗試從全局隊列拿一批G放到P的本地隊列,或從其他P的本地隊列偷取一般放入自己P的本地隊列

goroutine調度器和OS調度器是通過M結合起來的,每個M都代表了一個內核線程,OS調度器負責把內核線程分配到CPU的核上運行

一.go調度器的設計思想:

1.複用線程

避免頻繁的創建,銷燬線程,而是對線程進行復用

  • work stealing 機制 :當M綁定的P隊列中可運行的G時,嘗試從其他M綁定的P隊列中偷取G,而不是銷燬M
  • hand off機制 : 當M進行系統調用而阻塞時,線程釋放綁定的P

2.利用並行

GOMAXPROCS設置P的數量,最多有GOMAXPROCS個線程分佈在多個CPU上同時運行,GOMAXPROCS同時也限制了併發的程度,比如GOMAXPROCS=核數/2,則最多利用了一半的CPU核進行並行

3.協作調度

在coroutine中要等待一個協程主動讓出CPU才執行下一個協程,但是在go中,一個goroutine最多佔用CPU 10ms,防止其他的goroutine餓死,這就是goroutine不同於coroutine的一個地方

4.全局G隊列

在新的調度器中仍然有全局G隊列,但功能已經被弱化了,當M執行work stealing從其他P偷不到G時,它可以從全局G隊列獲取G

二.啓動一個goroutine的調度流程

通過上圖,我們可以得到幾個結論:

  • 通過go關鍵字來啓動一個goroutine
  • 有兩類G的存儲隊列,一個是P的局部G隊列,一個是全局G隊列,新建G會保存在P的本地G隊列中,如果P的本地G隊列滿了就會保存在全局的G隊列中
  • G只能運行在M中,一個M必須持有一個P,M與P的關係是一比一,M會從P的本地G隊列中彈出一個G來執行,如果P的本地隊列爲空,就會想衝其他的MP組合中偷取G來執行
  • 一個M調度G執行的過程是一個循環機制
  • 當M執行每一個G的時候如果發生了系統調用或阻塞操作,那麼這個M會被阻塞,如果當前有一些G在這個MP組合,runtime會吧這個M從P中摘除,然後再創建一個新的M或者尋找一個空閒的M來服務P
  • 當M的系統調用或阻塞操作結束的時候,這個G會嘗試獲取一個空閒的P,並放入到這個P的本地隊列,如果獲取不到P,則此M變成休眠狀態,加入到空閒M中,然後這個G會被放到全局的G隊列中

三.特殊的M0和G0

  • M0:M0是啓動程序後編號爲0的線程,M0負責執行初始化操作和啓動第一個G,M0對應的實例會在全局遍歷runtime.m0中
  • G0:每個M都有自己的G0,G0僅負責調度G,不執行其他任何可執行的函數,每啓動一個M,都會創建屬於此M的G0

四.有關P和M數量的問題

  • P的數量
    • 由啓動時環境變量\(GOMAXPROCS***或者***runtime***的***GOMAXPROCS***()決定,這意味着在程序執行的任意時刻都只有***\)GOMAXPROCSgoroutine在同時運行
  • M的數量
    • go語言本身的限制:go程序啓動時,會設置M的最大數量,默認10000,但是內核很難支持這麼多線程數,所以這個限制可以忽略
    • runtime/debug中serMaxThreads函數,設置M的最大數量
    • 一個M阻塞了,會創建新的M

M和P的數量沒有絕對關係,一個M阻塞,P就會去創建或者切換到另外一個M,所以,即使P的默認數量是1,也有可能會創建很多個M出來

五.P和M何時會被創建

  • P何時創建
    • 在確定P的最大數量N之後,runtime會根據這個創建N個P
  • M何時創建
    • 沒有足夠的M來關聯P,並運行其中可運行的G時,比如所有的M都阻塞住了,而P中還有很多待運行的G,就會去尋找空閒的M,沒有空閒的M就會去創建新的M

四.總結

Go調度本質是把大量的goroutine分配到少量線程上去執行,並且利用多核並行,實現強大的併發

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