天天看點

go排程器的源碼級分析

基于GMP模型的排程器是go實作其引以為傲的使用者态線程的核心。本文就以GMP排程器為核心分析一下排程流程,順便分析一下定時器Timer的實作,它和排程器息息相關。

本文的大綱如下:

1.GMP的關鍵資料結構

2.goroutine的生命周期

3.系統線程的生命周期

4.觸發shedule()的時機

5.網絡輪詢netpoll

6.sysmon守護線程

7.定時器實作

排程器相關的資料結構都在runtime/runtime2.go中,主要有g(goroutine,協程),m(machine,作業系統線程),p(processor,排程器的資源抽象,m争搶p來擷取執行權),schedt(scheduler_type,排程器的全局抽象)

以下都是隻給出一些關鍵字段,實際上每個完整資料結構都有幾十個字段!

stack: 協程棧的上下界,由lo和hi兩個字段組成

_panic: 協程的panic連結清單,頭部的是最内層的panic(也就是外層的panic可能會被abort)

_defer: 協程的defer連結清單,頭部的是最内層defer。不過本文不關心defer和panic,隻是想說明defer和panic都是goroutine級别的 

m: 目前g對應的m

sched: 記錄了協程的運作時狀态,内部包含sp、pc、bp等僞寄存器

atomicstatus: 協程的狀态機,共有近十種狀态,如idle,dead,runnable,running,syscall,waiting,preempted等

g0: 運作在系統棧上的協程,即負責排程協程的協程

gsignal: 負責響應信号的協程。由上可知一個空閑的m也自帶兩個g

curg: m目前運作的g,可能為空表示空閑

p: m目前綁定的p。要運作使用者代碼,必須綁定上一個p。一般p遠少于m。

nextp: m剛剛建立或從阻塞轉為就緒時,預先綁定的p。真正運作時,就調用acquirep綁定這個p

oldp: m陷入系統調用時會出讓p,這裡儲存這個p,系統調用傳回時試圖恢複這個p。這是希望利用局部性。

spinning: 是否自旋。如果目前的m是自旋的,而又從本地隊列runq和全局隊列globalrunq都擷取不到可運作的g時,就會從其他p的本地隊列嘗試竊取g

status: p的狀态機

m: p的m。與m的p雙向綁定

deferpool: _defer結構體的per-P緩存池。在deferproc建立新_defer時(本文不分析,詳見runtime/panic.go)會優先從中配置設定記憶體,按記憶體大小分為五個連結清單。這也是利用局部性

runq: 目前p的可運作g隊列

gFree: 目前p的空閑g隊列(即狀态為dead的g。g執行完畢後不會銷毀,而是等待被複用)

sudogcache: 建立sudog的緩存池,作用同deferpool。sudog代表一個阻塞狀态的g,其記錄了g所在的阻塞隊列等資訊,本文不介紹。

timers,timer0When: timers儲存了目前p全部定時器,timer0When儲存了所有定時器中最近的即将到達的時間。本文會介紹定時器的實作

可見,schedt的這些字段部分是狀态統計資訊,一些(最後4個)是p中的某些結構的全局版本。

以及第一個lastpoll是上一次進行網絡輪詢(netpoll)的時間。在講定時器時會說到

對于使用者來說,GMP排程器中距離自己最近的當然是g。那麼當我們輕輕松松地敲下go來啟動一個g時,golang為我們做了什麼呢?

在編譯階段,編譯器會把go關鍵字轉換成一個runtime.newproc調用。newproc有兩個參數,初始棧大小和go出來的函數指針,這兩個都是在編譯時就能确定的。接下來詳細看一下newproc

注意!本文的代碼都是高度簡化版,源代碼太複雜了,作者太強了,頂禮膜拜!

 先看newproc1具體怎麼初始化g

*1 所有新建立的g的pc都從goexit這個ABI(Application Binary Interface)函數所在的位置開始。這樣做等效于:當這個g執行完畢時,就會傳回到goexit,執行goexit函數。goexit在本節的最後講

現在g已經加入p的本地隊列了,接下來就是等待被排程了!觸發排程的方式有多種,之後會講到。這邊先說明一下,排程的主函數是schedule()函數。它的作用是進行一輪排程:嘗試擷取一個g,并執行它

findrunnable是一個非常複雜的方法,它阻塞直到擷取到一個可運作的g。期間還會順便做一下網絡輪詢(之後會說)。工作竊取(work-stealing)也是在這個函數裡完成的

上面标有***代碼是關鍵,完成了阻塞擷取g的邏輯。netpoll和定時器部分後面會講到

execute是goroutine的真正入口,它的核心邏輯非常簡單,就是調用gogo函數,定位到g.sched中儲存的運作位置

gogo是一個彙編(go assembly,和普通彙編略有不同)實作的函數,用g.sched裡儲存的pc、sp等寄存器資訊進行跳轉

 現在終于開始執行使用者代碼了!但是這個g依然不能高枕無憂了!有多種方式可以讓這個g失去執行權,即讓g.m與p解綁,之後會讨論。現在先假設g順利執行完了,那就會按之前說的,調用goexit

goexit也是個彙編函數,但邏輯比較簡單,總之最後調用到goexit0函數

可以看出,g退出時會先進行一些清理工作,最終會再次調用schedule(),開始新一輪排程!這就是排程器能持續不斷地運作的原因

此外,執行完畢的g不會被銷毀,而是加入了全局的空閑隊列。還記得這個空閑隊列哪裡用到嗎?newproc建立g的時候!以此實作g的複用

系統線程(m)是g的運作載體,一個m隻能在同一時刻運作一個g。m為了擷取代碼執行權必須綁定一個p。如果m不在執行使用者代碼(如進行系統調用),則和p解綁。

啟動一個m的入口是startm函數。

以下總結了調用startm的時機:

1. 有新的g可用時。一般是調用newproc和ready(标記一個g為就緒)函數時。前面提到newproc會調用wakep,其中就調的startm,第一個參數為nil,也就是不指定p,而是讓m等待空閑的p

2. p移交所有權時。典型的情況就是sysmon守護線程(後面會講)發現一個p處于syscall狀态的時間太長了,就會調用handoffp函數将它的所有權移交,具體在後面講sysmon的時候會說。其中也會調startm,第一個參數就是這個p本身,表示讓m和這個p綁定,也就是p的所有者被移交(handoff)了。

startm中提到,如果在空閑m清單中擷取不到m,就會調用newm建立一個。

allocm配置設定了一個m的空間,進行一些初始化,但還沒有和任何系統線程綁定

newm1将m真正和系統線程綁定

在linux環境下,newosproc會使用clone系統調用建立新線程

clone的第四個參數指定線程的入口,是mstart函數的位址,于是目光轉向mstart函數,它是真正建立系統線程的入口

mstart先是進行一些棧的初始化,最終進入mstart1函數

  

mstart1将之前設定的nextp與m綁定,使m終于獲得了代碼執行權!并且立即進入下一輪排程schedule()

注意,這個函數一開始還在g0.sched中儲存了運作狀态。别忘了schedule()會跳轉到下一個g的pc而不會傳回,是以,為了能讓mstart1傳回,必須有類似gogo(&_g_.m.g0.sched)的調用,跳轉到mstart1的調用者,才能傳回。傳回後,進入mexit函數銷毀線程。

mexit函數主要就是調用handoffp函數,讓即将銷毀的m的p轉交所有權。然後調用一些系統調用來摧毀線程,沒什麼好說的。

那麼什麼時候會有gogo(&_g_.m.g0.sched)的調用呢?我看了半天隻找到一處,就是在某個g退出時(調用goexit),會檢查這個g有沒有鎖定(鎖定,一般指的是調用公有函數runtime.LockOsThread,使目前g獨占某個作業系統線程)過目前m,有的話就會調用這個函數。換句話說,如果g鎖定了所在的m,那麼就認為這個m不幹淨了,等g結束後就會跳轉到g0.sched進而銷毀m。這個g真是坑爹啊!正常情況下,m是不會被銷毀的。

最後,在startm中提到會現在空閑m清單中擷取m,擷取不到再進入newm。這個空閑m清單,其實就是儲存了休眠狀态下的m。觸發垃圾回收時目前m會休眠。此外,當g從系統調用中傳回時,g.m會尋找可用的p以繼續執行代碼,如果沒有可用的p,也隻能進入休眠了。

之前看到,在啟動g或m的時候會立即開始一輪排程。除此之外,觸發排程還有一些時機

主要是運作态的g主動成為阻塞态或就緒态。

成為阻塞态就是調用gopark函數挂起自身。gopark函數最終調用park_m函數

成為就緒态就是調用公有函數runtime.GoSched()出讓目前g的執行權,有點像java裡的Thread.yield()。GoSched最終調用goschedImpl函數

兩者最終都調用schedule()開啟新一輪排程

在進行系統調用時,編譯器會在這個系統調用前後插入reentersyscall和exitsyscall兩個鈎子函數,在這兩個函數裡進行GMP狀态的儲存和恢複

reentersyscall中在解綁m與p時,會将p儲存到m.oldp中,在exitsyscall中嘗試恢複p,這樣做是希望利用局部性。

exitsyscall0是慢路徑,因為它由mcall調用,會造成協程切換(g切換到g0)。如果最終擷取不到p,就使g加入全局隊列,m休眠,開啟新一輪排程。

這個留在後面和sysmon一起說

netpoll是go對多路複用的實作。在linux系統上,主要依賴epoll系列系統調用。

在排程過程中,主要是在finerunnable函數中進行netpoll, 傳回一組就緒的g

netpoll接口需要實作netpollinit, netpoll, netpollBreak, netpollopen, netpollclose, netpollisPollDescriptor六個函數。與本文主題相關的是前三個

看到這幾個函數我不禁留下了感動的眼淚,太熟悉了,就是epoll的套皮。

epollinit先調用epoll_create,再建立epollevent結構體,再建立一個管道進行讀方向的監聽

netpoll根據傳入的參數不同有阻塞、非阻塞、逾時阻塞三種模式。調用epoll_wait傳回就緒事件,并用netpollready工具函數把這些事件轉換成g連結清單後傳回

netpollBreak向管道中寫入内容,導緻管道的讀端事件被觸發,這可以中止netpoll的阻塞狀态。

是以,現在可以回顧以下findrunnable中所做的關于網絡輪詢的事情:

1.調用checkTimers運作已經到達的定時器,如果沒有定時器到達,就傳回一個pollUntil表示下一個定時器的到達時間。如果沒有下一個定時器則傳回0

2.調用非阻塞的netpoll先嘗試擷取一波g,這些g可能是之前執行了read或connect等調用而進入阻塞

3.所有能做的事都做完了之後就進行阻塞的netpoll調用。如果pollUntil不為0,則進行帶逾時的netpoll調用,使得屆時能從netpoll中及時傳回以響應定時器到達事件

sysmon(system monitor)守護線程有點像linux守護程序或者mysql背景線程,總之都是打雜的。sysmon和其他守護線程一樣,也是在一個大循環裡進行輪詢監控,并對一些異常事件進行處理。

sysmon随着全局g0的啟動而啟動。

全局g0在main函數中通過newm建立了一個不依賴p的m(第二個參數為nil),并把sysmon函數作為m的底層線程的入口,即建立了sysmon守護線程。

sysmon也會想偷懶,沒什麼活幹就休息了,詳見上面的注釋

sysmon()函數很長,但功能劃分得很清楚,主要就四個部分:檢查死鎖、網絡輪詢兜底、對p發起搶占、垃圾收集兜底

核心邏輯是checkdead函數。去除了cgo相關的邏輯,還是蠻簡單的

和findrunnable裡的邏輯幾乎一樣。作用是每10ms會進行一次netpoll兜底,将就緒的g注入隊列

核心邏輯是retake函數。

preemptone(p)主要是依賴preemptM系統調用,讓目前的g停止在p上運作

handoffp(p)通過調用startm函數來啟動一個m與p綁定。handoffp秉持着盡量啟動m的原則,如果實在太閑了,則不會啟動m,而是将p加入空閑清單

如果retake沒能搶占任何p,則sysmon的idle自增,表明又白跑一輪。

這不是本文的内容,簡單帶過一下就是每120秒或觸發兜底的ix強制垃圾回收,沒什麼好說的

最後講一下定時器的實作,在前面的小節裡也出現過很多次了。相比GMP,定時器子產品就比較簡單了

定時器的資料結構是timer,主要字段如下

pp: 指向定時器所在的p。回憶一下p結構體裡也有一個[]*timer儲存了所有定時器

when: 下次觸發時間

period: 周期定時器(Ticker)的周期

f,arg: 定時器到達的回調函數及其參數

seq: 觸發序号

p結構體裡的timers其實不是一個簡單的切片,而是一個小頂四叉堆(4-ary heap,二叉堆的擴充,也是用數組表示樹形結構),堆頂元素就是when最小的,也就是最快到達的定時器。

不信的話看看這段源碼:

一行沒改!這就是一個經典的堆實作,隻不過是四叉的,取父親下标要除以4。這是向上篩選的函數,向下篩選的函數類似,不展示了

調用addtimer新增定時器,最終進入doaddtimer函數

定時器的觸發被放在排程器的排程周期裡進行,觸發定時器的入口是checkTimers(在schedule()函數内,之前展示過)

如果g的計算量較大,執行時間很長,或者進行頻繁大量的系統調用,schedule函數的主動觸發頻率就會降低,不得不依靠sysmon線程的搶占式排程。在最壞情況下,排程周期可達到10ms,這也就是定時器的最高保證精度。

checkTimers在成功擷取到已到達的定時器後,最終會走進runOneTimer

實際上定時器比這更加複雜,它也有複雜的狀态機,但是和本文主題相差太遠,就在此略過了。