Go語言goroutine調度器初始化(12)

本文是《Go語言調度器源代碼情景分析》系列的第12篇,也是第二章的第2小節。linux


 

本章將如下面這個簡單的Hello World程序爲例,經過跟蹤其從啓動到退出這一完整的運行流程來分析Go語言調度器的初始化、goroutine的建立與退出、工做線程的調度循環以及goroutine的切換等重要內容。bootstrap

package main

import "fmt"

func main() {
    fmt.Println("Hello World!")
}

 

首先咱們從程序啓動開始分析調度器的初始化。ubuntu

在分析程序的啓動過程以前,咱們首先來看看程序在執行第一條指令以前其棧的初始狀態。windows

任何一個由編譯型語言(不論是C,C++,go仍是彙編語言)所編寫的程序在被操做系統加載起來運行時都會順序通過以下幾個階段:數組

  1. 從磁盤上把可執行程序讀入內存;dom

  2. 建立進程和主線程;編輯器

  3. 爲主線程分配棧空間;函數

  4. 把由用戶在命令行輸入的參數拷貝到主線程的棧;ui

  5. 把主線程放入操做系統的運行隊列等待被調度執起來運行。atom

在主線程第一次被調度起來執行第一條指令以前,主線程的函數棧以下圖所示:

瞭解了程序的初始狀態以後,下面咱們正式開始。

程序入口

在Linux命令行用 go build 編譯hello.go,獲得可執行程序hello,而後使用gdb調試,在gdb中咱們首先使用 info files 命令找到程序入口(Entry point)地址爲0x452270,而後用 b *0x452270 在0x452270地址處下個斷點,gdb告訴咱們這個入口對應的源代碼爲 runtime/rt0_linux_amd64.s 文件的第8行。

bobo@ubuntu:~/study/go$ gobuild hello.go 
bobo@ubuntu:~/study/go$ gdbhello
GNU gdb (GDB) 8.0.1
(gdb) info files
Symbols from "/home/bobo/study/go/main".
Local exec file:
`/home/bobo/study/go/main', file type elf64-x86-64.
Entry point: 0x452270
0x0000000000401000 -0x0000000000486aac is .text
0x0000000000487000 -0x00000000004d1a73 is .rodata
0x00000000004d1c20 -0x00000000004d27f0 is .typelink
0x00000000004d27f0 -0x00000000004d2838 is .itablink
0x00000000004d2838 -0x00000000004d2838 is .gosymtab
0x00000000004d2840 -0x00000000005426d9 is .gopclntab
0x0000000000543000 -0x000000000054fa9c is .noptrdata
0x000000000054faa0 -0x0000000000556790 is .data
0x00000000005567a0 -0x0000000000571ef0 is .bss
0x0000000000571f00 -0x0000000000574658 is .noptrbss
0x0000000000400f9c -0x0000000000401000 is .note.go.buildid
(gdb) b *0x452270
Breakpoint 1at 0x452270: file /usr/local/go/src/runtime/rt0_linux_amd64.s, line 8.

 

打開代碼編輯器,找到 runtime/rt0_linx_amd64.s 文件,該文件是用go彙編語言編寫而成的源代碼文件,咱們已經在本書的第一部分討論過其格式。如今看看第8行:

runtime/rt0_linx_amd64.s : 8

TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8
    JMP _rt0_amd64(SB)

 

上面第一行代碼定義了_rt0_amd64_linux這個符號,並非真正的CPU指令,第二行的JMP指令纔是主線程的第一條指令,這條指令簡單的跳轉到(至關於go語言或c中的goto)_rt0_amd64 這個符號處繼續執行,_rt0_amd64 這個符號的定義在runtime/asm_amd64.s 文件中:

runtime/asm_amd64.s : 14

TEXT _rt0_amd64(SB),NOSPLIT,$-8
    MOVQ  0(SP), DI// argc 
    LEAQ   8(SP), SI// argv
    JMP     runtime·rt0_go(SB)

 

前兩行指令把操做系統內核傳遞過來的參數argc和argv數組的地址分別放在DI和SI寄存器中,第三行指令跳轉到 rt0_go 去執行。

rt0_go函數完成了go程序啓動時的全部初始化工做,所以這個函數比較長,也比較繁雜,但這裏咱們只關注與調度器相關的一些初始化,下面咱們分段來看:

runtime/asm_amd64.s : 87

TEXT runtime·rt0_go(SB),NOSPLIT,$0
    // copy arguments forward on an even stack
    MOVQ DI, AX # AX=argc
    MOVQ SI, BX # BX=argv
    SUBQ $(4*8+7), SP # 2args 2auto
    ANDQ $~15, SP    #調整棧頂寄存器使其按16字節對齊
    MOVQ AX, 16(SP) #argc放在SP+ 16字節處
    MOVQ BX, 24(SP) #argv放在SP+ 24字節處

 

上面的第4條指令用於調整棧頂寄存器的值使其按16字節對齊,也就是讓棧頂寄存器SP指向的內存的地址爲16的倍數,之因此要按16字節對齊,是由於CPU有一組SSE指令,這些指令中出現的內存地址必須是16的倍數,最後兩條指令把argc和argv搬到新的位置。這段代碼的其它部分已經作了比較詳細的註釋,因此這裏就不作過多的解釋了。

初始化g0

繼續看後面的代碼,下面開始初始化全局變量g0,前面咱們說過,g0的主要做用是提供一個棧供runtime代碼執行,所以這裏主要對g0的幾個與棧有關的成員進行了初始化,從這裏能夠看出g0的棧大約有64K,地址範圍爲 SP - 64*1024 + 104 ~ SP。

runtime/asm_amd64.s : 96

// create istack out of the given (operating system) stack.
// _cgo_initmay update stackguard.
//下面這段代碼從系統線程的棧空分出一部分看成g0的棧,而後初始化g0的棧信息和stackgard
MOVQ $runtime·g0(SB), DI      //g0的地址放入DI寄存器
LEAQ (-64*1024+104)(SP), BX//BX=SP- 64*1024 + 104
MOVQ BX, g_stackguard0(DI) //g0.stackguard0 =SP- 64*1024 + 104
MOVQ BX, g_stackguard1(DI) //g0.stackguard1 =SP- 64*1024 + 104
MOVQ BX, (g_stack+stack_lo)(DI) //g0.stack.lo =SP- 64*1024 + 104
MOVQ SP, (g_stack+stack_hi)(DI) //g0.stack.hi =SP

 

運行完上面這幾行指令後g0與棧之間的關係以下圖所示:

 

主線程與m0綁定

設置好g0棧以後,咱們跳過CPU型號檢查以及cgo初始化相關的代碼,直接從164行繼續分析。

runtime/asm_amd64.s : 164

//下面開始初始化tls(thread local storage,線程本地存儲)
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拷貝到fs段基地址偏移-8的內存位置,也就是m0.tls[0] =0x123
MOVQ runtime·m0+m_tls(SB), AX//AX=m0.tls[0]
CMPQ AX, $0x123 //檢查m0.tls[0]的值是不是經過線程本地存儲存入的0x123來驗證tls功能是否正常
JEQ 2(PC)
CALL runtime·abort(SB) //若是線程本地存儲不能正常工做,退出程序

 

這段代碼首先調用settls函數初始化主線程的線程本地存儲(TLS),目的是把m0與主線程關聯在一塊兒,至於爲何要把m和工做線程綁定在一塊兒,咱們已經在上一節介紹過了,這裏就再也不重複。設置了線程本地存儲以後接下來的幾條指令在於驗證TLS功能是否正常,若是不正常則直接abort退出程序。

下面咱們詳細來詳細看一下settls函數是如何實現線程私有全局變量的。

runtime/sys_linx_amd64.s : 606

// set tls base to DI
TEXT runtime·settls(SB),NOSPLIT,$32
//......
//DI寄存器中存放的是m.tls[0]的地址,m的tls成員是一個數組,讀者若是忘記了能夠回頭看一下m結構體的定義
//下面這一句代碼把DI寄存器中的地址加8,爲何要+8呢,主要跟ELF可執行文件格式中的TLS實現的機制有關
//執行下面這句指令以後DI寄存器中的存放的就是m.tls[1]的地址了
ADDQ $8, DI// ELF wants to use -8(FS)

  //下面經過arch_prctl系統調用設置FS段基址
MOVQ DI, SI//SI存放arch_prctl系統調用的第二個參數
MOVQ $0x1002, DI// ARCH_SET_FS //arch_prctl的第一個參數
MOVQ $SYS_arch_prctl, AX//系統調用編號
SYSCALL
CMPQ AX, $0xfffffffffffff001
JLS 2(PC)
MOVL $0xf1, 0xf1 // crash //系統調用失敗直接crash
RET

 

從代碼能夠看到,這裏經過arch_prctl系統調用把m0.tls[1]的地址設置成了fs段的段基址。CPU中有個叫fs的段寄存器與之對應,而每一個線程都有本身的一組CPU寄存器值,操做系統在把線程調離CPU運行時會幫咱們把全部寄存器中的值保存在內存中,調度線程起來運行時又會從內存中把這些寄存器的值恢復到CPU,這樣,在此以後,工做線程代碼就能夠經過fs寄存器來找到m.tls,讀者能夠參考上面初始化tls以後對tls功能驗證的代碼來理解這一過程。

下面繼續分析rt0_go,

runtime/asm_amd64.s : 174

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
MOVQ CX, m_g0(AX) //m0.g0 =g0
// save m0 to g0->m 
MOVQ AX, g_m(CX) //g0.m =m0

 

上面的代碼首先把g0的地址放入主線程的線程本地存儲中,而後經過

m0.g0 = &g0
g0.m = &m0

 

把m0和g0綁定在一塊兒,這樣,以後在主線程中經過get_tls能夠獲取到g0,經過g0的m成員又能夠找到m0,因而這裏就實現了m0和g0與主線程之間的關聯。從這裏還能夠看到,保存在主線程本地存儲中的值是g0的地址,也就是說工做線程的私有全局變量實際上是一個指向g的指針而不是指向m的指針,目前這個指針指向g0,表示代碼正運行在g0棧。此時,主線程,m0,g0以及g0的棧之間的關係以下圖所示:

 

 

初始化m0

下面代碼開始處理命令行參數,這部分咱們不關心,因此跳過。命令行參數處理完成後調用osinit函數獲取CPU核的數量並保存在全局變量ncpu之中,調度器初始化時須要知道當前系統有多少個CPU核。

runtime/asm_amd64.s : 189

//準備調用args函數,前面四條指令把參數放在棧上
MOVL 16(SP), AX// AX = argc
MOVL AX, 0(SP)       // argc放在棧頂
MOVQ 24(SP), AX// AX = argv
MOVQ AX, 8(SP)       // argv放在SP + 8的位置
CALL runtime·args(SB)  //處理操做系統傳遞過來的參數和env,不須要關心

//對於linx來講,osinit惟一功能就是獲取CPU的核數並放在global變量ncpu中,
//調度器初始化時須要知道當前系統有多少CPU核
CALL runtime·osinit(SB)  //執行的結果是全局變量 ncpu = CPU核數
CALL runtime·schedinit(SB) //調度系統初始化

 

接下來繼續看調度器是如何初始化的。

runtime/proc.go : 526

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結構體對象,按照go語言官方的說法,p就是processor的意思,它的數量決定了最多能夠有都少個goroutine同時並行運行。schedinit函數除了初始化m0和p,還設置了全局變量sched的maxmcount成員爲10000,限制最多能夠建立10000個操做系統線程出來工做。

這裏咱們須要重點關注一下mcommoninit如何初始化m0以及procresize函數如何建立和初始化p結構體對象。首先咱們深刻到mcommoninit函數中一探究竟。這裏須要注意的是不僅是初始化的時候會執行該函數,在程序運行過程當中若是建立了工做線程,也會執行它,因此咱們會在函數中看到加鎖和檢查線程數量是否已經超過最大值等相關的代碼。

runtime/proc.go : 596

func mcommoninit(mp*m) {
    _g_ := getg() //初始化過程當中_g_ = g0

    // g0 stack won't make sense for user (and is not necessary unwindable).
    if _g_ != _g_.m.g0 {  //函數調用棧traceback,不須要關心
        callers(1, mp.createstack[:])
    }

    lock(&sched.lock)
    if sched.mnext + 1 < sched.mnext {
        throw("runtime: thread ID overflow")
    }
    mp.id = sched.mnext
    sched.mnext++
    checkmcount() //檢查已建立系統線程是否超過了數量限制(10000)

    //random初始化
    mp.fastrand[0] = 1597334677*uint32(mp.id)
    mp.fastrand[1] = uint32(cputicks())
    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之中
    // 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))
    unlock(&sched.lock)

    // Allocate memory to hold a cgo traceback if the cgo call crashes.
    if iscgo || GOOS == "solaris" || GOOS == "windows" {
        mp.cgoCallers = new(cgoCallers)
    }
}

 

從這個函數的源代碼能夠看出,這裏並未對m0作什麼關於調度相關的初始化,因此能夠簡單的認爲這個函數只是把m0放入全局鏈表allm之中就返回了。

m0完成基本的初始化後,繼續調用procresize建立和初始化p結構體對象,在這個函數裏面會建立指定個數(根據cpu核數或環境變量肯定)的p結構體對象放在全變量allp裏, 並把m0和allp[0]綁定在一塊兒,所以當這個函數執行完成以後就有

m0.p = allp[0]
allp[0].m = &m0

 

到此m0, g0, 和m須要的p徹底關聯在一塊兒了。

初始化allp

下面咱們來看procresize函數,考慮到初始化完成以後用戶代碼還能夠經過 GOMAXPROCS()函數調用它從新建立和初始化p結構體對象,而在運行過程當中再動態的調整p牽涉到的問題比較多,因此這個函數的處理比較複雜,但若是隻考慮初始化,相對來講要簡單不少,因此這裏只保留了初始化時會執行的代碼:

runtime/proc.go : 3902

func procresize(nprocsint32) *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
} 

 

這個函數代碼比較長,但並不複雜,這裏總結一下這個函數的主要流程:

  1. 使用make([]*p, nprocs)初始化全局變量allp,即allp = make([]*p, nprocs)

  2. 循環建立並初始化nprocs個p結構體對象並依次保存在allp切片之中

  3. 把m0和allp[0]綁定在一塊兒,即m0.p = allp[0], allp[0].m = m0

  4. 把除了allp[0]以外的全部p放入到全局變量sched的pidle空閒隊列之中

procresize函數執行完後,調度器相關的初始化工做就基本結束了,這時整個調度器相關的各組成部分之間的聯繫以下圖所示:

 

分析完調度器的基本初始化後,下一節咱們來看程序中的第一個goroutine是如何建立的。

相關文章
相關標籤/搜索