关于go:23-GolangGo并发编程调度器schedule

7次阅读

共计 7207 个字符,预计需要花费 19 分钟才能阅读完成。

  咱们始终提到,每一个线程都有一个线程栈,也称为零碎栈;协程 g0 就运行在这个栈上,而且协程 g0 执行的就是调度逻辑 schedule。Go 语言调度器是如何治理以及调度这些成千上万个协程呢?和操作系统一样,保护着可运行队列和阻塞队列吗,有没有所谓的依照工夫片或者是优先级或者是抢占式调度呢?

调度器 schedule

  咱们曾经晓得每一个 P 都有一个协程队列 runq,该队列存储的都是处于可运行状态的协程,调度器个别状况下只须要从以后 p.runq 获取协程即可;另外,Go 语言为了防止多个 P 负载调配不平衡,还有一个全局队列 sched.runq,如果以后 p.runq 队列为空,也会从全局队列 sched.runq 尝试获取协程;如果还为获取不到可执行协程,甚至会从其余 P 的队列去偷。

  当然,无论是 p.runq,还是全局的 sched.runq,存储的都是处于可运行状态的协程;那处于阻塞状态的协程呢,这些协程在调度器执行的时候,还处于阻塞状态码?不晓得,所以在获取不到可执行协程时,还会尝试去看一下有没有协程解除阻塞了,如果有则还能够调度执行这些协程。

  调度器 schedule 看着比较简单,获取可运行协程,通过 execute 调度执行该协程:

func schedule() {
    // schedtick 调度计数器,没调度以此加 1
    // 调度器周期 61 次,首先从全局队列获取可运行协程
    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 {gp, inheritTime = findrunnable() // blocks until work is available
    }

    // 调度执行
    execute(gp, inheritTime)
}

  依照之前咱们说的,先查找以后 P 的协程队列,再查找全局队列,然而这样可能会导致全局队列的协程长时间得不到调度,所以 Go 语言调度器每执行 61 次,都会优先从全局队列获取可运行协程。留神,在查找全局队列的时候,存在多线程并发问题,所以是须要先加锁的。findrunnable 是一个比较复杂的函数,看正文 ”blocks until work is available”,获取不到协程时,甚至会 block(以后线程 M 暂停)。execute 当然就是切换栈,执行以后协程了。

func execute(gp *g, inheritTime bool) {
    // 设置协程 g 与 M 的相互援用关系
    _g_ := getg()
    _g_.m.curg = gp
    gp.m = _g_.m
    // 协程状态:运行中
    casgstatus(gp, _Grunnable, _Grunning)

    // 协程切换
    gogo(&gp.sched)
}

  gogo 咱们上一篇文章曾经介绍过了,纯汇编代码写的,实现了栈桢的切换,以及代码的跳转。不晓得你有没有留神到第二个参数 inheritTime,这是什么含意呢?示意这次协程执行是否继承上一个协程的工夫片。如果工夫片为 10ms,上一个协程曾经执行了 5ms,如果继承,则表明这一个协程最多只能执行 5ms,工夫片就会完结,从而再次调度其余协程。这么说 Go 语言调度器是有工夫片的概念了?咱们先保留一个疑难。

  Go 语言什么时候执行调度 schedule 呢?程序刚启动必定会执行,而协程因为某些起因阻塞了(chan 的读写,socket 的读写等等),或者是协程执行完结了,这时候也是须要从新调度其余协程的;协程阻塞通常是通过 runtime.gopark 函数实现的:

func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {
    // 切换到零碎栈,执行 park_m
    mcall(park_m)
}

func park_m(gp *g) {
    // 协程状态:阻塞
    casgstatus(gp, _Grunning, _Gwaiting)

    // 从新调度
    schedule()}

  协程阻塞之后,想复原协程的调度呢?与 gopark 对应的,runtime.goready 函数用于复原协程的调度:

func goready(gp *g, traceskip int) {systemstack(func() {ready(gp, traceskip, true)
    })
}

func ready(gp *g, traceskip int, next bool) {
    // 更改状态为可运行;增加到 P 的协程队列
    casgstatus(gp, _Gwaiting, _Grunnable)
    runqput(_g_.m.p.ptr(), gp, next)
    wakep()
    releasem(mp)
}

合作式抢占调度

  Go 语言调度器到底有工夫片的概念吗?其实咱们能够通过一个小程序测试一下:

package main

import (
    "fmt"
    "runtime"
)

func main() {
    // 设置 P 的数目为 1
    runtime.GOMAXPROCS(1)
    go func() {fmt.Println("hello world")
        for {// 死循环}

    }()
    //main 协程被动让出
    runtime.Gosched()
    fmt.Println("main end")
}

  上一篇文章解说协程创立的时候提到,go 关键字创立协程时,只是将该协程增加到以后 P 的队列,并没有调度执行;所以,为了防止主协程执行打印语句完结后程序退出,咱们能够通过 runtime.Gosched 函数使得 main 协程被动让出 CPU,这样 Go 调度器就能先调度执行其余协程了。另外,咱们通过 runtime.GOMAXPROCS 设置 P 的数目为 1,即最多只能有一个线程 M 绑定 P,即最多只能有一个调度器运行。这样,如果 Go 语言调度器没有工夫片的概念,则一旦子协程执行到循环,就会始终执行死循环,导致调度器再也没有机会调度其余协程了;最终的景象就是 main 协程的打印语句无奈执行。

  执行后果怎么样呢?如果你是在 Go1.18 环境运行该程序,你会发现失常输入了 ”main end”;然而如果你是在 Go1.13 版本及以下运行该程序,你将发现程序始终执行,没有输入 ”main end”。你能够下载两个版本的 Go 试一试,看看后果是不是这样的。Go1.13 版本及以下不会输入,是否阐明 Go1.13 版本及以下,没有工夫片的概念?其实也不然。你能够再试试上面这个程序:

package main

import (
    "fmt"
    "runtime"
)

func main() {runtime.GOMAXPROCS(1)

    go func() {fmt.Println("hello world")
        var arr []int
        for i := 0; i < 100; i ++ {arr = append(arr, i)
        }
        for {test(arr)
        }

    }()
    runtime.Gosched()
    fmt.Println("main end")
}

func test(arr []int) []int {diff := make([]int, len(arr), len(arr))
    diff[0] = arr[0]
    for i := 1; i < len(arr); i ++ {diff[i] = arr[i] - arr[i - 1]
    }
    return diff
}

  这一次咱们的死循环不是简略的空语句,而是函数调用,而且 test 函数也有一些略微简单的语句。Go1.13 版本及以下再执行这个程序试试呢?你会发现,神奇的是,主协程又输入了 ”main end”。为什么呢?惟一不同的是第一个程序的死循环只是简略的空语句,第二个程序的循环是函数调用!

  怎么,函数调用就非凡?是的,函数调用就是不同于一般语句,就是非凡。Go 语言在编译函数的时候,还增加了一些本人的代码。还记得上一篇文章,在介绍协程栈溢出时候提到,Go 语言编译阶段,在所有用户函数,都加了一点代码逻辑,判断栈顶指针 SP 小于某个地位时,阐明栈空间有余,须要扩容了。须要扩容的时候,执行的是函数 runtime.morestack_noctxt,而该函数(其实是 runtime.newstack)不仅仅是判断是否须要扩容,还会判断以后协程是否应该让出 CPU。

  留神,Go 语言并没有严格限度协程执行工夫片,而是通过一种合作式抢占调度(1.13 版本及以下)的形式,实现伪工夫片性能。这就须要一个帮手了,Go 程序启动时不止创立一般的调度线程,还存在辅助线程,辅助线程的主函数是 runtime.sysmon,每 10ms 轮询一次,检测是否有协程执行工夫过长,如果有,则告诉该协程让出 CPU。

// 创立新线程,主函数 sysmon
newm(sysmon, nil)

func sysmon() {
    delay = 10 * 1000   // up to 10ms
    usleep(delay)

    for {
        //preempt long running G's
        retake(nanotime())
    }
}

  preempt 的意思是抢占。咱们先思考两个问题:

  1)sysmon 线程如何判断哪些协程执行工夫过长?遍历协程吗?必定不是这样。想想线程 M 调度协程流程,要求必须先绑定 P,而且 M 正在调度执行的协程只有一个,所以呢?只须要遍历 P,通过 p.m.curg 就能获取到正在执行的协程。接下来就是检测协程执行工夫了,每个协程记录调度工夫吗?貌似也行,Go 语言为每一个 P 保护了 p.schedtick,M 每调度一次协程,该值加 1,而且还有一个变量 p.schedwhen 记录了上次调度的工夫。这就好办了,每 10 毫秒检测的时候,如果 p.schedtick 没法产生扭转,阐明这 10ms 内没有产生调度,则应该告诉以后协程 p.m.curg 让出 CPU 了。

  2)sysmon 线程如何跨线程告诉该协程让出 CPU 呢?还记得协程栈扩容是怎么判断的吗?stackguard0!告诉协程让出 CPU 也是通过在协程栈 stackguard0 地位设置非凡标识实现的。

  仿佛整个流程通顺了,第一步,Go 语言编译阶段在 test 函数增加一些本人的代码,如下:

"".test STEXT
    0x0000 00000 (test.go:26)    CMPQ    SP, 16(R14)
    0x0004 00004 (test.go:26)    PCDATA    $0, $-2
    0x0004 00004 (test.go:26)    JLS    404

    0x0194 00404 (test.go:26)    MOVQ    AX, 8(SP)
    0x0199 00409 (test.go:26)    MOVQ    BX, 16(SP)
    0x019e 00414 (test.go:26)    MOVQ    CX, 24(SP)
    0x01a3 00419 (test.go:26)    CALL    runtime.morestack_noctxt(SB)
    0x01b7 00439 (test.go:26)    JMP    0

  R14 寄存器就是以后协程 g,想想构造体 g 的第一个字段 stack 占 16 字节,第二个字段就是 stackguard0,所以这里比拟栈顶 SP 寄存器与 16(R14) 地址大小。那明确了,stackguard0 地位处设置的非凡标识必定是一个十分大的值,任何栈地位都小于该值。

  第二步,sysmon 线程 10ms 周期执行 retake 函数抢占长时间执行的 G:

func retake(now int64) uint32 {
    // 遍历所有的 P
    for i := 0; i < len(allp); i++ {_p_ := allp[i]
        s := _p_.status
        if s == _Prunning {t := int64(_p_.schedtick)
            // 不等于,阐明在这 10ms 期间从新调度协程了;if int64(pd.schedtick) != t {pd.schedtick = uint32(t)
                pd.schedwhen = now
                continue
            }
            // G 长时间运行
            if pd.schedwhen+forcePreemptNS > now {continue}
            preemptone(_p_)
        }
    }

}

func preemptone(_p_ *p) bool {mp := _p_.m.ptr()
    gp := mp.curg
    if gp == nil || gp == mp.g0 {return false}

    // 抢占标识
    gp.preempt = true
    gp.stackguard0 = stackPreempt
    return true
}

const forcePreemptNS = 10 * 1000 * 1000 // 10ms

stackPreempt = (1<<(8*sys.PtrSize) - 1) & -1314 // 十分大 

  个别状况下,每一个 P 都有可能有正在执行的协程 p.m.curg,所以这里须要遍历所有的 P,如果 P 的状态为_Prunning,阐明该 P 曾经被 M 绑定且正在调度协程。p.schedtick 每调度一次协程值加 1,所以检测时如果这个值与上次记录不一样,则阐明这 10ms 期间必定从新调度协程了,跳过即可。否则,如果距上次调度工夫曾经过来很久了,则通过 preemptone 抢占,看吧,抢占标识就是通过设置 gp.stackguard0 实现的。

  第三步,子协程执行 10ms 之后,进入到函数 test,检测 stackguard0 标识,发现栈顶指针 SP 小于 stackguard0,这时候跳转到了 runtime.morestack_noctxt 函数。函数 morestack_noctxt 也是汇编写的,一系列判断之后,最终调用了函数 runtime.newstack,就是在这里,判断是否被抢占了,如果是,则让出 CPU。

func newstack() {gp := getg().m.curg
    preempt := atomic.Loaduintptr(&gp.stackguard0) == stackPreempt
    if preempt {gopreempt_m(gp) // never return;抢占
    }
}

func gopreempt_m(gp *g) {
    // 批改协程状态
    casgstatus(gp, _Grunning, _Grunnable)
    // 增加到全局队列
    lock(&sched.lock)
    globrunqput(gp)
    unlock(&sched.lock)

    // 从新调度
    schedule()}

  newstack 就是通过 gp.stackguard0 判断是否被抢占了。另外,咱们发现协程被抢占时,被增加到了全局队列,这样相当于优先级升高了。

  这就是 Go1.13 版本及以下实现的合作式抢占调度,协程只有在进入函数时,才有可能检测是否被抢占了,所以死循环中只是简略的语句是无奈抢占的。而且函数如果非常简单,还有可能被优化掉,所以你测试的时候,可能发现循环中调用了函数,然而运行后果却显示无奈被抢占。

基于信号的抢占式调度

  Go1.13 版本及以下是基于合作式的抢占调度,所以死循环中是简略的语句,还是简单的函数调用,最终后果是不一样的。那 Go1.14 版本以上呢?如同无论哪一种状况,都能被抢占,是做了哪些优化吗?是的,Go1.14 版本以上实现的是基于信号的抢占。

  信号?kill -signal pid 发送的就是信号,比方咱们罕用 SIGTERM 信号终止过程,而 Linux 总共有 64 种信号可供选择。当然,程序想要接管并解决某种信号,还须要设置信号处理器:

struct sigaction{void (*sa_handler)(int); 
       sigset_t sa_mask; 
       int sa_flags;
       void (*sa_restorer)(void); 
}

  sa_hander 就是咱们的信号处理器函数指针。Go 语言设置的信号处理函数为 runtime.sighandler。

  上面看一下 Go1.18 实现的抢占逻辑,同样是函数 preemptone:

func preemptone(_p_ *p) bool {
    // 合作式抢占标记
    gp.stackguard0 = stackPreempt

    // 如果反对信号抢占,发送信号
    if preemptMSupported {preemptM(mp)
    }

    return true
}

func preemptM(mp *m) {pthread_kill(pthread(mp.procid), sigPreempt)
}

const sigPreempt = _SIGURG

  函数 pthread_kill 可用于向指定线程发送信号,抉择第几种信号也是有要求的,有很多信号有非凡含意,是不能轻易应用的,Go 语言选择的协程抢占信号是 SIGURG。sysmon 线程发送抢占信号,调度线程 M 就会收到信号,判断收到的是抢占信号,则换出以后协程,从新调度。

func sighandler(sig uint32, info *siginfo, ctxt unsafe.Pointer, gp *g) {
    // 抢占信号
    if sig == sigPreempt {doSigPreempt(gp, c)
    }
}

  最终其实和合作式抢占一样,都是将以后协程增加到全局队列,触发调度,这里就不在赘述。

总结

  本篇文章次要介绍了 Go 语言调度器,调度算法由 runtime.schedule 函数实现,程因为某些起因阻塞了(chan 的读写,socket 的读写等等),或者是协程执行完结了,都会触发从新调度。另外 Go 语言还反对抢占调度,辅助协程 sysmon 检测长时间执行协程,设置抢占标识或者发送抢占信号。Go1.13 版本及以下实现的是合作式调度,一般死循环语句没有方法被抢占,只有执行函数调用时(Go 编译阶段增加了一些代码),才有可能实现抢占,而 Go1.14 版本及以上,通过信号实现的抢占调度则没有这个问题。

正文完
 0