原創文章,歡迎轉載,轉載請註明出處,謝謝。
0. 前言
本系列將介紹 Go runtime 排程器。要學好 Go 語言,runtime 執行時是繞不過去的,它相當於一層“作業系統”對我們的程式做“各種型別”的處理。其中,排程器作為執行時的核心,是必須要了解的內容。本系列會結合 Go plan9 彙編,深入到 runtime 排程器的原始碼層面去看程式執行時,goroutine 協程建立等各種場景下 runtime 排程器是如何工作的。
本系列會運用到 Go plan9 彙編相關的知識,不熟悉的同學可先看看 這裡 瞭解下。
1. Go 程式初始化
首先,從一個經典的 Hello World
程式入手,檢視程式的啟動,以及啟動該程式時排程器做了什麼。
package main
func main() {
println("Hello World")
}
1.1 準備
程式啟動經過編譯和連結兩個階段,我們可以透過 go build -x hello.go
檢視構建程式的過程:
# go build -x hello.go
...
// compile 編譯 hello.go
/usr/local/go/pkg/tool/linux_amd64/compile -o $WORK/b001/_pkg_.a -trimpath "$WORK/b001=>" -p main -complete -buildid uHBjeIlqt1oQO9TLC5SE/uHBjeIlqt1oQO9TLC5SE -goversion go1.21.0 -c=3 -nolocalimports -importcfg $WORK/b001/importcfg -pack ./hello.go
...
// link 連結庫檔案生成可執行檔案
/usr/local/go/pkg/tool/linux_amd64/link -o $WORK/b001/exe/a.out -importcfg $WORK/b001/importcfg.link -buildmode=exe -buildid=27kmwBgRtsWy6cL5ofDV/uHBjeIlqt1oQO9TLC5SE/Ye3W7EEwzML-FanTsWbe/27kmwBgRtsWy6cL5ofDV -extld=gcc $WORK/b001/_pkg_.a
這裡省略了不相關的輸出,經過編譯,連結過程之後得到可執行檔案 hello
:
# ls
go.mod hello hello.go
# ./hello
Hello World
1.2 進入程式
上一節生成了可執行程式 hello
。接下來進入本文的主題,透過 dlv
進入 hello
程式,檢視在執行 Go 程式時,執行時做了什麼。
我們可以透過 readelf
檢視可執行程式的入口:
# readelf -h ./hello
ELF Header:
...
Entry point address: 0x455e40
省略了不相關的資訊,重點看 Entry point address
,它是進入 hello
程式的入口點。透過 dlv
進入該入口點:
# dlv exec ./hello
Type 'help' for list of commands.
(dlv) b *0x455e40
Breakpoint 1 set at 0x455e40 for _rt0_amd64_linux() /usr/local/go/src/runtime/rt0_linux_amd64.s:8
可以看到入口點指向的是 /go/src/runtime/rt0_linux_amd64.s
中的 _rt0_amd64_linux()
函式。
接下來,進入該函式檢視啟動 Go 程式時,執行時做了什麼。
// c 命令執行到入口點位置
(dlv) c
> _rt0_amd64_linux() /usr/local/go/src/runtime/rt0_linux_amd64.s:8 (hits total:1) (PC: 0x455e40)
Warning: debugging optimized function
3: // license that can be found in the LICENSE file.
4:
5: #include "textflag.h"
6:
7: TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8
=> 8: JMP _rt0_amd64(SB) // 跳轉到 _rt0_amd64
// si 單步執行指令
(dlv) si
> _rt0_amd64() /usr/local/go/src/runtime/asm_amd64.s:16 (PC: 0x454200)
Warning: debugging optimized function
TEXT _rt0_amd64(SB) /usr/local/go/src/runtime/asm_amd64.s
=> asm_amd64.s:16 0x454200 488b3c24 mov rdi, qword ptr [rsp]
asm_amd64.s:17 0x454204 488d742408 lea rsi, ptr [rsp+0x8]
asm_amd64.s:18 0x454209 e912000000 jmp $runtime.rt0_go // 這裡跳轉到 runtime 的 rt0_go
// 進入 rt0_go
(dlv) si
> runtime.rt0_go() /usr/local/go/src/runtime/asm_amd64.s:161 (PC: 0x454220)
Warning: debugging optimized function
TEXT runtime.rt0_go(SB) /usr/local/go/src/runtime/asm_amd64.s
=> asm_amd64.s:161 0x454220 4889f8 mov rax, rdi
asm_amd64.s:162 0x454223 4889f3 mov rbx, rsi
asm_amd64.s:163 0x454226 4883ec28 sub rsp, 0x28
asm_amd64.s:164 0x45422a 4883e4f0 and rsp, -0x10
asm_amd64.s:165 0x45422e 4889442418 mov qword ptr [rsp+0x18], rax
asm_amd64.s:166 0x454233 48895c2420 mov qword ptr [rsp+0x20], rbx
rt0_go
是 runtime 執行 Go 程式的入口。
需要補充說明的是:我們使用的 si 顯示的是 CPU 單步執行的指令,是 CPU 真正執行的指令。而 Go plan9 彙編是“最佳化”了的彙編指令,所以會發現 si 顯示的輸出和 asm_amd64.s 中定義的不一樣。在實際分析的時候可以結合兩者一起分析。
結合著 asm_amd64.s/rt0_go 分析 si 輸出的 CPU 指令:
=> asm_amd64.s:161 0x454220 4889f8 mov rax, rdi // 將 rdi 暫存器中的 argc 移到 rax 暫存器:rax = argc
asm_amd64.s:162 0x454223 4889f3 mov rbx, rsi // 將 rsi 暫存器中的 argv 移到 rbx 暫存器:rbx = argv
asm_amd64.s:163 0x454226 4883ec28 sub rsp, 0x28 // 開闢棧空間
asm_amd64.s:164 0x45422a 4883e4f0 and rsp, -0x10 // 對齊棧空間為 16 位元組的整數倍(因為 CPU 的一組 SSE 指令需要記憶體地址必須是 16 位元組的倍數)
asm_amd64.s:165 0x45422e 4889442418 mov qword ptr [rsp+0x18], rax // 將 argc 移到棧空間 [rsp+0x18]
asm_amd64.s:166 0x454233 48895c2420 mov qword ptr [rsp+0x20], rbx // 將 argv 移到棧空間 [rsp+0x20]
畫出棧空間如下圖:
繼續分析:
(dlv) si
> runtime.rt0_go() /usr/local/go/src/runtime/asm_amd64.s:170 (PC: 0x454238)
Warning: debugging optimized function
asm_amd64.s:166 0x454233 48895c2420 mov qword ptr [rsp+0x20], rbx
=> asm_amd64.s:170 0x454238 488d3d815b0700 lea rdi, ptr [runtime.g0] // 將 runtime.g0 的地址移到 rdi 暫存器中,rdi = &g0
asm_amd64.s:171 0x45423f 488d9c240000ffff lea rbx, ptr [rsp+0xffff0000] // 將 [rsp+0xffff0000] 地址的值移到 rbx 中,後面會講
asm_amd64.s:172 0x454247 48895f10 mov qword ptr [rdi+0x10], rbx // 將 rbx 中的地址,移到 [rdi+0x10],實際是移到 g0.stackguard0
asm_amd64.s:173 0x45424b 48895f18 mov qword ptr [rdi+0x18], rbx // 將 rbx 中的地址,移到 [rdi+0x18],實際是移到 g0.stackguard1
asm_amd64.s:174 0x45424f 48891f mov qword ptr [rdi], rbx // 將 rbx 中的地址,移到 [rdi],實際是移到 g0.stack.lo
asm_amd64.s:175 0x454252 48896708 mov qword ptr [rdi+0x8], rsp // 將 rsp 中的地址,移到 [rdi+0x8],實際是移到 g0.stack.hi
指令中 runtime.g0 為執行時主執行緒提供執行的執行環境,它並不是執行使用者程式碼的 goroutine。
使用 regs
檢視暫存器 rbx
儲存的是什麼:
(dlv) regs
Rip = 0x000000000045423f
Rsp = 0x00007ffec8d155f0
Rbx = 0x00007ffec8d15628
(dlv) si
> runtime.rt0_go() /usr/local/go/src/runtime/asm_amd64.s:172 (PC: 0x454247)
Warning: debugging optimized function
asm_amd64.s:171 0x45423f 488d9c240000ffff lea rbx, ptr [rsp+0xffff0000]
=> asm_amd64.s:172 0x454247 48895f10 mov qword ptr [rdi+0x10], rbx
(dlv) regs
Rip = 0x0000000000454247
Rsp = 0x00007ffec8d155f0
Rbx = 0x00007ffec8d055f0
可以看到,這段指令實際指向的是一段棧空間,rsp:0x00007ffec8d155f0
指向的是棧底,rbx:0x00007ffec8d055f0
指向的是棧頂,它們的記憶體空間是 64KB。
根據上述分析,畫出棧空間佈局如下圖:
繼續往下分析,省略一些不相關的彙編程式碼。直接從 asm_amd64.s/runtime·rt0_go:258
開始看:
258 LEAQ runtime·m0+m_tls(SB), DI
259 CALL runtime·settls(SB)
260
261 // store through it, to make sure it works
262 get_tls(BX)
263 MOVQ $0x123, g(BX)
264 MOVQ runtime·m0+m_tls(SB), AX
265 CMPQ AX, $0x123
266 JEQ 2(PC)
267 CALL runtime·abort(SB)
dlv
打斷點,進入 258 行彙編指令處:
(dlv) b /usr/local/go/src/runtime/asm_amd64.s:258
Breakpoint 2 set at 0x4542cb for runtime.rt0_go() /usr/local/go/src/runtime/asm_amd64.s:258
(dlv) c
(dlv) si
> runtime.rt0_go() /usr/local/go/src/runtime/asm_amd64.s:259 (PC: 0x4542d2)
Warning: debugging optimized function
// 將 [runtime.m0+136] 地址移到 rdi,rdi = &runtime.m0.tls
asm_amd64.s:258 0x4542cb* 488d3d565f0700 lea rdi, ptr [runtime.m0+136]
// 呼叫 runtime.settls 設定執行緒本地儲存
=> asm_amd64.s:259 0x4542d2 e809240000 call $runtime.settls
// 將 0x123 移到 fs:[0xfffffff8]
asm_amd64.s:263 0x4542d7 6448c70425f8ffffff23010000 mov qword ptr fs:[0xfffffff8], 0x123
// 將 [runtime.m0+136] 的值移到 rax 暫存器中
asm_amd64.s:264 0x4542e4 488b053d5f0700 mov rax, qword ptr [runtime.m0+136]
// 比較 rax 暫存器的值是否等於 0x123,如果不等於則執行 call $runtime.abort
asm_amd64.s:265 0x4542eb 483d23010000 cmp rax, 0x123
asm_amd64.s:266 0x4542f1 7405 jz 0x4542f8
asm_amd64.s:267 0x4542f3 e808040000 call $runtime.abort
這段指令涉及到執行緒本地儲存的知識。執行緒本地儲存(TLS)是一種機制,允許每個執行緒有自己獨立的一組變數,即使這些變數在多個執行緒之間共享相同的程式碼。在 Go runtime 中,每個作業系統執行緒(M)都需要知道自己當前正在執行哪個 goroutine(G)。為了高效地訪問這些資訊,Go runtime 使用 TLS 來儲存 G 的指標。這樣每個執行緒都可以透過 TLS 快速找到自己當前執行的 G。m0 是 Go 程式啟動時的第一個作業系統執行緒,並且負責初始化整個 Go runtime。在其他執行緒透過 Go runtime 的排程器建立時,排程器會自動為它們設定 TLS,並將 G 的指標寫入 TLS。但 m0 是一個特殊的執行緒,它直接由作業系統建立,而沒有經過 Go 排程器,因此需要透過彙編指令設定 TLS。
這段指令的邏輯是將 runtime.m0.tls
的地址送到 rdi
暫存器中,接著呼叫 runtime.settls 設定 fs 段基址暫存器的值,使得透過段基址和偏移量就能訪問到 m0.tls
。最後驗證設定的 [段基址:偏移量]
能否正確的訪問到 m0.tls
,將 0x123
傳到 [段基址:偏移量]
,這時如果訪問正確,應該傳給的是 m0.tls[0] = 0x123
,然後將 [runtime.m0+136]
的內容,即 m0.tls[0]
拿出來移到 rax
暫存器做比較,如果一樣,則說明透過 [段基址:偏移量]
可以正確訪問到 m0.tls
,否則呼叫 runtime.abort
退出 runtime
。
每個執行緒都有自己的一組 CPU 暫存器值,不同的執行緒透過不同的段 fs 基址暫存器私有的儲存全域性變數。更詳細的資訊請參考 Go語言排程器原始碼情景分析之十:執行緒本地儲存。
為加深這塊理解,我們從彙編角度看具體是怎麼設定的。
asm_amd64.s:258 0x4542cb* 488d3d565f0700 lea rdi, ptr [runtime.m0+136]
=> rdi = &runtime.m0.tls = 0x00000000004ca228
asm_amd64.s:259 0x4542d2 e809240000 call $runtime.settls
=> 設定的是 Fs_base 段基址暫存器的值,regs 檢視 Fs_base=0x00000000004ca230
asm_amd64.s:263 0x4542d7 6448c70425f8ffffff23010000 mov qword ptr fs:[0xfffffff8], 0x123
=> fs:[0xfffffff8],fs 是段基址,實際是 Fs_base 段基址暫存器的值,[0xfffffff8] 是偏移量。fs:[0xfffffff8] = 0x00000000004ca230:[0xfffffff8] = 0x00000000004ca228
=> 實際透過段基址暫存器 fs:[0xfffffff8] 訪問的記憶體地址就是 m0.tls 的地址 0x00000000004ca228
繼續往下執行:
=> asm_amd64.s:271 0x4542f8 488d0dc15a0700 lea rcx, ptr [runtime.g0] // 將 runtime.g0 的地址移到 rcx,rcx = &runtime.g0
asm_amd64.s:272 0x4542ff 6448890c25f8ffffff mov qword ptr fs:[0xfffffff8], rcx // 將 rcx 移到 m0.tls,實際是 m0.tls[0] = &runtime.g0
asm_amd64.s:273 0x454308 488d05915e0700 lea rax, ptr [runtime.m0] // 將 runtime.m0 的地址移到 rax,rax = &runtime.m0
asm_amd64.s:276 0x45430f 488908 mov qword ptr [rax], rcx // 將 runtime.g0 的地址移到 runtime.m0,實際是 runtime.m0.g0 = &runtime.g0
asm_amd64.s:278 0x454312 48894130 mov qword ptr [rcx+0x30], rax // 將 runtime.m0 的地址移到 runtime.g0.m,實際是 runtime.g0.m = &runtime.m0
上述指令做的是關聯主執行緒 m0
和 g0
,這樣 m0
就有了執行時執行環境。畫出記憶體佈局如下圖:
2. 小結
至此,我們的程式初始化部分就告一段落了,下一篇將正式進入排程器的部分。