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 平台的信号), 触发真正的抢占式调度,而不需要依赖以前在函数调用时,检查栈的方法。