ps:這是我19年的寫的總結,編輯成了pdf,當時用了很多截圖,導致沒法復制源碼,原來注釋的代碼也找不到了,只能將就了。
一、匯編代碼分析
本文將剖析,下面這段go語言代碼運行的背后邏輯,主要從goroutine的調度層面,深度挖掘其背后的運
行邏輯。以go 1.5.1的源代碼為分析:
1.1 go源代碼
package main
func main(){
go add(1,2)
}
func add(a,b int)(int,int,int){
return a+b,a,b
}
先來看看上面這段程序的反匯編代碼:
1.2 add函數反匯編代碼
0x401050 48c744241800000000 MOVQ $0x0, 0x18(SP)
0x401059 48c744242000000000 MOVQ $0x0, 0x20(SP)
0x401062 48c744242800000000 MOVQ $0x0, 0x28(SP)
0x40106b 488b5c2408 MOVQ 0x8(SP), BX
0x401070 488b6c2410 MOVQ 0x10(SP), BP
0x401075 4801eb ADDQ BP, BX
0x401078 48895c2418 MOVQ BX, 0x18(SP)
0x40107d 488b5c2408 MOVQ 0x8(SP), BX
0x401082 48895c2420 MOVQ BX, 0x20(SP)
0x401087 488b5c2410 MOVQ 0x10(SP), BX
0x40108c 48895c2428 MOVQ BX, 0x28(SP)
0x401091 c3 RET
理解這段匯編只需要搞清楚add的棧空間即可:
因為add的棧幀大小是0,所以SP在CALL之后沒有繼續擴展,而且沒有把BP壓棧
1.3 main函數反匯編代碼
0x401009 483b6110 CMPQ 0x10(CX), SP
0x40100d 7630 JBE 0x40103f
0x40100f 4883ec38 SUBQ $0x38, SP
0x401013 48c744241001000000 MOVQ $0x1, 0x10(SP)
0x40101c 48c744241802000000 MOVQ $0x2, 0x18(SP)
0x401025 c7042428000000 MOVL $0x28, 0(SP)
0x40102c 488d05257a0800 LEAQ 0x87a25(IP), AX
0x401033 4889442408 MOVQ AX, 0x8(SP)
0x401038 e8a3920200 CALL runtime.newproc(SB)
0x40103d ebfe JMP 0x40103d
0x40103f e80c9b0400 CALL runtime.morestack_noctxt(SB)
0x401044 ebba JMP main.main(SB)
- 第一句,是為了獲取TLS的地址,可以認為是:MOVQ TLS,CX,在原指令中,我們相對FS基址寄存器向下偏移了8個字節,這是因為我們才將TLS的地址寫入FS寄存器的時候是先加了8個字節,原因不知道,反正挺蠢的感覺,這樣CX就指向了TLS
- TLS中存放了g結構體,0x10(CX),指向了g.stack.stackguard0,也就是當前g結構棧的警戒線,當SP指針小 于該值時就需要進行棧的擴展,也就是跳轉到0x40103f ,執行 runtime.morestack_noctxt(SB)函數
-
此外,main函數的主要任務就是調用 runtime.newproc(SB)函數創建一個goroutime,此函數的參數包括 了:協程函數執行地址、參數返回值大小、參數及返回值共7個8字節的變量,因此SP擴展$0x38個字節, 棧空間:
main 棧空間
runtime.newproc函數的具體功能暫且不展開,因為main.main本身也是一個goroutine,它顯然不是go程序執行的入口,那么main goroutine是如何啟動的?G、M、P是如何構建的,需要我們回到回到go世界的起點。
二、GO runtime的初始化
2.1 極簡概述
初始化的流程,我們僅僅考慮goroutime的部分(其實也是主要的核心,GC和內存初始化模塊或者占比很小),主要流程是:
- 創建M0
- 初始化P數組:allp
- M0與allp[0]綁定
- 創建G0,綁定M0,也就是main goroutine
- 啟動M0的調度循環
當然,整個流程沒有這么的按部就班,接下來我們逐行分析初始流程
2.2 入口地址 rt0_linux_amd64
通過dlv調試工具,我們很容易的知道,整個程序的入口地址:
_rt0_amd64_linux() /usr/local/go/src/runtime/rt0_linux_amd64.s:8
其核心的匯編代碼如下:
所以入口函數的主要工作就是處理一下argc和argv兩個參數,然后跳轉到rt0_go完成整個初始化過程
2.3 初始化流程控制 rt0_go
其主要代碼如下,我們只關注和goroutine相關的主要內容,一些亂七八糟的我們就不看了,主要我也沒懂:
2.3.1 保存main參數到棧中
注意此時sp向下擴展了的48個字節,然后把argc和argv保存到了高位的兩個28字節,我突然明白了其用意,因為在進行函數調用時,默認都是0(sp)作為第一個參數,這里預留了兩個位置就是為了調用其他函數用的,當然前提保證了rt0_go調用的所有的函數的參數都不會超過2*8個字節
2.3.2 初始化g0的棧結構
g0的身份已經明確了,每一個M都自帶一個g0結構,用于執行管理類的指令,從而將其和M執行的用戶goroutine區分開,這里的g0就是我們的初始化的M0的g0結構。
2.3.3 存儲g0到TLS中
TLS是線程本地存儲,在GO語言中,用于存放指向當前G的g結構體指針,也就是每次切換在M上執行的G,或者寫換到M的g0結構的時候都要進行TLS的切換。
上面的過程進行了部分的操作:
- 第一部分通過系統調用arch_prctl,把tls0這個全局變量的地址寫到了FS寄存器,TLS正是通過FS基質寄存器進行殉職的,tls0指向的是一個8*8字節大小的指針數組
- 第二部分是一個測試代碼,整明tls0和TLS之間的指向關系
- 第三部分吧g0的地址寫入到TLS中
整個過程的內存操作細節如下:
tls0是一個8*8字節的空間
通過arch_prctl,讓FS基址寄存器指向該空間
settls函數的源代碼中,賦值給FS的就是 tls0+8,我們可以查看main.main的反匯編代碼的第一句:
0x401000 64488b0c25f8ffffff FS MOVQ FS:0xfffffff8, CX
這里在進行段尋址的時候時候,偏移量是0xfffffff8,也就是-8,剛好指向了tls0的首地址。
因此 TLS就指向了正確的地址
存儲g0
get_tls(r) 和 g()都是宏函數:
#define get_tls(r) MOVQ TLS, r
#define g(r) 0(r)(TLS*1)
兩者可以用一個指令代替:
MOVQ (TLS), r
因此存儲g0也可以簡寫為:
MOVQ (TLS),BX
MOVQ $runtime.g0,BX
注意,g0本身也是一個指針
2.3.4 構建M0和g0的關系
這里的m0是一個全局變量,也是系統創建的第一個M,用于執行main goroutine,這里將M0和g0相互綁定,當然M0還有很多的內容需要初始化
2.3.5 初始化main參數
參數入棧,然后通過runtime.args()函數,保存到全局變量的runtime.agrc、runtime.argv中,之后就可以在go中引用了
2.3.6 確定系統的內核數目
2.3.7 初始化P,實現M0與P的綁定
這個部分我們放到第三部分展開
2.3.8 創建Main goroutine,并將其綁定到P的可運行隊列中
又一次見到runtime.newproc函數,這是該函數第一次被調用,用于創建main goroutine,其中協程的函數地址是runtime.mainPC,這是一個全局變量,定義如下:
runtime.main距離main.main還有一點距離,我們也將在第四部分展開
2.3.9 最后,啟動M0,開啟M0的執行調度循環
mstart() 函數,在第五部分展開。
三、 schedinit() 過程
這里對應于2.3.7小節,初始化P,實現M0與P的綁定
3.1 其他內容
這部分與goroutine的建立沒有直接關系,我們暫不展開,看一下注釋即可3.2 初始化M0
到目前為止,M0還是一個全局的變量,僅僅和g0進行了相互綁定,還沒有被納入到M的管理框架中,將使用mcommoninit(g.m)初始化M0
- 每一個M都有自己唯一的ID,每一個M只要被創建就永遠不會被銷毀,M是可以復用的,并且M是有最大創建個數的,默認是10000個,因此代碼的前三行就是為M0分配ID,然后檢查是否已經超過最大的M創建限制
- mpreinit(mp)是個空操作
- 將M0添加到allm中,allm是一個鏈表頭,指向了新加入的鏈表項,每次都在鏈表的尾部追加新的M
3.3 確定P的個數
P的個數,最終是由系統的核數、環境變量GOMAXPROCS、自定義最大值_MaxGomaxprocs(256),三者共同確定
3.4 初始化P
func procresize(nprocs int32) *p
從函數名可以看出,該函數適用于調整P的個數的。P的個數是擁有最大值的,也就是_MaxGomaxprocs(256),因此為了節省開銷,P是由數組進行管理的,allp是一個全局數組,用于存放P,gomaxprocs用于存放有效的P個數。
3.4.1 獲取原先的P個數
在初始化的過程中,其實是創建P的過程,因此old的值是0
3.4.2 根據nprocs初始化P數組
代碼的邏輯是很好理解,主要分為了兩個部分:
-
初始化P,指定它的id、status等xinxi
● 為p分配cache,這部分內容屬于內存分配,暫時不展開,可以理解為cache是goroutine申請內容用的,這里面需要注意的是,針對初始化過程,P0的cache是由M0提供的,而M0的cache其實是在進行內存分配初始化(schedinit.mallocinit)的時候指定的:
3.4.3 釋放多余的P
代碼很長,但是這部分其實在初始化的時候是用不到的,因為所有的P都是剛剛創建的。其主要的內容可以概括為:
- 將P上的所有的G轉移到全局隊列
- 釋放P的cache
- 將P的復用G鏈表轉移到全局隊列
- 修改其狀態為_Pdead
3.4.4 P0與M0綁定
其實核心操作無非就是cache和m與p之間相互指向,最后修改P的狀態為_Prunning
3.4.5 最后,沒有本地任務的P放入空閑鏈表
上述代碼的邏輯是很容易看懂的,執行結束后,除了P0以外,所有的P都放入了空閑列表,當然P也沒有放入有任務的列表,畢竟當前P是沒有任務的,正在執行的g是M0的g0,因此接下來就是創建我們的G0,也就是maingoroutine了。
四、創建Main Goroutine
這里對應于2.3.8小節,創建Main goroutine,并將其綁定到P的可運行隊列中。
現在我們來看看newproc函數都做了什么工作,在此之前,我們先可視化一下函數棧
4.1 整理參數,切換執行棧到g0
這個函數其實沒什么好講的,agrp其實是協程的第一個參數,因為根據壓棧的規則,協程的函數地址再往上就是協程函數的參數和返回值,但是在這個函數中是沒有調用任何的參數的,因為size是0。PC獲取的是返回IP也就是棧中的callerIP,將&siz減8就是了。
最后通過systemsatck函數來執行newproc1,前者的作用是切換到g0中執行,執行完成后恢復到原有的g中,當然了,此時已經在g0的棧中執行了,所以這個場景下這個函數是沒有作用的,我們將在后面附錄中分析這個g0棧的切換函數。
4.2 Main Goroutine的誕生
千呼萬喚始出來,先看一下函數的聲明:- funcval是協程函數的入口地址
- argp是協程函數的參數地址
- narg表示協程函數參數的大小
- nret表示協程函數返回值的大小,但是這個參數是沒有意義的,因為參數包含了返回值
- caller PC可以理解為go func的地址,即啟動該協程的函數地址
4.2.1 創建或者復用一個G
如果有可以復用的G,那么就選擇復用,否則就創建一個新的G,因為我們是初始化的過程,因此一定是新建一個G,具
體的細節我們放在后面討論
4.2.2 參數COPY
很容易理解,就是把協程函數的參數從go的棧空間copy到G的棧空間
4.2.3 初始化gobuf,用于goroutine切換
gobuf是一個重要的數據結構,對應的變量是newg.sched,保存了重要的寄存器信息,當發生goroutine切換的時候,將會用到,這里需要注意,newg.sched.pc,這里載入的值是goexit,這是一個goroutine在運行結束后通過ret跳轉到的函數地址,用于善后工作,我們將在調度循環中描述
4.2.4 分配全局的ID
4.2.5 將G加入p的運行隊列
4.2.6 讓空閑的P出來工作
到這里對于普通的goroutine來說已經結束了,只需要等待被調度到就可以了,但是對于Main goroutine還沒有結束,因為M的調度循環還沒有開啟
五、啟動M0,開啟調度循環
這是runtime的最后一步,開啟循環,執行goroutine.
5.1 重新初始化棧guard
首先注意,這里的g依然是M0的g0,而不是main goroutine。
g0棧的guard參數我們已經在最開始的初始化操作中設置了,當時的結構圖是:
5.2 保存g0的現場
- 首先把gobuf的指針賦值給AX,然后可以通過AX訪問gobuf
- 接下來把caller的SP指針保存到gobuf中,里是比較有趣的,從棧的信息看,這里保存的是gosave函數
RET之后的SP指針,十分的合理,因為偽寄存器FP指向的就是參數的地址,arg0的地址就是caller的SP
指針 - 將callerPC保存到PC中,這一步顯然是沒有必要的,因為我們根本就用不到,用戶的G的gobuf.pc保存
的是go_exit的地址 - 后面的就是保存相應的數據了,沒啥好說的
經過gosave,我們就把M0的g0的gobuf給初始化好了
5.3 設置信號處理
當時的我對信號機制不熟悉,所以沒看懂,現在的我應該可以吧,233,但是懶得看了
5.4其他
這部分的內容在初始化的過程中并不會被執行,先不深究
5.4開啟調度循環
這一句執行后,我們的M將開始執行,將從其綁定的P上取任務開始執行調度的循環,由于此時只有maingoroutine,因此我們的main函數終于可以執行了,但是前面我們已經提到過了,mian goroutine的入口函數是runtime.main,而不是main.main,因此我們還有一段路程要走,至于調度循環,我們將單獨拿出來討論。
六、Main Goroutine的執行
runtime.main() /usr/local/go/src/runtime/proc.go:28我們先看一下,當執行到此處時的資源情況:
- 首先,堆棧的追蹤,當前執行的是runtime.main,其調用函數是goexit,其實這部分呢我們在前面已經說過了,所有的goroutine的調用函數都是goexit,這樣當其執行結束時,可以返回到goexit中,繼續調度循環,goexit的地址保存在gobuf.pc中
- 此時只有一個OS線程,也就是初始化線程本身,其實我們知道,每個M都是個一個OS線程綁定的,那么我們的m0默認綁定的線程就是初始化線程本身,而所謂M和os線程的綁定,說白了就是棧空間,當OS線程的棧地址是M的g0的棧地址,那么兩者就綁定了,回憶一下,g0的棧是怎么初始化的,就是在當前OS線程的棧空間中劃出了64KB的大小
- 此時只有一個goroutine,也就是我們的main goroutine
6.1 啟動系統后臺監控
這里首先使用systemstack函數切換到m0的g0棧空間來執行命令
newm是創建一個新的M,并指定了啟動函數(sysmon),sysmon的詳細內容我們就不研究了,但是newm我們將在后面展開討論,注意newm的第一個參數,在啟動M0這一節中的“其他”小節的代碼上中,執行的fn就是這個時候傳遞進去的。
6.2 執行runtime的init函數
此函數是一個由編譯器自動生成的函數,其目的是執行runtime這個package下的所有的init()函數
6.3 執行其他的init函數
同runtime_init一樣,也是一個由編譯器生成的函數,其目的是執行除了runtime之外,其他所有的被引用的package的init()函數
6.4最后執行main.main
時間線收束,到現在為止,我們已經把main函數的執行過程全部走了一邊,除了一些函數的細節沒有展開,我們將放到附錄中。
六、回憶goroutine的的創建
還記得我們的add goroutine嗎?和main goroutine一樣,都是調用了newproc(4.2)函數,而該函數的結尾是這里(4.2.6):如注釋所寫,如果當前存在空閑的P,那么將會喚醒一個M來干活,如果此時創建的是main goroutine,那么不需要執行,如果當前有自旋等待的M,那么也不需要執行,很顯然,在我們創建add goroutine時,顯然存在空閑的P,因為只有allp[0]在執行,顯然也沒有自選等待的M,那么將會執行wakep()操作,wakep()做了什么呢?
wakep()
此函數的初心是,嘗試啟動一個P來執行G,源碼的注釋就是啟動一個P,而不是啟動一個M,當然了,啟動M需要和P進行綁定。函數非常的簡單:
- 檢查當前是否存在處于spinning狀態的M,如果存在,那么放棄執行,以避免創建過多的M,如果不存在,則spinning狀態的M計數加1
- 執行stratm(),來喚醒一個M,具體看下,七
七、M的生命周期
M和G、P一樣都是復用的,一旦創建,就不會被銷毀,M的狀態轉化是最簡單,除了M0之外,都是通過newm()函數創建,然后通過mstart,進入調度循環,之后可以通過stopm進入阻塞狀態,再通過startm喚醒,狀態圖如下:
圖來自博客,很好的一個博客。mstart我們已經交代過了。
這篇博客寫的很好,但是不是特別的詳細,他有一段前言總結:
- mstart,Go程序初始化時,第一個M是由mstart創建,新的物理線程創建時,調用的函數也是mstart。
- startm,當有新的G創建或者有G從waiting進入running且還有空閑的P,此時會調用startm,獲取一個M和空閑的P綁定執行G。
- newm,當調用startm時,如果沒有空閑的M則會通過newm創建M。
- stopm,在2種情況下會執行stopm,一是當M綁定的P無可運行的G且無法從其它P竊取可運行的G時,M先進入spinning狀態,然后退出。二是當M和G進入系統調用后,長時間未退出,P被retake且M找不到空閑的P綁定,此時M會調用stopm。
- spinning狀態,在findrunnable函數中,會短暫進入spinning狀態,如果找不到可運行的G則調用stopm。
這里最迷惑的就是這個spinning,其實他并不是一種M的狀態,博客中說的非常好,從M的狀態其實是對應于OS線程的狀態,通過線程的同步機制futex來實現了線程的喚醒和阻塞,分別對應于圖中的startm和stopm,stopm類似于一種暫停,喚醒之后還是要從停下的位置繼續執行的,這里在findrunnable的代碼中就可以看出來,stopm后面是goto語句,回到函數的的最開始,重新尋找可用的G。我們可以把running狀態分為兩種情況: - 執行用戶的程序
- 尋找可用的G
其中尋找可用的G的過程就是spinning狀態:
spinning狀態 = running狀態中執行findrunnable的部分
7.1 M startm()
用,那就返回。
7.1.1獲取空閑的P
調用函數pidleget()獲取空閑列別中的P,如果獲取失敗,那么M的自旋計數-1(對應于wakep中的自旋+1),然后返回。
7.1.2 獲取復用M或創建新M
如果需要新新創建M,那么就調用newm,新創建M同時意味著產生新的os線程,新線程將調用mstart,啟動自己的調度循環。
7.1.3 綁定P,喚醒阻塞的M
M在執行的時候一定要和P進行綁定,因此我們在這里這只M被喚醒之后的P。
這里的notewakep就是利用了Linux的Futex技術。
7.2 M stopm()
7.2.1 取消自旋計數
7.2.2 M放入空閑隊列并阻塞
在執行notewakep后,代碼將從noteclear開始執行,clear完全是為了和win兼容,其實就是把note值清零
7.2.3 M被喚醒后的初始化工作
因為被喚醒后的代碼是從noteclear開始,因此這部分應該是startm的后續,主要是處理GC和綁定P,P就是我們在stratm的時候指定的。
附錄
主要展開講述一下小函數:
- newm() 生成新的M
- allocm() 初始化M對象
- newosproc() 申請OS線程并與M綁定
- malg 創建一個G
- systemstack 切換到M的g0棧空間
- gfget() 從G的復用列表中獲取一個G
1. newm
1.1 創建和初始化M對象
1.1.1 new一個M對象然后初始化
mcommoninit(3.2)函數已經介紹過了
1.1.2 創建g0
創建G的函數malg在下面馬上講,參數是棧的大小。
這里默認g0的棧大小是8KB(stackGuardMutiplier = 1),但是如果gcflags設置了-N標志(不啟動編譯優化),那么stackGuardMutiplier的值就會變成2.
1.1.3 暫存P對象
此時M和P沒有綁定,綁定是在mstart(五)函數的中的其他(5.4)中進行的
1.2 分配OS線程
rtsigprocmask對應于系統調用rt_sigprocmask,作用是設定對信號屏蔽集內的信號的處理方式(阻塞或不阻塞)。
參數:DI、SI、DX、R10、R8、R9
返回值:AX、DX
1.2.1 調用clone系統調用
1.2.2 父進程部分
1.2.3 子進程部分
這里的stackcheck就是檢查當前的SP指針是否在[g->stack.lo, g->stack.hi)范圍,如果不在將觸發int 3中斷
臥槽讀了這個匯編,突然發現一個大問題啊,這個返回父子進程的方法和fork很像,但是傳遞一個void*函數的方法尤其確實我們的clone,嘶~~~,clone的實現,不會就是這樣的吧,不管了。
2. malg
stackalloc是內存分配相關的,就不看了
3.systemstack
3.1 判斷是否需要切換
這部分沒什么好說的如果已經是g0或者是gsignal(不知道是啥)就不需要切換
3.2 保存G的現場
3.3 切換到g0
這里主要就是把g0寫到TLS中,然后修改SP指針,從這里可以看出,并沒有參數的copy操作,也就是此方法是不支持運行帶有參數的函數。
這里面比較詭異的是吧runtime.mstart入棧,看不懂
3.4 調用原函數
注意這里不是CALL DI
3.5 恢復G現場
4. gfget
每一個P包含一個本地的可復用G鏈表:p.gfree及其計數p.gfreecnt;
系統中還有一個全局的可復用G鏈表:sched.gfree及其計數p.ngfree.
在gfput時,當p.gfree中的可復用G數目超過64時(是的,代碼中直接用了常數64,甚至沒有定義常量),會將一半數目的也就是32個p.gfreeG放入sched.gfree;
在gfget時,若p.gfree中沒有可用的G,那么將會從sched.gfree中取走32個放入本地。
4.1 獲取和竊取
上述代碼實現了竊取操作,如果本地沒有,那么就從全局可復用鏈表中竊取32個,注意鏈表操作。
4.2 獲取后的處理
成功獲取后需要對本地的鏈表以及計數進行更新。
如果棧被釋放,那么還需要重新分配棧空間。