Go runtime 排程器精講(一):Go 程式初始化

胡云Troy發表於2024-09-11

原創文章,歡迎轉載,轉載請註明出處,謝謝。


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]

畫出棧空間如下圖:

image

繼續分析:

(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。

根據上述分析,畫出棧空間佈局如下圖:

image

繼續往下分析,省略一些不相關的彙編程式碼。直接從 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

上述指令做的是關聯主執行緒 m0g0,這樣 m0 就有了執行時執行環境。畫出記憶體佈局如下圖:

image

2. 小結

至此,我們的程式初始化部分就告一段落了,下一篇將正式進入排程器的部分。


相關文章