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是如何建立的。

相关文章
相关标签/搜索