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 看看,也就是

```go
runtime.GOMAXPROCS(2)
```

运行结果:

```shell
$ go run preemptible.go
12
outside
```

直接输出结束了。

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

```go
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()` ,这是为了调用一个函数,但是不影响输出效果,
可以看到输出:

```bash
$ 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
<a id="code-snippet--1.13"></a>
```go
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
<a id="code-snippet--1.14"></a>
```go
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,可是一直没有函数调度的机会:

    <a id="code-snippet--1.13"></a>
    ```shell
    $ 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` 后退出。

    <a id="code-snippet--1.14"></a>
    ```shell
    $ 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` 后加入了:

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

`preemptM` 的实现就和平台相关了,可以看到,unix 平台的实现位于:[runtime/signal\_unix.go](https://github.com/golang/go/blob/329317472fd3fbc3179523bd70e03e452c829846/src/runtime/signal%5Funix.go#L346)
主要就是给 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 平台的信号), 触发真正的抢占式调度,而不需要依赖以前在函数调用时,检查栈的方法。