Go語言排程器之主動排程(20)

愛寫程式的阿波張發表於2019-05-28

本文是《Go語言排程器原始碼情景分析》系列的第20篇,也是第五章《主動排程》的第1小節。


 

Goroutine的主動排程是指當前正在執行的goroutine通過直接呼叫runtime.Gosched()函式暫時放棄執行而發生的排程

主動排程完全是使用者程式碼自己控制的,我們根據程式碼就可以預見什麼地方一定會發生排程。比如下面的程式,在main goroutine中建立了一個新的我們稱之為g2的goroutine去執行start函式,g2在start函式的迴圈中反覆呼叫Gosched()函式放棄自己的執行權,主動把CPU讓給排程器去執行排程。

package main

import (
    "runtime"
    "sync"
)

const N = 1

func main() {
    var wg sync.WaitGroup
 
    wg.Add(N)
    for i := 0; i < N; i++ {
        go start(&wg)
    }

    wg.Wait()
}

func start(wg *sync.WaitGroup) {
    for i := 0; i < 1000 * 1000 * 1000; i++ {
        runtime.Gosched()
    }

    wg.Done()
}

下面我們就從這個程式開始分析主動排程是如何實現的。

首先從主動排程的入口函式Gosched()開始分析。

runtime/proc.go : 262

// Gosched yields the processor, allowing other goroutines to run. It does not
// suspend the current goroutine, so execution resumes automatically.
func Gosched() {
    checkTimeouts() //amd64 linux平臺空函式
   
    //切換到當前m的g0棧執行gosched_m函式
    mcall(gosched_m)
    //再次被排程起來則從這裡開始繼續執行
}

因為我們需要關注程式執行起來之後g2 goroutine的狀態,所以這裡用gdb配合原始碼一起來進行除錯和分析,首先使用b proc.go:266在Gosched函式的mcall(gosched_m)這一行設定一個斷點,然後執行程式,等程式被斷下來之後,反彙編一下程式當前正在執行的函式

(gdb) disass
Dump of assembler code for function main.start:
     0x000000000044fc90 <+0>:mov   %fs:0xfffffffffffffff8,%rcx
     0x000000000044fc99 <+9>:cmp   0x10(%rcx),%rsp
     0x000000000044fc9d <+13>:jbe   0x44fcfa <main.start+106>
     0x000000000044fc9f <+15>:sub   $0x20,%rsp
     0x000000000044fca3 <+19>:mov   %rbp,0x18(%rsp)
     0x000000000044fca8 <+24>:lea   0x18(%rsp),%rbp
     0x000000000044fcad <+29>:xor   %eax,%eax
     0x000000000044fcaf <+31>:jmp   0x44fcd0 <main.start+64>
     0x000000000044fcb1 <+33>:mov   %rax,0x10(%rsp)
     0x000000000044fcb6 <+38>:nop
     0x000000000044fcb7 <+39>:nop
=> 0x000000000044fcb8 <+40>:lea   0x241e1(%rip),%rax        # 0x473ea0
     0x000000000044fcbf <+47>:mov   %rax,(%rsp)
     0x000000000044fcc3 <+51>:callq 0x447380 <runtime.mcall>
     0x000000000044fcc8 <+56>:mov   0x10(%rsp),%rax
     0x000000000044fccd <+61>:inc   %rax
     0x000000000044fcd0 <+64>:cmp   $0x3b9aca00,%rax
     0x000000000044fcd6 <+70>:jl     0x44fcb1 <main.start+33>
     0x000000000044fcd8 <+72>:nop
    0x000000000044fcd9 <+73>:mov   0x28(%rsp),%rax
     0x000000000044fcde <+78>:mov   %rax,(%rsp)
     0x000000000044fce2 <+82>:movq   $0xffffffffffffffff,0x8(%rsp)
     0x000000000044fceb <+91>:callq 0x44f8f0 <sync.(*WaitGroup).Add>
     0x000000000044fcf0 <+96>:mov   0x18(%rsp),%rbp
     0x000000000044fcf5 <+101>:add   $0x20,%rsp
     0x000000000044fcf9 <+105>:retq  
     0x000000000044fcfa <+106>:callq 0x447550 <runtime.morestack_noctxt>
     0x000000000044fcff <+111>:jmp   0x44fc90 <main.start>

可以看到當前正在執行的函式是main.start而不是runtime.Gosched,在整個start函式中都找不到Gosched函式的身影,原來它被編譯器優化了。程式現在停在了0x000000000044fcb8 <+40>: lea 0x241e1(%rip),%rax 這一指令處,該指令下面的第二條callq指令在呼叫runtime.mcall,我們首先使用si 2來執行2條彙編指令讓程式停在下面這條指令處:

=> 0x000000000044fcc3 <+51>: callq 0x447380 <runtime.mcall>

然後使用i r rsp rbp rip記錄一下CPU的rsp、rbp和rip暫存器的值備用:

(gdb) i r rsprbprip
rsp   0xc000031fb0     0xc000031fb0
rbp   0xc000031fc8     0xc000031fc8
rip    0x44fcc3             0x44fcc3 <main.start+51>

繼續看0x000000000044fcc3位置的callq指令,它首先會把緊挨其後的下一條指令的地址0x000000000044fcc8放入g2的棧,然後跳轉到mcall函式的第一條指令開始執行。回憶一下第二章我們詳細分析過的mcall函式的執行流程,結合現在這個場景,mcall將依次完成下面幾件事:

  1. 把上面call指令壓棧的返回地址0x000000000044fcc8取出來儲存在g2的sched.pc欄位,把上面我們檢視到的rsp(0xc000031fb0)和rbp(0xc000031fc8)分別儲存在g2的sched.sp和sched.bp欄位,這幾個暫存器代表了g2的排程現場資訊;

  2. 把儲存在g0的sched.sp和sched.bp欄位中的值分別恢復到CPU的rsp和rbp暫存器,這樣完成從g2的棧到g0的棧的切換;

  3. 在g0棧執行gosched_m函式(gosched_m函式是runtime.Gosched函式呼叫mcall時傳遞給mcall的引數)。

繼續看gosched_m函式

runtime/proc.go : 2623

// Gosched continuation on g0.
func gosched_m(gp *g) {
    if trace.enabled { //traceback 不關注
        traceGoSched()
    }
    goschedImpl(gp)  //我們這個場景:gp = g2
}

gosched_m函式只是簡單的在呼叫goschedImpl:

runtime/proc.go : 2608

func goschedImpl(gp *g) {
    ......
    casgstatus(gp, _Grunning, _Grunnable)
    dropg() //設定當前m.curg = nil, gp.m = nil
    lock(&sched.lock)
    globrunqput(gp) //把gp放入sched的全域性執行佇列runq
    unlock(&sched.lock)

    schedule() //進入新一輪排程
}

goschedImpl函式有一個g指標型別的形參,我們這個場景傳遞給它的實參是g2,goschedImpl函式首先把g2的狀態從_Grunning設定為_Grunnable,並通過dropg函式解除當前工作執行緒m和g2之間的關係(把m.curg設定成nil,把g2.m設定成nil),然後通過呼叫我們已經分析過的globrunqput函式把g2放入全域性執行佇列之中。

g2被掛入全域性執行佇列之後,g2以及其它一些相關部分的狀態和關係如下圖所示:

從上圖我們可以清晰的看到,g2被掛在了sched的全域性執行佇列裡面,該佇列有一個head頭指標指向佇列中的第一個g物件,還有一個tail尾指標指向佇列中的最後一個g物件,佇列中各個g物件通過g的schedlink指標成員相互連結起在一起;g2的sched結構體成員中儲存了排程所需的所有現場資訊(比如棧暫存器sp和bp的值,pc指令暫存器的值等等),這樣當g2下次被schedule函式排程時,gogo函式會負責把這些資訊恢復到CPU的rsp, rbp和rip暫存器中,從而使g2又得以從0x44fcc8地址處開始在g2的棧中執行g2的程式碼。

把g2掛入全域性執行佇列之後,goschedImpl函式繼續呼叫schedule()進入下一輪排程迴圈,至此g2通過自己主動呼叫Gosched()函式自願放棄了執行權,達到了排程的目的。

相關文章