偷偷摘套内射激情视频,久久精品99国产国产精,中文字幕无线乱码人妻,中文在线中文a,性爽19p

理解 Go 協(xié)程調(diào)度的本質(zhì)

開(kāi)發(fā)
本文將從涉及到的一些基礎(chǔ)知識(shí)開(kāi)始,逐步介紹到go協(xié)程調(diào)度的核心原理,希望你能有所收獲。

作者 | jiayan

golang的一大特色就是goroutine,它是支持高并發(fā)程序的重要保障;通過(guò) go 關(guān)鍵字我們就能輕易創(chuàng)建大量的輕量級(jí)協(xié)程,但它和我們認(rèn)知中的線程有什么區(qū)別呢,輕量在哪里,具體是如何進(jìn)行調(diào)度的..... 本文將從涉及到的一些基礎(chǔ)知識(shí)開(kāi)始,逐步介紹到go協(xié)程調(diào)度的核心原理,希望你能有所收獲~

一、函數(shù)調(diào)用棧

1.進(jìn)程在內(nèi)存中的布局

首先回顧下進(jìn)程的內(nèi)存布局~ 操作系統(tǒng)把磁盤(pán)上的可執(zhí)行文件加載到內(nèi)存運(yùn)行之前,會(huì)做很多工作,其中很重要的一件事情就是把可執(zhí)行文件中的代碼,數(shù)據(jù)放在內(nèi)存中合適的位置,并分配和初始化程序運(yùn)行過(guò)程中所必須的堆棧,所有準(zhǔn)備工作完成后操作系統(tǒng)才會(huì)調(diào)度程序起來(lái)運(yùn)行。

用戶程序所使用的內(nèi)存空間在低地址,內(nèi)核空間所使用的內(nèi)存在高地址,需要特別注意的是棧是從高地址往低地址生長(zhǎng)。

2.各區(qū)域詳解

  • 代碼區(qū),也被稱為代碼段,這部分內(nèi)存存放了程序的機(jī)器代碼。這部分內(nèi)存通常是只讀的,以防止程序意外地修改其自身的指令。
  • 數(shù)據(jù)區(qū),包括程序的全局變量和靜態(tài)變量,程序加載完畢后數(shù)據(jù)區(qū)的大小也不會(huì)發(fā)生改變。
  • 堆,堆是用于動(dòng)態(tài)內(nèi)存分配的區(qū)域,例如c語(yǔ)言的malloc函數(shù)和go語(yǔ)言的new函數(shù)就是在堆上分配內(nèi)存。堆從低地址向高地址增長(zhǎng)。
  • 棧,棧內(nèi)存是一個(gè)連續(xù)的內(nèi)存區(qū)域,通常從高地址向低地址增長(zhǎng)。每次函數(shù)調(diào)用都會(huì)在棧上分配一個(gè)新的棧幀,函數(shù)返回時(shí)棧幀會(huì)被釋放。棧幀包含了函數(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í)行的指令地址;

每個(gè)函數(shù)在執(zhí)行過(guò)程中都需要使用一塊棧內(nèi)存用來(lái)保存上述這些值,我們稱這塊棧內(nèi)存為某函數(shù)的棧幀(stack frame)。

(2) 與棧密切相關(guān)的三個(gè)寄存器

AMD64 CPU中有3個(gè)與棧密切相關(guān)的寄存器:

  • rsp寄存器 ,始終指向當(dāng)前函數(shù)調(diào)用棧棧頂。
  • rbp寄存器 ,一般用來(lái)指向當(dāng)前函數(shù)棧幀的起始位置,即棧底。
  • ip寄存器,保存著下一條將要執(zhí)行的指令的內(nèi)存地址。CPU在執(zhí)行指令時(shí),會(huì)根據(jù)IP寄存器的值從內(nèi)存中獲取指令從而執(zhí)行,在大多數(shù)情況下,IP寄存器的值會(huì)按順序遞增,以指向下一條指令,這使得程序能夠順序執(zhí)行。

假設(shè)現(xiàn)在有如下的一段go函數(shù)調(diào)用鏈且**當(dāng)前正在執(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)大致如下圖所示(注意,棧從高地址向低地址方向生長(zhǎng)):

對(duì)于上圖,有幾點(diǎn)需要說(shuō)明一下:

  • go語(yǔ)言中調(diào)用函數(shù)時(shí),參數(shù)和返回值都是存放在調(diào)用者的棧幀之中,而不是在被調(diào)函數(shù)之中;
  • 目前正在執(zhí)行C函數(shù),且函數(shù)調(diào)用鏈為A()->B()->C(),所以以棧幀為單位來(lái)看的話,C函數(shù)的棧幀目前位于棧頂;
  • cpu硬件寄存器rsp指向整個(gè)棧的棧頂,當(dāng)然它也指向C函數(shù)的棧幀的棧頂,而rbp寄存器指向的是C函數(shù)棧幀的起始位置;
  • 雖然圖中ABC三個(gè)函數(shù)的棧幀看起來(lái)都差不多大,但事實(shí)上在真實(shí)的程序中,每個(gè)函數(shù)的棧幀大小可能都不同,因?yàn)椴煌暮瘮?shù)局部變量的個(gè)數(shù)以及所占內(nèi)存的大小都不盡相同;
  • 有些編譯器比如gcc會(huì)把參數(shù)和返回值放在寄存器中而不是棧中,go語(yǔ)言中函數(shù)的參數(shù)和返回值都是放在棧上的;

隨著程序的運(yùn)行,如果C、B兩個(gè)函數(shù)都執(zhí)行完成并返回到了A函數(shù)繼續(xù)執(zhí)行,則棧狀態(tài)如下圖:

因?yàn)镃、B兩個(gè)函數(shù)都已經(jīng)執(zhí)行完成并返回到了A函數(shù)之中,所以C、B兩個(gè)函數(shù)的棧幀就已經(jīng)被POP出棧了,也就是說(shuō)它們所消耗的棧內(nèi)存被自動(dòng)回收了。因?yàn)楝F(xiàn)在正在執(zhí)行A函數(shù),所以寄存器rbp和rsp指向的是A函數(shù)的棧中的相應(yīng)位置??梢钥吹絚pu 的rbp, rsp分別指向了a函數(shù)的棧底和棧頂,同時(shí)ip寄存器指向了A函數(shù)調(diào)用完B后的下一行代碼 test:= 123的地址,接下來(lái)CPU會(huì)根據(jù)IP寄存器,從代碼區(qū)的內(nèi)存中獲取下一行代碼所對(duì)應(yīng)的匯編指令去執(zhí)行。

(3) 棧溢出

即使是同一個(gè)函數(shù),每次調(diào)用都會(huì)產(chǎn)生一個(gè)不同的棧幀,因此對(duì)于遞歸函數(shù),每遞歸一次都會(huì)消耗一定的棧內(nèi)存,如果遞歸層數(shù)太多就有導(dǎo)致棧溢出的風(fēng)險(xiǎn),這也是為什么我們?cè)趯?shí)際的開(kāi)發(fā)過(guò)程中應(yīng)該盡量避免使用遞歸函數(shù)的原因之一,另外一個(gè)原因是遞歸函數(shù)執(zhí)行效率比較低,因?yàn)樗磸?fù)調(diào)用函數(shù),而調(diào)用函數(shù)有較大的性能開(kāi)銷。

二、Linux線程以及線程調(diào)度

1.一段c程序

要深入理解go的協(xié)程調(diào)度邏輯,就需要對(duì)操作系統(tǒng)線程有個(gè)大致的了解,因?yàn)間o的調(diào)度系統(tǒng)是建立在操作系統(tǒng)線程之上的,所以我們先來(lái)通過(guò)linux下的C語(yǔ)言demo入手,我們把這個(gè)程序跑在一臺(tái)單核CPU的機(jī)器上。

C語(yǔ)言中我們一般使用pthread線程庫(kù),而使用該線程庫(kù)創(chuàng)建的用戶態(tài)線程其實(shí)就是Linux操作系統(tǒng)內(nèi)核所支持的線程,它與go語(yǔ)言中的工作線程是一樣的,這些線程都由Linux內(nèi)核負(fù)責(zé)管理和調(diào)度,然后go語(yǔ)言在操作系統(tǒng)線程之上又做了goroutine,實(shí)現(xiàn)了一個(gè)二級(jí)線程模型。

#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)建一個(gè)新線程執(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é)束運(yùn)行

        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

該程序運(yùn)行起來(lái)之后將會(huì)有2個(gè)線程,一個(gè)是操作系統(tǒng)把程序加載起來(lái)運(yùn)行時(shí)創(chuàng)建的主線程,另一個(gè)是主線程調(diào)用pthread_create創(chuàng)建的start子線程,主線程在創(chuàng)建完子線程之后每隔500毫秒打印一下全局變量 g 的值直到 g 等于10億,而start線程啟動(dòng)后就開(kāi)始執(zhí)行一個(gè)10億次的對(duì) g 自增加 1 的循環(huán),這兩個(gè)線程同時(shí)并發(fā)運(yùn)行在系統(tǒng)中,操作系統(tǒng)負(fù)責(zé)對(duì)它們進(jìn)行調(diào)度,我們無(wú)法精確預(yù)知某個(gè)線程在什么時(shí)候會(huì)運(yùn)行。

2.操作系統(tǒng)在調(diào)度線程時(shí)會(huì)做哪些事情

  • 選擇線程:操作系統(tǒng)調(diào)度器會(huì)根據(jù)特定的調(diào)度算法(如優(yōu)先級(jí)調(diào)度、輪轉(zhuǎn)調(diào)度、最短作業(yè)優(yōu)先等)選擇下一個(gè)要執(zhí)行的線程。
  • 上下文切換:操作系統(tǒng)會(huì)保存當(dāng)前正在運(yùn)行的線程的狀態(tài)(這被稱為上下文),然后加載被選中的線程的上下文。上下文包括了線程的程序計(jì)數(shù)器、寄存器的值等。
  • 線程切換:操作系統(tǒng)會(huì)將CPU的控制權(quán)交給被選中的線程,該線程會(huì)從它上次停止的地方開(kāi)始執(zhí)行。
  • 回到原線程:當(dāng)被選中的線程的執(zhí)行時(shí)間片用完或者被阻塞時(shí),操作系統(tǒng)會(huì)再次保存它的上下文,然后選擇另一個(gè)線程執(zhí)行。這個(gè)過(guò)程會(huì)不斷重復(fù)。

3.具體需要保存哪些寄存器呢

  • 通用寄存器,線程運(yùn)行時(shí)很可能會(huì)用到,保存當(dāng)前線程的一些工作變量。
  • ip寄存器(指令指針寄存器)(程序運(yùn)行起來(lái)后低地址有一部分專門(mén)用來(lái)存放代碼數(shù)據(jù),IP寄存器通常指向這一區(qū)域,指明下一條要運(yùn)行的代碼地址)。
  • 棧寄存器RBP(指向當(dāng)前棧的棧底)和RSP(當(dāng)前棧的棧頂),2個(gè)棧寄存器確定了線程執(zhí)行時(shí)需要使用的棧內(nèi)存。所以恢復(fù)CPU寄存器的值就相當(dāng)于改變了CPU下一條需要執(zhí)行的指令,同時(shí)也切換了函數(shù)調(diào)用棧。

4.線程調(diào)度的核心是什么

操作系統(tǒng)對(duì)線程的調(diào)度可以簡(jiǎn)單的理解為內(nèi)核調(diào)度器對(duì)不同線程所使用的寄存器和棧的切換。

三、goroutine調(diào)度器

1.調(diào)度模型

(1) 傳統(tǒng)線程模型的問(wèn)題

① 調(diào)度

上面講到了線程是操作系統(tǒng)級(jí)別的調(diào)度單位,通常由操作系統(tǒng)內(nèi)核管理。切上下文切換的開(kāi)銷通常在微秒級(jí)別,且頻繁的上下文切換會(huì)顯著影響性能。

② 資源消耗

每個(gè)線程都有自己的堆棧和線程局部存儲(chǔ)(Thread Local Storage),這會(huì)消耗更多的內(nèi)存資源。創(chuàng)建和銷毀線程的開(kāi)銷也相對(duì)較大。在 Linux 中,默認(rèn)的線程棧大小通常為 8 MB。

③ 調(diào)度策略

線程調(diào)度通常由操作系統(tǒng)內(nèi)核使用復(fù)雜的調(diào)度算法(如輪轉(zhuǎn)調(diào)度、優(yōu)先級(jí)調(diào)度等)來(lái)管理。調(diào)度器需要考慮多個(gè)線程的優(yōu)先級(jí)、狀態(tài)、資源占用等因素,調(diào)度過(guò)程相對(duì)復(fù)雜。

(2)  goroutine有多輕量

而相對(duì)的,用戶態(tài)的goroutine則輕量得多:

  • goroutine是用戶態(tài)線程,其創(chuàng)建和切換都在用戶代碼中完成而無(wú)需進(jìn)入操作系統(tǒng)內(nèi)核,所以其開(kāi)銷要遠(yuǎn)遠(yuǎn)小于系統(tǒng)線程的創(chuàng)建和切換;
  • goroutine啟動(dòng)時(shí)默認(rèn)棧大小只有2k,這在多數(shù)情況下已經(jīng)夠用了,即使不夠用,goroutine的棧也會(huì)自動(dòng)擴(kuò)大,同時(shí),如果棧太大了過(guò)于浪費(fèi)它還能自動(dòng)收縮,這樣既沒(méi)有棧溢出的風(fēng)險(xiǎn),也不會(huì)造成棧內(nèi)存空間的大量浪費(fèi)。

正是因?yàn)間o語(yǔ)言中實(shí)現(xiàn)了如此輕量級(jí)的線程,才使得我們?cè)贕o程序中,可以輕易的創(chuàng)建成千上萬(wàn)甚至上百萬(wàn)的goroutine出來(lái)并發(fā)的執(zhí)行任務(wù)而不用太擔(dān)心性能和內(nèi)存等問(wèn)題。

(3) go調(diào)度器的簡(jiǎn)化模型

goroutine建立在操作系統(tǒng)線程基礎(chǔ)之上,它與操作系統(tǒng)線程之間實(shí)現(xiàn)了一個(gè)多對(duì)多(M:N)的兩級(jí)線程模型 這里的 M:N 是指M個(gè)goroutine運(yùn)行在N個(gè)操作系統(tǒng)線程之上,內(nèi)核負(fù)責(zé)對(duì)這N個(gè)操作系統(tǒng)線程進(jìn)行調(diào)度,而這N個(gè)系統(tǒng)線程又負(fù)責(zé)對(duì)這M個(gè)goroutine進(jìn)行調(diào)度和運(yùn)行。

所謂的對(duì)goroutine的調(diào)度,是指程序代碼按照一定的算法在適當(dāng)?shù)臅r(shí)候挑選出合適的goroutine并放到CPU上去運(yùn)行的過(guò)程,這些負(fù)責(zé)對(duì)goroutine進(jìn)行調(diào)度的程序代碼我們稱之為goroutine調(diào)度器。用極度簡(jiǎn)化了的偽代碼來(lái)描述goroutine調(diào)度器的工作流程大概是下面這個(gè)樣子:

// 程序啟動(dòng)時(shí)的初始化代碼
......
for i := 0; i < N; i++ { // 創(chuàng)建N個(gè)操作系統(tǒng)線程執(zhí)行schedule函數(shù)
     create_os_thread(schedule) // 創(chuàng)建一個(gè)操作系統(tǒng)線程執(zhí)行schedule函數(shù)
}

//schedule函數(shù)實(shí)現(xiàn)調(diào)度邏輯
func schedule() {
    for { //調(diào)度循環(huán)
          // 根據(jù)某種算法從M個(gè)goroutine中找出一個(gè)需要運(yùn)行的goroutine
          g := find_a_runnable_goroutine_from_M_goroutines()
          run_g(g) // CPU運(yùn)行該goroutine,直到需要調(diào)度其它goroutine才返回
          save_status_of_g(g) // 保存goroutine的狀態(tài),主要是寄存器的值
      }
}

這段偽代碼表達(dá)的意思是,程序運(yùn)行起來(lái)之后創(chuàng)建了N個(gè)由內(nèi)核調(diào)度的操作系統(tǒng)線程(為了方便描述,我們稱這些系統(tǒng)線程為工作線程)去執(zhí)行shedule函數(shù),而schedule函數(shù)在一個(gè)調(diào)度循環(huán)中反復(fù)從M個(gè)goroutine中挑選出一個(gè)需要運(yùn)行的goroutine并跳轉(zhuǎn)到該goroutine去運(yùn)行,直到需要調(diào)度其它goroutine時(shí)才返回到schedule函數(shù)中通過(guò)save_status_of_g保存剛剛正在運(yùn)行的goroutine的狀態(tài)然后再次去尋找下一個(gè)goroutine。

(4) GM模型

在 Go 1.1版本之前,其實(shí)用的就是GM模型。GM模型的調(diào)度邏輯和上面講到的簡(jiǎn)化版模型非常類似,是一種多對(duì)多的模型,go程序底層使用了多個(gè)操作系統(tǒng)線程,同時(shí)在go語(yǔ)言層面實(shí)現(xiàn)了語(yǔ)言級(jí)的輕量級(jí)協(xié)程goroutine(對(duì)操作系統(tǒng)來(lái)說(shuō)是透明的,操作系統(tǒng)只知道切換線程并且執(zhí)行線程上的代碼),每個(gè)操作系統(tǒng)線程都會(huì)不斷的去全局隊(duì)列中獲取goroutine來(lái)執(zhí)行。

  • goroutine (G): goroutine 是 go 語(yǔ)言中的輕量級(jí)線程。它們由 go 運(yùn)行時(shí)管理,創(chuàng)建和銷毀的開(kāi)銷相對(duì)較小。用戶可以通過(guò) go 關(guān)鍵字輕松地啟動(dòng)一個(gè)新的 goroutine。
  • 操作系統(tǒng)線程 (M): M 代表操作系統(tǒng)線程,go 運(yùn)行時(shí)使用這些線程來(lái)執(zhí)行 goroutine。每個(gè) M 線程可以在操作系統(tǒng)的線程池中運(yùn)行,負(fù)責(zé)執(zhí)行 goroutine 的代碼。

(5) GM模型的缺點(diǎn)

有一個(gè)全局隊(duì)列帶來(lái)了一個(gè)問(wèn)題,因?yàn)閺年?duì)列中獲取 goroutine 必須要加鎖,導(dǎo)致鎖的爭(zhēng)用非常頻繁。尤其是在大量 goroutine 被調(diào)度的情況下,對(duì)性能的影響也會(huì)非常明顯。

每個(gè)線程在運(yùn)行時(shí)都可能會(huì)遇到需要進(jìn)行系統(tǒng)調(diào)用的情況。早期GM模型中每個(gè)M都關(guān)聯(lián)了內(nèi)存緩存(mcache)和其他的緩存(??臻g),但實(shí)際上只有正在運(yùn)行的 go 代碼的 M 才需要 mcache(阻塞在系統(tǒng)調(diào)用的 M 不需要 mcache)。運(yùn)行 go 代碼的 M 和系統(tǒng)調(diào)用阻塞的 M 比例大概在 1:100,這就導(dǎo)致了大量的資源消耗(每個(gè) mcache 會(huì)占用到 2M)。

造成延遲和額外的系統(tǒng)負(fù)載。比如當(dāng)G中包含創(chuàng)建新協(xié)程的時(shí)候,M創(chuàng)建了G’,為了繼續(xù)執(zhí)行G,需要把G’交給M’執(zhí)行,也造成了很差的局部性,因?yàn)镚’和G是相關(guān)的,最好放在M上執(zhí)行,而不是其他M'。

(6) GMP模型

基于沒(méi)有什么是加一個(gè)中間層不能解決的思路,golang在原有的GM模型的基礎(chǔ)上加入了一個(gè)調(diào)度器P,可以簡(jiǎn)單理解為是在G和M中間加了個(gè)中間層。于是就有了現(xiàn)在的GMP模型里。

  • P 的加入,還帶來(lái)了一個(gè)本地協(xié)程隊(duì)列,跟前面提到的全局隊(duì)列類似,也是用于存放G,想要獲取等待運(yùn)行的G,會(huì)優(yōu)先從本地隊(duì)列里拿,訪問(wèn)本地隊(duì)列無(wú)需加鎖。而全局協(xié)程隊(duì)列依然是存在的,但是功能被弱化,不到萬(wàn)不得已是不會(huì)去全局隊(duì)列里拿G的。
  • GM模型里M想要運(yùn)行G,直接去全局隊(duì)列里拿就行了;GMP模型里,M想要運(yùn)行G,就得先獲取P,然后從 P 的本地隊(duì)列獲取 G。
  • 新建 G 時(shí),新G會(huì)優(yōu)先加入到 P 的本地隊(duì)列;如果本地隊(duì)列滿了,則會(huì)把本地隊(duì)列中一半的 G 移動(dòng)到全局隊(duì)列。P 的本地隊(duì)列為空時(shí),就從全局隊(duì)列里去取。

  • 新建 G 時(shí),新G會(huì)優(yōu)先加入到 P 的本地隊(duì)列;如果本地隊(duì)列滿了,則會(huì)把本地隊(duì)列中一半的 G 移動(dòng)到全局隊(duì)列。
  • P 的本地隊(duì)列為空時(shí),就從全局隊(duì)列里去取。

  • 如果全局隊(duì)列為空時(shí),M 會(huì)從其他 P 的本地隊(duì)列偷(stealing)一半 G放到自己 P 的本地隊(duì)列。

  • M 運(yùn)行 G,G 執(zhí)行之后,M 會(huì)從 P 獲取下一個(gè) G,不斷重復(fù)下去。

(7) 為什么要有P

這時(shí)候就有會(huì)疑惑了,如果是想實(shí)現(xiàn)本地隊(duì)列、Work Stealing 算法,那為什么不直接在 M 上加呢,M 也照樣可以實(shí)現(xiàn)類似的功能。為什么又再加多一個(gè) P 組件?結(jié)合 M(系統(tǒng)線程) 的定位來(lái)看,若這么做,有以下問(wèn)題。

  • 一般來(lái)講,M 的數(shù)量都會(huì)多于 P。像在 Go 中,M 的數(shù)量最大限制是 10000,P 的默認(rèn)數(shù)量的 CPU 核數(shù)。另外由于 M 的屬性,也就是如果存在系統(tǒng)阻塞調(diào)用,阻塞了 M,又不夠用的情況下,M 會(huì)不斷增加。
  • M 不斷增加的話,如果本地隊(duì)列掛載在 M 上,那就意味著本地隊(duì)列也會(huì)隨之增加。這顯然是不合理的,因?yàn)楸镜仃?duì)列的管理會(huì)變得復(fù)雜,且 Work Stealing 性能會(huì)大幅度下降。
  • M 被系統(tǒng)調(diào)用阻塞后,我們是期望把他既有未執(zhí)行的任務(wù)分配給其他繼續(xù)運(yùn)行的,而不是一阻塞就導(dǎo)致全部停止

2.調(diào)度器數(shù)據(jù)結(jié)構(gòu)概述

系統(tǒng)線程對(duì)goroutine的調(diào)度與內(nèi)核對(duì)系統(tǒng)線程的調(diào)度原理是一樣的,實(shí)質(zhì)都是通過(guò)保存和修改CPU寄存器的值來(lái)達(dá)到切換線程/goroutine的目的。因此,為了實(shí)現(xiàn)對(duì)goroutine的調(diào)度,需要引入一個(gè)數(shù)據(jù)結(jié)構(gòu)來(lái)保存CPU寄存器的值以及goroutine的其它一些狀態(tài)信息,在go語(yǔ)言調(diào)度器源代碼中,這個(gè)數(shù)據(jù)結(jié)構(gòu)是一個(gè)名叫g(shù)的結(jié)構(gòu)體,它保存了goroutine的所有信息,該結(jié)構(gòu)體的每一個(gè)實(shí)例對(duì)象都代表了一個(gè)goroutine。調(diào)度器代碼可以通過(guò)g對(duì)象來(lái)對(duì)goroutine進(jìn)行調(diào)度。

  • 當(dāng)goroutine被調(diào)離CPU時(shí),調(diào)度器代碼負(fù)責(zé)把CPU寄存器的值保存在g對(duì)象的成員變量之中
  • 當(dāng)goroutine被調(diào)度起來(lái)運(yùn)行時(shí),調(diào)度器代碼又負(fù)責(zé)把g對(duì)象的成員變量所保存的寄存器的值恢復(fù)到CPU的寄存器

前面我們所講的G,M,P,在源碼中均有與之對(duì)應(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使用的棧,當(dāng)前 goroutine 的棧內(nèi)存范圍 [stack.lo, stack.hi)
    stack       stack 
    // 下面兩個(gè)成員用于棧溢出檢查,實(shí)現(xiàn)棧的自動(dòng)伸縮,搶占調(diào)度也會(huì)用到stackguard0  
    stackguard0 uintptr   

    _panic       *_panic  
    _defer       *_defer  
    // 此goroutine正在被哪個(gè)工作線程執(zhí)行
    m            *m       
    // 存儲(chǔ) goroutine 的調(diào)度相關(guān)的數(shù)據(jù)
    sched        gobuf 
    
 // schedlink字段指向全局運(yùn)行隊(duì)列中的下一個(gè)g,
 // 所有位于全局運(yùn)行隊(duì)列中的g形成一個(gè)鏈表
 schedlink      guintptr
 
    // 不涉及本篇內(nèi)容的字段已剔除
 ...
}

下面看看gobuf結(jié)構(gòu)體,主要在調(diào)度器保存或者恢復(fù)上下文的時(shí)候用到:

type gobuf struct {
    // 棧指針,對(duì)應(yīng)上文講到的RSP寄存器的值
    sp   uintptr
    // 程序計(jì)數(shù)器,對(duì)應(yīng)上文講到的RIP寄存器
    pc   uintptr
    // 記錄當(dāng)前這個(gè)gobuf對(duì)象屬于哪個(gè)goroutine
    g    guintptr 
    // 系統(tǒng)調(diào)用的返回值
    ret  sys.Uintreg
 // 保存CPU的rbp寄存器的值 
 bp   uintptr
    ...
}

stack結(jié)構(gòu)體主要用來(lái)記錄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í)行過(guò)程中,G可能處于以下幾種狀態(tài):

const (
    //  剛剛被分配并且還沒(méi)有被初始化
    _Gidle = iota // 0 
    // 沒(méi)有執(zhí)行代碼,沒(méi)有棧的所有權(quán),存儲(chǔ)在運(yùn)行隊(duì)列中
    _Grunnable // 1 
    // 可以執(zhí)行代碼,擁有棧的所有權(quán),被賦予了內(nèi)核線程 M 和處理器 P
    _Grunning // 2 
    // 正在執(zhí)行系統(tǒng)調(diào)用,擁有棧的所有權(quán),沒(méi)有執(zhí)行用戶代碼,
    // 被賦予了內(nèi)核線程 M 但是不在運(yùn)行隊(duì)列上
    _Gsyscall // 3 
    // 由于運(yùn)行時(shí)而被阻塞,沒(méi)有執(zhí)行用戶代碼并且不在運(yùn)行隊(duì)列上,
    // 但是可能存在于 Channel 的等待隊(duì)列上
    _Gwaiting // 4  
    // 表示當(dāng)前goroutine沒(méi)有被使用,沒(méi)有執(zhí)行代碼,可能有分配的棧
    _Gdead // 6  
    // 棧正在被拷貝,沒(méi)有執(zhí)行代碼,不在運(yùn)行隊(duì)列上
    _Gcopystack // 8 
    // 由于搶占而被阻塞,沒(méi)有執(zhí)行用戶代碼并且不在運(yùn)行隊(duì)列上,等待喚醒
    _Gpreempted // 9 
    // GC 正在掃描??臻g,沒(méi)有執(zhí)行代碼,可以與其他狀態(tài)同時(shí)存在
    _Gscan          = 0x1000 
    ...
)

上面的狀態(tài)看起來(lái)很多,但是實(shí)際上只需要關(guān)注下面幾種就好了:

  • 等待中:_ Gwaiting、_Gsyscall 和 _Gpreempted,這幾個(gè)狀態(tài)表示G沒(méi)有在執(zhí)行;
  • 可運(yùn)行:_Grunnable,表示G已經(jīng)準(zhǔn)備就緒,可以在線程運(yùn)行;
  • 運(yùn)行中:_Grunning,表示G正在運(yùn)行;

② m結(jié)構(gòu)體

type m struct {
    // g0主要用來(lái)記錄工作線程使用的棧信息,在執(zhí)行調(diào)度代碼時(shí)需要使用這個(gè)棧 // 執(zhí)行用戶goroutine代碼時(shí),使用用戶goroutine自己的棧,調(diào)度時(shí)會(huì)發(fā)生棧的切換
    g0      *g       
         
    // 線程本地存儲(chǔ) thread-local,通過(guò)TLS實(shí)現(xiàn)m結(jié)構(gòu)體對(duì)象與工作線程之間的綁定,下文會(huì)詳細(xì)介紹
    tls           [6]uintptr   // thread-local storage (for x86 extern register)
    // 當(dāng)前運(yùn)行的G
    curg          *g       // current running goroutine
    // 正在運(yùn)行代碼的P
    p             puintptr // attached p for executing go code (nil if not executing go code)
    nextp         puintptr
    // 之前使用的P
    oldp          puintptr  
 
 // 記錄所有工作線程的一個(gè)鏈表
    alllink       *m // on allm
    schedlink     muintptr

    // Linux平臺(tái)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)用會(huì)+1
    schedtick   uint32     // incremented on every scheduler call
    // 系統(tǒng)調(diào)用會(huì)+1
    syscalltick uint32     // incremented on every system call
    // 對(duì)應(yīng)關(guān)聯(lián)的 M
    m           muintptr    
    mcache      *mcache
    pcache      pageCache 
    // defer 結(jié)構(gòu)池
    deferpool    [5][]*_defer  
    deferpoolbuf [5][32]*_defer  
    // 可運(yùn)行的 goroutine 隊(duì)列,可無(wú)鎖訪問(wèn)
    runqhead uint32
    runqtail uint32
    runq     [256]guintptr
    // 緩存可立即執(zhí)行的 G
    runnext guintptr 
    // 可用的 G 列表,G 狀態(tài)等于 Gdead 
    gFree struct {
        gList
        n int32
    }
    ...
}

P的幾個(gè)狀態(tài):

const ( 
    // 表示P沒(méi)有運(yùn)行用戶代碼或者調(diào)度器 
    _Pidle = iota 
    // 被線程 M 持有,并且正在執(zhí)行用戶代碼或者調(diào)度器
    _Prunning 
    // 沒(méi)有執(zhí)行用戶代碼,當(dāng)前線程陷入系統(tǒng)調(diào)用
    _Psyscall
    // 被線程 M 持有,當(dāng)前處理器由于垃圾回收 STW 被停止
    _Pgcstop 
    // 當(dāng)前處理器已經(jīng)不被使用
    _Pdead
)

④ schedt結(jié)構(gòu)體

type schedt struct {
    ...
 // 鎖,從全局隊(duì)列獲取G時(shí)需要使用到
    lock mutex 
    // 空閑的 M 列表
    midle        muintptr  
    // 空閑的 M 列表數(shù)量
    nmidle       int32      
    // 下一個(gè)被創(chuàng)建的 M 的 id
    mnext        int64  
    // 能擁有的最大數(shù)量的 M  
    maxmcount    int32    
    // 由空閑的p結(jié)構(gòu)體對(duì)象組成的鏈表
    pidle      puintptr // idle p's
    // 空閑 p 數(shù)量
    npidle     uint32

    // 全局 runnable G 隊(duì)列
    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)成的一個(gè)鏈表,包括下面的m0
allp       []*p    // 保存所有的p,len(allp) == gomaxprocs

ncpu             int32   // 系統(tǒng)中cpu核的數(shù)量,程序啟動(dòng)時(shí)由runtime代碼初始化
gomaxprocs int32   // p的最大值,默認(rèn)等于ncpu,但可以通過(guò)GOMAXPROCS修改

sched      schedt     // 調(diào)度器結(jié)構(gòu)體對(duì)象,記錄了調(diào)度器的工作狀態(tài)

m0  m       // 代表進(jìn)程的主線程
g0   g        // m0的g0,也就是m0.g0 = &g0

在程序初始化時(shí),這些全變量都會(huì)被初始化為0值,指針會(huì)被初始化為nil指針,切片初始化為nil切片,int被初始化為數(shù)字0,結(jié)構(gòu)體的所有成員變量按其本類型初始化為其類型的0值。所以程序剛啟動(dòng)時(shí)allgs,allm和allp都不包含任何g,m和p。

(3) 線程執(zhí)行的代碼是如何找到屬于自己的那個(gè)m結(jié)構(gòu)體實(shí)例對(duì)象的呢

前面我們說(shuō)GMP模型中每個(gè)工作線程都有一個(gè)m結(jié)構(gòu)體對(duì)象與之對(duì)應(yīng),但并未詳細(xì)說(shuō)明它們之間是如何對(duì)應(yīng)起來(lái)的~ 如果只有一個(gè)工作線程,那么就只會(huì)有一個(gè)m結(jié)構(gòu)體對(duì)象,問(wèn)題就很簡(jiǎn)單,定義一個(gè)全局的m結(jié)構(gòu)體變量就行了??墒俏覀冇卸鄠€(gè)工作線程和多個(gè)m需要一一對(duì)應(yīng),這里就需要用到線程的本地存儲(chǔ)了。

(4) 線程本地存儲(chǔ)(TLS)

TLS 是一種機(jī)制,允許每個(gè)線程有自己的獨(dú)立數(shù)據(jù)副本。這意味著多個(gè)線程可以同時(shí)運(yùn)行而不會(huì)相互干擾,因?yàn)槊總€(gè)線程都可以訪問(wèn)自己的數(shù)據(jù)副本。

① 寄存器中**fs 段的作用**

在 Linux 系統(tǒng)中,fs 段可以用于存儲(chǔ)線程的 TLS 數(shù)據(jù),通常通過(guò) fs 段寄存器來(lái)訪問(wèn)。

② go 語(yǔ)言中的使用

  • 在 Go 語(yǔ)言的運(yùn)行時(shí)(runtime)中,fs 段被用來(lái)存儲(chǔ)與每個(gè) goroutine 相關(guān)的線程局部數(shù)據(jù)。Go 的 GMP 模型(goroutine, M, P)中,m 結(jié)構(gòu)體的 tls 字段通常會(huì)被設(shè)置為當(dāng)前線程的 fs 段,以便快速訪問(wèn)線程局部存儲(chǔ)。
  • 通過(guò)將 fs 段與 m 結(jié)構(gòu)體的 tls 字段關(guān)聯(lián),Go 可以高效地管理和訪問(wèn)每個(gè) goroutine 的特定數(shù)據(jù)。

具體到goroutine調(diào)度器代碼,每個(gè)工作線程在剛剛被創(chuàng)建出來(lái)進(jìn)入調(diào)度循環(huán)之前就利用線程本地存儲(chǔ)機(jī)制為該工作線程實(shí)現(xiàn)了一個(gè)指向m結(jié)構(gòu)體實(shí)例對(duì)象的私有全局變量,這樣在之后的代碼中就使用該全局變量來(lái)訪問(wèn)自己的m結(jié)構(gòu)體對(duì)象以及與m相關(guān)聯(lián)的p和g對(duì)象。

有了上述數(shù)據(jù)結(jié)構(gòu)以及工作線程與數(shù)據(jù)結(jié)構(gòu)之間的映射機(jī)制,我們可以再豐富下前面講到的初始調(diào)度模型:

// 程序啟動(dòng)時(shí)的初始化代碼
......
for i := 0; i < N; i++ { // 創(chuàng)建N個(gè)操作系統(tǒng)線程執(zhí)行schedule函數(shù)
     create_os_thread(schedule) // 創(chuàng)建一個(gè)操作系統(tǒng)線程執(zhí)行schedule函數(shù)
}


// 定義一個(gè)線程私有全局變量,注意它是一個(gè)指向m結(jié)構(gòu)體對(duì)象的指針
// ThreadLocal用來(lái)定義線程私有全局變量
ThreadLocal self *m
//schedule函數(shù)實(shí)現(xiàn)調(diào)度邏輯
func schedule() {
    // 創(chuàng)建和初始化m結(jié)構(gòu)體對(duì)象,并賦值給私有全局變量self
    self = initm()  
    for { //調(diào)度循環(huán)
          if (self.p.runqueue is empty) {
                 // 根據(jù)某種算法從全局運(yùn)行隊(duì)列中找出一個(gè)需要運(yùn)行的goroutine
                 g := find_a_runnable_goroutine_from_global_runqueue()
           } else {
                 // 根據(jù)某種算法從私有的局部運(yùn)行隊(duì)列中找出一個(gè)需要運(yùn)行的goroutine
                 g := find_a_runnable_goroutine_from_local_runqueue()
           }
          run_g(g) // CPU運(yùn)行該goroutine,直到需要調(diào)度其它goroutine才返回
          save_status_of_g(g) // 保存goroutine的狀態(tài),主要是寄存器的值
     }
}

僅僅從上面這個(gè)偽代碼來(lái)看,我們完全不需要線程私有全局變量,只需在schedule函數(shù)中定義一個(gè)局部變量就行了。但真實(shí)的調(diào)度代碼錯(cuò)綜復(fù)雜,不光是這個(gè)schedule函數(shù)會(huì)需要訪問(wèn)m,其它很多地方還需要訪問(wèn)它,所以需要使用全局變量來(lái)方便其它地方對(duì)m的以及與m相關(guān)的g和p的訪問(wèn)。

三、從main函數(shù)啟動(dòng)開(kāi)始分析

下面我們通過(guò)一個(gè)簡(jiǎn)單的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)的啟動(dòng)函數(shù)是在asm_amd64.s的runtime·rt0_go函數(shù)中。當(dāng)然,不同的平臺(tái)有不同的程序入口。rt0_go函數(shù)完成了go程序啟動(dòng)時(shí)的所有初始化工作,因此這個(gè)函數(shù)比較長(zhǎng),也比較繁雜,但這里我們只關(guān)注與調(diào)度器相關(guān)的一些初始化,下面我們分段來(lái)看:

TEXT runtime·rt0_go(SB),NOSPLIT|NOFRAME|TOPFRAME,$0
    // copy arguments forward on an even stack
    MOVQ   DI, AX    // 這行代碼將寄存器 `DI` 的值(通常是命令行參數(shù)的數(shù)量,即 `argc`)復(fù)制到寄存器 `AX` 中。`AX` 現(xiàn)在存儲(chǔ)了程序的參數(shù)個(gè)數(shù)
    MOVQ   SI, BX    // 這行代碼將寄存器 `SI` 的值(通常是指向命令行參數(shù)字符串?dāng)?shù)組的指針,即 `argv`)復(fù)制到寄存器 `BX` 中。`BX` 現(xiàn)在存儲(chǔ)了指向命令行參數(shù)的指針。
    SUBQ   $(5*8), SP    // 這行代碼從棧指針 `SP` 中減去 `40` 字節(jié)(`5*8`),為局部變量和函數(shù)參數(shù)分配空間。這里的 `5` 可能表示 3 個(gè)參數(shù)和 2 個(gè)自動(dòng)變量(局部變量)。這行代碼的目的是在棧上為這些變量留出空間。
    ANDQ   $~15, SP // 這行代碼將棧指針 `SP` 對(duì)齊到 16 字節(jié)的邊界。確保棧在函數(shù)調(diào)用時(shí)是對(duì)齊的
    MOVQ   AX, 24(SP) // 這行代碼將 `AX` 中的值(即 `argc`)存儲(chǔ)到棧上相對(duì)于 `SP` 的偏移量 +`24` 的位置。
    MOVQ   BX, 32(SP) // - 這行代碼將 `BX` 中的值(即 `argv`)存儲(chǔ)到棧上相對(duì)于 `SP` 的偏移量 +`32` 的位置。

上面的第4條指令用于調(diào)整棧頂寄存器的值使其按16字節(jié)對(duì)齊,也就是讓棧頂寄存器SP指向的內(nèi)存的地址為16的倍數(shù),最后兩條指令把a(bǔ)rgc和argv搬到新的位置。

2.初始化g0

繼續(xù)看后面的代碼,下面開(kāi)始初始化全局變量g0,前面我們說(shuō)過(guò),g0的主要作用是提供一個(gè)棧供runtime代碼執(zhí)行,因此這里主要對(duì)g0的幾個(gè)與棧有關(guān)的成員進(jì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

運(yùn)行完上面這幾行指令后g0與棧之間的關(guān)系如下圖所示:

四、主線程與m0綁定

設(shè)置好g0棧之后,我們跳過(guò)CPU型號(hào)檢查以及cgo初始化相關(guān)的代碼,接著分析如何把m數(shù)據(jù)結(jié)構(gòu) 和 線程綁定在一起,原因在上面已描述過(guò):每個(gè)線程需要能快速找到自己所屬的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è)置線程本地存儲(chǔ),settls函數(shù)的參數(shù)在DI寄存器中

 // store through it, to make sure it works
 // 驗(yàn)證settls是否可以正常工作,如果有問(wèn)題則abort退出程序
 get_tls(BX)  //獲取fs段基地址并放入BX寄存器,其實(shí)就是m0.tls[1]的地址,get_tls的代碼由編譯器生成
 MOVQ $0x123, g(BX) //把整型常量0x123設(shè)置到線程本地存儲(chǔ)中
 MOVQ runtime·m0+m_tls(SB), AX  //獲取m.tls結(jié)構(gòu)體的地址到AX寄存器中
 CMPQ AX, $0x123 // 判斷m.tls[0]的值是否等于123,是的話說(shuō)明tls工作正常
 JEQ 2(PC)
 CALL runtime·abort(SB)

設(shè)置 tls 的函數(shù) runtime·settls(SB) 位于源碼 src/runtime/sys_linux_amd64.s 處,主要內(nèi)容就是通過(guò)一個(gè)系統(tǒng)調(diào)用將 fs 段基址設(shè)置成 m.tls[1] 的地址,而 fs 段基址又可以通過(guò) CPU 里的寄存器 fs 來(lái)獲取。這段代碼運(yùn)行后,工作線程代碼就可以通過(guò)CPU的 fs 寄存器來(lái)找到 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的地址保存在線程本地存儲(chǔ)里面,也就是m0.tls[0]=&g0
 LEAQ runtime·m0(SB), AX //AX = m0的地址

 // 下面把m0和g0關(guān)聯(lián)起來(lái)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的地址放入主線程的線程本地存儲(chǔ)中,然后把m0和g0綁定在一起,這樣,之后在主線程中通過(guò)get_tls可以獲取到g0,通過(guò)g0的m成員又可以找到m0,于是這里就實(shí)現(xiàn)了m0和g0與主線程之間的關(guān)聯(lián)。

從這里還可以看到,保存在主線程本地存儲(chǔ)中的值是g0的地址,也就是說(shuō)工作線程的私有全局變量其實(shí)是一個(gè)指向g的指針而不是指向m的指針,目前這個(gè)指針指向g0,表示代碼正運(yùn)行在g0棧。此時(shí),主線程,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)

 // 新建一個(gè) goroutine,該 goroutine 綁定 runtime.main
 MOVQ $runtime·mainPC(SB), AX  // entry
 PUSHQ AX
 CALL runtime·newproc(SB)
 POPQ AX

 // 啟動(dòng)M,開(kāi)始調(diào)度循環(huán),運(yùn)行剛剛創(chuàng)建的goroutine
 CALL runtime·mstart(SB)

 // 上面的mstart永遠(yuǎn)不應(yīng)該返回的,如果返回了,一定是代碼邏輯有問(wèn)題,直接abort
 CALL runtime·abort(SB) // mstart should never return
 RET

上面的CALL方法中:

  • schedinit進(jìn)行各種運(yùn)行時(shí)組件初始化工作,這包括我們的調(diào)度器與內(nèi)存分配器、回收器的初始化;
  • newproc負(fù)責(zé)根據(jù)主 G 入口地址創(chuàng)建可被運(yùn)行時(shí)調(diào)度的執(zhí)行單元;
  • mstart開(kāi)始啟動(dòng)調(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ù)在源代碼中沒(méi)有對(duì)應(yīng)的定義,由編譯器插入類似下面兩行代碼
    //get_tls(CX)
    //MOVQ g(CX), BX; BX存器里面現(xiàn)在放的是當(dāng)前g結(jié)構(gòu)體對(duì)象的地址
    _g_ := getg() // _g_ = &g0

    ......

    //設(shè)置最多啟動(dòng)10000個(gè)操作系統(tǒng)線程,也是最多10000個(gè)M
    sched.maxmcount = 10000

    ......
   
    mcommoninit(_g_.m) //初始化m0,因?yàn)閺那懊娴拇a我們知道g0->m = &m0

    ......

    sched.lastpoll = uint64(nanotime())
    procs := ncpu  //系統(tǒng)中有多少核,就創(chuàng)建和初始化多少個(gè)p結(jié)構(gòu)體對(duì)象
    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è)置到了線程本地存儲(chǔ)之中,schedinit通過(guò)getg函數(shù)(getg函數(shù)是編譯器實(shí)現(xiàn)的,我們?cè)谠创a中是找不到其定義的)從線程本地存儲(chǔ)中獲取當(dāng)前正在運(yùn)行的g,這里獲取出來(lái)的是g0,然后調(diào)用mcommoninit函數(shù)對(duì)m0(g0.m)進(jìn)行必要的初始化,對(duì)m0初始化完成之后調(diào)用procresize初始化系統(tǒng)需要用到的p結(jié)構(gòu)體對(duì)象,p就是processor的意思,它的數(shù)量決定了最多可以有多少個(gè)goroutine同時(shí)并行運(yùn)行。

schedinit函數(shù)除了初始化m0和p,還設(shè)置了全局變量sched的maxmcount成員為10000,限制最多可以創(chuàng)建10000個(gè)操作系統(tǒng)線程出來(lái)工作。

(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)建用于信號(hào)處理的gsignal,只是簡(jiǎn)單的從堆上分配一個(gè)g結(jié)構(gòu)體對(duì)象,然后把棧設(shè)置好就返回了
    mpreinit(mp)
    if mp.gsignal != nil {
        mp.gsignal.stackguard1 = mp.gsignal.stack.lo + _StackGuard
    }

    // 把 M 掛入全局鏈表allm之中
    mp.alllink = allm
    ...
}

這里傳入的 id 是-1,初次調(diào)用會(huì)將 id 設(shè)置為 0,這里并未對(duì)m0做什么關(guān)于調(diào)度相關(guān)的初始化,所以可以簡(jiǎn)單的認(rèn)為這個(gè)函數(shù)只是把m0放入全局鏈表allm之中就返回了。

m0完成基本的初始化后,繼續(xù)調(diào)用procresize創(chuàng)建和初始化p結(jié)構(gòu)體對(duì)象,在這個(gè)函數(shù)里面會(huì)創(chuàng)建指定個(gè)數(shù)(根據(jù)cpu核數(shù)或環(huán)境變量確定)的p結(jié)構(gòu)體對(duì)象放在全變量allp里, 并把m0和allp[0]綁定在一起,因此當(dāng)這個(gè)函數(shù)執(zhí)行完成之后就有。

m0.p = allp[0]
allp[0].m = &m0

到這里m0, g0, 和m需要的p完全關(guān)聯(lián)在一起了

(2) P初始化

由于用戶代碼運(yùn)行過(guò)程中也支持通過(guò) GOMAXPROCS()函數(shù)調(diào)用procresize來(lái)重新創(chuàng)建和初始化p結(jié)構(gòu)體對(duì)象,而在運(yùn)行過(guò)程中再動(dòng)態(tài)的調(diào)整p牽涉到的問(wèn)題比較多,所以這個(gè)函數(shù)的處理比較復(fù)雜,這里只保留了初始化時(shí)會(huì)執(zhí)行的代碼。

func procresize(nprocs int32) *p {
    old := gomaxprocs //系統(tǒng)初始化時(shí) gomaxprocs = 0

    ......

    // Grow allp if necessary.
    if nprocs > int32(len(allp)) { //初始化時(shí) 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 { 
   //初始化時(shí)進(jìn)入此分支,創(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個(gè)p并完成基本初始化
    for i := int32(0); i < nprocs; i++ {
        pp := allp[i]
        if pp == nil {
            pp = new(p)//調(diào)用內(nèi)存分配器從堆上分配一個(gè)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 {//初始化時(shí)m0->p還未初始化,所以不會(huì)執(zhí)行這個(gè)分支
        // continue to use the current P
        _g_.m.p.ptr().status = _Prunning
        _g_.m.p.ptr().mcache.prepareForSweep()
    } else {
  //初始化時(shí)執(zhí)行這個(gè)分支
        // release the current P and acquire allp[0]
        if _g_.m.p != 0 {//初始化時(shí)這里不執(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)起來(lái),其實(shí)是這兩個(gè)strct的成員相互賦值
        if trace.enabled {
            traceGoStart()
        }
    }
   
    //下面這個(gè)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) {//初始化時(shí)除了allp[0]其它p全部執(zhí)行這個(gè)分支,放入空閑鏈表
            pidleput(p)
        } else {
            ......
        }
    }

    ......
   
    return runnablePs
}

這里總結(jié)一下這個(gè)函數(shù)的主要流程:

  • 使用make([]*p, nprocs)初始化全局變量allp,即allp = make([]*p, nprocs)
  • 循環(huán)創(chuàng)建并初始化nprocs個(gè)p結(jié)構(gòu)體對(duì)象并依次保存在allp切片之中
  • 把m0和allp[0]綁定在一起,即m0.p = allp[0], allp[0].m = m0
  • 把除了allp[0]之外的所有p放入到全局變量sched的pidle空閑隊(duì)列之中

下面我們用圖來(lái)總結(jié)下整個(gè)調(diào)度器各部分的組成:

(3) goroutine的創(chuàng)建(newproc)

經(jīng)過(guò)上文介紹我們介紹m0初始化中有說(shuō)到,初始化過(guò)程中會(huì)新建一個(gè) goroutine,該 goroutine 綁定 runtime.main,而runtime.main實(shí)際上最后會(huì)走到我們實(shí)現(xiàn)的main函數(shù)上。新建goroutine的操作就是通過(guò)newproc()調(diào)用來(lái)實(shí)現(xiàn)的。

newproc函數(shù)用于創(chuàng)建新的goroutine,它有兩個(gè)參數(shù),先說(shuō)第二個(gè)參數(shù)fn,新創(chuàng)建出來(lái)的goroutine將從fn這個(gè)函數(shù)開(kāi)始執(zhí)行,而這個(gè)fn函數(shù)可能也會(huì)有參數(shù),newproc的第一個(gè)參數(shù)正是fn函數(shù)的參數(shù)以字節(jié)為單位的大小。比如有如下go代碼片段:

func start(a, b, c int64) {
    ......
}

func main() {
    go start(1, 2, 3)
}

編譯器在編譯上面的go語(yǔ)句時(shí),就會(huì)把其替換為對(duì)newproc函數(shù)的調(diào)用,編譯后的代碼邏輯上等同于下面的偽代碼。

func main() {
    push 0x3
    push 0x2
    push 0x1
    runtime.newproc(24, start)
}

可以看到編譯器會(huì)幫我們把三個(gè)參數(shù)1,2,3分別壓棧作為start函數(shù)的參數(shù),然后再調(diào)用newproc函數(shù)。我們會(huì)注意到newproc函數(shù)本身還需要兩個(gè)參數(shù),第一個(gè)是24,表示start函數(shù)需要24個(gè)字節(jié)大小的參數(shù) 為什么需要傳遞start函數(shù)的參數(shù)大小給到newproc函數(shù)呢? 這里是因?yàn)樾陆╣oroutine會(huì)在堆上創(chuàng)建一個(gè)全新的棧,需要把start需要用到的參數(shù)先從當(dāng)前goroutine的棧上拷貝到新的goroutine的棧上之后才能讓其開(kāi)始執(zhí)行,而newproc函數(shù)本身并不知道需要拷貝多少數(shù)據(jù)到新創(chuàng)建的goroutine的棧上去,所以需要用參數(shù)的方式指定拷貝多少數(shù)據(jù)。

newproc函數(shù)是對(duì)newproc1的一個(gè)包裝,這里最重要的準(zhǔn)備工作有兩個(gè),一個(gè)是獲取fn函數(shù)第一個(gè)參數(shù)的地址(代碼中的argp),另一個(gè)是使用systemstack函數(shù)切換到g0棧,當(dāng)然,對(duì)于我們這個(gè)初始化場(chǎng)景來(lái)說(shuō)現(xiàn)在本來(lái)就在g0棧,所以不需要切換,然而這個(gè)函數(shù)是通用的,在用戶的goroutine中也會(huì)創(chuàng)建goroutine,這時(shí)就需要進(jìn)行棧的切換。

// 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ù)入棧順序是從右向左,而且棧是從高地址向低地址增長(zhǎng)的
    //注意:argp指向fn函數(shù)的第一個(gè)參數(shù),而不是newproc函數(shù)的參數(shù)
    //參數(shù)fn在棧上的地址+8的位置存放的是fn函數(shù)的第一個(gè)參數(shù)
    argp := add(unsafe.Pointer(&fn), sys.PtrSize)
    gp := getg()  //獲取正在運(yùn)行的g,初始化時(shí)是m0.g0
   
    //getcallerpc()返回一個(gè)地址,也就是調(diào)用newproc時(shí)由call指令壓棧的函數(shù)返回地址,
    //對(duì)于我們現(xiàn)在這個(gè)場(chǎng)景來(lái)說(shuō),pc就是CALLruntime·newproc(SB)指令后面的POPQ AX這條指令的地址
    pc := getcallerpc()
   
    //systemstack的作用是切換到g0棧執(zhí)行作為參數(shù)的函數(shù)
    //我們這個(gè)場(chǎng)景現(xiàn)在本身就在g0棧,因此什么也不做,直接調(diào)用作為參數(shù)的函數(shù)
    systemstack(func() {
        newproc1(fn, (*uint8)(argp), siz, gp, pc)
    })
}

newproc1函數(shù)的第一個(gè)參數(shù)fn是新創(chuàng)建的goroutine需要執(zhí)行的函數(shù),注意這個(gè)fn的類型是funcval結(jié)構(gòu)體類型,其定義如下:

type funcval struct {
    fn uintptr
    // variable-size, fn-specific data here
}

第二個(gè)參數(shù)argp是fn函數(shù)的第一個(gè)參數(shù)的地址,第三個(gè)參數(shù)是fn函數(shù)的參數(shù)以字節(jié)為單位的大小,后面兩個(gè)參數(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) {
    //因?yàn)橐呀?jīng)切換到g0棧,所以無(wú)論什么場(chǎng)景都有 _g_ = g0,當(dāng)然這個(gè)g0是指當(dāng)前工作線程的g0
    //對(duì)于我們這個(gè)場(chǎng)景來(lái)說(shuō),當(dāng)前工作線程是主線程,所以這里的g0 = m0.g0
    _g_ := getg()

    ......

    _p_ := _g_.m.p.ptr() //初始化時(shí)_p_ = g0.m.p,從前面的分析可以知道其實(shí)就是allp[0]
    newg := gfget(_p_) //從p的本地緩沖里獲取一個(gè)沒(méi)有使用的g,初始化時(shí)沒(méi)有,返回nil
    if newg == nil {
         //new一個(gè)g結(jié)構(gòu)體對(duì)象,然后從堆上為其分配棧,并設(shè)置g的stack成員和兩個(gè)stackgard成員
        newg = malg(_StackMin)
        casgstatus(newg, _Gidle, _Gdead) //初始化g的狀態(tài)為_(kāi)Gdead
         //放入全局變量allgs切片中
        allgadd(newg) // publishes with a g->status of Gdead so GC scanner doesn't look at uninitialized stack.
    }
   
    ......
   
    //調(diào)整g的棧頂置針,無(wú)需關(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ù)的棧(初始化時(shí)是g0棧)拷貝到新g的棧
        memmove(unsafe.Pointer(spArg), unsafe.Pointer(argp), uintptr(narg))
        // ......
    }

這段代碼主要從堆上分配一個(gè)g結(jié)構(gòu)體對(duì)象并為這個(gè)newg分配一個(gè)大小為2048字節(jié)的棧,并設(shè)置好newg的stack成員,然后把newg需要執(zhí)行的函數(shù)的參數(shù)從執(zhí)行newproc函數(shù)的棧(初始化時(shí)是g0棧)拷貝到newg的棧,完成這些事情之后newg的狀態(tài)如下圖所示:這里需要注意的是,新創(chuàng)建出來(lái)的g都是在堆上分配內(nèi)存的,主要有以下幾個(gè)原因:

  • 生命周期:goroutine的生命周期可能會(huì)超過(guò)創(chuàng)建它的函數(shù)的生命周期。如果在棧上分配goroutine,那么當(dāng)創(chuàng)建goroutine的函數(shù)返回時(shí),goroutine可能還在運(yùn)行,這將導(dǎo)致棧被回收,從而引發(fā)錯(cuò)誤。而在堆上分配goroutine可以避免這個(gè)問(wèn)題,因?yàn)槎焉系膬?nèi)存只有在沒(méi)有任何引用指向它時(shí)才會(huì)被垃圾回收。
  • 大小:goroutine的棧大小是動(dòng)態(tài)的,它可以根據(jù)需要進(jìn)行擴(kuò)展和收縮。如果在棧上分配goroutine,那么每次goroutine的棧大小改變時(shí),都需要移動(dòng)整個(gè)goroutine的內(nèi)存,這將導(dǎo)致大量的性能開(kāi)銷。而在堆上分配goroutine可以避免這個(gè)問(wèn)題,因?yàn)槎焉系膬?nèi)存可以動(dòng)態(tài)地進(jìn)行擴(kuò)展和收縮。
  • 并發(fā):在go語(yǔ)言中,可以同時(shí)運(yùn)行多個(gè)goroutine。如果在棧上分配goroutine,那么每個(gè)線程的棧大小都需要足夠大,以容納所有的goroutine,這將導(dǎo)致大量的內(nèi)存浪費(fèi)。而在堆上分配goroutine可以避免這個(gè)問(wèn)題,因?yàn)槎咽撬芯€程共享的,因此可以更有效地利用內(nèi)存。因此,出于生命周期、大小和并發(fā)等考慮,GMP模型中新創(chuàng)建的goroutine都是在堆上分配內(nèi)存的。下面讓我們用一個(gè)圖示來(lái)總結(jié)下目前的GMP模型中各個(gè)部分的情況。

繼續(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上運(yùn)行。
    newg.sched.sp = sp  //newg的棧頂
    newg.stktopsp = sp
    //newg.sched.pc表示當(dāng)newg被調(diào)度起來(lái)運(yùn)行時(shí)從這個(gè)地址開(kāi)始執(zhí)行指令
    //把pc設(shè)置成了goexit這個(gè)函數(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的入口地址,初始化時(shí)對(duì)應(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 //為返回地址預(yù)留空間,
    //這里在偽裝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)度起來(lái)運(yùn)行時(shí),調(diào)度器會(huì)把buf.pc放入cpu的IP寄存器,
    //從而使newg得以在cpu上真正的運(yùn)行起來(lái)
    buf.pc = uintptr(fn)
    buf.ctxt = ctxt
}

gostartcall函數(shù)的主要作用有兩個(gè):

  • 調(diào)整newg的??臻g,把goexit函數(shù)的第二條指令的地址入棧,偽造成goexit函數(shù)調(diào)用了fn,從而使fn執(zhí)行完成后執(zhí)行ret指令時(shí)返回到goexit繼續(xù)執(zhí)行完成最后的清理工作;
  • 重新設(shè)置newg.buf.pc 為需要執(zhí)行的函數(shù)的地址,即fn,我們這個(gè)場(chǎng)景為runtime.main函數(shù)的地址,如果是在運(yùn)行中g(shù)o aa()啟動(dòng)的協(xié)程,那么newg.buf.pc會(huì)為aa()函數(shù)的地址。
//newg真正從哪里開(kāi)始執(zhí)行并不依賴于這個(gè)成員,而是sched.pc
    newg.startpc = fn.fn  

    ......
   
    //設(shè)置g的狀態(tài)為_(kāi)Grunnable,表示這個(gè)g代表的goroutine可以運(yùn)行了
    casgstatus(newg, _Gdead, _Grunnable)

    ......
   
    //把newg放入_p_的運(yùn)行隊(duì)列,初始化的時(shí)候一定是p的本地運(yùn)行隊(duì)列,其它時(shí)候可能因?yàn)楸镜仃?duì)列滿了而放入全局隊(duì)列
    runqput(_p_, newg, true)

    ......
}

這時(shí)newg也就是main goroutine的狀態(tài)如下圖所示:

可以看到newproc執(zhí)行完畢時(shí),p, m0, g0, newg, allp的內(nèi)存均已分配好且它們之間的關(guān)系也通過(guò)指針掛鉤上,我們留意下newg的堆棧中目前棧頂是 goexit+1 這個(gè)位置的返回地址;目前newg尚未被調(diào)度起來(lái)運(yùn)行,只是剛加入p的本地隊(duì)列,后續(xù)該newg被cpu調(diào)度到時(shí),cpu sp寄存器會(huì)指向棧頂,在newg自身的邏輯開(kāi)始執(zhí)行后,newg的棧內(nèi)存會(huì)被不斷使用,SP寄存器不斷移動(dòng)來(lái)指示棧的內(nèi)存的增長(zhǎng)與回收,當(dāng)所有邏輯執(zhí)行完時(shí),sp寄存器重新指回這個(gè)goexit+1 這個(gè)位置。最后彈出該位置的值作為cpu rip寄存器的值,從而去執(zhí)行 runtime.goexit1(SB) 這個(gè)命令,繼續(xù)進(jìn)行調(diào)度循環(huán)(這個(gè)后面會(huì)講到)。

下面我們總結(jié)下newproc做了什么事情:

  • 在堆上給新goroutine分配一段內(nèi)存作為??臻g,設(shè)置堆棧信息到新goroutine對(duì)應(yīng)的g結(jié)構(gòu)體上,核心是設(shè)置gobuf.pc指向要執(zhí)行的代碼段,待調(diào)度到該g時(shí),會(huì)將保存的pc值設(shè)置到cpu的RIP寄存器上從而去執(zhí)行該goroutine對(duì)應(yīng)的代碼。
  • 把傳遞給goroutine的參數(shù)從當(dāng)前??截惖叫耮oroutine所在的棧上。
  • 把g加入到p的本地隊(duì)列等待調(diào)度,如果本地隊(duì)列滿了會(huì)加入到全局隊(duì)列(程序剛啟動(dòng)時(shí)只會(huì)加入到p的本地隊(duì)列)。

4.調(diào)度循環(huán)的啟動(dòng)(mstart)

前面我們完成了 newproc 函數(shù),接下來(lái)是runtime·rt0_go中的最后一步,啟動(dòng)調(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的啟動(dòng),這里省略部分無(wú)關(guān)的代碼:

func mstart1() {
    // 啟動(dòng)過(guò)程時(shí) _g_ = m0.g0
    _g_ := getg()

 //getcallerpc()獲取mstart1執(zhí)行完的返回地址
    //getcallersp()獲取調(diào)用mstart1時(shí)的棧頂?shù)刂?    save(getcallerpc(), getcallersp())
    ...
    // 進(jìn)入調(diào)度循環(huán)。永不返回
    schedule()

save函數(shù)非常重要,它主要做了這兩個(gè)操作:

gp.sched.pc = getcallerpc()
gp.sched.sp = getcallersp()

將mstart調(diào)用mstart1的返回地址以及當(dāng)時(shí)的棧頂指針保存到g0的sched結(jié)構(gòu)中,保存好后,我們可以看到現(xiàn)在的指針指向情況(注意紅線部分) 。

這里設(shè)置好后,g0對(duì)象的sp值就不會(huì)變化了,一直指向mstart函數(shù)的棧頂,后續(xù)每次切換回g0時(shí),都會(huì)從g0對(duì)象的sp值中恢復(fù)寄存器SP,從而切換到g0棧。

繼續(xù)分析代碼,先看下schedule()函數(shù)的邏輯,這是GMP模型調(diào)度邏輯的核心,每次調(diào)度goroutine都是從它開(kāi)始的:

// One round of scheduler: find a runnable goroutine and execute it.

// Never returns.
func schedule() {
    _g_ := getg()  //_g_ = 每個(gè)工作線程m對(duì)應(yīng)的g0,初始化時(shí)是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)度的公平性,每進(jìn)行61次調(diào)度就需要優(yōu)先從全局運(yùn)行隊(duì)列中獲取goroutine,
        //因?yàn)槿绻徽{(diào)度本地隊(duì)列中的g,那么全局運(yùn)行隊(duì)列中的goroutine將得不到運(yùn)行
        if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
            lock(&sched.lock) //所有工作線程都能訪問(wèn)全局運(yùn)行隊(duì)列,所以需要加鎖
            gp = globrunqget(_g_.m.p.ptr(), 1) //從全局運(yùn)行隊(duì)列中獲取1個(gè)goroutine
            unlock(&sched.lock)
        }
    }
    if gp == nil {
        //從與m關(guān)聯(lián)的p的本地運(yùn)行隊(duì)列中獲取goroutine
        gp, inheritTime = runqget(_g_.m.p.ptr())
        if gp != nil && _g_.m.spinning {
            throw("schedule: spinning with local work")
        }
    }
    if gp == nil {
        //如果從本地運(yùn)行隊(duì)列和全局運(yùn)行隊(duì)列都沒(méi)有找到需要運(yùn)行的goroutine,
        //則調(diào)用findrunnable函數(shù)從其它工作線程的運(yùn)行隊(duì)列中偷取,如果偷取不到,則當(dāng)前工作線程進(jìn)入睡眠,
        //直到獲取到需要運(yùn)行的goroutine之后findrunnable函數(shù)才會(huì)返回。
      gp, inheritTime = findrunnable() // blocks until work is available
    }
    //跟啟動(dòng)無(wú)關(guān)的代碼.....
    //當(dāng)前運(yùn)行的是runtime的代碼,函數(shù)調(diào)用棧使用的是g0的??臻g
    //調(diào)用execte切換到gp的代碼和棧空間去運(yùn)行
    execute(gp, inheritTime)  
}

schedule函數(shù)優(yōu)先從p本地隊(duì)列獲取goroutine,獲取不到時(shí)會(huì)去全局運(yùn)行隊(duì)列中加鎖獲取goroutine,在我們的場(chǎng)景中,前面的啟動(dòng)流程已經(jīng)創(chuàng)建好第一個(gè)goroutine并放入了當(dāng)前工作線程的本地運(yùn)行隊(duì)列(即runtime.main對(duì)應(yīng)的goroutine)。獲取到g后,會(huì)調(diào)用execute去切換到g,具體的切換邏輯繼續(xù)看下execute函數(shù)。

func execute(gp *g, inheritTime bool) {
    _g_ := getg() //g0
    //設(shè)置待運(yùn)行g(shù)的狀態(tài)為_(kāi)Grunning
    casgstatus(gp, _Grunnable, _Grunning)
  
    //......
    
    //把g和m關(guān)聯(lián)起來(lái)
    _g_.m.curg = gp 
    gp.m = _g_.m
    //......
    //gogo完成從g0到gp真正的切換
    gogo(&gp.sched)
}

這里的重點(diǎn)是gogo函數(shù),真正完成了g0到g的切換,切換的實(shí)質(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
  
    //下面這行代碼沒(méi)有實(shí)質(zhì)作用,檢查gp.sched.g是否是nil,如果是nil進(jìn)程會(huì)crash死掉
    MOVQ  0(DX), CX   # make sure g != nil
  
    get_tls(CX) 
  
    //把要運(yùn)行的g的指針?lè)湃刖€程本地存儲(chǔ),這樣后面的代碼就可以通過(guò)線程本地存儲(chǔ)
    //獲取到當(dāng)前正在執(zhí)行的goroutine的g結(jié)構(gòu)體對(duì)象,從而找到與之關(guān)聯(lián)的m和p
    MOVQ  DX, g(CX)
  
    //把CPU的SP寄存器設(shè)置為sched.sp,完成了棧的切換(畫(huà)重點(diǎn)!)
    MOVQ  gobuf_sp(BX), SP  # restore SP
  
    //下面三條同樣是恢復(fù)調(diào)度上下文到CPU相關(guān)寄存器
    MOVQ  gobuf_ret(BX), AX
    MOVQ  gobuf_ctxt(BX), DX
    MOVQ  gobuf_bp(BX), BP
  
    //清空sched的值,因?yàn)槲覀円寻严嚓P(guān)值放入CPU對(duì)應(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

這個(gè)函數(shù),其實(shí)就只做了兩件事:

  • 把gp.sched的成員恢復(fù)到CPU的寄存器完成狀態(tài)以及棧的切換;
  • 跳轉(zhuǎn)到gp.sched.pc所指的指令地址(runtime.main)處執(zhí)行。最后我們?cè)倏偨Y(jié)一下程序開(kāi)始運(yùn)行后從g0棧切換到main goroutine棧的流程

保存g0的調(diào)度信息,主要是保存CPU棧頂寄存器SP到g0.sched.sp成員之中;調(diào)用schedule函數(shù)尋找需要運(yùn)行的goroutine,我們這個(gè)場(chǎng)景找到的是main goroutine;調(diào)用gogo函數(shù)首先從g0棧切換到main goroutine的棧,然后從main goroutine的g結(jié)構(gòu)體對(duì)象之中取出sched.pc的值并使用JMP指令跳轉(zhuǎn)到該地址去執(zhí)行;

五、go的調(diào)度循環(huán)是什么

上文我們分析了main goroutine的啟動(dòng),main的goroutine和非main得goroutine稍微會(huì)有一點(diǎn)差別,主要在于main goutine對(duì)應(yīng)的runtime.main函數(shù),執(zhí)行完畢后會(huì)直接在匯編代碼中執(zhí)行exit從而退出程序,而非main goroutine在執(zhí)行完對(duì)應(yīng)的邏輯后,會(huì)進(jìn)入調(diào)度循環(huán),不斷找到下一個(gè)goroutine來(lái)執(zhí)行。假設(shè)我們?cè)诖a中使用go aa()啟動(dòng)了一個(gè)協(xié)程,從aa()被開(kāi)始調(diào)度到aa運(yùn)行完后退出,是沿著這個(gè)路徑來(lái)執(zhí)行的。

schedule()->execute()->gogo()->aa()->goexit()->goexit1()->mcall()->goexit0()->schedule()

可以看出,一輪調(diào)度是從調(diào)用schedule函數(shù)開(kāi)始的,然后經(jīng)過(guò)一系列代碼的執(zhí)行到最后又再次通過(guò)調(diào)用schedule函數(shù)來(lái)進(jìn)行新一輪的調(diào)度,從一輪調(diào)度到新一輪調(diào)度的這一過(guò)程我們稱之為一個(gè)調(diào)度循環(huán),這里說(shuō)的調(diào)度循環(huán)是指某一個(gè)工作線程的調(diào)度循環(huán),而同一個(gè)Go程序中可能存在多個(gè)工作線程,每個(gè)工作線程都有自己的調(diào)度循環(huán),也就是說(shuō)每個(gè)工作線程都在進(jìn)行著自己的調(diào)度循環(huán)。

調(diào)度循環(huán)的細(xì)節(jié)這里就不再分析,本文就只介紹到協(xié)程調(diào)度的核心原理,相信看完本文你已經(jīng)有所收獲~ 最后讓我們用一個(gè)圖來(lái)了解下調(diào)度循環(huán)的大體流程:


責(zé)任編輯:趙寧寧 來(lái)源: 騰訊技術(shù)工程
相關(guān)推薦

2025-06-03 00:00:02

Go協(xié)程鎖機(jī)制

2016-10-28 17:39:47

phpgolangcoroutine

2024-06-27 07:56:49

2018-12-04 14:00:41

協(xié)程編程模式PHP

2021-04-25 09:36:20

Go協(xié)程線程

2022-12-30 07:50:05

無(wú)棧協(xié)程Linux

2021-09-16 09:59:13

PythonJavaScript代碼

2024-09-25 08:28:45

2023-07-27 13:46:10

go開(kāi)源項(xiàng)目

2024-08-27 09:46:39

Go協(xié)程效率

2023-11-17 11:36:59

協(xié)程纖程操作系統(tǒng)

2023-07-13 08:06:05

應(yīng)用協(xié)程阻塞

2024-05-29 08:05:15

Go協(xié)程通信

2025-02-28 09:04:08

2021-09-27 23:28:29

Go多協(xié)程并發(fā)

2022-10-28 10:45:22

Go協(xié)程GoFrame

2023-12-18 09:31:35

Go容器Linux

2021-05-21 08:21:57

Go語(yǔ)言基礎(chǔ)技術(shù)

2020-11-30 08:25:41

程序員高并發(fā)協(xié)程

2021-05-20 08:59:47

Go調(diào)度本質(zhì)
點(diǎn)贊
收藏

51CTO技術(shù)棧公眾號(hào)