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

胡云Troy發表於2024-09-11

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


0. 前言

上一講 介紹了 Go 程式初始化的過程,這一講繼續往下看,進入排程器的初始化過程。

接著上一講的執行過程,省略一些不相關的程式碼,執行到 runtime/asm_amd64.s:rt0_go:343L

(dlv) si
        asm_amd64.s:343 0x45431c*       8b442418        mov eax, dword ptr [rsp+0x18]       // [rsp+0x18] 儲存的是 argc 的值,eax = argc
        asm_amd64.s:344 0x454320        890424          mov dword ptr [rsp], eax            // 將 argc 移到 rsp,[rsp] = argc
        asm_amd64.s:345 0x454323        488b442420      mov rax, qword ptr [rsp+0x20]       // [rsp+0x20] 儲存的是 argv 的值,rax = [rsp+0x20]
        asm_amd64.s:346 0x454328        4889442408      mov qword ptr [rsp+0x8], rax        // 將 argv 移到 [rsp+0x8],[rsp+0x8] = argv
        asm_amd64.s:347 0x45432d        e88e2a0000      call $runtime.args                  // 呼叫 runtime.args 處理棧上的 argc 和 argv
        asm_amd64.s:348 0x454332        e8c9280000      call $runtime.osinit                // 呼叫 runtime.osinit 初始化系統核心數
        asm_amd64.s:349 0x454337        e8e4290000      call $runtime.schedinit

上述指令呼叫 runtime.args 處理函式引數,接著呼叫 runtime.osinit 初始化系統核心數。runtime.osinitruntime.os_linux.go 中定義:

func osinit() {
	ncpu = getproccount()
	physHugePageSize = getHugePageSize()
	osArchInit()
}

runtime.osinit 主要初始化系統核心數 ncpu,該核心是邏輯核心數。

接著進入到本文的正題排程器初始化 runtime.schedinit 函式。

1. 排程器初始化

排程器初始化的程式碼在 runtime.schedinit

// The bootstrap sequence is:
//
//	call osinit
//	call schedinit
//	make & queue new G
//	call runtime·mstart
//
// The new G calls runtime·main.
func schedinit() {
    // step1: 從 TLS 中獲取當前執行執行緒的 goroutine,gp = m0.tls[0] = g0
    gp := getg()

    // step2: 設定最大執行緒數
	sched.maxmcount = 10000

    // step3: 初始化執行緒,這裡初始化的是執行緒 m0
    mcommoninit(gp.m, -1)

    // step4: 呼叫 procresize 建立 Ps
    procs := ncpu
	if procresize(procs) != nil {
		throw("unknown runnable goroutine during bootstrap")
	}
}

省略了函式中不相關的程式碼。

首先,step1 呼叫 getg() 獲取當前執行緒執行的 goroutine。runtime 中隨處可見 getg(),它是一個內聯的彙編函式,用於直接從當前執行緒的暫存器或棧 TLS 中獲取當前執行緒執行的 goroutine。Go runtime 會為每個執行緒(作業系統執行緒或 Go 執行時執行緒)維護一個 g 的指標,表示當前執行緒正在執行的 goroutine。

直觀的分析,get() 的彙編實現類似於以下內容:

TEXT runtime·getg(SB), NOSPLIT, $0
    MOVQ TLS, AX       // 從執行緒區域性儲存 (Thread Local Storage) 獲取 g
    MOVQ g(AX), BX     // 把 g 的值移動到 BX 暫存器
    RET

獲取到當前執行 goroutine 之後,在 step3 呼叫 mcommoninit 初始化執行 goroutine 的執行緒:

func mcommoninit(mp *m, id int64) {
    // 獲取執行緒的 goroutine,這裡獲取的是 g0
	gp := getg()
    ...

    // 對全域性變數 sched 加鎖
    lock(&sched.lock)

    // 設定 mp 的 id
    if id >= 0 {
		mp.id = id
	} else {
		mp.id = mReserveID()
	}

    // Add to allm so garbage collector doesn't free g->m
	// when it is just in a register or thread-local storage.
	mp.alllink = allm

	// NumCgoCall() iterates over allm w/o schedlock,
	// so we need to publish it safely.
	atomicstorep(unsafe.Pointer(&allm), unsafe.Pointer(mp))         // allm = &m0
	unlock(&sched.lock)
}

mcommoninit 函式會為 mp 設定 id,並且將 mp 和全域性變數 allm 關聯。更新記憶體分佈如下圖:

image

繼續執行到 step4 procresize 函式,它是 schedinit 的重點:

func procresize(nprocs int32) *p {
    // old = gomaxprocs = 0
	old := gomaxprocs
	if old < 0 || nprocs <= 0 {
		throw("procresize: invalid arg")
	}

    // procresize 會根據新的 nprocs 調整 P 的數量,這裡不做調整,跳過
    if nprocs > int32(len(allp)) {
        ...
    }

    // 初始化 P
	for i := old; i < nprocs; i++ {
		pp := allp[i]
		if pp == nil {
			pp = new(p)
		}
        // 初始化新建立的 P
		pp.init(i)
        // 將新建立的 P 和全域性變數 allp 關聯
		atomicstorep(unsafe.Pointer(&allp[i]), unsafe.Pointer(pp))      // allp[i] = &pp
	}
    ...
}

procresize 函式比較長,這裡分段介紹。

首先建立 P,接著呼叫 init 初始化建立的 P:

func (pp *p) init(id int32) {
	pp.id = id
	pp.status = _Pgcstop        // _Pgcstop = 3
    ...
}

新建立的 P 的 id 是迴圈的索引 i,狀態是 _Pgcstop。接著,將建立的 P 和全域性變數 allp 進行關聯。

接著看 procresize 函式:

func procresize(nprocs int32) *p {
    // gp = g0
    gp := getg()

    // 判斷執行的 goroutine 執行緒是否繫結到 P 上
    // 如果有,並且是有效的 P,則繼續繫結;如果沒有,進入 else 邏輯;
	if gp.m.p != 0 && gp.m.p.ptr().id < nprocs {
		// continue to use the current P
		gp.m.p.ptr().status = _Prunning
		gp.m.p.ptr().mcache.prepareForSweep()
    } else {
		...
		gp.m.p = 0              // 初始化 gp.m.p = 0
		pp := allp[0]           // 從 allp 中拿第一個 P
		pp.m = 0                // 設定 P 的 m 等於 0
		pp.status = _Pidle      // 更新 P 的狀態為 _Pidle(0)
		acquirep(pp)            // 關聯 P 和 m
		...
	}
}

acquirep() 函式將 P 和當前的執行緒 m 繫結,如下:

func acquirep(pp *p) {
	wirep(pp)
	...
}

func wirep(pp *p) {
    // gp = g0
	gp := getg()

    // 如果當前執行緒已經繫結了 P 則丟擲異常
	if gp.m.p != 0 {
		throw("wirep: already in go")
	}

    // 如果當前 P 已經繫結 m,並且 P 的狀態不等於 _Pidle 則丟擲異常
	if pp.m != 0 || pp.status != _Pidle {
		id := int64(0)
		if pp.m != 0 {
			id = pp.m.ptr().id
		}
		print("wirep: p->m=", pp.m, "(", id, ") p->status=", pp.status, "\n")
		throw("wirep: invalid p state")
	}

	gp.m.p.set(pp)              // 繫結當前執行緒 m 的 P 到 pp,這裡是 g0.m.p = allp[0]
	pp.m.set(gp.m)              // 繫結 P 的 m 到當前執行緒,這裡是 allp[0].m = m0
	pp.status = _Prunning       // 如果 P 繫結到 m,意味著 P 可以排程 g 線上程上執行了。這裡設定 P 的狀態為 _Prunning(1)
}

根據上述分析,更新記憶體分佈如下圖:

image
(這裡我們的 nprocs = 3,所以圖中 len(allp) = 3)

到此還沒有結束。繼續看 procresize

func procresize(nprocs int32) *p {
    ...
    // runnablePs 儲存可執行的 Ps
    var runnablePs *p
	for i := nprocs - 1; i >= 0; i-- {
		pp := allp[i]
        // 如果 P 是當前執行緒繫結的 P 則跳過
		if gp.m.p.ptr() == pp {
			continue
		}
        // 將 P 的狀態設為 _Pidle(0),表示當前 P 是空閒的
		pp.status = _Pidle

        // runqempty 判斷 P 中的本地執行佇列是否是空佇列
        // 如果是空,表明 P 中不存在 goroutine
		if runqempty(pp) {
			pidleput(pp, now)           // 如果是空,將 P 和全域性變數 sched 繫結,執行緒可以透過 sched 找到空閒狀態的 P
		} else {
			pp.m.set(mget())            // 如果不為空,呼叫 mget() 獲取空閒的執行緒 m。並且將 P.m 繫結到該執行緒
			pp.link.set(runnablePs)     // 將 P 的 link 指向 runnablePs,表明 P 是可執行的
			runnablePs = pp             // 將 runnablePs 指向 P,呼叫者透過 runnalbePs 拿到可執行的 P
		}
	}

	...
	return runnablePs
}

最後的一段就是對 allp 中沒有繫結到當前執行緒的 P 做處理。首先,設定 P 的狀態為 _Pidle(0),接著呼叫 runqempty 判斷當前執行緒的本地執行佇列是否為空:

// runqempty reports whether pp has no Gs on its local run queue.
// It never returns true spuriously.
func runqempty(pp *p) bool {
	// Defend against a race where 1) pp has G1 in runqnext but runqhead == runqtail,
	// 2) runqput on pp kicks G1 to the runq, 3) runqget on pp empties runqnext.
	// Simply observing that runqhead == runqtail and then observing that runqnext == nil
	// does not mean the queue is empty.
	for {
		head := atomic.Load(&pp.runqhead)
		tail := atomic.Load(&pp.runqtail)
		runnext := atomic.Loaduintptr((*uintptr)(unsafe.Pointer(&pp.runnext)))
		if tail == atomic.Load(&pp.runqtail) {
			return head == tail && runnext == 0
		}
	}
}

這裡 P 中的 runq 儲存的是本地執行佇列。P 的 runqhead 指向 runq 佇列(實際是陣列) 的頭,runqtail 指向 runq 隊尾。
P 中的 runnext 指向下一個執行的 goroutine,它的優先順序是最高的。可以參考 runqempty 中的註釋去看為什麼判斷空佇列要這麼寫。

如果 P 中無可執行的 goroutine,則呼叫 pidleput 將 P 新增到全域性變數 sched 中:

func pidleput(pp *p, now int64) int64 {
	...
	pp.link = sched.pidle           // P.link = shced.pidle             
	sched.pidle.set(pp)             // shced.pidle = P
	sched.npidle.Add(1)             // sched.npidle 表示空間的 P 數量
	...
	return now
}

這裡我們的 nprocs = 3,初始化只有一個 allp[0] 是 _Prunning 的,其餘兩個 Ps 是 _Pidle 狀態。更新記憶體分佈如下圖:

image

2. 小結

好了,到這裡我們的排程器初始化邏輯基本介紹完了。下一講,將繼續分析 main gouroutine 的建立。


相關文章