理解 Go 協程調度的本質
作者:jiayan
golang 的一大特色就是 goroutine,它是支持高併發程序的重要保障;通過 go 關鍵字我們就能輕易創建大量的輕量級協程,但它和我們認知中的線程有什麼區別呢,輕量在哪裏,具體是如何進行調度的..... 本文將從涉及到的一些基礎知識開始,逐步介紹到 go 協程調度的核心原理,希望你能有所收穫~
函數調用棧
進程在內存中的佈局
首先回顧下進程的內存佈局~ 操作系統把磁盤上的可執行文件加載到內存運行之前,會做很多工作,其中很重要的一件事情就是把可執行文件中的代碼,數據放在內存中合適的位置,並分配和初始化程序運行過程中所必須的堆棧,所有準備工作完成後操作系統纔會調度程序起來運行。
用戶程序所使用的內存空間在低地址,內核空間所使用的內存在高地址,需要特別注意的是棧是從高地址往低地址生長。
各區域詳解
-
代碼區,也被稱爲代碼段,這部分內存存放了程序的機器代碼。這部分內存通常是隻讀的,以防止程序意外地修改其自身的指令。
-
數據區,包括程序的全局變量和靜態變量,程序加載完畢後數據區的大小也不會發生改變。
-
堆,堆是用於動態內存分配的區域,例如 c 語言的
malloc
函數和 go 語言的new
函數就是在堆上分配內存。堆從低地址向高地址增長。 -
棧,棧內存是一個連續的內存區域,通常從高地址向低地址增長。每次函數調用都會在棧上分配一個新的棧幀,函數返回時棧幀會被釋放。棧幀包含了函數調用的上下文信息,包括函數的參數、局部變量和返回地址。
棧詳解
棧內存中保存了什麼
-
保存函數的局部變量;
-
返回函數的返回值;
-
向被調用函數傳遞參數;
-
保存函數的返回地址,返回地址是指從被調用函數返回後調用者應該繼續執行的指令地址;
每個函數在執行過程中都需要使用一塊棧內存用來保存上述這些值,我們稱這塊棧內存爲某函數的棧幀 (stack frame)。
與棧密切相關的三個寄存器
AMD64 CPU 中有 3 個與棧密切相關的寄存器:
-
rsp 寄存器 ,始終指向當前函數調用棧棧頂。
-
rbp 寄存器 ,一般用來指向當前函數棧幀的起始位置,即棧底。
-
ip 寄存器,保存着下一條將要執行的指令的內存地址。CPU 在執行指令時,會根據 IP 寄存器的值從內存中獲取指令從而執行,在大多數情況下,IP 寄存器的值會按順序遞增,以指向下一條指令,這使得程序能夠順序執行。
假設現在有如下的一段 go 函數調用鏈且 ** 當前正在執行函數 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
}
則函數 ABC 的棧幀以及 rsp/rbp/ip 寄存器的狀態大致如下圖所示(注意,棧從高地址向低地址方向生長):
對於上圖,有幾點需要說明一下:
-
go 語言中調用函數時,參數和返回值都是存放在調用者的棧幀之中,而不是在被調函數之中;
-
目前正在執行 C 函數,且函數調用鏈爲 A()->B()->C(),所以以棧幀爲單位來看的話,C 函數的棧幀目前位於棧頂;
-
cpu 硬件寄存器 rsp 指向整個棧的棧頂,當然它也指向 C 函數的棧幀的棧頂,而 rbp 寄存器指向的是 C 函數棧幀的起始位置;
-
雖然圖中 ABC 三個函數的棧幀看起來都差不多大,但事實上在真實的程序中,每個函數的棧幀大小可能都不同,因爲不同的函數局部變量的個數以及所佔內存的大小都不盡相同;
-
有些編譯器比如 gcc 會把參數和返回值放在寄存器中而不是棧中,go 語言中函數的參數和返回值都是放在棧上的;
隨着程序的運行,如果 C、B 兩個函數都執行完成並返回到了 A 函數繼續執行,則棧狀態如下圖:
因爲 C、B 兩個函數都已經執行完成並返回到了 A 函數之中,所以 C、B 兩個函數的棧幀就已經被 POP 出棧了,也就是說它們所消耗的棧內存被自動回收了。因爲現在正在執行 A 函數,所以寄存器 rbp 和 rsp 指向的是 A 函數的棧中的相應位置。可以看到 cpu 的 rbp, rsp 分別指向了 a 函數的棧底和棧頂,同時 ip 寄存器指向了 A 函數調用完 B 後的下一行代碼 test:= 123 的地址,接下來 CPU 會根據 IP 寄存器,從代碼區的內存中獲取下一行代碼所對應的彙編指令去執行。
棧溢出
即使是同一個函數,每次調用都會產生一個不同的棧幀,因此對於遞歸函數,每遞歸一次都會消耗一定的棧內存,如果遞歸層數太多就有導致棧溢出的風險,這也是爲什麼我們在實際的開發過程中應該儘量避免使用遞歸函數的原因之一,另外一個原因是遞歸函數執行效率比較低,因爲它要反覆調用函數,而調用函數有較大的性能開銷。
linux 線程以及線程調度
一段 c 程序
要深入理解 go 的協程調度邏輯,就需要對操作系統線程有個大致的瞭解,因爲 go 的調度系統是建立在操作系統線程之上的,所以我們先來通過 linux 下的 C 語言 demo 入手,我們把這個程序跑在一臺單核 CPU 的機器上。
C 語言中我們一般使用 pthread 線程庫,而使用該線程庫創建的用戶態線程其實就是 Linux 操作系統內核所支持的線程,它與 go 語言中的工作線程是一樣的,這些線程都由 Linux 內核負責管理和調度,然後 go 語言在操作系統線程之上又做了 goroutine,實現了一個二級線程模型。
#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函數創建一個新線程執行start函數
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); // 等待子線程結束運行
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 個線程,一個是操作系統把程序加載起來運行時創建的主線程,另一個是主線程調用 pthread_create 創建的 start 子線程,主線程在創建完子線程之後每隔 500 毫秒打印一下全局變量 g 的值直到 g 等於 10 億,而 start 線程啓動後就開始執行一個 10 億次的對 g 自增加 1 的循環,這兩個線程同時併發運行在系統中,操作系統負責對它們進行調度,我們無法精確預知某個線程在什麼時候會運行。
操作系統在調度線程時會做哪些事情
-
選擇線程:操作系統調度器會根據特定的調度算法(如優先級調度、輪轉調度、最短作業優先等)選擇下一個要執行的線程。
-
上下文切換:操作系統會保存當前正在運行的線程的狀態(這被稱爲上下文),然後加載被選中的線程的上下文。上下文包括了線程的程序計數器、寄存器的值等。
-
線程切換:操作系統會將 CPU 的控制權交給被選中的線程,該線程會從它上次停止的地方開始執行。
-
回到原線程:當被選中的線程的執行時間片用完或者被阻塞時,操作系統會再次保存它的上下文,然後選擇另一個線程執行。這個過程會不斷重複。
具體需要保存哪些寄存器呢
-
通用寄存器,線程運行時很可能會用到,保存當前線程的一些工作變量。
-
ip 寄存器(指令指針寄存器)(程序運行起來後低地址有一部分專門用來存放代碼數據,IP 寄存器通常指向這一區域,指明下一條要運行的代碼地址)。
-
棧寄存器 RBP(指向當前棧的棧底)和 RSP(當前棧的棧頂),2 個棧寄存器確定了線程執行時需要使用的棧內存。所以恢復 CPU 寄存器的值就相當於改變了 CPU 下一條需要執行的指令,同時也切換了函數調用棧。
線程調度的核心是什麼
操作系統對線程的調度可以簡單的理解爲內核調度器對不同線程所使用的寄存器和棧的切換。
goroutine 調度器
調度模型
傳統線程模型的問題
調度
上面講到了線程是操作系統級別的調度單位,通常由操作系統內核管理。切上下文切換的開銷通常在微秒級別,且頻繁的上下文切換會顯著影響性能。
資源消耗
每個線程都有自己的堆棧和線程局部存儲(Thread Local Storage),這會消耗更多的內存資源。創建和銷燬線程的開銷也相對較大。在 Linux 中,默認的線程棧大小通常爲 8 MB。
調度策略
線程調度通常由操作系統內核使用複雜的調度算法(如輪轉調度、優先級調度等)來管理。調度器需要考慮多個線程的優先級、狀態、資源佔用等因素,調度過程相對複雜。
goroutine 有多輕量
而相對的,用戶態的 goroutine 則輕量得多:
-
goroutine 是用戶態線程,其創建和切換都在用戶代碼中完成而無需進入操作系統內核,所以其開銷要遠遠小於系統線程的創建和切換;
-
goroutine 啓動時默認棧大小隻有 2k,這在多數情況下已經夠用了,即使不夠用,goroutine 的棧也會自動擴大,同時,如果棧太大了過於浪費它還能自動收縮,這樣既沒有棧溢出的風險,也不會造成棧內存空間的大量浪費。
正是因爲 go 語言中實現瞭如此輕量級的線程,才使得我們在 Go 程序中,可以輕易的創建成千上萬甚至上百萬的 goroutine 出來併發的執行任務而不用太擔心性能和內存等問題。
go 調度器的簡化模型
goroutine 建立在操作系統線程基礎之上,它與操作系統線程之間實現了一個多對多 (M:N) 的兩級線程模型 這裏的 M:N 是指 M 個 goroutine 運行在 N 個操作系統線程之上,內核負責對這 N 個操作系統線程進行調度,而這 N 個系統線程又負責對這 M 個 goroutine 進行調度和運行。
所謂的對 goroutine 的調度,是指程序代碼按照一定的算法在適當的時候挑選出合適的 goroutine 並放到 CPU 上去運行的過程,這些負責對 goroutine 進行調度的程序代碼我們稱之爲 goroutine 調度器。用極度簡化了的僞代碼來描述 goroutine 調度器的工作流程大概是下面這個樣子:
// 程序啓動時的初始化代碼
......
for i := 0; i < N; i++ { // 創建N個操作系統線程執行schedule函數
create_os_thread(schedule) // 創建一個操作系統線程執行schedule函數
}
//schedule函數實現調度邏輯
func schedule() {
for { //調度循環
// 根據某種算法從M個goroutine中找出一個需要運行的goroutine
g := find_a_runnable_goroutine_from_M_goroutines()
run_g(g) // CPU運行該goroutine,直到需要調度其它goroutine才返回
save_status_of_g(g) // 保存goroutine的狀態,主要是寄存器的值
}
}
這段僞代碼表達的意思是,程序運行起來之後創建了 N 個由內核調度的操作系統線程(爲了方便描述,我們稱這些系統線程爲工作線程)去執行 shedule 函數,而 schedule 函數在一個調度循環中反覆從 M 個 goroutine 中挑選出一個需要運行的 goroutine 並跳轉到該 goroutine 去運行,直到需要調度其它 goroutine 時才返回到 schedule 函數中通過 save_status_of_g 保存剛剛正在運行的 goroutine 的狀態然後再次去尋找下一個 goroutine。
GM 模型
在 Go 1.1 版本之前,其實用的就是GM
模型。GM 模型的調度邏輯和上面講到的簡化版模型非常類似,是一種多對多的模型,go 程序底層使用了多個操作系統線程,同時在 go 語言層面實現了語言級的輕量級協程 goroutine(對操作系統來說是透明的,操作系統只知道切換線程並且執行線程上的代碼),每個操作系統線程都會不斷的去全局隊列中獲取 goroutine 來執行。
-
goroutine (G): goroutine 是 go 語言中的輕量級線程。它們由 go 運行時管理,創建和銷燬的開銷相對較小。用戶可以通過
go
關鍵字輕鬆地啓動一個新的 goroutine。 -
操作系統線程 (M): M 代表操作系統線程,go 運行時使用這些線程來執行 goroutine。每個 M 線程可以在操作系統的線程池中運行,負責執行 goroutine 的代碼。
GM 模型的缺點
1️⃣ 有一個全局隊列帶來了一個問題,因爲從隊列中獲取 goroutine 必須要加鎖,導致鎖的爭用非常頻繁。尤其是在大量 goroutine 被調度的情況下,對性能的影響也會非常明顯。
2️⃣ 每個線程在運行時都可能會遇到需要進行系統調用的情況。早期 GM 模型中每個 M 都關聯了內存緩存(mcache)和其他的緩存(棧空間),但實際上只有正在運行的 go 代碼的 M 才需要 mcache(阻塞在系統調用的 M 不需要 mcache)。運行 go 代碼的 M 和系統調用阻塞的 M 比例大概在 1:100,這就導致了大量的資源消耗(每個 mcache 會佔用到 2M)。
3️⃣ 造成延遲和額外的系統負載。比如當 G 中包含創建新協程的時候,M 創建了 G’,爲了繼續執行 G,需要把 G’交給 M’執行,也造成了很差的局部性,因爲 G’和 G 是相關的,最好放在 M 上執行,而不是其他 M'。
GMP 模型
基於沒有什麼是加一箇中間層不能解決的思路,golang
在原有的GM
模型的基礎上加入了一個調度器P
,可以簡單理解爲是在G
和M
中間加了箇中間層。於是就有了現在的GMP
模型裏。
-
P
的加入,還帶來了一個本地協程隊列,跟前面提到的全局隊列類似,也是用於存放G
,想要獲取等待運行的G
,會優先從本地隊列裏拿,訪問本地隊列無需加鎖。而全局協程隊列依然是存在的,但是功能被弱化,不到萬不得已是不會去全局隊列裏拿G
的。 -
GM
模型裏 M 想要運行G
,直接去全局隊列裏拿就行了;GMP
模型裏,M
想要運行G
,就得先獲取P
,然後從P
的本地隊列獲取G
。 -
新建
G
時,新G
會優先加入到P
的本地隊列;如果本地隊列滿了,則會把本地隊列中一半的G
移動到全局隊列。P
的本地隊列爲空時,就從全局隊列裏去取。 -
新建
G
時,新G
會優先加入到P
的本地隊列;如果本地隊列滿了,則會把本地隊列中一半的G
移動到全局隊列。 -
P
的本地隊列爲空時,就從全局隊列裏去取。
- 如果全局隊列爲空時,
M
會從其他P
的本地隊列偷(stealing)一半 G 放到自己P
的本地隊列。
M
運行G
,G
執行之後,M
會從P
獲取下一個G
,不斷重複下去。
爲什麼要有 P
這時候就有會疑惑了,如果是想實現本地隊列、Work Stealing 算法,那爲什麼不直接在 M 上加呢,M 也照樣可以實現類似的功能。爲什麼又再加多一個 P 組件?結合 M(系統線程) 的定位來看,若這麼做,有以下問題。
-
一般來講,M 的數量都會多於 P。像在 Go 中,M 的數量最大限制是 10000,P 的默認數量的 CPU 核數。另外由於 M 的屬性,也就是如果存在系統阻塞調用,阻塞了 M,又不夠用的情況下,M 會不斷增加。
-
M 不斷增加的話,如果本地隊列掛載在 M 上,那就意味着本地隊列也會隨之增加。這顯然是不合理的,因爲本地隊列的管理會變得複雜,且 Work Stealing 性能會大幅度下降。
-
M 被系統調用阻塞後,我們是期望把他既有未執行的任務分配給其他繼續運行的,而不是一阻塞就導致全部停止
調度器數據結構概述
系統線程對 goroutine 的調度與內核對系統線程的調度原理是一樣的,實質都是通過保存和修改 CPU 寄存器的值來達到切換線程 / goroutine 的目的。因此,爲了實現對 goroutine 的調度,需要引入一個數據結構來保存 CPU 寄存器的值以及 goroutine 的其它一些狀態信息,在 go 語言調度器源代碼中,這個數據結構是一個名叫 g 的結構體,它保存了 goroutine 的所有信息,該結構體的每一個實例對象都代表了一個 goroutine。調度器代碼可以通過 g 對象來對 goroutine 進行調度。
-
當goroutine被調離CPU時,調度器代碼負責把CPU寄存器的值保存在g對象的成員變量之中
-
當goroutine被調度起來運行時,調度器代碼又負責把g對象的成員變量所保存的寄存器的值恢復到CPU的寄存器
前面我們所講的 G,M,P,在源碼中均有與之對應的數據結構。
重要的結構體
G M P 結構體定義於 src/runtime/runtime2.go。
g 結構體
type g struct {
// 記錄該goroutine使用的棧,當前 goroutine 的棧內存範圍 [stack.lo, stack.hi)
stack stack
// 下面兩個成員用於棧溢出檢查,實現棧的自動伸縮,搶佔調度也會用到stackguard0
stackguard0 uintptr
_panic *_panic
_defer *_defer
// 此goroutine正在被哪個工作線程執行
m *m
// 存儲 goroutine 的調度相關的數據
sched gobuf
// schedlink字段指向全局運行隊列中的下一個g,
// 所有位於全局運行隊列中的g形成一個鏈表
schedlink guintptr
// 不涉及本篇內容的字段已剔除
...
}
下面看看 gobuf 結構體,主要在調度器保存或者恢復上下文的時候用到:
type gobuf struct {
// 棧指針,對應上文講到的RSP寄存器的值
sp uintptr
// 程序計數器,對應上文講到的RIP寄存器
pc uintptr
// 記錄當前這個gobuf對象屬於哪個goroutine
g guintptr
// 系統調用的返回值
ret sys.Uintreg
// 保存CPU的rbp寄存器的值
bp uintptr
...
}
stack 結構體主要用來記錄 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使用的棧的起始和結束位置
type stack struct {
lo uintptr // 棧頂,指向內存低地址
hi uintptr // 棧底,指向內存高地址
}
在執行過程中,G 可能處於以下幾種狀態:
const (
// 剛剛被分配並且還沒有被初始化
_Gidle = iota // 0
// 沒有執行代碼,沒有棧的所有權,存儲在運行隊列中
_Grunnable // 1
// 可以執行代碼,擁有棧的所有權,被賦予了內核線程 M 和處理器 P
_Grunning // 2
// 正在執行系統調用,擁有棧的所有權,沒有執行用戶代碼,
// 被賦予了內核線程 M 但是不在運行隊列上
_Gsyscall // 3
// 由於運行時而被阻塞,沒有執行用戶代碼並且不在運行隊列上,
// 但是可能存在於 Channel 的等待隊列上
_Gwaiting // 4
// 表示當前goroutine沒有被使用,沒有執行代碼,可能有分配的棧
_Gdead // 6
// 棧正在被拷貝,沒有執行代碼,不在運行隊列上
_Gcopystack // 8
// 由於搶佔而被阻塞,沒有執行用戶代碼並且不在運行隊列上,等待喚醒
_Gpreempted // 9
// GC 正在掃描棧空間,沒有執行代碼,可以與其他狀態同時存在
_Gscan = 0x1000
...
)
上面的狀態看起來很多,但是實際上只需要關注下面幾種就好了:
-
等待中:_ Gwaiting、_Gsyscall 和 _Gpreempted,這幾個狀態表示 G 沒有在執行;
-
可運行:_Grunnable,表示 G 已經準備就緒,可以在線程運行;
-
運行中:_Grunning,表示 G 正在運行;
m 結構體
type m struct {
// g0主要用來記錄工作線程使用的棧信息,在執行調度代碼時需要使用這個棧 // 執行用戶goroutine代碼時,使用用戶goroutine自己的棧,調度時會發生棧的切換
g0 *g
// 線程本地存儲 thread-local,通過TLS實現m結構體對象與工作線程之間的綁定,下文會詳細介紹
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的值就是操作系統線程ID
thread uintptr // thread handle
freelink *m // on sched.freem
...
}
p 結構體
調度器中的處理器 P 是線程 M 和 G 的中間層,用於調度 G 在 M 上執行。
type p struct {
id int32
// p 的狀態
status uint32
// 調度器調用會+1
schedtick uint32 // incremented on every scheduler call
// 系統調用會+1
syscalltick uint32 // incremented on every system call
// 對應關聯的 M
m muintptr
mcache *mcache
pcache pageCache
// defer 結構池
deferpool [5][]*_defer
deferpoolbuf [5][32]*_defer
// 可運行的 goroutine 隊列,可無鎖訪問
runqhead uint32
runqtail uint32
runq [256]guintptr
// 緩存可立即執行的 G
runnext guintptr
// 可用的 G 列表,G 狀態等於 Gdead
gFree struct {
gList
n int32
}
...
}
P 的幾個狀態:
const (
// 表示P沒有運行用戶代碼或者調度器
_Pidle = iota
// 被線程 M 持有,並且正在執行用戶代碼或者調度器
_Prunning
// 沒有執行用戶代碼,當前線程陷入系統調用
_Psyscall
// 被線程 M 持有,當前處理器由於垃圾回收 STW 被停止
_Pgcstop
// 當前處理器已經不被使用
_Pdead
)
schedt 結構體
type schedt struct {
...
// 鎖,從全局隊列獲取G時需要使用到
lock mutex
// 空閒的 M 列表
midle muintptr
// 空閒的 M 列表數量
nmidle int32
// 下一個被創建的 M 的 id
mnext int64
// 能擁有的最大數量的 M
maxmcount int32
// 由空閒的p結構體對象組成的鏈表
pidle puintptr // idle p's
// 空閒 p 數量
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 結構的集中緩存
sudoglock mutex
sudogcache *sudog
// defer 結構的池
deferlock mutex
deferpool [5]*_defer
...
}
重要的全局變量
allgs []*g // 保存所有的g
allm *m // 所有的m構成的一個鏈表,包括下面的m0
allp []*p // 保存所有的p,len(allp) == gomaxprocs
ncpu int32 // 系統中cpu核的數量,程序啓動時由runtime代碼初始化
gomaxprocs int32 // p的最大值,默認等於ncpu,但可以通過GOMAXPROCS修改
sched schedt // 調度器結構體對象,記錄了調度器的工作狀態
m0 m // 代表進程的主線程
g0 g // m0的g0,也就是m0.g0 = &g0
在程序初始化時,這些全變量都會被初始化爲 0 值,指針會被初始化爲 nil 指針,切片初始化爲 nil 切片,int 被初始化爲數字 0,結構體的所有成員變量按其本類型初始化爲其類型的 0 值。所以程序剛啓動時 allgs,allm 和 allp 都不包含任何 g,m 和 p。
線程執行的代碼是如何找到屬於自己的那個 m 結構體實例對象的呢
前面我們說 GMP 模型中每個工作線程都有一個 m 結構體對象與之對應,但並未詳細說明它們之間是如何對應起來的~ 如果只有一個工作線程,那麼就只會有一個 m 結構體對象,問題就很簡單,定義一個全局的 m 結構體變量就行了。可是我們有多個工作線程和多個 m 需要一一對應,這裏就需要用到線程的本地存儲了。
線程本地存儲(TLS)
TLS 是一種機制,允許每個線程有自己的獨立數據副本。這意味着多個線程可以同時運行而不會相互干擾,因爲每個線程都可以訪問自己的數據副本。
寄存器中 **fs
段的作用 **
在 Linux 系統中,fs
段可以用於存儲線程的 TLS 數據,通常通過 fs
段寄存器來訪問。
go 語言中的使用
-
在 Go 語言的運行時(runtime)中,
fs
段被用來存儲與每個 goroutine 相關的線程局部數據。Go 的 GMP 模型(goroutine, M, P)中,m
結構體的tls
字段通常會被設置爲當前線程的fs
段,以便快速訪問線程局部存儲。 -
通過將
fs
段與m
結構體的tls
字段關聯,Go 可以高效地管理和訪問每個 goroutine 的特定數據。
具體到 goroutine 調度器代碼,每個工作線程在剛剛被創建出來進入調度循環之前就利用線程本地存儲機制爲該工作線程實現了一個指向 m 結構體實例對象的私有全局變量,這樣在之後的代碼中就使用該全局變量來訪問自己的 m 結構體對象以及與 m 相關聯的 p 和 g 對象。
有了上述數據結構以及工作線程與數據結構之間的映射機制,我們可以再豐富下前面講到的初始調度模型:
// 程序啓動時的初始化代碼
......
for i := 0; i < N; i++ { // 創建N個操作系統線程執行schedule函數
create_os_thread(schedule) // 創建一個操作系統線程執行schedule函數
}
// 定義一個線程私有全局變量,注意它是一個指向m結構體對象的指針
// ThreadLocal用來定義線程私有全局變量
ThreadLocal self *m
//schedule函數實現調度邏輯
func schedule() {
// 創建和初始化m結構體對象,並賦值給私有全局變量self
self = initm()
for { //調度循環
if (self.p.runqueue is empty) {
// 根據某種算法從全局運行隊列中找出一個需要運行的goroutine
g := find_a_runnable_goroutine_from_global_runqueue()
} else {
// 根據某種算法從私有的局部運行隊列中找出一個需要運行的goroutine
g := find_a_runnable_goroutine_from_local_runqueue()
}
run_g(g) // CPU運行該goroutine,直到需要調度其它goroutine才返回
save_status_of_g(g) // 保存goroutine的狀態,主要是寄存器的值
}
}
僅僅從上面這個僞代碼來看,我們完全不需要線程私有全局變量,只需在 schedule 函數中定義一個局部變量就行了。但真實的調度代碼錯綜複雜,不光是這個 schedule 函數會需要訪問 m,其它很多地方還需要訪問它,所以需要使用全局變量來方便其它地方對 m 的以及與 m 相關的 g 和 p 的訪問。
從 main 函數啓動開始分析
下面我們通過一個簡單的 go 程序入手分析 調度器的初始化,go routine 的創建與退出,工作線程的調度循環以及 goroutine 的切換。
package main
import "fmt"
func main() {
fmt.Println("Hello World!")
}
程序入口
linux amd64 系統的啓動函數是在 asm_amd64.s 的 runtime·rt0_go 函數中。當然,不同的平臺有不同的程序入口。rt0_go 函數完成了 go 程序啓動時的所有初始化工作,因此這個函數比較長,也比較繁雜,但這裏我們只關注與調度器相關的一些初始化,下面我們分段來看:
TEXT runtime·rt0_go(SB),NOSPLIT|NOFRAME|TOPFRAME,$0
// copy arguments forward on an even stack
MOVQ DI, AX // 這行代碼將寄存器 `DI` 的值(通常是命令行參數的數量,即 `argc`)複製到寄存器 `AX` 中。`AX` 現在存儲了程序的參數個數
MOVQ SI, BX // 這行代碼將寄存器 `SI` 的值(通常是指向命令行參數字符串數組的指針,即 `argv`)複製到寄存器 `BX` 中。`BX` 現在存儲了指向命令行參數的指針。
SUBQ $(5*8), SP // 這行代碼從棧指針 `SP` 中減去 `40` 字節(`5*8`),爲局部變量和函數參數分配空間。這裏的 `5` 可能表示 3 個參數和 2 個自動變量(局部變量)。這行代碼的目的是在棧上爲這些變量留出空間。
ANDQ $~15, SP // 這行代碼將棧指針 `SP` 對齊到 16 字節的邊界。確保棧在函數調用時是對齊的
MOVQ AX, 24(SP) // 這行代碼將 `AX` 中的值(即 `argc`)存儲到棧上相對於 `SP` 的偏移量 +`24` 的位置。
MOVQ BX, 32(SP) // - 這行代碼將 `BX` 中的值(即 `argv`)存儲到棧上相對於 `SP` 的偏移量 +`32` 的位置。
上面的第 4 條指令用於調整棧頂寄存器的值使其按 16 字節對齊,也就是讓棧頂寄存器 SP 指向的內存的地址爲 16 的倍數,最後兩條指令把 argc 和 argv 搬到新的位置。
初始化 g0
繼續看後面的代碼,下面開始初始化全局變量 g0,前面我們說過,g0 的主要作用是提供一個棧供 runtime 代碼執行,因此這裏主要對 g0 的幾個與棧有關的成員進行了初始化,從這裏可以看出 g0 的棧大約有 64K。
從系統線程的棧中劃分出一部分作爲g0的棧,然後初始化g0的棧信息和stackgard
MOVQ $runtime·g0(SB), DI // 把g0的地址放入寄存器DI
LEAQ (-64*1024)(SP), BX // 設置寄存器BX的值爲 SP(主線程棧棧頂指針) - 64k 的位置
MOVQ BX, g_stackguard0(DI) // g0.stackguard0 = BX , 也就是設置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 與棧之間的關係如下圖所示:
主線程與 m0 綁定
設置好 g0 棧之後,我們跳過 CPU 型號檢查以及 cgo 初始化相關的代碼,接着分析如何把 m 數據結構 和 線程綁定在一起,原因在上面已描述過:每個線程需要能快速找到自己所屬的 m 結構體。
LEAQ runtime·m0+m_tls(SB), DI // DI = &m0.tls,取m0的tls成員的地址到DI寄存器
CALL runtime·settls(SB) // 調用settls設置線程本地存儲,settls函數的參數在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設置到線程本地存儲中
MOVQ runtime·m0+m_tls(SB), AX //獲取m.tls結構體的地址到AX寄存器中
CMPQ AX, $0x123 // 判斷m.tls[0]的值是否等於123,是的話說明tls工作正常
JEQ 2(PC)
CALL runtime·abort(SB)
設置 tls 的函數 runtime·settls(SB) 位於源碼 src/runtime/sys_linux_amd64.s
處,主要內容就是通過一個系統調用將 fs 段基址設置成 m.tls[1] 的地址,而 fs 段基址又可以通過 CPU 裏的寄存器 fs 來獲取。這段代碼運行後,工作線程代碼就可以通過 CPU 的 fs 寄存器來找到 m.tls。
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關聯起來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,於是這裏就實現了 m0 和 g0 與主線程之間的關聯。
從這裏還可以看到,保存在主線程本地存儲中的值是 g0 的地址,也就是說工作線程的私有全局變量其實是一個指向 g 的指針而不是指向 m 的指針,目前這個指針指向 g0,表示代碼正運行在 g0 棧。此時,主線程,m0,g0 以及 g0 的棧之間的關係如下圖所示:
初始化 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)
// 調度器初始化
CALL runtime·schedinit(SB)
// 新建一個 goroutine,該 goroutine 綁定 runtime.main
MOVQ $runtime·mainPC(SB), AX // entry
PUSHQ AX
CALL runtime·newproc(SB)
POPQ AX
// 啓動M,開始調度循環,運行剛剛創建的goroutine
CALL runtime·mstart(SB)
// 上面的mstart永遠不應該返回的,如果返回了,一定是代碼邏輯有問題,直接abort
CALL runtime·abort(SB) // mstart should never return
RET
上面的 CALL 方法中:
-
schedinit 進行各種運行時組件初始化工作,這包括我們的調度器與內存分配器、回收器的初始化;
-
newproc 負責根據主 G 入口地址創建可被運行時調度的執行單元;
-
mstart 開始啓動調度器的調度循環;
調度器初始化(schedinit)
func schedinit() {
// raceinit must be the first call to race detector.
// In particular, it must be done before mallocinit below calls racemapshadow.
//getg函數在源代碼中沒有對應的定義,由編譯器插入類似下面兩行代碼
//get_tls(CX)
//MOVQ g(CX), BX; BX存器裏面現在放的是當前g結構體對象的地址
_g_ := getg() // _g_ = &g0
......
//設置最多啓動10000個操作系統線程,也是最多10000個M
sched.maxmcount = 10000
......
mcommoninit(_g_.m) //初始化m0,因爲從前面的代碼我們知道g0->m = &m0
......
sched.lastpoll = uint64(nanotime())
procs := ncpu //系統中有多少核,就創建和初始化多少個p結構體對象
if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
procs = n //如果環境變量指定了GOMAXPROCS,則創建指定數量的p
}
if procresize(procs) != nil {//創建和初始化全局變量allp
throw("unknown runnable goroutine during bootstrap")
}
......
}
前面我們已經看到,g0 的地址已經被設置到了線程本地存儲之中,schedinit 通過 getg 函數(getg 函數是編譯器實現的,我們在源代碼中是找不到其定義的)從線程本地存儲中獲取當前正在運行的 g,這裏獲取出來的是 g0,然後調用 mcommoninit 函數對 m0(g0.m) 進行必要的初始化,對 m0 初始化完成之後調用 procresize 初始化系統需要用到的 p 結構體對象,p 就是 processor 的意思,它的數量決定了最多可以有多少個 goroutine 同時並行運行。
schedinit 函數除了初始化 m0 和 p,還設置了全局變量 sched 的 maxmcount 成員爲 10000,限制最多可以創建 10000 個操作系統線程出來工作。
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
}
// 創建用於信號處理的gsignal,只是簡單的從堆上分配一個g結構體對象,然後把棧設置好就返回了
mpreinit(mp)
if mp.gsignal != nil {
mp.gsignal.stackguard1 = mp.gsignal.stack.lo + _StackGuard
}
// 把 M 掛入全局鏈表allm之中
mp.alllink = allm
...
}
這裏傳入的 id 是 - 1,初次調用會將 id 設置爲 0,這裏並未對 m0 做什麼關於調度相關的初始化,所以可以簡單的認爲這個函數只是把 m0 放入全局鏈表 allm 之中就返回了。
m0 完成基本的初始化後,繼續調用 procresize 創建和初始化 p 結構體對象,在這個函數里面會創建指定個數(根據 cpu 核數或環境變量確定)的 p 結構體對象放在全變量 allp 裏, 並把 m0 和 allp[0] 綁定在一起,因此當這個函數執行完成之後就有。
m0.p = allp[0]
allp[0].m = &m0
到這裏 m0, g0, 和 m 需要的 p 完全關聯在一起了
P 初始化
由於用戶代碼運行過程中也支持通過 GOMAXPROCS() 函數調用 procresize 來重新創建和初始化 p 結構體對象,而在運行過程中再動態的調整 p 牽涉到的問題比較多,所以這個函數的處理比較複雜,這裏只保留了初始化時會執行的代碼。
func procresize(nprocs int32) *p {
old := gomaxprocs //系統初始化時 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 {
//初始化時進入此分支,創建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
//循環創建nprocs個p並完成基本初始化
for i := int32(0); i < nprocs; i++ {
pp := allp[i]
if pp == nil {
pp = new(p)//調用內存分配器從堆上分配一個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還未初始化,所以不會執行這個分支
// continue to use the current P
_g_.m.p.ptr().status = _Prunning
_g_.m.p.ptr().mcache.prepareForSweep()
} else {
//初始化時執行這個分支
// release the current P and acquire allp[0]
if _g_.m.p != 0 {//初始化時這裏不執行
_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關聯起來,其實是這兩個strct的成員相互賦值
if trace.enabled {
traceGoStart()
}
}
//下面這個for 循環把所有空閒的p放入空閒鏈表
var runnablePs *p
for i := nprocs - 1; i >= 0; i-- {
p := allp[i]
if _g_.m.p.ptr() == p {//allp[0]跟m0關聯了,所以是不能放任
continue
}
p.status = _Pidle
if runqempty(p) {//初始化時除了allp[0]其它p全部執行這個分支,放入空閒鏈表
pidleput(p)
} else {
......
}
}
......
return runnablePs
}
這裏總結一下這個函數的主要流程:
-
使用 make([]*p, nprocs) 初始化全局變量 allp,即 allp = make([]*p, nprocs)
-
循環創建並初始化 nprocs 個 p 結構體對象並依次保存在 allp 切片之中
-
把 m0 和 allp[0] 綁定在一起,即 m0.p = allp[0], allp[0].m = m0
-
把除了 allp[0] 之外的所有 p 放入到全局變量 sched 的 pidle 空閒隊列之中
下面我們用圖來總結下整個調度器各部分的組成:
goroutine 的創建(newproc)
經過上文介紹我們介紹 m0 初始化中有說到,初始化過程中會新建一個 goroutine,該 goroutine 綁定 runtime.main,而 runtime.main 實際上最後會走到我們實現的 main 函數上。新建 goroutine 的操作就是通過 newproc() 調用來實現的。
newproc 函數用於創建新的 goroutine,它有兩個參數,先說第二個參數 fn,新創建出來的 goroutine 將從 fn 這個函數開始執行,而這個 fn 函數可能也會有參數,newproc 的第一個參數正是 fn 函數的參數以字節爲單位的大小。比如有如下 go 代碼片段:
func start(a, b, c int64) {
......
}
func main() {
go start(1, 2, 3)
}
編譯器在編譯上面的 go 語句時,就會把其替換爲對 newproc 函數的調用,編譯後的代碼邏輯上等同於下面的僞代碼。
func main() {
push 0x3
push 0x2
push 0x1
runtime.newproc(24, start)
}
可以看到編譯器會幫我們把三個參數 1,2,3 分別壓棧作爲 start 函數的參數,然後再調用 newproc 函數。我們會注意到 newproc 函數本身還需要兩個參數,第一個是 24,表示 start 函數需要 24 個字節大小的參數 爲什麼需要傳遞 start 函數的參數大小給到 newproc 函數呢? 這裏是因爲新建 goroutine 會在堆上創建一個全新的棧,需要把 start 需要用到的參數先從當前 goroutine 的棧上拷貝到新的 goroutine 的棧上之後才能讓其開始執行,而 newproc 函數本身並不知道需要拷貝多少數據到新創建的 goroutine 的棧上去,所以需要用參數的方式指定拷貝多少數據。
newproc 函數是對 newproc1 的一個包裝,這裏最重要的準備工作有兩個,一個是獲取 fn 函數第一個參數的地址(代碼中的 argp),另一個是使用 systemstack 函數切換到 g0 棧,當然,對於我們這個初始化場景來說現在本來就在 g0 棧,所以不需要切換,然而這個函數是通用的,在用戶的 goroutine 中也會創建 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) {
//函數調用參數入棧順序是從右向左,而且棧是從高地址向低地址增長的
//注意:argp指向fn函數的第一個參數,而不是newproc函數的參數
//參數fn在棧上的地址+8的位置存放的是fn函數的第一個參數
argp := add(unsafe.Pointer(&fn), sys.PtrSize)
gp := getg() //獲取正在運行的g,初始化時是m0.g0
//getcallerpc()返回一個地址,也就是調用newproc時由call指令壓棧的函數返回地址,
//對於我們現在這個場景來說,pc就是CALLruntime·newproc(SB)指令後面的POPQ AX這條指令的地址
pc := getcallerpc()
//systemstack的作用是切換到g0棧執行作爲參數的函數
//我們這個場景現在本身就在g0棧,因此什麼也不做,直接調用作爲參數的函數
systemstack(func() {
newproc1(fn, (*uint8)(argp), siz, gp, pc)
})
}
newproc1 函數的第一個參數 fn 是新創建的 goroutine 需要執行的函數,注意這個 fn 的類型是 funcval 結構體類型,其定義如下:
type funcval struct {
fn uintptr
// variable-size, fn-specific data here
}
第二個參數 argp 是 fn 函數的第一個參數的地址,第三個參數是 fn 函數的參數以字節爲單位的大小,後面兩個參數我們不用關心。這裏需要注意的是,newproc1 是在 g0 的棧上執行的。
// 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) {
//因爲已經切換到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結構體對象,然後從堆上爲其分配棧,並設置g的stack成員和兩個stackgard成員
newg = malg(_StackMin)
casgstatus(newg, _Gidle, _Gdead) //初始化g的狀態爲_Gdead
//放入全局變量allgs切片中
allgadd(newg) // publishes with a g->status of Gdead so GC scanner doesn't look at uninitialized stack.
}
......
//調整g的棧頂置針,無需關注
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 {
//把參數從執行newproc函數的棧(初始化時是g0棧)拷貝到新g的棧
memmove(unsafe.Pointer(spArg), unsafe.Pointer(argp), uintptr(narg))
// ......
}
這段代碼主要從堆上分配一個 g 結構體對象併爲這個 newg 分配一個大小爲 2048 字節的棧,並設置好 newg 的 stack 成員,然後把 newg 需要執行的函數的參數從執行 newproc 函數的棧(初始化時是 g0 棧)拷貝到 newg 的棧,完成這些事情之後 newg 的狀態如下圖所示:這裏需要注意的是,新創建出來的 g 都是在堆上分配內存的,主要有以下幾個原因:
-
生命週期:goroutine 的生命週期可能會超過創建它的函數的生命週期。如果在棧上分配 goroutine,那麼當創建 goroutine 的函數返回時,goroutine 可能還在運行,這將導致棧被回收,從而引發錯誤。而在堆上分配 goroutine 可以避免這個問題,因爲堆上的內存只有在沒有任何引用指向它時纔會被垃圾回收。
-
大小:goroutine 的棧大小是動態的,它可以根據需要進行擴展和收縮。如果在棧上分配 goroutine,那麼每次 goroutine 的棧大小改變時,都需要移動整個 goroutine 的內存,這將導致大量的性能開銷。而在堆上分配 goroutine 可以避免這個問題,因爲堆上的內存可以動態地進行擴展和收縮。
-
併發:在 go 語言中,可以同時運行多個 goroutine。如果在棧上分配 goroutine,那麼每個線程的棧大小都需要足夠大,以容納所有的 goroutine,這將導致大量的內存浪費。而在堆上分配 goroutine 可以避免這個問題,因爲堆是所有線程共享的,因此可以更有效地利用內存。因此,出於生命週期、大小和併發等考慮,GMP 模型中新創建的 goroutine 都是在堆上分配內存的。下面讓我們用一個圖示來總結下目前的 GMP 模型中各個部分的情況。
繼續分析 newproc1():
//把newg.sched結構體成員的所有成員設置爲0
memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched))
//設置newg的sched成員,調度器需要依靠這些字段才能把goroutine調度到CPU上運行。
newg.sched.sp = sp //newg的棧頂
newg.stktopsp = sp
//newg.sched.pc表示當newg被調度起來運行時從這個地址開始執行指令
//把pc設置成了goexit這個函數偏移1(sys.PCQuantum等於1)的位置,
//至於爲什麼要這麼做需要等到分析完gostartcallfn函數才知道
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) //調整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的入口地址,初始化時對應的是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函數的參數,sp指向的是fn的第一參數
if sys.RegSize > sys.PtrSize {
sp -= sys.PtrSize
*(*uintptr)(unsafe.Pointer(sp)) = 0
}
sp -= sys.PtrSize //爲返回地址預留空間,
//這裏在僞裝fn是被goexit函數調用的,使得fn執行完後返回到goexit繼續執行,從而完成清理工作
*(*uintptr)(unsafe.Pointer(sp)) = buf.pc //在棧上放入goexit+1的地址
buf.sp = sp //重新設置newg的棧頂寄存器
//這裏才真正讓newg的ip寄存器指向fn函數,注意,這裏只是在設置newg的一些信息,newg還未執行,
//等到newg被調度起來運行時,調度器會把buf.pc放入cpu的IP寄存器,
//從而使newg得以在cpu上真正的運行起來
buf.pc = uintptr(fn)
buf.ctxt = ctxt
}
gostartcall 函數的主要作用有兩個:
-
調整 newg 的棧空間,把 goexit 函數的第二條指令的地址入棧,僞造成 goexit 函數調用了 fn,從而使 fn 執行完成後執行 ret 指令時返回到 goexit 繼續執行完成最後的清理工作;
-
重新設置 newg.buf.pc 爲需要執行的函數的地址,即 fn,我們這個場景爲 runtime.main 函數的地址,如果是在運行中 go aa() 啓動的協程,那麼 newg.buf.pc 會爲 aa() 函數的地址。
//newg真正從哪裏開始執行並不依賴於這個成員,而是sched.pc
newg.startpc = fn.fn
......
//設置g的狀態爲_Grunnable,表示這個g代表的goroutine可以運行了
casgstatus(newg, _Gdead, _Grunnable)
......
//把newg放入_p_的運行隊列,初始化的時候一定是p的本地運行隊列,其它時候可能因爲本地隊列滿了而放入全局隊列
runqput(_p_, newg, true)
......
}
這時 newg 也就是 main goroutine 的狀態如下圖所示:
可以看到 newproc 執行完畢時,p, m0, g0, newg, allp 的內存均已分配好且它們之間的關係也通過指針掛鉤上,我們留意下 newg 的堆棧中目前棧頂是 goexit+1 這個位置的返回地址;目前 newg 尚未被調度起來運行,只是剛加入 p 的本地隊列,後續該 newg 被 cpu 調度到時,cpu sp 寄存器會指向棧頂,在 newg 自身的邏輯開始執行後,newg 的棧內存會被不斷使用,SP 寄存器不斷移動來指示棧的內存的增長與回收,當所有邏輯執行完時,sp 寄存器重新指回這個 goexit+1 這個位置。最後彈出該位置的值作爲 cpu rip 寄存器的值,從而去執行 runtime.goexit1(SB) 這個命令,繼續進行調度循環(這個後面會講到)。
下面我們總結下 newproc 做了什麼事情:
-
在堆上給新 goroutine 分配一段內存作爲棧空間,設置堆棧信息到新 goroutine 對應的 g 結構體上,核心是設置 gobuf.pc 指向要執行的代碼段,待調度到該 g 時,會將保存的 pc 值設置到 cpu 的 RIP 寄存器上從而去執行該 goroutine 對應的代碼。
-
把傳遞給 goroutine 的參數從當前棧拷貝到新 goroutine 所在的棧上。
-
把 g 加入到 p 的本地隊列等待調度,如果本地隊列滿了會加入到全局隊列(程序剛啓動時只會加入到 p 的本地隊列)。
調度循環的啓動(mstart)
前面我們完成了 newproc
函數,接下來是 runtime·rt0_go 中的最後一步,啓動調度循環,即 mstart 函數。
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
函數設置了 stackguard0 和 stackguard1 字段後,就直接調用 mstart1() 函數,由於只分析 main goroutine 的啓動,這裏省略部分無關的代碼:
func mstart1() {
// 啓動過程時 _g_ = m0.g0
_g_ := getg()
//getcallerpc()獲取mstart1執行完的返回地址
//getcallersp()獲取調用mstart1時的棧頂地址
save(getcallerpc(), getcallersp())
...
// 進入調度循環。永不返回
schedule()
}
save 函數非常重要,它主要做了這兩個操作
gp.sched.pc = getcallerpc()
gp.sched.sp = getcallersp()
將 mstart 調用 mstart1 的返回地址以及當時的棧頂指針保存到 g0 的 sched 結構中,保存好後,我們可以看到現在的指針指向情況(注意紅線部分) 。
這裏設置好後,g0 對象的 sp 值就不會變化了,一直指向 mstart 函數的棧頂,後續每次切換回 g0 時,都會從 g0 對象的 sp 值中恢復寄存器 SP,從而切換到 g0 棧。
繼續分析代碼,先看下 schedule() 函數的邏輯,這是 GMP 模型調度邏輯的核心,每次調度 goroutine 都是從它開始的:
// One round of scheduler: find a runnable goroutine and execute it.
// Never returns.
func schedule() {
_g_ := getg() //_g_ = 每個工作線程m對應的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.
//爲了保證調度的公平性,每進行61次調度就需要優先從全局運行隊列中獲取goroutine,
//因爲如果只調度本地隊列中的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關聯的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,
//則調用findrunnable函數從其它工作線程的運行隊列中偷取,如果偷取不到,則當前工作線程進入睡眠,
//直到獲取到需要運行的goroutine之後findrunnable函數纔會返回。
gp, inheritTime = findrunnable() // blocks until work is available
}
//跟啓動無關的代碼.....
//當前運行的是runtime的代碼,函數調用棧使用的是g0的棧空間
//調用execte切換到gp的代碼和棧空間去運行
execute(gp, inheritTime)
}
schedule 函數優先從 p 本地隊列獲取 goroutine,獲取不到時會去全局運行隊列中加鎖獲取 goroutine,在我們的場景中,前面的啓動流程已經創建好第一個 goroutine 並放入了當前工作線程的本地運行隊列(即 runtime.main 對應的 goroutine)。獲取到 g 後,會調用 execute 去切換到 g,具體的切換邏輯繼續看下 execute 函數。
func execute(gp *g, inheritTime bool) {
_g_ := getg() //g0
//設置待運行g的狀態爲_Grunning
casgstatus(gp, _Grunnable, _Grunning)
//......
//把g和m關聯起來
_g_.m.curg = gp
gp.m = _g_.m
//......
//gogo完成從g0到gp真正的切換
gogo(&gp.sched)
}
這裏的重點是 gogo 函數,真正完成了 g0 到 g 的切換,切換的實質就是 CPU 寄存器以及函數調用棧的切換:
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
//下面這行代碼沒有實質作用,檢查gp.sched.g是否是nil,如果是nil進程會crash死掉
MOVQ 0(DX), CX # make sure g != nil
get_tls(CX)
//把要運行的g的指針放入線程本地存儲,這樣後面的代碼就可以通過線程本地存儲
//獲取到當前正在執行的goroutine的g結構體對象,從而找到與之關聯的m和p
MOVQ DX, g(CX)
//把CPU的SP寄存器設置爲sched.sp,完成了棧的切換(畫重點!)
MOVQ gobuf_sp(BX), SP # restore SP
//下面三條同樣是恢復調度上下文到CPU相關寄存器
MOVQ gobuf_ret(BX), AX
MOVQ gobuf_ctxt(BX), DX
MOVQ gobuf_bp(BX), BP
//清空sched的值,因爲我們已把相關值放入CPU對應的寄存器了,不再需要,這樣做可以少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跳轉到該地址繼續執行指令,
JMP BX
這個函數,其實就只做了兩件事:
-
把 gp.sched 的成員恢復到 CPU 的寄存器完成狀態以及棧的切換;
-
跳轉到 gp.sched.pc 所指的指令地址(runtime.main)處執行。
最後我們再總結一下程序開始運行後從 g0 棧切換到 main goroutine 棧的流程
保存 g0 的調度信息,主要是保存 CPU 棧頂寄存器 SP 到 g0.sched.sp 成員之中;
調用 schedule 函數尋找需要運行的 goroutine,我們這個場景找到的是 main goroutine;
調用 gogo 函數首先從 g0 棧切換到 main goroutine 的棧,然後從 main goroutine 的 g 結構體對象之中取出 sched.pc 的值並使用 JMP 指令跳轉到該地址去執行;
go 的調度循環是什麼
上文我們分析了 main goroutine 的啓動,main 的 goroutine 和非 main 得 goroutine 稍微會有一點差別,主要在於 main goutine 對應的 runtime.main 函數,執行完畢後會直接在彙編代碼中執行 exit 從而退出程序,而非 main goroutine 在執行完對應的邏輯後,會進入調度循環,不斷找到下一個 goroutine 來執行。假設我們在代碼中使用 go aa() 啓動了一個協程,從 aa() 被開始調度到 aa 運行完後退出,是沿着這個路徑來執行的。
schedule()->execute()->gogo()->aa()->goexit()->goexit1()->mcall()->goexit0()->schedule()
可以看出,一輪調度是從調用 schedule 函數開始的,然後經過一系列代碼的執行到最後又再次通過調用 schedule 函數來進行新一輪的調度,從一輪調度到新一輪調度的這一過程我們稱之爲一個調度循環,這裏說的調度循環是指某一個工作線程的調度循環,而同一個 Go 程序中可能存在多個工作線程,每個工作線程都有自己的調度循環,也就是說每個工作線程都在進行着自己的調度循環。
調度循環的細節這裏就不再分析,本文就只介紹到協程調度的核心原理,相信看完本文你已經有所收穫~ 最後讓我們用一個圖來了解下調度循環的大體流程:
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/j9OpuIxXRWa9524oacGCzw