Golang 1.14 发布

Golang 1.14 在 2020-02-25 正式发布,看了一下 Release Notes,发现有两个点比较有意思:

  • 调度器抢占优化,不再需要函数调用作为抢占点
  • Timer 更高效

这篇文章主要是聊聊调度器这部分。

调度器抢占优化

Golang 主打高并发,调度器自然也是关注的重点,以前一致有一个毛病, 如果一个 Goroutine 没有调用其他函数,就会一直占用当前的 Process,无法被调度走。

Golang 1.14 解决了这个问题。

实例程序

package main

import (
    "fmt"
    "runtime"
)

func main() {
    fmt.Println(runtime.GOMAXPROCS(1))
    go func() {
        for {
        }
    }()

    runtime.Gosched()
    fmt.Println("outside")
}

GOMAXPROCS, NumCPU, Gosched

实例程序里用到了 3 个函数,都是 rintime 包里的。

GOMAXPROCS

GOMAXPROCS 用于设置一个 Go 程序能够使用的操作系统线程数, 现在默认值等于 NumCPU ,默认尽可能地使用系统资源。

1.5 以前,Go 会默认把 GOMAXPROCS 设置成 1,不过应该没有人在用 1.5 以前的版本了吧。

另一个需要注意的地方是 GOMAXPROCS 的返回值是 NumCPU ,而不是设置的 GOMAXPROCS 值。

NumCPU

NumCPU 返回的是可用的 CPU 线程数,注意是线程数,不是核心数, 举个栗子,我现在用的 Mackbook Pro 16 寸 CPU 是 i7-9750H 是 6 核,但是由于支持超线程, 所以是 12 个线程,所以 NumCPU 的值是 12

Gosched

Gosched 用于 Goroutine 主动让出 CPU 线程执行权。

现象和现状

1.13 以前的问题

我们运行实例程序看看效果:

$ go run preemptible.go
12
^Csignal: interrupt

可以看到,直到我们输入了 ^C ,也就是 Ctrl-C 中止程序之后,程序才退出,而且没有打印 outside

  • 尝试修改

为了验证是由于我们的 Goroutine 阻塞住了程序,我们给两个 Process 看看,也就是

runtime.GOMAXPROCS(2)

运行结果:

$ go run preemptible.go
12
outside

直接输出结束了。

另外,我们还提到了,1.13 之前,Go 是在调用函数时,进行抢占的,那我们再把线程数改到 1 ,调用一个函数试试。

package main

import (
    "fmt"
    "runtime"
)

func main() {
    fmt.Println(runtime.GOMAXPROCS(1))
    go func() {
        for {
            fmt.Print()
        }
    }()

    runtime.Gosched()
    fmt.Println("outside")
}

注意, GOMAXPROCS 又设置成 1 了, 而且在 Goroutine 里加了一个 fmt.Print() ,这是为了调用一个函数,但是不影响输出效果, 可以看到输出:

$ go run preemptible.go
12
outside

没有阻塞,正常退出了,印证了调用函数的时候能够抢占调度。

1.14 优化

直接就用上面的实例程序,跑一下:

$ GOROOT=~/code/go114 PATH=$GOROOT/bin:$PATH go run preemptible.go
12
outside

什么都没有修改,成功打印 outside 退出,说明 main 确实抢占了 Goroutine 并退出。

源码分析

调试方法

我们要调试的是 Golang 调度器,能够打印一些信息总是会方便我们调试的, 好在 Golang 有一个 builtin 的 print 函数,可以在 Golang 的源码里进行一些打印, 但是官方对 print 函数是没有保证的,但是我们调试是够用了。

我们可以在希望输出的地方加上 print ,例如下面我提到的调度的位置,都可以加上:

print("paper: ", "before ", "gomaxprocs=", gomaxprocs, " idleprocs=", sched.npidle, " threads=", mcount(), " spinningthreads=", sched.nmspinning, " idlethreads=", sched.nmidle, " runqueue=", sched.runqsize, "\n")

这样程序运行是,就可以看到类似输出:

$ GODEBUG=schedtrace=100 ./preempt                                                                        [12:22:17]
SCHED 0ms: gomaxprocs=12 idleprocs=11 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0 0 0 0 0]
paper: retake gomaxprocs=12 idleprocs=10 threads=5 spinningthreads=1 idlethreads=2 runqueue=0
12
paper: retake gomaxprocs=1 idleprocs=0 threads=5 spinningthreads=0 idlethreads=3 runqueue=1
paper: before gomaxprocs=1 idleprocs=0 threads=5 spinningthreads=0 idlethreads=3 runqueue=1
paper: after gomaxprocs=1 idleprocs=0 threads=5 spinningthreads=0 idlethreads=3 runqueue=1
outside

调度分析

Go 在运行时,会有一个 sysmon ,函数,作为监控线程,不需要 P 就可以运行, 其中一个作用就是抢占长时间阻塞的 P。

sysmon 定义在 runtime/proc.go 其中 retake 函数就是用于抢占 P。

可以看到:

else if pd.schedwhen+forcePreemptNS <= now {
    preemptone(_p_)
    // In case of syscall, preemptone() doesn't
    // work, because there is no M wired to P.
    sysretake = true
}

当超过 forcePreemptNS 时,就会抢占 P, 1.14 的优化就在 preemptone

优化点

先展示一下两个版本的函数,注意,这里的几个 print 是我加的,用于调试:

1.13

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

    gp.preempt = true

    // Every call in a go routine checks for stack overflow by
    // comparing the current stack pointer to gp->stackguard0.
    // Setting gp->stackguard0 to StackPreempt folds
    // preemption into the normal stack overflow check.
    gp.stackguard0 = stackPreempt
    print("paper: ", "", "gomaxprocs=", gomaxprocs, " idleprocs=", sched.npidle, " threads=", mcount(), " spinningthreads=", sched.nmspinning, " idlethreads=", sched.nmidle, " runqueue=", sched.runqsize, "\n")
    return true
}
1.14

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

    gp.preempt = true

    // Every call in a go routine checks for stack overflow by
    // comparing the current stack pointer to gp->stackguard0.
    // Setting gp->stackguard0 to StackPreempt folds
    // preemption into the normal stack overflow check.
    gp.stackguard0 = stackPreempt

    // Request an async preemption of this P.
    if preemptMSupported && debug.asyncpreemptoff == 0 {
        _p_.preempt = true
        print("paper: ", "before ", "gomaxprocs=", gomaxprocs, " idleprocs=", sched.npidle, " threads=", mcount(), " spinningthreads=", sched.nmspinning, " idlethreads=", sched.nmidle, " runqueue=", sched.runqsize, "\n")
        preemptM(mp)
        print("paper: ", "after ", "gomaxprocs=", gomaxprocs, " idleprocs=", sched.npidle, " threads=", mcount(), " spinningthreads=", sched.nmspinning, " idlethreads=", sched.nmidle, " runqueue=", sched.runqsize, "\n")
    }

    return true
}

我们先看看效果:

  • 可以看到 1.13 一直在尝试抢占 P,可是一直没有函数调度的机会:

    $ go build -o preempt preemptible.go
    $ ./preempt
    12
    paper: gomaxprocs=1 idleprocs=0 threads=5 spinningthreads=0 idlethreads=3 runqueue=1
    paper: gomaxprocs=1 idleprocs=0 threads=5 spinningthreads=0 idlethreads=3 runqueue=1
    paper: gomaxprocs=1 idleprocs=0 threads=5 spinningthreads=0 idlethreads=3 runqueue=1
    paper: gomaxprocs=1 idleprocs=0 threads=5 spinningthreads=0 idlethreads=3 runqueue=1
    paper: gomaxprocs=1 idleprocs=0 threads=5 spinningthreads=0 idlethreads=3 runqueue=1
    
  • 可以看到, 1.14 调用 preemptM 之后, main 就抢占了 P,打印 outside 后退出。

    $ GOROOT=~/code/go114 PATH=$GOROOT/bin:$PATH go build -o preempt preemptible.go
    $ ./preempt
    12
    paper: before gomaxprocs=1 idleprocs=0 threads=5 spinningthreads=0 idlethreads=3 runqueue=1
    paper: after gomaxprocs=1 idleprocs=0 threads=5 spinningthreads=0 idlethreads=3 runqueue=1
    outside
    

对比一下,其实 1.14 就是在 preemptone 后加入了:

// Request an async preemption of this P.
if preemptMSupported && debug.asyncpreemptoff == 0 {
    _p_.preempt = true
    preemptM(mp)
}

preemptM 的实现就和平台相关了,可以看到,unix 平台的实现位于:runtime/signal_unix.go 主要就是给 M 发送了一个 SIGURG 信号,表示有 urgent 紧急的事件需要处理。

不可抢占部分

另外,碰巧看到另一段代码:

if next < now {
    // There are timers that should have already run,
    // perhaps because there is an unpreemptible P.
    // Try to start an M to run them.
    startm(nil, false)
}

感觉 Go 在碰到不可抢占的地方,还会尝试再启动一个 M 用于处理? 本文感觉到这里就差不多了,这个 不可抢占部分 有机会再进一步看看。

总结

总结一下,其实就一句话,1.14 在以前的抢占调度基础上,利用平台的能力(UNIX 平台的信号), 触发真正的抢占式调度,而不需要依赖以前在函数调用时,检查栈的方法。