理解 Go 協(xié)程調(diào)度的本質(zhì)
作者 | jiayan
golang的一大特色就是goroutine,它是支持高并發(fā)程序的重要保障;通過 go 關(guān)鍵字我們就能輕易創(chuàng)建大量的輕量級協(xié)程,但它和我們認知中的線程有什么區(qū)別呢,輕量在哪里,具體是如何進行調(diào)度的..... 本文將從涉及到的一些基礎(chǔ)知識開始,逐步介紹到go協(xié)程調(diào)度的核心原理,希望你能有所收獲~
一、函數(shù)調(diào)用棧
1.進程在內(nèi)存中的布局
首先回顧下進程的內(nèi)存布局~ 操作系統(tǒng)把磁盤上的可執(zhí)行文件加載到內(nèi)存運行之前,會做很多工作,其中很重要的一件事情就是把可執(zhí)行文件中的代碼,數(shù)據(jù)放在內(nèi)存中合適的位置,并分配和初始化程序運行過程中所必須的堆棧,所有準備工作完成后操作系統(tǒng)才會調(diào)度程序起來運行。
用戶程序所使用的內(nèi)存空間在低地址,內(nèi)核空間所使用的內(nèi)存在高地址,需要特別注意的是棧是從高地址往低地址生長。
2.各區(qū)域詳解
- 代碼區(qū),也被稱為代碼段,這部分內(nèi)存存放了程序的機器代碼。這部分內(nèi)存通常是只讀的,以防止程序意外地修改其自身的指令。
- 數(shù)據(jù)區(qū),包括程序的全局變量和靜態(tài)變量,程序加載完畢后數(shù)據(jù)區(qū)的大小也不會發(fā)生改變。
- 堆,堆是用于動態(tài)內(nèi)存分配的區(qū)域,例如c語言的malloc函數(shù)和go語言的new函數(shù)就是在堆上分配內(nèi)存。堆從低地址向高地址增長。
- 棧,棧內(nèi)存是一個連續(xù)的內(nèi)存區(qū)域,通常從高地址向低地址增長。每次函數(shù)調(diào)用都會在棧上分配一個新的棧幀,函數(shù)返回時棧幀會被釋放。棧幀包含了函數(shù)調(diào)用的上下文信息,包括函數(shù)的參數(shù)、局部變量和返回地址。
3.棧詳解
(1) 棧內(nèi)存中保存了什么
- 保存函數(shù)的局部變量;
- 返回函數(shù)的返回值;
- 向被調(diào)用函數(shù)傳遞參數(shù);
- 保存函數(shù)的返回地址,返回地址是指從被調(diào)用函數(shù)返回后調(diào)用者應(yīng)該繼續(xù)執(zhí)行的指令地址;
每個函數(shù)在執(zhí)行過程中都需要使用一塊棧內(nèi)存用來保存上述這些值,我們稱這塊棧內(nèi)存為某函數(shù)的棧幀(stack frame)。
(2) 與棧密切相關(guān)的三個寄存器
AMD64 CPU中有3個與棧密切相關(guān)的寄存器:
- rsp寄存器 ,始終指向當前函數(shù)調(diào)用棧棧頂。
- rbp寄存器 ,一般用來指向當前函數(shù)棧幀的起始位置,即棧底。
- ip寄存器,保存著下一條將要執(zhí)行的指令的內(nèi)存地址。CPU在執(zhí)行指令時,會根據(jù)IP寄存器的值從內(nèi)存中獲取指令從而執(zhí)行,在大多數(shù)情況下,IP寄存器的值會按順序遞增,以指向下一條指令,這使得程序能夠順序執(zhí)行。
假設(shè)現(xiàn)在有如下的一段go函數(shù)調(diào)用鏈且**當前正在執(zhí)行函數(shù)C()**:
main() {
A()
}
func A() {
... 聲明了一些局部變量...
B(1, 2)
test := 123
}
func B (a int , b int) {
... 聲明了一些局部變量...
C()
}
func C (a int, b int , c int) {
test := 123
}
則函數(shù)ABC的棧幀以及rsp/rbp/ip寄存器的狀態(tài)大致如下圖所示(注意,棧從高地址向低地址方向生長):
對于上圖,有幾點需要說明一下:
- go語言中調(diào)用函數(shù)時,參數(shù)和返回值都是存放在調(diào)用者的棧幀之中,而不是在被調(diào)函數(shù)之中;
- 目前正在執(zhí)行C函數(shù),且函數(shù)調(diào)用鏈為A()->B()->C(),所以以棧幀為單位來看的話,C函數(shù)的棧幀目前位于棧頂;
- cpu硬件寄存器rsp指向整個棧的棧頂,當然它也指向C函數(shù)的棧幀的棧頂,而rbp寄存器指向的是C函數(shù)棧幀的起始位置;
- 雖然圖中ABC三個函數(shù)的棧幀看起來都差不多大,但事實上在真實的程序中,每個函數(shù)的棧幀大小可能都不同,因為不同的函數(shù)局部變量的個數(shù)以及所占內(nèi)存的大小都不盡相同;
- 有些編譯器比如gcc會把參數(shù)和返回值放在寄存器中而不是棧中,go語言中函數(shù)的參數(shù)和返回值都是放在棧上的;
隨著程序的運行,如果C、B兩個函數(shù)都執(zhí)行完成并返回到了A函數(shù)繼續(xù)執(zhí)行,則棧狀態(tài)如下圖:
因為C、B兩個函數(shù)都已經(jīng)執(zhí)行完成并返回到了A函數(shù)之中,所以C、B兩個函數(shù)的棧幀就已經(jīng)被POP出棧了,也就是說它們所消耗的棧內(nèi)存被自動回收了。因為現(xiàn)在正在執(zhí)行A函數(shù),所以寄存器rbp和rsp指向的是A函數(shù)的棧中的相應(yīng)位置??梢钥吹絚pu 的rbp, rsp分別指向了a函數(shù)的棧底和棧頂,同時ip寄存器指向了A函數(shù)調(diào)用完B后的下一行代碼 test:= 123的地址,接下來CPU會根據(jù)IP寄存器,從代碼區(qū)的內(nèi)存中獲取下一行代碼所對應(yīng)的匯編指令去執(zhí)行。
(3) 棧溢出
即使是同一個函數(shù),每次調(diào)用都會產(chǎn)生一個不同的棧幀,因此對于遞歸函數(shù),每遞歸一次都會消耗一定的棧內(nèi)存,如果遞歸層數(shù)太多就有導致棧溢出的風險,這也是為什么我們在實際的開發(fā)過程中應(yīng)該盡量避免使用遞歸函數(shù)的原因之一,另外一個原因是遞歸函數(shù)執(zhí)行效率比較低,因為它要反復調(diào)用函數(shù),而調(diào)用函數(shù)有較大的性能開銷。
二、Linux線程以及線程調(diào)度
1.一段c程序
要深入理解go的協(xié)程調(diào)度邏輯,就需要對操作系統(tǒng)線程有個大致的了解,因為go的調(diào)度系統(tǒng)是建立在操作系統(tǒng)線程之上的,所以我們先來通過linux下的C語言demo入手,我們把這個程序跑在一臺單核CPU的機器上。
C語言中我們一般使用pthread線程庫,而使用該線程庫創(chuàng)建的用戶態(tài)線程其實就是Linux操作系統(tǒng)內(nèi)核所支持的線程,它與go語言中的工作線程是一樣的,這些線程都由Linux內(nèi)核負責管理和調(diào)度,然后go語言在操作系統(tǒng)線程之上又做了goroutine,實現(xiàn)了一個二級線程模型。
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#define N (1000 * 1000 * 1000)
volatile int g = 0;
void *start(void *arg)
{
int i;
for (i = 0; i < N; i++) {
g++;
}
return NULL;
}
int main(int argc, char *argv[])
{
pthread_t tid;
// 使用pthread_create函數(shù)創(chuàng)建一個新線程執(zhí)行start函數(shù)
pthread_create(&tid, NULL, start, NULL);
for (;;) {
usleep(1000 * 100 * 5);
printf("loop g: %d\n", g);
if (g == N) {
break;
}
}
pthread_join(tid, NULL); // 等待子線程結(jié)束運行
return 0;
}
```c
$./thread
loop g: 98938361
loop g: 198264794
loop g: 297862478
loop g: 396750048
loop g: 489684941
loop g: 584723988
loop g: 679293257
loop g: 777715939
loop g: 876083765
loop g: 974378774
loop g: 1000000000
該程序運行起來之后將會有2個線程,一個是操作系統(tǒng)把程序加載起來運行時創(chuàng)建的主線程,另一個是主線程調(diào)用pthread_create創(chuàng)建的start子線程,主線程在創(chuàng)建完子線程之后每隔500毫秒打印一下全局變量 g 的值直到 g 等于10億,而start線程啟動后就開始執(zhí)行一個10億次的對 g 自增加 1 的循環(huán),這兩個線程同時并發(fā)運行在系統(tǒng)中,操作系統(tǒng)負責對它們進行調(diào)度,我們無法精確預知某個線程在什么時候會運行。
2.操作系統(tǒng)在調(diào)度線程時會做哪些事情
- 選擇線程:操作系統(tǒng)調(diào)度器會根據(jù)特定的調(diào)度算法(如優(yōu)先級調(diào)度、輪轉(zhuǎn)調(diào)度、最短作業(yè)優(yōu)先等)選擇下一個要執(zhí)行的線程。
- 上下文切換:操作系統(tǒng)會保存當前正在運行的線程的狀態(tài)(這被稱為上下文),然后加載被選中的線程的上下文。上下文包括了線程的程序計數(shù)器、寄存器的值等。
- 線程切換:操作系統(tǒng)會將CPU的控制權(quán)交給被選中的線程,該線程會從它上次停止的地方開始執(zhí)行。
- 回到原線程:當被選中的線程的執(zhí)行時間片用完或者被阻塞時,操作系統(tǒng)會再次保存它的上下文,然后選擇另一個線程執(zhí)行。這個過程會不斷重復。
3.具體需要保存哪些寄存器呢
- 通用寄存器,線程運行時很可能會用到,保存當前線程的一些工作變量。
- ip寄存器(指令指針寄存器)(程序運行起來后低地址有一部分專門用來存放代碼數(shù)據(jù),IP寄存器通常指向這一區(qū)域,指明下一條要運行的代碼地址)。
- 棧寄存器RBP(指向當前棧的棧底)和RSP(當前棧的棧頂),2個棧寄存器確定了線程執(zhí)行時需要使用的棧內(nèi)存。所以恢復CPU寄存器的值就相當于改變了CPU下一條需要執(zhí)行的指令,同時也切換了函數(shù)調(diào)用棧。
4.線程調(diào)度的核心是什么
操作系統(tǒng)對線程的調(diào)度可以簡單的理解為內(nèi)核調(diào)度器對不同線程所使用的寄存器和棧的切換。
三、goroutine調(diào)度器
1.調(diào)度模型
(1) 傳統(tǒng)線程模型的問題
① 調(diào)度
上面講到了線程是操作系統(tǒng)級別的調(diào)度單位,通常由操作系統(tǒng)內(nèi)核管理。切上下文切換的開銷通常在微秒級別,且頻繁的上下文切換會顯著影響性能。
② 資源消耗
每個線程都有自己的堆棧和線程局部存儲(Thread Local Storage),這會消耗更多的內(nèi)存資源。創(chuàng)建和銷毀線程的開銷也相對較大。在 Linux 中,默認的線程棧大小通常為 8 MB。
③ 調(diào)度策略
線程調(diào)度通常由操作系統(tǒng)內(nèi)核使用復雜的調(diào)度算法(如輪轉(zhuǎn)調(diào)度、優(yōu)先級調(diào)度等)來管理。調(diào)度器需要考慮多個線程的優(yōu)先級、狀態(tài)、資源占用等因素,調(diào)度過程相對復雜。
(2) goroutine有多輕量
而相對的,用戶態(tài)的goroutine則輕量得多:
- goroutine是用戶態(tài)線程,其創(chuàng)建和切換都在用戶代碼中完成而無需進入操作系統(tǒng)內(nèi)核,所以其開銷要遠遠小于系統(tǒng)線程的創(chuàng)建和切換;
- goroutine啟動時默認棧大小只有2k,這在多數(shù)情況下已經(jīng)夠用了,即使不夠用,goroutine的棧也會自動擴大,同時,如果棧太大了過于浪費它還能自動收縮,這樣既沒有棧溢出的風險,也不會造成棧內(nèi)存空間的大量浪費。
正是因為go語言中實現(xiàn)了如此輕量級的線程,才使得我們在Go程序中,可以輕易的創(chuàng)建成千上萬甚至上百萬的goroutine出來并發(fā)的執(zhí)行任務(wù)而不用太擔心性能和內(nèi)存等問題。
(3) go調(diào)度器的簡化模型
goroutine建立在操作系統(tǒng)線程基礎(chǔ)之上,它與操作系統(tǒng)線程之間實現(xiàn)了一個多對多(M:N)的兩級線程模型 這里的 M:N 是指M個goroutine運行在N個操作系統(tǒng)線程之上,內(nèi)核負責對這N個操作系統(tǒng)線程進行調(diào)度,而這N個系統(tǒng)線程又負責對這M個goroutine進行調(diào)度和運行。
所謂的對goroutine的調(diào)度,是指程序代碼按照一定的算法在適當?shù)臅r候挑選出合適的goroutine并放到CPU上去運行的過程,這些負責對goroutine進行調(diào)度的程序代碼我們稱之為goroutine調(diào)度器。用極度簡化了的偽代碼來描述goroutine調(diào)度器的工作流程大概是下面這個樣子:
// 程序啟動時的初始化代碼
......
for i := 0; i < N; i++ { // 創(chuàng)建N個操作系統(tǒng)線程執(zhí)行schedule函數(shù)
create_os_thread(schedule) // 創(chuàng)建一個操作系統(tǒng)線程執(zhí)行schedule函數(shù)
}
//schedule函數(shù)實現(xiàn)調(diào)度邏輯
func schedule() {
for { //調(diào)度循環(huán)
// 根據(jù)某種算法從M個goroutine中找出一個需要運行的goroutine
g := find_a_runnable_goroutine_from_M_goroutines()
run_g(g) // CPU運行該goroutine,直到需要調(diào)度其它goroutine才返回
save_status_of_g(g) // 保存goroutine的狀態(tài),主要是寄存器的值
}
}
這段偽代碼表達的意思是,程序運行起來之后創(chuàng)建了N個由內(nèi)核調(diào)度的操作系統(tǒng)線程(為了方便描述,我們稱這些系統(tǒng)線程為工作線程)去執(zhí)行shedule函數(shù),而schedule函數(shù)在一個調(diào)度循環(huán)中反復從M個goroutine中挑選出一個需要運行的goroutine并跳轉(zhuǎn)到該goroutine去運行,直到需要調(diào)度其它goroutine時才返回到schedule函數(shù)中通過save_status_of_g保存剛剛正在運行的goroutine的狀態(tài)然后再次去尋找下一個goroutine。
(4) GM模型
在 Go 1.1版本之前,其實用的就是GM模型。GM模型的調(diào)度邏輯和上面講到的簡化版模型非常類似,是一種多對多的模型,go程序底層使用了多個操作系統(tǒng)線程,同時在go語言層面實現(xiàn)了語言級的輕量級協(xié)程goroutine(對操作系統(tǒng)來說是透明的,操作系統(tǒng)只知道切換線程并且執(zhí)行線程上的代碼),每個操作系統(tǒng)線程都會不斷的去全局隊列中獲取goroutine來執(zhí)行。
- goroutine (G): goroutine 是 go 語言中的輕量級線程。它們由 go 運行時管理,創(chuàng)建和銷毀的開銷相對較小。用戶可以通過 go 關(guān)鍵字輕松地啟動一個新的 goroutine。
- 操作系統(tǒng)線程 (M): M 代表操作系統(tǒng)線程,go 運行時使用這些線程來執(zhí)行 goroutine。每個 M 線程可以在操作系統(tǒng)的線程池中運行,負責執(zhí)行 goroutine 的代碼。
(5) GM模型的缺點
有一個全局隊列帶來了一個問題,因為從隊列中獲取 goroutine 必須要加鎖,導致鎖的爭用非常頻繁。尤其是在大量 goroutine 被調(diào)度的情況下,對性能的影響也會非常明顯。
每個線程在運行時都可能會遇到需要進行系統(tǒng)調(diào)用的情況。早期GM模型中每個M都關(guān)聯(lián)了內(nèi)存緩存(mcache)和其他的緩存(棧空間),但實際上只有正在運行的 go 代碼的 M 才需要 mcache(阻塞在系統(tǒng)調(diào)用的 M 不需要 mcache)。運行 go 代碼的 M 和系統(tǒng)調(diào)用阻塞的 M 比例大概在 1:100,這就導致了大量的資源消耗(每個 mcache 會占用到 2M)。
造成延遲和額外的系統(tǒng)負載。比如當G中包含創(chuàng)建新協(xié)程的時候,M創(chuàng)建了G’,為了繼續(xù)執(zhí)行G,需要把G’交給M’執(zhí)行,也造成了很差的局部性,因為G’和G是相關(guān)的,最好放在M上執(zhí)行,而不是其他M'。
(6) GMP模型
基于沒有什么是加一個中間層不能解決的思路,golang在原有的GM模型的基礎(chǔ)上加入了一個調(diào)度器P,可以簡單理解為是在G和M中間加了個中間層。于是就有了現(xiàn)在的GMP模型里。
- P 的加入,還帶來了一個本地協(xié)程隊列,跟前面提到的全局隊列類似,也是用于存放G,想要獲取等待運行的G,會優(yōu)先從本地隊列里拿,訪問本地隊列無需加鎖。而全局協(xié)程隊列依然是存在的,但是功能被弱化,不到萬不得已是不會去全局隊列里拿G的。
- GM模型里M想要運行G,直接去全局隊列里拿就行了;GMP模型里,M想要運行G,就得先獲取P,然后從 P 的本地隊列獲取 G。
- 新建 G 時,新G會優(yōu)先加入到 P 的本地隊列;如果本地隊列滿了,則會把本地隊列中一半的 G 移動到全局隊列。P 的本地隊列為空時,就從全局隊列里去取。
- 新建 G 時,新G會優(yōu)先加入到 P 的本地隊列;如果本地隊列滿了,則會把本地隊列中一半的 G 移動到全局隊列。
- P 的本地隊列為空時,就從全局隊列里去取。
- 如果全局隊列為空時,M 會從其他 P 的本地隊列偷(stealing)一半 G放到自己 P 的本地隊列。
- M 運行 G,G 執(zhí)行之后,M 會從 P 獲取下一個 G,不斷重復下去。
(7) 為什么要有P
這時候就有會疑惑了,如果是想實現(xiàn)本地隊列、Work Stealing 算法,那為什么不直接在 M 上加呢,M 也照樣可以實現(xiàn)類似的功能。為什么又再加多一個 P 組件?結(jié)合 M(系統(tǒng)線程) 的定位來看,若這么做,有以下問題。
- 一般來講,M 的數(shù)量都會多于 P。像在 Go 中,M 的數(shù)量最大限制是 10000,P 的默認數(shù)量的 CPU 核數(shù)。另外由于 M 的屬性,也就是如果存在系統(tǒng)阻塞調(diào)用,阻塞了 M,又不夠用的情況下,M 會不斷增加。
- M 不斷增加的話,如果本地隊列掛載在 M 上,那就意味著本地隊列也會隨之增加。這顯然是不合理的,因為本地隊列的管理會變得復雜,且 Work Stealing 性能會大幅度下降。
- M 被系統(tǒng)調(diào)用阻塞后,我們是期望把他既有未執(zhí)行的任務(wù)分配給其他繼續(xù)運行的,而不是一阻塞就導致全部停止
2.調(diào)度器數(shù)據(jù)結(jié)構(gòu)概述
系統(tǒng)線程對goroutine的調(diào)度與內(nèi)核對系統(tǒng)線程的調(diào)度原理是一樣的,實質(zhì)都是通過保存和修改CPU寄存器的值來達到切換線程/goroutine的目的。因此,為了實現(xiàn)對goroutine的調(diào)度,需要引入一個數(shù)據(jù)結(jié)構(gòu)來保存CPU寄存器的值以及goroutine的其它一些狀態(tài)信息,在go語言調(diào)度器源代碼中,這個數(shù)據(jù)結(jié)構(gòu)是一個名叫g(shù)的結(jié)構(gòu)體,它保存了goroutine的所有信息,該結(jié)構(gòu)體的每一個實例對象都代表了一個goroutine。調(diào)度器代碼可以通過g對象來對goroutine進行調(diào)度。
- 當goroutine被調(diào)離CPU時,調(diào)度器代碼負責把CPU寄存器的值保存在g對象的成員變量之中
- 當goroutine被調(diào)度起來運行時,調(diào)度器代碼又負責把g對象的成員變量所保存的寄存器的值恢復到CPU的寄存器
前面我們所講的G,M,P,在源碼中均有與之對應(yīng)的數(shù)據(jù)結(jié)構(gòu)。
(1) 重要的結(jié)構(gòu)體
G M P 結(jié)構(gòu)體定義于src/runtime/runtime2.go。
① g結(jié)構(gòu)體
type g struct {
// 記錄該goroutine使用的棧,當前 goroutine 的棧內(nèi)存范圍 [stack.lo, stack.hi)
stack stack
// 下面兩個成員用于棧溢出檢查,實現(xiàn)棧的自動伸縮,搶占調(diào)度也會用到stackguard0
stackguard0 uintptr
_panic *_panic
_defer *_defer
// 此goroutine正在被哪個工作線程執(zhí)行
m *m
// 存儲 goroutine 的調(diào)度相關(guān)的數(shù)據(jù)
sched gobuf
// schedlink字段指向全局運行隊列中的下一個g,
// 所有位于全局運行隊列中的g形成一個鏈表
schedlink guintptr
// 不涉及本篇內(nèi)容的字段已剔除
...
}
下面看看gobuf結(jié)構(gòu)體,主要在調(diào)度器保存或者恢復上下文的時候用到:
type gobuf struct {
// 棧指針,對應(yīng)上文講到的RSP寄存器的值
sp uintptr
// 程序計數(shù)器,對應(yīng)上文講到的RIP寄存器
pc uintptr
// 記錄當前這個gobuf對象屬于哪個goroutine
g guintptr
// 系統(tǒng)調(diào)用的返回值
ret sys.Uintreg
// 保存CPU的rbp寄存器的值
bp uintptr
...
}
stack結(jié)構(gòu)體主要用來記錄goroutine所使用的棧的信息,包括棧頂和棧底位置:
// Stack describes a Go execution stack.
// The bounds of the stack are exactly [lo, hi),
// with no implicit data structures on either side.
//用于記錄goroutine使用的棧的起始和結(jié)束位置
type stack struct {
lo uintptr // 棧頂,指向內(nèi)存低地址
hi uintptr // 棧底,指向內(nèi)存高地址
}
在執(zhí)行過程中,G可能處于以下幾種狀態(tài):
const (
// 剛剛被分配并且還沒有被初始化
_Gidle = iota // 0
// 沒有執(zhí)行代碼,沒有棧的所有權(quán),存儲在運行隊列中
_Grunnable // 1
// 可以執(zhí)行代碼,擁有棧的所有權(quán),被賦予了內(nèi)核線程 M 和處理器 P
_Grunning // 2
// 正在執(zhí)行系統(tǒng)調(diào)用,擁有棧的所有權(quán),沒有執(zhí)行用戶代碼,
// 被賦予了內(nèi)核線程 M 但是不在運行隊列上
_Gsyscall // 3
// 由于運行時而被阻塞,沒有執(zhí)行用戶代碼并且不在運行隊列上,
// 但是可能存在于 Channel 的等待隊列上
_Gwaiting // 4
// 表示當前goroutine沒有被使用,沒有執(zhí)行代碼,可能有分配的棧
_Gdead // 6
// 棧正在被拷貝,沒有執(zhí)行代碼,不在運行隊列上
_Gcopystack // 8
// 由于搶占而被阻塞,沒有執(zhí)行用戶代碼并且不在運行隊列上,等待喚醒
_Gpreempted // 9
// GC 正在掃描??臻g,沒有執(zhí)行代碼,可以與其他狀態(tài)同時存在
_Gscan = 0x1000
...
)
上面的狀態(tài)看起來很多,但是實際上只需要關(guān)注下面幾種就好了:
- 等待中:_ Gwaiting、_Gsyscall 和 _Gpreempted,這幾個狀態(tài)表示G沒有在執(zhí)行;
- 可運行:_Grunnable,表示G已經(jīng)準備就緒,可以在線程運行;
- 運行中:_Grunning,表示G正在運行;
② m結(jié)構(gòu)體
type m struct {
// g0主要用來記錄工作線程使用的棧信息,在執(zhí)行調(diào)度代碼時需要使用這個棧 // 執(zhí)行用戶goroutine代碼時,使用用戶goroutine自己的棧,調(diào)度時會發(fā)生棧的切換
g0 *g
// 線程本地存儲 thread-local,通過TLS實現(xiàn)m結(jié)構(gòu)體對象與工作線程之間的綁定,下文會詳細介紹
tls [6]uintptr // thread-local storage (for x86 extern register)
// 當前運行的G
curg *g // current running goroutine
// 正在運行代碼的P
p puintptr // attached p for executing go code (nil if not executing go code)
nextp puintptr
// 之前使用的P
oldp puintptr
// 記錄所有工作線程的一個鏈表
alllink *m // on allm
schedlink muintptr
// Linux平臺thread的值就是操作系統(tǒng)線程ID
thread uintptr // thread handle
freelink *m // on sched.freem
...
}
③ p結(jié)構(gòu)體
調(diào)度器中的處理器 P 是線程 M 和 G 的中間層,用于調(diào)度 G 在 M 上執(zhí)行。
type p struct {
id int32
// p 的狀態(tài)
status uint32
// 調(diào)度器調(diào)用會+1
schedtick uint32 // incremented on every scheduler call
// 系統(tǒng)調(diào)用會+1
syscalltick uint32 // incremented on every system call
// 對應(yīng)關(guān)聯(lián)的 M
m muintptr
mcache *mcache
pcache pageCache
// defer 結(jié)構(gòu)池
deferpool [5][]*_defer
deferpoolbuf [5][32]*_defer
// 可運行的 goroutine 隊列,可無鎖訪問
runqhead uint32
runqtail uint32
runq [256]guintptr
// 緩存可立即執(zhí)行的 G
runnext guintptr
// 可用的 G 列表,G 狀態(tài)等于 Gdead
gFree struct {
gList
n int32
}
...
}
P的幾個狀態(tài):
const (
// 表示P沒有運行用戶代碼或者調(diào)度器
_Pidle = iota
// 被線程 M 持有,并且正在執(zhí)行用戶代碼或者調(diào)度器
_Prunning
// 沒有執(zhí)行用戶代碼,當前線程陷入系統(tǒng)調(diào)用
_Psyscall
// 被線程 M 持有,當前處理器由于垃圾回收 STW 被停止
_Pgcstop
// 當前處理器已經(jīng)不被使用
_Pdead
)
④ schedt結(jié)構(gòu)體
type schedt struct {
...
// 鎖,從全局隊列獲取G時需要使用到
lock mutex
// 空閑的 M 列表
midle muintptr
// 空閑的 M 列表數(shù)量
nmidle int32
// 下一個被創(chuàng)建的 M 的 id
mnext int64
// 能擁有的最大數(shù)量的 M
maxmcount int32
// 由空閑的p結(jié)構(gòu)體對象組成的鏈表
pidle puintptr // idle p's
// 空閑 p 數(shù)量
npidle uint32
// 全局 runnable G 隊列
runq gQueue
runqsize int32
// 有效 dead G 的全局緩存.
gFree struct {
lock mutex
stack gList // Gs with stacks
noStack gList // Gs without stacks
n int32
}
// sudog 結(jié)構(gòu)的集中緩存
sudoglock mutex
sudogcache *sudog
// defer 結(jié)構(gòu)的池
deferlock mutex
deferpool [5]*_defer
...
}
(2) 重要的全局變量
allgs []*g // 保存所有的g
allm *m // 所有的m構(gòu)成的一個鏈表,包括下面的m0
allp []*p // 保存所有的p,len(allp) == gomaxprocs
ncpu int32 // 系統(tǒng)中cpu核的數(shù)量,程序啟動時由runtime代碼初始化
gomaxprocs int32 // p的最大值,默認等于ncpu,但可以通過GOMAXPROCS修改
sched schedt // 調(diào)度器結(jié)構(gòu)體對象,記錄了調(diào)度器的工作狀態(tài)
m0 m // 代表進程的主線程
g0 g // m0的g0,也就是m0.g0 = &g0
在程序初始化時,這些全變量都會被初始化為0值,指針會被初始化為nil指針,切片初始化為nil切片,int被初始化為數(shù)字0,結(jié)構(gòu)體的所有成員變量按其本類型初始化為其類型的0值。所以程序剛啟動時allgs,allm和allp都不包含任何g,m和p。
(3) 線程執(zhí)行的代碼是如何找到屬于自己的那個m結(jié)構(gòu)體實例對象的呢
前面我們說GMP模型中每個工作線程都有一個m結(jié)構(gòu)體對象與之對應(yīng),但并未詳細說明它們之間是如何對應(yīng)起來的~ 如果只有一個工作線程,那么就只會有一個m結(jié)構(gòu)體對象,問題就很簡單,定義一個全局的m結(jié)構(gòu)體變量就行了。可是我們有多個工作線程和多個m需要一一對應(yīng),這里就需要用到線程的本地存儲了。
(4) 線程本地存儲(TLS)
TLS 是一種機制,允許每個線程有自己的獨立數(shù)據(jù)副本。這意味著多個線程可以同時運行而不會相互干擾,因為每個線程都可以訪問自己的數(shù)據(jù)副本。
① 寄存器中**fs 段的作用**
在 Linux 系統(tǒng)中,fs 段可以用于存儲線程的 TLS 數(shù)據(jù),通常通過 fs 段寄存器來訪問。
② go 語言中的使用
- 在 Go 語言的運行時(runtime)中,fs 段被用來存儲與每個 goroutine 相關(guān)的線程局部數(shù)據(jù)。Go 的 GMP 模型(goroutine, M, P)中,m 結(jié)構(gòu)體的 tls 字段通常會被設(shè)置為當前線程的 fs 段,以便快速訪問線程局部存儲。
- 通過將 fs 段與 m 結(jié)構(gòu)體的 tls 字段關(guān)聯(lián),Go 可以高效地管理和訪問每個 goroutine 的特定數(shù)據(jù)。
具體到goroutine調(diào)度器代碼,每個工作線程在剛剛被創(chuàng)建出來進入調(diào)度循環(huán)之前就利用線程本地存儲機制為該工作線程實現(xiàn)了一個指向m結(jié)構(gòu)體實例對象的私有全局變量,這樣在之后的代碼中就使用該全局變量來訪問自己的m結(jié)構(gòu)體對象以及與m相關(guān)聯(lián)的p和g對象。
有了上述數(shù)據(jù)結(jié)構(gòu)以及工作線程與數(shù)據(jù)結(jié)構(gòu)之間的映射機制,我們可以再豐富下前面講到的初始調(diào)度模型:
// 程序啟動時的初始化代碼
......
for i := 0; i < N; i++ { // 創(chuàng)建N個操作系統(tǒng)線程執(zhí)行schedule函數(shù)
create_os_thread(schedule) // 創(chuàng)建一個操作系統(tǒng)線程執(zhí)行schedule函數(shù)
}
// 定義一個線程私有全局變量,注意它是一個指向m結(jié)構(gòu)體對象的指針
// ThreadLocal用來定義線程私有全局變量
ThreadLocal self *m
//schedule函數(shù)實現(xiàn)調(diào)度邏輯
func schedule() {
// 創(chuàng)建和初始化m結(jié)構(gòu)體對象,并賦值給私有全局變量self
self = initm()
for { //調(diào)度循環(huán)
if (self.p.runqueue is empty) {
// 根據(jù)某種算法從全局運行隊列中找出一個需要運行的goroutine
g := find_a_runnable_goroutine_from_global_runqueue()
} else {
// 根據(jù)某種算法從私有的局部運行隊列中找出一個需要運行的goroutine
g := find_a_runnable_goroutine_from_local_runqueue()
}
run_g(g) // CPU運行該goroutine,直到需要調(diào)度其它goroutine才返回
save_status_of_g(g) // 保存goroutine的狀態(tài),主要是寄存器的值
}
}
僅僅從上面這個偽代碼來看,我們完全不需要線程私有全局變量,只需在schedule函數(shù)中定義一個局部變量就行了。但真實的調(diào)度代碼錯綜復雜,不光是這個schedule函數(shù)會需要訪問m,其它很多地方還需要訪問它,所以需要使用全局變量來方便其它地方對m的以及與m相關(guān)的g和p的訪問。
三、從main函數(shù)啟動開始分析
下面我們通過一個簡單的go程序入手分析 調(diào)度器的初始化,go routine的創(chuàng)建與退出,工作線程的調(diào)度循環(huán)以及goroutine的切換。
package main
import "fmt"
func main() {
fmt.Println("Hello World!")
}
1.程序入口
linux amd64系統(tǒng)的啟動函數(shù)是在asm_amd64.s的runtime·rt0_go函數(shù)中。當然,不同的平臺有不同的程序入口。rt0_go函數(shù)完成了go程序啟動時的所有初始化工作,因此這個函數(shù)比較長,也比較繁雜,但這里我們只關(guān)注與調(diào)度器相關(guān)的一些初始化,下面我們分段來看:
TEXT runtime·rt0_go(SB),NOSPLIT|NOFRAME|TOPFRAME,$0
// copy arguments forward on an even stack
MOVQ DI, AX // 這行代碼將寄存器 `DI` 的值(通常是命令行參數(shù)的數(shù)量,即 `argc`)復制到寄存器 `AX` 中。`AX` 現(xiàn)在存儲了程序的參數(shù)個數(shù)
MOVQ SI, BX // 這行代碼將寄存器 `SI` 的值(通常是指向命令行參數(shù)字符串數(shù)組的指針,即 `argv`)復制到寄存器 `BX` 中。`BX` 現(xiàn)在存儲了指向命令行參數(shù)的指針。
SUBQ $(5*8), SP // 這行代碼從棧指針 `SP` 中減去 `40` 字節(jié)(`5*8`),為局部變量和函數(shù)參數(shù)分配空間。這里的 `5` 可能表示 3 個參數(shù)和 2 個自動變量(局部變量)。這行代碼的目的是在棧上為這些變量留出空間。
ANDQ $~15, SP // 這行代碼將棧指針 `SP` 對齊到 16 字節(jié)的邊界。確保棧在函數(shù)調(diào)用時是對齊的
MOVQ AX, 24(SP) // 這行代碼將 `AX` 中的值(即 `argc`)存儲到棧上相對于 `SP` 的偏移量 +`24` 的位置。
MOVQ BX, 32(SP) // - 這行代碼將 `BX` 中的值(即 `argv`)存儲到棧上相對于 `SP` 的偏移量 +`32` 的位置。
上面的第4條指令用于調(diào)整棧頂寄存器的值使其按16字節(jié)對齊,也就是讓棧頂寄存器SP指向的內(nèi)存的地址為16的倍數(shù),最后兩條指令把argc和argv搬到新的位置。
2.初始化g0
繼續(xù)看后面的代碼,下面開始初始化全局變量g0,前面我們說過,g0的主要作用是提供一個棧供runtime代碼執(zhí)行,因此這里主要對g0的幾個與棧有關(guān)的成員進行了初始化,從這里可以看出g0的棧大約有64K。
從系統(tǒng)線程的棧中劃分出一部分作為g0的棧,然后初始化g0的棧信息和stackgard
MOVQ $runtime·g0(SB), DI // 把g0的地址放入寄存器DI
LEAQ (-64*1024)(SP), BX // 設(shè)置寄存器BX的值為 SP(主線程棧棧頂指針) - 64k 的位置
MOVQ BX, g_stackguard0(DI) // g0.stackguard0 = BX , 也就是設(shè)置g0.stackguard0 指向主線程棧的棧頂-64k的位置
MOVQ BX, g_stackguard1(DI) //g0.stackguard1 = SP - 64k
MOVQ BX, (g_stack+stack_lo)(DI) //g0.stack_lo = SP - 64k
MOVQ SP, (g_stack+stack_hi)(DI) //g0.stack_lo = SP
運行完上面這幾行指令后g0與棧之間的關(guān)系如下圖所示:
四、主線程與m0綁定
設(shè)置好g0棧之后,我們跳過CPU型號檢查以及cgo初始化相關(guān)的代碼,接著分析如何把m數(shù)據(jù)結(jié)構(gòu) 和 線程綁定在一起,原因在上面已描述過:每個線程需要能快速找到自己所屬的m結(jié)構(gòu)體。
LEAQ runtime·m0+m_tls(SB), DI // DI = &m0.tls,取m0的tls成員的地址到DI寄存器
CALL runtime·settls(SB) // 調(diào)用settls設(shè)置線程本地存儲,settls函數(shù)的參數(shù)在DI寄存器中
// store through it, to make sure it works
// 驗證settls是否可以正常工作,如果有問題則abort退出程序
get_tls(BX) //獲取fs段基地址并放入BX寄存器,其實就是m0.tls[1]的地址,get_tls的代碼由編譯器生成
MOVQ $0x123, g(BX) //把整型常量0x123設(shè)置到線程本地存儲中
MOVQ runtime·m0+m_tls(SB), AX //獲取m.tls結(jié)構(gòu)體的地址到AX寄存器中
CMPQ AX, $0x123 // 判斷m.tls[0]的值是否等于123,是的話說明tls工作正常
JEQ 2(PC)
CALL runtime·abort(SB)
設(shè)置 tls 的函數(shù) runtime·settls(SB) 位于源碼 src/runtime/sys_linux_amd64.s 處,主要內(nèi)容就是通過一個系統(tǒng)調(diào)用將 fs 段基址設(shè)置成 m.tls[1] 的地址,而 fs 段基址又可以通過 CPU 里的寄存器 fs 來獲取。這段代碼運行后,工作線程代碼就可以通過CPU的 fs 寄存器來找到 m.tls。
1.m0和g0綁定
ok:
// set the per-goroutine and per-mach "registers"
get_tls(BX) //獲取fs段基址到BX寄存器
LEAQ runtime·g0(SB), CX //CX = g0的地址
MOVQ CX, g(BX) //把g0的地址保存在線程本地存儲里面,也就是m0.tls[0]=&g0
LEAQ runtime·m0(SB), AX //AX = m0的地址
// 下面把m0和g0關(guān)聯(lián)起來m0->g0 = g0,g0->m = m0 // save m->g0 = g0
// save m->g0 = g0
MOVQ CX, m_g0(AX) //m0.g0 = g0
// save m0 to g0->m
MOVQ AX, g_m(CX) //g0.m = m0
上面的代碼首先把g0的地址放入主線程的線程本地存儲中,然后把m0和g0綁定在一起,這樣,之后在主線程中通過get_tls可以獲取到g0,通過g0的m成員又可以找到m0,于是這里就實現(xiàn)了m0和g0與主線程之間的關(guān)聯(lián)。
從這里還可以看到,保存在主線程本地存儲中的值是g0的地址,也就是說工作線程的私有全局變量其實是一個指向g的指針而不是指向m的指針,目前這個指針指向g0,表示代碼正運行在g0棧。此時,主線程,m0,g0以及g0的棧之間的關(guān)系如下圖所示:
2.初始化m0
CALL runtime·check(SB)
MOVL 24(SP), AX // copy argc
MOVL AX, 0(SP)
MOVQ 32(SP), AX // copy argv
MOVQ AX, 8(SP)
CALL runtime·args(SB)
CALL runtime·osinit(SB)
// 調(diào)度器初始化
CALL runtime·schedinit(SB)
// 新建一個 goroutine,該 goroutine 綁定 runtime.main
MOVQ $runtime·mainPC(SB), AX // entry
PUSHQ AX
CALL runtime·newproc(SB)
POPQ AX
// 啟動M,開始調(diào)度循環(huán),運行剛剛創(chuàng)建的goroutine
CALL runtime·mstart(SB)
// 上面的mstart永遠不應(yīng)該返回的,如果返回了,一定是代碼邏輯有問題,直接abort
CALL runtime·abort(SB) // mstart should never return
RET
上面的CALL方法中:
- schedinit進行各種運行時組件初始化工作,這包括我們的調(diào)度器與內(nèi)存分配器、回收器的初始化;
- newproc負責根據(jù)主 G 入口地址創(chuàng)建可被運行時調(diào)度的執(zhí)行單元;
- mstart開始啟動調(diào)度器的調(diào)度循環(huán);
3.調(diào)度器初始化(schedinit)
func schedinit() {
// raceinit must be the first call to race detector.
// In particular, it must be done before mallocinit below calls racemapshadow.
//getg函數(shù)在源代碼中沒有對應(yīng)的定義,由編譯器插入類似下面兩行代碼
//get_tls(CX)
//MOVQ g(CX), BX; BX存器里面現(xiàn)在放的是當前g結(jié)構(gòu)體對象的地址
_g_ := getg() // _g_ = &g0
......
//設(shè)置最多啟動10000個操作系統(tǒng)線程,也是最多10000個M
sched.maxmcount = 10000
......
mcommoninit(_g_.m) //初始化m0,因為從前面的代碼我們知道g0->m = &m0
......
sched.lastpoll = uint64(nanotime())
procs := ncpu //系統(tǒng)中有多少核,就創(chuàng)建和初始化多少個p結(jié)構(gòu)體對象
if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
procs = n //如果環(huán)境變量指定了GOMAXPROCS,則創(chuàng)建指定數(shù)量的p
}
if procresize(procs) != nil {//創(chuàng)建和初始化全局變量allp
throw("unknown runnable goroutine during bootstrap")
}
......
}
前面我們已經(jīng)看到,g0的地址已經(jīng)被設(shè)置到了線程本地存儲之中,schedinit通過getg函數(shù)(getg函數(shù)是編譯器實現(xiàn)的,我們在源代碼中是找不到其定義的)從線程本地存儲中獲取當前正在運行的g,這里獲取出來的是g0,然后調(diào)用mcommoninit函數(shù)對m0(g0.m)進行必要的初始化,對m0初始化完成之后調(diào)用procresize初始化系統(tǒng)需要用到的p結(jié)構(gòu)體對象,p就是processor的意思,它的數(shù)量決定了最多可以有多少個goroutine同時并行運行。
schedinit函數(shù)除了初始化m0和p,還設(shè)置了全局變量sched的maxmcount成員為10000,限制最多可以創(chuàng)建10000個操作系統(tǒng)線程出來工作。
(1) M0 初始化
func mcommoninit(mp *m, id int64) {
_g_ := getg()
...
lock(&sched.lock)
// 如果傳入id小于0,那么id則從mReserveID獲取,初次從mReserveID獲取id為0
if id >= 0 {
mp.id = id
} else {
mp.id = mReserveID()
}
//random初始化,用于竊取 G
mp.fastrand[0] = uint32(int64Hash(uint64(mp.id), fastrandseed))
mp.fastrand[1] = uint32(int64Hash(uint64(cputicks()), ^fastrandseed))
if mp.fastrand[0]|mp.fastrand[1] == 0 {
mp.fastrand[1] = 1
}
// 創(chuàng)建用于信號處理的gsignal,只是簡單的從堆上分配一個g結(jié)構(gòu)體對象,然后把棧設(shè)置好就返回了
mpreinit(mp)
if mp.gsignal != nil {
mp.gsignal.stackguard1 = mp.gsignal.stack.lo + _StackGuard
}
// 把 M 掛入全局鏈表allm之中
mp.alllink = allm
...
}
這里傳入的 id 是-1,初次調(diào)用會將 id 設(shè)置為 0,這里并未對m0做什么關(guān)于調(diào)度相關(guān)的初始化,所以可以簡單的認為這個函數(shù)只是把m0放入全局鏈表allm之中就返回了。
m0完成基本的初始化后,繼續(xù)調(diào)用procresize創(chuàng)建和初始化p結(jié)構(gòu)體對象,在這個函數(shù)里面會創(chuàng)建指定個數(shù)(根據(jù)cpu核數(shù)或環(huán)境變量確定)的p結(jié)構(gòu)體對象放在全變量allp里, 并把m0和allp[0]綁定在一起,因此當這個函數(shù)執(zhí)行完成之后就有。
m0.p = allp[0]
allp[0].m = &m0
到這里m0, g0, 和m需要的p完全關(guān)聯(lián)在一起了
(2) P初始化
由于用戶代碼運行過程中也支持通過 GOMAXPROCS()函數(shù)調(diào)用procresize來重新創(chuàng)建和初始化p結(jié)構(gòu)體對象,而在運行過程中再動態(tài)的調(diào)整p牽涉到的問題比較多,所以這個函數(shù)的處理比較復雜,這里只保留了初始化時會執(zhí)行的代碼。
func procresize(nprocs int32) *p {
old := gomaxprocs //系統(tǒng)初始化時 gomaxprocs = 0
......
// Grow allp if necessary.
if nprocs > int32(len(allp)) { //初始化時 len(allp) == 0
// Synchronize with retake, which could be running
// concurrently since it doesn't run on a P.
lock(&allpLock)
if nprocs <= int32(cap(allp)) {
allp = allp[:nprocs]
} else {
//初始化時進入此分支,創(chuàng)建allp 切片
nallp := make([]*p, nprocs)
// Copy everything up to allp's cap so we
// never lose old allocated Ps.
copy(nallp, allp[:cap(allp)])
allp = nallp
}
unlock(&allpLock)
}
// initialize new P's
//循環(huán)創(chuàng)建nprocs個p并完成基本初始化
for i := int32(0); i < nprocs; i++ {
pp := allp[i]
if pp == nil {
pp = new(p)//調(diào)用內(nèi)存分配器從堆上分配一個struct p
pp.id = i
pp.status = _Pgcstop
......
atomicstorep(unsafe.Pointer(&allp[i]), unsafe.Pointer(pp))
}
......
}
......
_g_ := getg() // _g_ = g0
if _g_.m.p != 0 && _g_.m.p.ptr().id < nprocs {//初始化時m0->p還未初始化,所以不會執(zhí)行這個分支
// continue to use the current P
_g_.m.p.ptr().status = _Prunning
_g_.m.p.ptr().mcache.prepareForSweep()
} else {
//初始化時執(zhí)行這個分支
// release the current P and acquire allp[0]
if _g_.m.p != 0 {//初始化時這里不執(zhí)行
_g_.m.p.ptr().m = 0
}
_g_.m.p = 0
_g_.m.mcache = nil
p := allp[0]
p.m = 0
p.status = _Pidle
acquirep(p) //把p和m0關(guān)聯(lián)起來,其實是這兩個strct的成員相互賦值
if trace.enabled {
traceGoStart()
}
}
//下面這個for 循環(huán)把所有空閑的p放入空閑鏈表
var runnablePs *p
for i := nprocs - 1; i >= 0; i-- {
p := allp[i]
if _g_.m.p.ptr() == p {//allp[0]跟m0關(guān)聯(lián)了,所以是不能放任
continue
}
p.status = _Pidle
if runqempty(p) {//初始化時除了allp[0]其它p全部執(zhí)行這個分支,放入空閑鏈表
pidleput(p)
} else {
......
}
}
......
return runnablePs
}
這里總結(jié)一下這個函數(shù)的主要流程:
- 使用make([]*p, nprocs)初始化全局變量allp,即allp = make([]*p, nprocs)
- 循環(huán)創(chuàng)建并初始化nprocs個p結(jié)構(gòu)體對象并依次保存在allp切片之中
- 把m0和allp[0]綁定在一起,即m0.p = allp[0], allp[0].m = m0
- 把除了allp[0]之外的所有p放入到全局變量sched的pidle空閑隊列之中
下面我們用圖來總結(jié)下整個調(diào)度器各部分的組成:
(3) goroutine的創(chuàng)建(newproc)
經(jīng)過上文介紹我們介紹m0初始化中有說到,初始化過程中會新建一個 goroutine,該 goroutine 綁定 runtime.main,而runtime.main實際上最后會走到我們實現(xiàn)的main函數(shù)上。新建goroutine的操作就是通過newproc()調(diào)用來實現(xiàn)的。
newproc函數(shù)用于創(chuàng)建新的goroutine,它有兩個參數(shù),先說第二個參數(shù)fn,新創(chuàng)建出來的goroutine將從fn這個函數(shù)開始執(zhí)行,而這個fn函數(shù)可能也會有參數(shù),newproc的第一個參數(shù)正是fn函數(shù)的參數(shù)以字節(jié)為單位的大小。比如有如下go代碼片段:
func start(a, b, c int64) {
......
}
func main() {
go start(1, 2, 3)
}
編譯器在編譯上面的go語句時,就會把其替換為對newproc函數(shù)的調(diào)用,編譯后的代碼邏輯上等同于下面的偽代碼。
func main() {
push 0x3
push 0x2
push 0x1
runtime.newproc(24, start)
}
可以看到編譯器會幫我們把三個參數(shù)1,2,3分別壓棧作為start函數(shù)的參數(shù),然后再調(diào)用newproc函數(shù)。我們會注意到newproc函數(shù)本身還需要兩個參數(shù),第一個是24,表示start函數(shù)需要24個字節(jié)大小的參數(shù) 為什么需要傳遞start函數(shù)的參數(shù)大小給到newproc函數(shù)呢? 這里是因為新建goroutine會在堆上創(chuàng)建一個全新的棧,需要把start需要用到的參數(shù)先從當前goroutine的棧上拷貝到新的goroutine的棧上之后才能讓其開始執(zhí)行,而newproc函數(shù)本身并不知道需要拷貝多少數(shù)據(jù)到新創(chuàng)建的goroutine的棧上去,所以需要用參數(shù)的方式指定拷貝多少數(shù)據(jù)。
newproc函數(shù)是對newproc1的一個包裝,這里最重要的準備工作有兩個,一個是獲取fn函數(shù)第一個參數(shù)的地址(代碼中的argp),另一個是使用systemstack函數(shù)切換到g0棧,當然,對于我們這個初始化場景來說現(xiàn)在本來就在g0棧,所以不需要切換,然而這個函數(shù)是通用的,在用戶的goroutine中也會創(chuàng)建goroutine,這時就需要進行棧的切換。
// Create a new g running fn with siz bytes of arguments.
// Put it on the queue of g's waiting to run.
// The compiler turns a go statement into a call to this.
// Cannot split the stack because it assumes that the arguments
// are available sequentially after &fn; they would not be
// copied if a stack split occurred.
//go:nosplit
func newproc(siz int32, fn *funcval) {
//函數(shù)調(diào)用參數(shù)入棧順序是從右向左,而且棧是從高地址向低地址增長的
//注意:argp指向fn函數(shù)的第一個參數(shù),而不是newproc函數(shù)的參數(shù)
//參數(shù)fn在棧上的地址+8的位置存放的是fn函數(shù)的第一個參數(shù)
argp := add(unsafe.Pointer(&fn), sys.PtrSize)
gp := getg() //獲取正在運行的g,初始化時是m0.g0
//getcallerpc()返回一個地址,也就是調(diào)用newproc時由call指令壓棧的函數(shù)返回地址,
//對于我們現(xiàn)在這個場景來說,pc就是CALLruntime·newproc(SB)指令后面的POPQ AX這條指令的地址
pc := getcallerpc()
//systemstack的作用是切換到g0棧執(zhí)行作為參數(shù)的函數(shù)
//我們這個場景現(xiàn)在本身就在g0棧,因此什么也不做,直接調(diào)用作為參數(shù)的函數(shù)
systemstack(func() {
newproc1(fn, (*uint8)(argp), siz, gp, pc)
})
}
newproc1函數(shù)的第一個參數(shù)fn是新創(chuàng)建的goroutine需要執(zhí)行的函數(shù),注意這個fn的類型是funcval結(jié)構(gòu)體類型,其定義如下:
type funcval struct {
fn uintptr
// variable-size, fn-specific data here
}
第二個參數(shù)argp是fn函數(shù)的第一個參數(shù)的地址,第三個參數(shù)是fn函數(shù)的參數(shù)以字節(jié)為單位的大小,后面兩個參數(shù)我們不用關(guān)心。這里需要注意的是,newproc1是在g0的棧上執(zhí)行的。
// Create a new g running fn with narg bytes of arguments starting
// at argp. callerpc is the address of the go statement that created
// this. The new g is put on the queue of g's waiting to run.
func newproc1(fn *funcval, argp *uint8, narg int32, callergp *g, callerpc uintptr) {
//因為已經(jīng)切換到g0棧,所以無論什么場景都有 _g_ = g0,當然這個g0是指當前工作線程的g0
//對于我們這個場景來說,當前工作線程是主線程,所以這里的g0 = m0.g0
_g_ := getg()
......
_p_ := _g_.m.p.ptr() //初始化時_p_ = g0.m.p,從前面的分析可以知道其實就是allp[0]
newg := gfget(_p_) //從p的本地緩沖里獲取一個沒有使用的g,初始化時沒有,返回nil
if newg == nil {
//new一個g結(jié)構(gòu)體對象,然后從堆上為其分配棧,并設(shè)置g的stack成員和兩個stackgard成員
newg = malg(_StackMin)
casgstatus(newg, _Gidle, _Gdead) //初始化g的狀態(tài)為_Gdead
//放入全局變量allgs切片中
allgadd(newg) // publishes with a g->status of Gdead so GC scanner doesn't look at uninitialized stack.
}
......
//調(diào)整g的棧頂置針,無需關(guān)注
totalSize := 4*sys.RegSize + uintptr(siz) + sys.MinFrameSize // extra space in case of reads slightly beyond frame
totalSize += -totalSize & (sys.SpAlign - 1) // align to spAlign
sp := newg.stack.hi - totalSize
spArg := sp
//......
if narg > 0 {
//把參數(shù)從執(zhí)行newproc函數(shù)的棧(初始化時是g0棧)拷貝到新g的棧
memmove(unsafe.Pointer(spArg), unsafe.Pointer(argp), uintptr(narg))
// ......
}
這段代碼主要從堆上分配一個g結(jié)構(gòu)體對象并為這個newg分配一個大小為2048字節(jié)的棧,并設(shè)置好newg的stack成員,然后把newg需要執(zhí)行的函數(shù)的參數(shù)從執(zhí)行newproc函數(shù)的棧(初始化時是g0棧)拷貝到newg的棧,完成這些事情之后newg的狀態(tài)如下圖所示:這里需要注意的是,新創(chuàng)建出來的g都是在堆上分配內(nèi)存的,主要有以下幾個原因:
- 生命周期:goroutine的生命周期可能會超過創(chuàng)建它的函數(shù)的生命周期。如果在棧上分配goroutine,那么當創(chuàng)建goroutine的函數(shù)返回時,goroutine可能還在運行,這將導致棧被回收,從而引發(fā)錯誤。而在堆上分配goroutine可以避免這個問題,因為堆上的內(nèi)存只有在沒有任何引用指向它時才會被垃圾回收。
- 大?。篻oroutine的棧大小是動態(tài)的,它可以根據(jù)需要進行擴展和收縮。如果在棧上分配goroutine,那么每次goroutine的棧大小改變時,都需要移動整個goroutine的內(nèi)存,這將導致大量的性能開銷。而在堆上分配goroutine可以避免這個問題,因為堆上的內(nèi)存可以動態(tài)地進行擴展和收縮。
- 并發(fā):在go語言中,可以同時運行多個goroutine。如果在棧上分配goroutine,那么每個線程的棧大小都需要足夠大,以容納所有的goroutine,這將導致大量的內(nèi)存浪費。而在堆上分配goroutine可以避免這個問題,因為堆是所有線程共享的,因此可以更有效地利用內(nèi)存。因此,出于生命周期、大小和并發(fā)等考慮,GMP模型中新創(chuàng)建的goroutine都是在堆上分配內(nèi)存的。下面讓我們用一個圖示來總結(jié)下目前的GMP模型中各個部分的情況。
繼續(xù)分析newproc1():
//把newg.sched結(jié)構(gòu)體成員的所有成員設(shè)置為0
memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched))
//設(shè)置newg的sched成員,調(diào)度器需要依靠這些字段才能把goroutine調(diào)度到CPU上運行。
newg.sched.sp = sp //newg的棧頂
newg.stktopsp = sp
//newg.sched.pc表示當newg被調(diào)度起來運行時從這個地址開始執(zhí)行指令
//把pc設(shè)置成了goexit這個函數(shù)偏移1(sys.PCQuantum等于1)的位置,
//至于為什么要這么做需要等到分析完gostartcallfn函數(shù)才知道
newg.sched.pc = funcPC(goexit) + sys.PCQuantum // +PCQuantum so that previous instruction is in same function
newg.sched.g = guintptr(unsafe.Pointer(newg))
gostartcallfn(&newg.sched, fn) //調(diào)整sched成員和newg的棧
// adjust Gobuf as if it executed a call to fn
// and then did an immediate gosave.
func gostartcallfn(gobuf *gobuf, fv *funcval) {
var fn unsafe.Pointer
if fv != nil {
fn = unsafe.Pointer(fv.fn) //fn: goroutine的入口地址,初始化時對應(yīng)的是runtime.main
} else {
fn = unsafe.Pointer(funcPC(nilfunc))
}
gostartcall(gobuf, fn, unsafe.Pointer(fv))
}
// adjust Gobuf as if it executed a call to fn with context ctxt
// and then did an immediate gosave.
func gostartcall(buf *gobuf, fn, ctxt unsafe.Pointer) {
sp := buf.sp //newg的棧頂,目前newg棧上只有fn函數(shù)的參數(shù),sp指向的是fn的第一參數(shù)
if sys.RegSize > sys.PtrSize {
sp -= sys.PtrSize
*(*uintptr)(unsafe.Pointer(sp)) = 0
}
sp -= sys.PtrSize //為返回地址預留空間,
//這里在偽裝fn是被goexit函數(shù)調(diào)用的,使得fn執(zhí)行完后返回到goexit繼續(xù)執(zhí)行,從而完成清理工作
*(*uintptr)(unsafe.Pointer(sp)) = buf.pc //在棧上放入goexit+1的地址
buf.sp = sp //重新設(shè)置newg的棧頂寄存器
//這里才真正讓newg的ip寄存器指向fn函數(shù),注意,這里只是在設(shè)置newg的一些信息,newg還未執(zhí)行,
//等到newg被調(diào)度起來運行時,調(diào)度器會把buf.pc放入cpu的IP寄存器,
//從而使newg得以在cpu上真正的運行起來
buf.pc = uintptr(fn)
buf.ctxt = ctxt
}
gostartcall函數(shù)的主要作用有兩個:
- 調(diào)整newg的??臻g,把goexit函數(shù)的第二條指令的地址入棧,偽造成goexit函數(shù)調(diào)用了fn,從而使fn執(zhí)行完成后執(zhí)行ret指令時返回到goexit繼續(xù)執(zhí)行完成最后的清理工作;
- 重新設(shè)置newg.buf.pc 為需要執(zhí)行的函數(shù)的地址,即fn,我們這個場景為runtime.main函數(shù)的地址,如果是在運行中g(shù)o aa()啟動的協(xié)程,那么newg.buf.pc會為aa()函數(shù)的地址。
//newg真正從哪里開始執(zhí)行并不依賴于這個成員,而是sched.pc
newg.startpc = fn.fn
......
//設(shè)置g的狀態(tài)為_Grunnable,表示這個g代表的goroutine可以運行了
casgstatus(newg, _Gdead, _Grunnable)
......
//把newg放入_p_的運行隊列,初始化的時候一定是p的本地運行隊列,其它時候可能因為本地隊列滿了而放入全局隊列
runqput(_p_, newg, true)
......
}
這時newg也就是main goroutine的狀態(tài)如下圖所示:
可以看到newproc執(zhí)行完畢時,p, m0, g0, newg, allp的內(nèi)存均已分配好且它們之間的關(guān)系也通過指針掛鉤上,我們留意下newg的堆棧中目前棧頂是 goexit+1 這個位置的返回地址;目前newg尚未被調(diào)度起來運行,只是剛加入p的本地隊列,后續(xù)該newg被cpu調(diào)度到時,cpu sp寄存器會指向棧頂,在newg自身的邏輯開始執(zhí)行后,newg的棧內(nèi)存會被不斷使用,SP寄存器不斷移動來指示棧的內(nèi)存的增長與回收,當所有邏輯執(zhí)行完時,sp寄存器重新指回這個goexit+1 這個位置。最后彈出該位置的值作為cpu rip寄存器的值,從而去執(zhí)行 runtime.goexit1(SB) 這個命令,繼續(xù)進行調(diào)度循環(huán)(這個后面會講到)。
下面我們總結(jié)下newproc做了什么事情:
- 在堆上給新goroutine分配一段內(nèi)存作為棧空間,設(shè)置堆棧信息到新goroutine對應(yīng)的g結(jié)構(gòu)體上,核心是設(shè)置gobuf.pc指向要執(zhí)行的代碼段,待調(diào)度到該g時,會將保存的pc值設(shè)置到cpu的RIP寄存器上從而去執(zhí)行該goroutine對應(yīng)的代碼。
- 把傳遞給goroutine的參數(shù)從當前棧拷貝到新goroutine所在的棧上。
- 把g加入到p的本地隊列等待調(diào)度,如果本地隊列滿了會加入到全局隊列(程序剛啟動時只會加入到p的本地隊列)。
4.調(diào)度循環(huán)的啟動(mstart)
前面我們完成了 newproc 函數(shù),接下來是runtime·rt0_go中的最后一步,啟動調(diào)度循環(huán),即mstart函數(shù)。
func mstart0() {
gp := getg()
...
// Initialize stack guard so that we can start calling regular
// Go code.
gp.stackguard0 = gp.stack.lo + stackGuard
gp.stackguard1 = gp.stackguard0
mstart1()
// Exit this thread.
if GOOS == "windows" || GOOS == "solaris" || GOOS == "plan9" || GOOS == "darwin" || GOOS == "aix" {
// Window, Solaris, Darwin, AIX and Plan 9 always system-allocate
// the stack, but put it in _g_.stack before mstart,
// so the logic above hasn't set osStack yet.
osStack = true
}
}
mstart 函數(shù)設(shè)置了 stackguard0 和 stackguard1 字段后,就直接調(diào)用 mstart1() 函數(shù),由于只分析main goroutine的啟動,這里省略部分無關(guān)的代碼:
func mstart1() {
// 啟動過程時 _g_ = m0.g0
_g_ := getg()
//getcallerpc()獲取mstart1執(zhí)行完的返回地址
//getcallersp()獲取調(diào)用mstart1時的棧頂?shù)刂? save(getcallerpc(), getcallersp())
...
// 進入調(diào)度循環(huán)。永不返回
schedule()
save函數(shù)非常重要,它主要做了這兩個操作:
gp.sched.pc = getcallerpc()
gp.sched.sp = getcallersp()
將mstart調(diào)用mstart1的返回地址以及當時的棧頂指針保存到g0的sched結(jié)構(gòu)中,保存好后,我們可以看到現(xiàn)在的指針指向情況(注意紅線部分) 。
這里設(shè)置好后,g0對象的sp值就不會變化了,一直指向mstart函數(shù)的棧頂,后續(xù)每次切換回g0時,都會從g0對象的sp值中恢復寄存器SP,從而切換到g0棧。
繼續(xù)分析代碼,先看下schedule()函數(shù)的邏輯,這是GMP模型調(diào)度邏輯的核心,每次調(diào)度goroutine都是從它開始的:
// One round of scheduler: find a runnable goroutine and execute it.
// Never returns.
func schedule() {
_g_ := getg() //_g_ = 每個工作線程m對應(yīng)的g0,初始化時是m0的g0
//......
var gp *g
//......
if gp == nil {
// Check the global runnable queue once in a while to ensure fairness.
// Otherwise two goroutines can completely occupy the local runqueue
// by constantly respawning each other.
//為了保證調(diào)度的公平性,每進行61次調(diào)度就需要優(yōu)先從全局運行隊列中獲取goroutine,
//因為如果只調(diào)度本地隊列中的g,那么全局運行隊列中的goroutine將得不到運行
if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock) //所有工作線程都能訪問全局運行隊列,所以需要加鎖
gp = globrunqget(_g_.m.p.ptr(), 1) //從全局運行隊列中獲取1個goroutine
unlock(&sched.lock)
}
}
if gp == nil {
//從與m關(guān)聯(lián)的p的本地運行隊列中獲取goroutine
gp, inheritTime = runqget(_g_.m.p.ptr())
if gp != nil && _g_.m.spinning {
throw("schedule: spinning with local work")
}
}
if gp == nil {
//如果從本地運行隊列和全局運行隊列都沒有找到需要運行的goroutine,
//則調(diào)用findrunnable函數(shù)從其它工作線程的運行隊列中偷取,如果偷取不到,則當前工作線程進入睡眠,
//直到獲取到需要運行的goroutine之后findrunnable函數(shù)才會返回。
gp, inheritTime = findrunnable() // blocks until work is available
}
//跟啟動無關(guān)的代碼.....
//當前運行的是runtime的代碼,函數(shù)調(diào)用棧使用的是g0的棧空間
//調(diào)用execte切換到gp的代碼和??臻g去運行
execute(gp, inheritTime)
}
schedule函數(shù)優(yōu)先從p本地隊列獲取goroutine,獲取不到時會去全局運行隊列中加鎖獲取goroutine,在我們的場景中,前面的啟動流程已經(jīng)創(chuàng)建好第一個goroutine并放入了當前工作線程的本地運行隊列(即runtime.main對應(yīng)的goroutine)。獲取到g后,會調(diào)用execute去切換到g,具體的切換邏輯繼續(xù)看下execute函數(shù)。
func execute(gp *g, inheritTime bool) {
_g_ := getg() //g0
//設(shè)置待運行g(shù)的狀態(tài)為_Grunning
casgstatus(gp, _Grunnable, _Grunning)
//......
//把g和m關(guān)聯(lián)起來
_g_.m.curg = gp
gp.m = _g_.m
//......
//gogo完成從g0到gp真正的切換
gogo(&gp.sched)
}
這里的重點是gogo函數(shù),真正完成了g0到g的切換,切換的實質(zhì)就是CPU寄存器以及函數(shù)調(diào)用棧的切換:
TEXT runtime·gogo(SB), NOSPLIT, $16-8
//buf = &gp.sched
MOVQ buf+0(FP), BX # BX = buf
//gobuf->g --> dx register
MOVQ gobuf_g(BX), DX # DX = gp.sched.g
//下面這行代碼沒有實質(zhì)作用,檢查gp.sched.g是否是nil,如果是nil進程會crash死掉
MOVQ 0(DX), CX # make sure g != nil
get_tls(CX)
//把要運行的g的指針放入線程本地存儲,這樣后面的代碼就可以通過線程本地存儲
//獲取到當前正在執(zhí)行的goroutine的g結(jié)構(gòu)體對象,從而找到與之關(guān)聯(lián)的m和p
MOVQ DX, g(CX)
//把CPU的SP寄存器設(shè)置為sched.sp,完成了棧的切換(畫重點!)
MOVQ gobuf_sp(BX), SP # restore SP
//下面三條同樣是恢復調(diào)度上下文到CPU相關(guān)寄存器
MOVQ gobuf_ret(BX), AX
MOVQ gobuf_ctxt(BX), DX
MOVQ gobuf_bp(BX), BP
//清空sched的值,因為我們已把相關(guān)值放入CPU對應(yīng)的寄存器了,不再需要,這樣做可以少gc的工作量
MOVQ $0, gobuf_sp(BX) # clear to help garbage collector
MOVQ $0, gobuf_ret(BX)
MOVQ $0, gobuf_ctxt(BX)
MOVQ $0, gobuf_bp(BX)
//把sched.pc值放入BX寄存器
MOVQ gobuf_pc(BX), BX
//JMP把BX寄存器的包含的地址值放入CPU的IP寄存器,于是,CPU跳轉(zhuǎn)到該地址繼續(xù)執(zhí)行指令,
JMP BX
這個函數(shù),其實就只做了兩件事:
- 把gp.sched的成員恢復到CPU的寄存器完成狀態(tài)以及棧的切換;
- 跳轉(zhuǎn)到gp.sched.pc所指的指令地址(runtime.main)處執(zhí)行。最后我們再總結(jié)一下程序開始運行后從g0棧切換到main goroutine棧的流程
保存g0的調(diào)度信息,主要是保存CPU棧頂寄存器SP到g0.sched.sp成員之中;調(diào)用schedule函數(shù)尋找需要運行的goroutine,我們這個場景找到的是main goroutine;調(diào)用gogo函數(shù)首先從g0棧切換到main goroutine的棧,然后從main goroutine的g結(jié)構(gòu)體對象之中取出sched.pc的值并使用JMP指令跳轉(zhuǎn)到該地址去執(zhí)行;
五、go的調(diào)度循環(huán)是什么
上文我們分析了main goroutine的啟動,main的goroutine和非main得goroutine稍微會有一點差別,主要在于main goutine對應(yīng)的runtime.main函數(shù),執(zhí)行完畢后會直接在匯編代碼中執(zhí)行exit從而退出程序,而非main goroutine在執(zhí)行完對應(yīng)的邏輯后,會進入調(diào)度循環(huán),不斷找到下一個goroutine來執(zhí)行。假設(shè)我們在代碼中使用go aa()啟動了一個協(xié)程,從aa()被開始調(diào)度到aa運行完后退出,是沿著這個路徑來執(zhí)行的。
schedule()->execute()->gogo()->aa()->goexit()->goexit1()->mcall()->goexit0()->schedule()
可以看出,一輪調(diào)度是從調(diào)用schedule函數(shù)開始的,然后經(jīng)過一系列代碼的執(zhí)行到最后又再次通過調(diào)用schedule函數(shù)來進行新一輪的調(diào)度,從一輪調(diào)度到新一輪調(diào)度的這一過程我們稱之為一個調(diào)度循環(huán),這里說的調(diào)度循環(huán)是指某一個工作線程的調(diào)度循環(huán),而同一個Go程序中可能存在多個工作線程,每個工作線程都有自己的調(diào)度循環(huán),也就是說每個工作線程都在進行著自己的調(diào)度循環(huán)。
調(diào)度循環(huán)的細節(jié)這里就不再分析,本文就只介紹到協(xié)程調(diào)度的核心原理,相信看完本文你已經(jīng)有所收獲~ 最后讓我們用一個圖來了解下調(diào)度循環(huán)的大體流程: