Go 语言调度器与 Goroutine

HenleyDonna 发布于6月前
0 条问题

Go 语言在并发编程方面有着非常强大的能力,这也离不开语言层面对并发编程的支持,我们会在 Go 语言中使用 Goroutine 并行执行任务并将 Channel 作为 Goroutine 之间的通信方式,虽然使用互斥锁和共享内存在 Go 语言中也可以完成 Goroutine 间的通信,但是使用 Channel 才是更推荐的做法 — 不要通过共享内存的方式进行通信,而是应该通过通信的方式共享内存 。

Go 语言调度器与 Goroutine

我们在这一节中不仅会介绍 Go 语言中的协程 Goroutine 的数据结构和实现原理,还会介绍调度器是如何对 Goroutine 进行调度的,其中包括调度的时机和过程。

1. 概述

谈到 Go 语言的调度器,我们不得不提的就是操作系统、进程与线程这些概念,操作系统其实为我们提供的 POSIX API 中就包含对 线程 ) 的相关操作,线程其实也是操作系统在做调度时的最基本单元,线程和进程的实现在不同操作系统上也有所不同,但是在大多数的实现中线程都是进程的一个组件:

Go 语言调度器与 Goroutine

多个线程可以存在于同一个进程中并共享了同一片内存空间,由于不需要创建新的虚拟内存空间,所以它们也不需要内存管理单元处理上下文的切换,线程之前的通信也正是基于共享的内存进行的,相比于重量级的进程,线程显得比较轻量,所以我们可以在一个进程中创建出多个线程。

虽然线程相对进程比较轻量,但是线程仍然会占用较多的资源并且调度时也会造成比较大的额外开销,每个线程会都占用 1M 以上的内存空间,在对线程进行切换时不止会消耗较多的内存空间,对寄存器中的内容进行恢复还需要向操作系统申请或者销毁对应的资源,每一次线程上下文的切换都需要消耗 ~1ms 左右的时间,但是 Go 调度器对 Goroutine 的上下文切换 ~0.2ms ,减少了 80% 的额外开销。除了减少上下文切换带来的开销,Golang 的调度器还能够更有效地利用 CPU 的缓存。

Go 语言调度器与 Goroutine

Go 语言的调度器其实就是通过使用数量合适的线程并在每一个线程上执行更多的工作来降低操作系统和硬件的负载。

2. 数据结构

相信各位读者已经对 Go 语言调度相关的数据结构已经非常熟悉了,但是我们在这里还是要简单回顾一下其中的三个组成部分线程 M、协程 G 和处理器 P:

Go 语言调度器与 Goroutine

  1. M — 表示操作系统的线程,它是被操作系统管理的线程,与 POSIX 中的标准线程非常类似;
  2. G — 表示 Goroutine,每一个 Goroutine 都包含堆栈、指令指针和其他用于调度的重要信息;
  3. P — 表示调度的上下文,它可以被看做一个运行于线程 M 上的本地调度器;

我们会在这一节中分别介绍不同的组成部分,详细介绍它们的基本作用、数据结构以及在运行期间可能处于的状态。

Go 语言中并发的执行单元其实就是 Goroutine,它很像操作系统中的线程,但是占用了更小的内存空间并降低了 Goroutine 切换的开销。

Goroutine 只存在于 Go 语言的运行时,它是 Go 语言在用户态为我们提供的『线程』,如果一个 Goroutine 由于 IO 操作而陷入阻塞,操作系统并不会对上下文进行切换,但是 Go 语言的调度器会将陷入阻塞 Goroutine 『切换』下去等待系统调用结束并让出计算资源,作为一种粒度更细的资源调度单元,如果使用得当能够在高并发的场景下更高效地利用机器的 CPU。

Goroutine 在 Go 语言运行时使用一个名为 g 的私有结构体表示,这个私有结构体非常复杂,总共有 40 多个用于表示各种状态的成员变量,我们在这里也不能介绍全部的属性,而是会挑选其中的一部分重点进行介绍,我们可以在 runtime2.go#L387-L450 文件中查看 g 结构体的全部属性:

type g struct {
    m              *m      // current m; offset known to arm liblink
    sched          gobuf
    syscallsp      uintptr        // if status==Gsyscall, syscallsp = sched.sp to use during gc
    syscallpc      uintptr        // if status==Gsyscall, syscallpc = sched.pc to use during gc
    param          unsafe.Pointer // passed parameter on wakeup
    atomicstatus   uint32
    goid           int64
    schedlink      guintptr
    waitsince      int64      // approx time when the g become blocked
    waitreason     waitReason // if status==Gwaiting
    preempt        bool       // preemption signal, duplicates stackguard0 = stackpreempt
    lockedm        muintptr
    writebuf       []byte
    sigcode0       uintptr
    sigcode1       uintptr
    sigpc          uintptr
    gopc           uintptr         // pc of go statement that created this goroutine
    startpc        uintptr         // pc of goroutine function
    waiting        *sudog         // sudog structures this g is waiting on (that have a valid elem ptr); in lock order
}

为了减少无关的干扰项,我们在这里删除了跟堆栈以及追踪相关的字段,剩下的都是 g 结构体中比较重要的字段。

结构体 g 的字段 atomicstatus 就存储了当前 Goroutine 的状态, runtime2.go 文件中定义了 Goroutine 全部可能存在的状态,除了几个已经不被使用的以及与 GC 相关的状态之外,全部常见的状态都展示在这里:

状态 描述
_Gidle 刚刚被分配并且还没有被初始化
_Grunnable 没有执行代码、没有栈的所有权、存储在运行队列中
_Grunning 可以执行代码、拥有栈的所有权,被赋予了内核线程 M 和处理器 P
_Gsyscall 正在执行系统调用、拥有栈的所有权、没有执行用户代码,被赋予了内核线程 M 但是不在运行队列上
_Gwaiting 由于运行时而被阻塞,没有执行用户代码并且不在运行队列上,但是可能存在于 Channel 的等待队列上
_Gdead 没有被使用,没有执行代码,可能有分配的栈
_Gcopystack 栈正在被拷贝、没有执行代码、不在运行队列上

上述状态中比较常见是 _Grunnable 、 _Grunning 、 _Gsyscall 和 _Gwaiting 四个状态,我们在这里也会重点介绍这几个状态,Goroutine 中所有状态的迁移是一个非常复杂的过程,会触发 Goroutine 状态迁移的方法也非常多,在这里我们也没有办法介绍全部的迁移线路,我们会从其中选择一些进行介绍。

Go 语言调度器与 Goroutine

虽然 Goroutine 在运行时中定义的状态非常多而且复杂,但是我们可以将这些不同的状态聚合成最终的三种:等待中、可运行、运行中,在运行期间我们会在这三种不同的状态来回切换:

  • 等待中:表示当前 Goroutine 等待某些条件满足后才会继续执行,例如当前 Goroutine 正在执行系统调用或者同步操作;
  • 可运行:表示当前 Goroutine 等待在某个 M 执行 Goroutine 的指令,如果当前程序中有非常多的 Goroutine,每个 Goroutine 就可能会等待更多的时间;
  • 运行中:表示当前 Goroutine 正在某个 M 上执行指令;

Go 语言并发模型中的 M 其实表示的是操作系统线程,在默认情况下调度器能够允许创建 10000 个线程,但是其中绝大多数的线程都不会执行用户代码(可能陷入系统调用),最多只会有 GOMAXPROCS 个线程 M 能够正常运行。

所有 Golang 程序中的最大『可运行』线程数其实就等于 GOMAXPROCS 这个变量的值;在默认情况下,它会被设置成当前应用的核数,我们也可以使用 runtime.GOMAXPROCS 方法来改变当前程序中最大的线程数。

Go 语言调度器与 Goroutine

在默认情况下,一个四核机器上会创建四个操作系统线程,每一个线程其实都是一个 m 结构体,我们也可以通过 runtime.GOMAXPROCS 改变最大可运行线程的数量,我们可以使用 runtime.GOMAXPROCS(3) 将 Go 程序中的线程数改变成 3 个。

在大多数情况下,我们都会使用 Go 的默认设置,也就是 #thread == #CPU ,在这种情况下不会触发操作系统级别的线程调度和上下文切换,所有的调度都会发生在用户态,由 Go 语言调度器触发,能够减少非常多的额外开销。

操作系统线程在 Go 语言中就会使用私有结构体 m 来表示,这个结构体中也包含了几十个私有的字段,我们这里还是对其进行了简单的删减,感兴趣的读者可以查看 runtime2.go#L452-L521 了解更多的内容:

type m struct {
    g0      *g     // goroutine with scheduling stack
    curg          *g       // current running goroutine

    ...
}

其中 g0 是持有调度堆栈的 Goroutine, curg 是在当前线程上运行的 Goroutine,这也是作为操作系统线程唯一关心的两个 Goroutine 了。

Go 语言调度器中的最后一个重要结构就是处理器 P,其实就是线程需要的上下文环境,也是用于处理代码逻辑的处理器,通过处理器 P 的调度,每一个内核线程 M 都能够执行多个 G,这样就能在 G 进行一些 IO 操作时及时对它们进行切换,提高 CPU 的利用率。

每一个 Go 语言程序中所以处理器的数量一定会等于 GOMAXPROCS ,这是因为调度器在启动时就会创建 GOMAXPROCS 个处理器 P,这些处理器会绑定到不同的线程 M 上并为它们调度 Goroutine。

处理器在 Go 语言运行时中同样使用私有结构体 p 表示,作为调度器的内部实现,它包含的字段也非常多,我们在这里就简单展示一下结构体中的大致内容,感兴趣的读者可以查看 runtime2.go#L523-L602)

type p struct {
    id          int32
    status      uint32 // one of pidle/prunning/...
    link        puintptr
    schedtick   uint32     // incremented on every scheduler call
    syscalltick uint32     // incremented on every system call
    sysmontick  sysmontick // last tick observed by sysmon
    m           muintptr   // back-link to associated m (nil if idle)
    mcache      *mcache

    runqhead uint32
    runqtail uint32
    runq     [256]guintptr
    runnext guintptr

    sudogcache []*sudog
    sudogbuf   [128]*sudog

    ...
}

我们将结构体中 GC 以及用于追踪调试的字段全部删除以简化这里需要展示的属性,在上述字段中, status 表示了当前处理器的状态, runhead 、 runqtail 、 runq 以及 runnext 等字段表示处理器持有的运行队列,运行队列中就包含待执行的 Goroutine 列表。

p 结构体中的状态 status 其实就会是以下五种状态其中的一种,我们能在 runtime2.go#L99-L147 文件中找到处理器 P 的全部状态:

状态 描述
_Pidle 处理器没有运行用户代码或者调度器,被空闲队列或者改变其状态的结构持有,运行队列为空
_Prunning 被线程 M 持有,并且正在执行用户代码或者调度器
_Psyscall 没有执行用户代码,当前线程陷入系统调用
_Pgcstop 被线程 M 持有,当前处理器由于垃圾回收被停止
_Pdead 当前处理器已经不被使用

通过分析处理器 P 的这些状态,我们其实能够对处理器的工作过程有一些简单的理解,例如处理器在执行用户代码时会处于 _Prunning 状态,在当前线程执行 IO 操作时会陷入 _Psyscall 状态。

2.4. 小结

我们在这一小节中简单介绍了 Go 语言调度器中常见的数据结构,包括线程 M、处理器 P 和 Goroutine G,它们在 Go 语言运行时中分别使用不同的私有结构体表示,我们在下面的小节中就会深入介绍 Go 语言调度器的实现原理。

3. 实现原理

这里会以 Go 语言中 Goroutine 的相关操作为入口,详细介绍 Goroutine 的不同状态、它是如何被创建和销毁的以及调度器的启动过程。

3.1. 创建 Goroutine

想要启动一个新的 Goroutine 来执行任务时,我们需要使用 Go 语言中的 go 关键字,这个关键字会在编译期间通过以下方法 stmt 和 call 两个方法将该关键字转换成 newproc 函数调用,代码的路径和原理与defer 关键字几乎完全相同,两者的区别也只是 defer 被转化成 deferproc 而 go 被转换成 newproc 方法:

func (s *state) stmt(n *Node) {
    switch n.Op {
    case OGO:
        s.call(n.Left, callGo)
    }
}

func (s *state) call(n *Node, k callKind) *ssa.Value {
    // ...
    if k == callDeferStack {
        // ...
    } else {
        switch {
        case k == callGo:
            call = s.newValue1A(ssa.OpStaticCall, types.TypeMem, newproc, s.mem())
        default:
        }
    }

    // ...
    return ...
}

经过了 Go 语言的中间代码生成 的过程,所有的 go 关键字都会被转换成 newproc 函数调用,我们向 newproc 中传入一个表示函数的指针 funcval ,在这个函数中我们还会获取当前调用 newproc 函数的 Goroutine 以及调用方的程序计数器 PC,然后调用 newproc1 函数:

func newproc(siz int32, fn *funcval) {
    argp := add(unsafe.Pointer(&fn), sys.PtrSize)
    gp := getg()
    pc := getcallerpc()
    newproc1(fn, (*uint8)(argp), siz, gp, pc)
}

newproc1 函数的主要作用就是创建一个运行传入参数 fn 的 g 结构体,在这个方法中我们也会拷贝当前方法的全部参数, argp 和 narg 共同表示函数 fn 的入参,我们在该方法中其实也会直接将所有参数对应的内存空间整片的拷贝到新 Goroutine 的栈上。

func newproc1(fn *funcval, argp *uint8, narg int32, callergp *g, callerpc uintptr) {
    _g_ := getg()
    siz := narg
    siz = (siz + 7) &^ 7

    _p_ := _g_.m.p.ptr()
    newg := gfget(_p_)
    if newg == nil {
        newg = malg(_StackMin)
        casgstatus(newg, _Gidle, _Gdead)
        allgadd(newg)
    }

    totalSize := 4*sys.RegSize + uintptr(siz) + sys.MinFrameSize
    totalSize += -totalSize & (sys.SpAlign - 1)
    sp := newg.stack.hi - totalSize
    spArg := sp
    if narg > 0 {
        memmove(unsafe.Pointer(spArg), unsafe.Pointer(argp), uintptr(narg))
    }

    memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched))
    newg.sched.sp = sp
    newg.stktopsp = sp
    newg.sched.pc = funcPC(goexit) + sys.PCQuantum
    newg.sched.g = guintptr(unsafe.Pointer(newg))
    gostartcallfn(&newg.sched, fn)
    newg.gopc = callerpc
    newg.startpc = fn.fn
    if isSystemGoroutine(newg, false) {
        atomic.Xadd(&sched.ngsys, +1)
    }
    casgstatus(newg, _Gdead, _Grunnable)

    newg.goid = int64(_p_.goidcache)
    _p_.goidcache++
    runqput(_p_, newg, true)

    if atomic.Load(&sched.npidle) != 0 && atomic.Load(&sched.nmspinning) == 0 && mainStarted {
        wakep()
    }
}

newproc1 函数的执行过程其实可以分成以下的几个步骤:

  1. 获取当前 Goroutine 对应的处理器 P 并从它的列表中取出一个空闲的 Goroutine,如果当前不存在空闲的 Goroutine,就会通过 malg 方法重新分配一个 g 结构体并将它的状态从 _Gidle 转换成 _Gdead ;
  2. 获取新创建 Goroutine 的堆栈并直接通过 memmove 将函数 fn 需要的参数全部拷贝到栈中;
  3. 初始化新 Goroutine 的栈指针、程序计数器、调用方程序计数器等属性;
  4. 将新 Goroutine 的状态从 _Gdead 切换成 _Grunnable 并设置 Goroutine 的标识符(goid);
  5. runqput 函数会将新的 Goroutine 添加到处理器 P 的结构体中;
  6. 如果符合条件,当前函数会通过 wakep 来添加一个新的 p 结构体来执行 Goroutine;

获取结构体

在这个过程中我们会有两种不同的方法获取一个新的 g 结构体,一种情况是直接从当前 Goroutine 所在处理器的 _p_.gFree 列表或者调度器的 sched.gFree 列表中获取 g 结构体,另一种方式就是通过 malg 方法生成一个新的 g 结构体并将当前结构体追加到全局的 Goroutine 列表 allgs 中。

Go 语言调度器与 Goroutine

gfget 函数中包含两部分逻辑,当处理器结构体中的空闲 Goroutine 列表已经为空时就会从就会将调度器持有的空闲 Goroutine 转移到当前处理器上,当 Goroutine 数量充足时它会将当前处理器的空闲 Goroutine 数量『装载』到 32 个,随后 gfget 函数就会从当前非空的 gFree 列表中获取空闲的 Goroutine 了:

func gfget(_p_ *p) *g {
retry:
    if _p_.gFree.empty() && (!sched.gFree.stack.empty() || !sched.gFree.noStack.empty()) {
        lock(&sched.gFree.lock)
        for _p_.gFree.n < 32 {
            gp := sched.gFree.stack.pop()
            if gp == nil {
                gp = sched.gFree.noStack.pop()
                if gp == nil {
                    break
                }
            }
            sched.gFree.n--
            _p_.gFree.push(gp)
            _p_.gFree.n++
        }
        unlock(&sched.gFree.lock)
        goto retry
    }
    gp := _p_.gFree.pop()
    if gp == nil {
        return nil
    }
    _p_.gFree.n--
    if gp.stack.lo == 0 {
        gp.stack = stackalloc(_FixedStack)
        gp.stackguard0 = gp.stack.lo + _StackGuard
    }
    return gp
}

当然调度器的 gFree 和当前处理器的 gFree 列表都为空时,我们就会调用 malg 方法初始化一个新的 g 结构体,如果申请的堆栈大小大于 0,在这里我们就会通过 stackalloc 初始化一片栈空间,栈空间的大小一般是 1KB:

func malg(stacksize int32) *g {
    newg := new(g)
    if stacksize >= 0 {
        stacksize = round2(_StackSystem + stacksize)
        newg.stack = stackalloc(uint32(stacksize))
        newg.stackguard0 = newg.stack.lo + _StackGuard
        newg.stackguard1 = ^uintptr(0)
    }
    return newg
}

这也就是 newproc1 获取 g 结构体的两种不同路径,通过 malg 获取的结构体会被存储到全局变量 allgs 中。

运行队列

新创建的 Goroutine 在大多数情况下都可以通过调用 runqput 函数将当前 Goroutine 添加到处理器 P 的运行队列上,该运行队列是一个使用数组构成的环形链表,其中最多能够存储 256 个指向 Goroutine 的指针,除了 runq 中能够存储待执行的 Goroutine 之外, runnext 指针中也可以存储 Goroutine, runnext 指向的 Goroutine 会成为下一个被运行的 Goroutine:

func runqput(_p_ *p, gp *g, next bool) {
    if next {
    retryNext:
        oldnext := _p_.runnext
        if !_p_.runnext.cas(oldnext, guintptr(unsafe.Pointer(gp))) {
            goto retryNext
        }
        if oldnext == 0 {
            return
        }
        gp = oldnext.ptr()
    }

retry:
    h := atomic.LoadAcq(&_p_.runqhead)
    t := _p_.runqtail
    if t-h < uint32(len(_p_.runq)) {
        _p_.runq[t%uint32(len(_p_.runq))].set(gp)
        atomic.StoreRel(&_p_.runqtail, t+1)
        return
    }
    if runqputslow(_p_, gp, h, t) {
        return
    }
    goto retry
}
  1. 当 next=true 时将 Goroutine 设置到处理器的 runnext 上作为下一个被当前处理器执行的 Goroutine;
  2. 当 next=false 并且运行队列还有剩余空间时,将 Goroutine 加入处理器持有的本地运行队列;
  3. 当处理器的本地运行队列已经没有剩余空间时就会把本地队列中的一部分 Goroutine 和待加入的 Goroutine 通过 runqputslow 添加到调度器持有的全局运行队列上;

Go 语言调度器与 Goroutine

简单总结一下,Go 语言中有两个运行队列,其中一个是处理器本地的运行队列,另一个是调度器持有的全局运行队列,只有在本地运行队列没有剩余空间时才会使用全局队列存储 Goroutine。

3.2. Goroutine 调度

在 Go 语言程序的运行期间,所有触发 Goroutine 调度的方式最终都会调用 gopark 函数让出当前处理器 P 的控制权, gopark 函数中会更新当前处理器的状态并在处理器上设置该 Goroutine 的等待原因:

func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {
    mp := acquirem()
    gp := mp.curg
    mp.waitlock = lock
    mp.waitunlockf = unlockf
    gp.waitreason = reason
    mp.waittraceev = traceEv
    mp.waittraceskip = traceskip
    releasem(mp)
    mcall(park_m)
}

上述函数中调用的 park_m 函数会将当前 Goroutine 的状态从 _Grunning 切换至 _Gwaiting 并调用 waitunlockf 函数进行解锁,如果解锁失败就会将该 Goroutine 的状态切换回 _Grunning 并重新执行:

func park_m(gp *g) {
    _g_ := getg()

    casgstatus(gp, _Grunning, _Gwaiting)
    dropg()

    if fn := _g_.m.waitunlockf; fn != nil {
        ok := fn(gp, _g_.m.waitlock)
        _g_.m.waitunlockf = nil
        _g_.m.waitlock = nil
        if !ok {
             casgstatus(gp, _Gwaiting, _Grunnable)
            execute(gp, true) // Schedule it back, never returns.
        }
    }
    schedule()
}

在大多数情况下都会调用 schedule 触发一次 Goroutine 调度,这个函数的主要作用就是从不同的地方查找待执行的 Goroutine:

func schedule() {
    _g_ := getg()

top:
    var gp *g
    var inheritTime bool

    if gp == nil {
        if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
            lock(&sched.lock)
            gp = globrunqget(_g_.m.p.ptr(), 1)
            unlock(&sched.lock)
        }
    }
    if gp == nil {
        gp, inheritTime = runqget(_g_.m.p.ptr())
        if gp != nil && _g_.m.spinning {
            throw("schedule: spinning with local work")
        }
    }
    if gp == nil {
        gp, inheritTime = findrunnable() // blocks until work is available
    }

    execute(gp, inheritTime)
}
findrunnable

findrunnable 函数会再次从本地运行队列、全局运行队列、网络轮训器和其他的处理器中获取待执行的任务,该方法一定会返回待执行的 Goroutine,否则就会一直阻塞。

获取可以执行的任务之后就会调用 execute 函数执行该 Goroutine,执行的过程中会先将其状态修改成 _Grunning 、与线程 M 建立起双向的关系并调用 gogo 触发调度。

func execute(gp *g, inheritTime bool) {
    _g_ := getg()

    casgstatus(gp, _Grunnable, _Grunning)
    gp.waitsince = 0
    gp.preempt = false
    gp.stackguard0 = gp.stack.lo + _StackGuard
    if !inheritTime {
        _g_.m.p.ptr().schedtick++
    }
    _g_.m.curg = gp
    gp.m = _g_.m


    gogo(&gp.sched)
}

gogo 在不同处理器架构上的实现都不相同,但是不同的实现其实也大同小异,下面是该函数在 386 架构上的实现:

TEXT runtime·gogo(SB), NOSPLIT, $8-4
    MOVL    buf+0(FP), BX        // gobuf
    MOVL    gobuf_g(BX), DX
    MOVL    0(DX), CX        // make sure g != nil
    get_tls(CX)
    MOVL    DX, g(CX)
    MOVL    gobuf_sp(BX), SP    // restore SP
    MOVL    gobuf_ret(BX), AX
    MOVL    gobuf_ctxt(BX), DX
    MOVL    $0, gobuf_sp(BX)    // clear to help garbage collector
    MOVL    $0, gobuf_ret(BX)
    MOVL    $0, gobuf_ctxt(BX)
    MOVL    gobuf_pc(BX), BX
    JMP    BX

这个函数会从 gobuf 中取出 Goroutine 指针、栈指针、返回值、上下文以及程序计数器并将通过 JMP 指令跳转至 Goroutine 应该继续执行代码的位置。

3.3. 系统调用

系统调用对于 Go 语言调度器的调度也有比较大的影响,为了处理这些特殊的系统调用,我们甚至专门在 Goroutine 中加入了 _Gsyscall 这一状态,Go 语言通过 Syscall 和 Rawsyscall 等使用汇编语言编写的方法封装了操作系统提供的所有系统调用,其中 Syscall 在 Linux 386 上的实现如下:

TEXT ·Syscall(SB),NOSPLIT,$0-28
    CALL    runtime·entersyscall(SB)
    MOVL    trap+0(FP), AX    // syscall entry
    MOVL    a1+4(FP), BX
    MOVL    a2+8(FP), CX
    MOVL    a3+12(FP), DX
    MOVL    $0, SI
    MOVL    $0, DI
    INVOKE_SYSCALL
    CMPL    AX, $0xfffff001
    JLS    ok
    MOVL    $-1, r1+16(FP)
    MOVL    $0, r2+20(FP)
    NEGL    AX
    MOVL    AX, err+24(FP)
    CALL    runtime·exitsyscall(SB)
    RET
ok:
    MOVL    AX, r1+16(FP)
    MOVL    DX, r2+20(FP)
    MOVL    $0, err+24(FP)
    CALL    runtime·exitsyscall(SB)
    RET

在真正通过汇编指令 INVOKE_SYSCALL 执行系统调用前后,都会调用运行时的 entersyscall 和 exitsyscall 进行一些处理,正是这一层包装能够让我们在陷入系统调用之前触发调度器的一些操作,但是另外的 RawSyscall 等方法就会省略调用运行时方法的过程。

Go 语言调度器与 Goroutine

进入系统调用

entersyscall 函数会在获取当前 PC 程序计数器和 SP 栈指针之后调用 reentersyscall ,这个函数会完成绝大部分 Goroutine 进入系统调用之前的准备工作:

func reentersyscall(pc, sp uintptr) {
    _g_ := getg()
    _g_.m.locks++
    _g_.stackguard0 = stackPreempt
    _g_.throwsplit = true

    save(pc, sp)
    _g_.syscallsp = sp
    _g_.syscallpc = pc
    casgstatus(_g_, _Grunning, _Gsyscall)

    _g_.m.syscalltick = _g_.m.p.ptr().syscalltick
    _g_.sysblocktraced = true
    _g_.m.mcache = nil
    pp := _g_.m.p.ptr()
    pp.m = 0
    _g_.m.oldp.set(pp)
    _g_.m.p = 0
    atomic.Store(&pp.status, _Psyscall)
    if sched.gcwaiting != 0 {
        systemstack(entersyscall_gcwait)
        save(pc, sp)
    }

    _g_.m.locks--
}
_Gsyscall
_Psyscall

需要注意的是 reentersyscall 方法会导致处理器 P 和线程 M 的分离,当前线程 M 会陷入系统调用等待返回,处理器 P 上其他的 Goroutine 在这时就可能被其他处理器『取走』并执行,避免饥饿问题的发生,这也是 Go 语言程序创建的线程数可能会对于 GOMAXPROCS 的原因。

退出系统调用

当系统调用结束之后,就会调用退出系统调用的函数 exitsyscall 为当前 Goroutine 重新分配一个新的 CPU,这个函数有两个不同的执行路径,其中第一条是快速执行路径,也就是调用 exitsyscallfast 函数,另一条路径就是较慢的路径,它会切换至调度器的 Goroutine 并调用 exitsyscall0 函数:

func exitsyscall() {
    _g_ := getg()
    _g_.m.locks++

    _g_.waitsince = 0
    oldp := _g_.m.oldp.ptr()
    _g_.m.oldp = 0
    if exitsyscallfast(oldp) {
        _g_.m.p.ptr().syscalltick++
        casgstatus(_g_, _Gsyscall, _Grunning)

        _g_.syscallsp = 0
        _g_.m.locks--
        if _g_.preempt {
            _g_.stackguard0 = stackPreempt
        } else {
            _g_.stackguard0 = _g_.stack.lo + _StackGuard
        }
        _g_.throwsplit = false

        return
    }

    _g_.sysexitticks = 0
    _g_.m.locks--

    mcall(exitsyscall0)

    _g_.syscallsp = 0
    _g_.m.p.ptr().syscalltick++
    _g_.throwsplit = false
}

这两种不同的路径会分别通过不同的方法查找一个用于执行当前 Goroutine 处理器 P,快速路径 exitsyscallfast 中包含两个不同的分支:

  1. 如果 Goroutine 的原处理器处于 _Psyscall 状态,就会直接调用 wirep 将 Goroutine 与处理器进行关联;
  2. 如果调度器中存在闲置的处理器,就会调用 acquirep 函数使用闲置的处理器处理当前 Goroutine;

另一个相对较慢的路径 exitsyscall0 就会将当前 Goroutine 切换至 _Grunnable 状态,并移除线程 M 和当前 Goroutine 的关联:

pidleget

无论哪种情况,我们在这个函数中都会调用 schedule 函数触发调度器的调度,我们在上一节中已经介绍过调度器的调度过程,所以在这里就不展开介绍了。

我们需要注意的是,不是所有的系统调用都会调用 entersyscall 和 exitsyscall 这两个运行时函数,出于性能的考虑,一些系统调用是不会调用这两个方法的,你可以在这个 列表 中查询到 Go 语言对 Linux 386 架构上不同系统调用的分类,我们在这里只简单展示其中的一部分内容:

Syscall Type
SYS_TIME RawSyscall
SYS_GETTIMEOFDAY RawSyscall
SYS_SETRLIMIT RawSyscall
SYS_GETRLIMIT RawSyscall
SYS_EPOLL_WAIT Syscall
... ...

由于直接进行系统调用会阻塞当前的线程,所以只有可以立刻返回的系统调用才可能会被『设置』成 RawSyscall 不被 Go 语言的调度器控制,例如: SYS_EPOLL_CREATE 、 SYS_EPOLL_WAIT (超时时间为 0)、 SYS_TIME 等。

3.4. 调度器启动

上面的这几个场景其实都是我们在使用 Goroutine 期间会遇到,在我们创建 Goroutine 用于执行并发任务或者执行 IO 操作时都会触发调度器的调度,对于一个有经验的 Go 语言使用者,对于触发调度的方式和时机其实会有一些比较靠谱的推测的,但是调度器的启动却是我们平时比较难以接触的部分,我们在这里就介绍一下 Golang 调度器的启动过程:

func schedinit() {
    _g_ := getg()

    ...

    sched.maxmcount = 10000

    ...

    sched.lastpoll = uint64(nanotime())
    procs := ncpu
    if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
        procs = n
    }
    if procresize(procs) != nil {
        throw("unknown runnable goroutine during bootstrap")
    }
}

在调度器初始函数执行的过程中会将 maxmcount 设置成 10000 ,这也就是一个 Go 语言程序中能够创建的最大线程数,虽然最多可以创建 10000 个线程,但是可以同时运行的线程还是由 GOMAXPROCS 这个环境变量控制。

我们从环境变量 GOMAXPROCS 获取了程序能够同时运行的最多处理器 P 之后就会调用 procresize 更新程序中处理器的数量,在这时『整个世界』都会停止(不会有任何用户协程被执行)并且调度器也会进入锁定状态, procresize 的执行过程如下:

import "unsafe"

func procresize(nprocs int32) *p {
    old := gomaxprocs

    // grow allp if necessary.
    // ...

    // initialize new P's
    // ...

    _g_ := getg()
    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)

    // release resources from unused P's
    // ...

    // trim allp.
    // ...

    var runnablePs *p
    for i := nprocs - 1; i >= 0; i-- {
        p := allp[i]
        if _g_.m.p.ptr() == p {
            continue
        }
        p.status = _Pidle
        if runqempty(p) {
            pidleput(p)
        } else {
            p.m.set(mget())
            p.link.set(runnablePs)
            runnablePs = p
        }
    }
    stealOrder.reset(uint32(nprocs))
    var int32p *int32 = &gomaxprocs
    atomic.Store((*uint32)(unsafe.Pointer(int32p)), uint32(nprocs))
    return runnablePs
}
  1. 如果全局变量 allp 切片中的处理器数量少于期望数量就会对切片进行扩容;
  2. 使用 new 创建新的处理器结构体并调用 init 方法初始化刚刚扩容的处理器;
  3. 通过指针将线程 m0 和处理器 allp[0] 绑定到一起;
  4. 调用 destroy 方法释放不再使用的处理器结构;
  5. 通过截断改变全局变量 allp 的长度保证与期望处理器数量相等;
  6. 将除 allp[0] 之外的处理器 P 全部设置成 _Pidle 并加入到全局空闲队列中;

调用 procresize 就是调度器启动的最后一步,在这一步过后调度器会完成相应数量处理器的启动,等待用户创建运行新的 Goroutine 并为 Goroutine 调度处理器资源。

4. 总结

Goroutine 和调度器是 Go 语言能够高效地处理任务并且最大化利用资源的最主要原因,我们在这一节中介绍了 Golang 用于处理并发任务的 M - G - P 模型,包括它们各自的数据结构以及状态,除此之外我们还通过一些常见的场景介绍调度器的工作原理以及不同数据结构之间的协作关系,相信能够对各位读者理解调度器有一定的帮助。

查看原文: Go 语言调度器与 Goroutine

  • greenduck
  • silvermouse
  • beautifulbear
需要 登录 后回复方可回复, 如果你还没有账号你可以 注册 一个帐号。