自拍偷在线精品自拍偷,亚洲欧美中文日韩v在线观看不卡

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

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

作者 | 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)的大體流程:


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

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

無棧協(xié)程Linux

2021-09-16 09:59:13

PythonJavaScript代碼

2024-09-25 08:28:45

2023-07-27 13:46:10

go開源項目

2024-08-27 09:46:39

Go協(xié)程效率

2023-07-13 08:06:05

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

2024-05-29 08:05:15

Go協(xié)程通信

2025-02-28 09:04:08

2023-11-17 11:36:59

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

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語言基礎(chǔ)技術(shù)

2020-11-30 08:25:41

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

2021-05-20 08:59:47

Go調(diào)度本質(zhì)

2024-02-05 09:06:25

Python協(xié)程Asyncio庫
點贊
收藏

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