转载请声明出处哦~,本篇文章发布于 luozhiyun 的博客:https://www.luozhiyun.com/archives/485

本文使用的 go 的源码 15.7

这一次来讲讲基于信号式抢占式调度。

介绍

在 Go 的 1.14 版本之前抢占试调度都是基于协作的,需要自己主动的让出执行,但是这样是无法处理一些无法被抢占的边缘情况。例如:for 循环或者垃圾回收长时间占用线程,这些问题中的一部分直到 1.14 才被基于信号的抢占式调度解决。

下面我们通过一个例子来验证一下 1.14 版本和 1.13 版本之间的抢占差异:

  1. package main
  2. import (
  3. "fmt"
  4. "os"
  5. "runtime"
  6. "runtime/trace"
  7. "sync"
  8. )
  9. func main() {
  10. runtime.GOMAXPROCS(1)
  11. f, _ := os.Create("trace.output")
  12. defer f.Close()
  13. _ = trace.Start(f)
  14. defer trace.Stop()
  15. var wg sync.WaitGroup
  16. for i := 0; i < 30; i++ {
  17. wg.Add(1)
  18. go func() {
  19. defer wg.Done()
  20. t := 0
  21. for i:=0;i<1e8;i++ {
  22. t+=2
  23. }
  24. fmt.Println("total:", t)
  25. }()
  26. }
  27. wg.Wait()
  28. }

这个例子中会通过 go trace 来进行执行过程的调用跟踪。在代码中指定 runtime.GOMAXPROCS(1)设置最大的可同时使用的 CPU 核数为 1,只用一个 P(处理器),这样就确保是单处理器的场景。然后调用一个 for 循环开启 10 个 goroutines 来执行 func 函数,这是一个纯计算且耗时的函数,防止 goroutines 空闲让出执行。

下面我们编译程序分析 trace 输出:

  1. $ go build -gcflags "-N -l" main.go
  2. -N表示禁用优化
  3. -l禁用内联
  4. $ ./main

然后我们获取到 trace.output 文件后进行可视化展示:

  1. $ go tool trace -http=":6060" ./trace.output

Go1.13 trace 分析

从源码剖析Go语言基于信号抢占式调度 - luozhiyun - 博客园 - 图1

从上面的这个图可以看出:

  1. 因为我们限定了只有一个 P,所以在 PROCS 这一栏里面只有一个 Proc0;
  2. 我们在 for 循环里面启动了 30 个 goroutines ,所以我们可以数一下 Proc0 里面的颜色框框,刚好 30 个;
  3. 30 个 goroutines 在 Proc0 里面是串行执行的,一个执行完再执行另一个,没有进行抢占;
  4. 随便点击一个 goroutines 的详情栏可以看到 Wall Duration 为 0.23s 左右,表示这个 goroutines 持续执行了 0.23s,总共 10 个 goroutines 执行时间是 7s 左右;
  5. 切入调用栈 Start Stack Trace 是 main.main.func1:20,在代码上面是 func 函数执行头: go func()
  6. 切走调用栈 End Stack Trace 是 main.main.func1:26,在代码上是 func 函数最后执行打印:fmt.Println("total:", t)

从上面的 trace 分析可以知道,Go 的协作式调度对 calcSum 函数是毫无作用的,一旦执行开始,只能等执行结束。每个 goroutine 耗费了 0.23s 这么长的时间,也无法抢占它的执行权。

Go 1.14 以上 trace 分析

从源码剖析Go语言基于信号抢占式调度 - luozhiyun - 博客园 - 图2

在 Go 1.14 之后引入了基于信号的抢占式调度,从上面的图可以看到 Proc0 这一栏中密密麻麻都是 goroutines 在切换时的调用情况,不会再出现 goroutines 一旦执行开始,只能等执行结束这种情况。

上面跑动的时间是 4s 左右这个情况可以忽略,因为我是在两台配置不同的机器上跑的(主要是我闲麻烦要找两台一样的机器)。

下面我们拉近了看一下明细情况:

从源码剖析Go语言基于信号抢占式调度 - luozhiyun - 博客园 - 图3

通过这个明细可以看出:

  1. 这个 goroutine 运行了 0.025s 就让出执行了;
  2. 切入调用栈 Start Stack Trace 是 main.main.func1:21,和上面一样;
  3. 切走调用栈 End Stack Trace 是 runtime.asyncPreempt:50 ,这个函数是收到抢占信号时执行的函数,从这个地方也能明确的知道,被异步抢占了;

分析

抢占信号的安装

runtime/signal_unix.go

程序启动时,在runtime.sighandler中注册 SIGURG 信号的处理函数runtime.doSigPreempt

initsig

  1. func initsig(preinit bool) {
  2. if !preinit {
  3. signalsOK = true
  4. }
  5. for i := uint32(0); i < _NSIG; i++ {
  6. t := &sigtable[i]
  7. if t.flags == 0 || t.flags&_SigDefault != 0 {
  8. continue
  9. }
  10. ...
  11. setsig(i, funcPC(sighandler))
  12. }
  13. }

在 initsig 函数里面会遍历所有的信号量,然后调用 setsig 函数进行注册。我们可以查看 sigtable 这个全局变量看看有什么信息:

  1. var sigtable = [...]sigTabT{
  2. {0, "SIGNONE: no trap"},
  3. {_SigNotify + _SigKill, "SIGHUP: terminal line hangup"},
  4. {_SigNotify + _SigKill, "SIGINT: interrupt"},
  5. {_SigNotify + _SigThrow, "SIGQUIT: quit"},
  6. {_SigThrow + _SigUnblock, "SIGILL: illegal instruction"},
  7. {_SigThrow + _SigUnblock, "SIGTRAP: trace trap"},
  8. {_SigNotify + _SigThrow, "SIGABRT: abort"},
  9. {_SigPanic + _SigUnblock, "SIGBUS: bus error"},
  10. {_SigPanic + _SigUnblock, "SIGFPE: floating-point exception"},
  11. {0, "SIGKILL: kill"},
  12. {_SigNotify, "SIGUSR1: user-defined signal 1"},
  13. {_SigPanic + _SigUnblock, "SIGSEGV: segmentation violation"},
  14. {_SigNotify, "SIGUSR2: user-defined signal 2"},
  15. {_SigNotify, "SIGPIPE: write to broken pipe"},
  16. {_SigNotify, "SIGALRM: alarm clock"},
  17. {_SigNotify + _SigKill, "SIGTERM: termination"},
  18. {_SigThrow + _SigUnblock, "SIGSTKFLT: stack fault"},
  19. {_SigNotify + _SigUnblock + _SigIgn, "SIGCHLD: child status has changed"},
  20. {_SigNotify + _SigDefault + _SigIgn, "SIGCONT: continue"},
  21. {0, "SIGSTOP: stop, unblockable"},
  22. {_SigNotify + _SigDefault + _SigIgn, "SIGTSTP: keyboard stop"},
  23. {_SigNotify + _SigDefault + _SigIgn, "SIGTTIN: background read from tty"},
  24. {_SigNotify + _SigDefault + _SigIgn, "SIGTTOU: background write to tty"},
  25. {_SigNotify + _SigIgn, "SIGURG: urgent condition on socket"},
  26. {_SigNotify, "SIGXCPU: cpu limit exceeded"},
  27. {_SigNotify, "SIGXFSZ: file size limit exceeded"},
  28. {_SigNotify, "SIGVTALRM: virtual alarm clock"},
  29. {_SigNotify + _SigUnblock, "SIGPROF: profiling alarm clock"},
  30. {_SigNotify + _SigIgn, "SIGWINCH: window size change"},
  31. {_SigNotify, "SIGIO: i/o now possible"},
  32. {_SigNotify, "SIGPWR: power failure restart"},
  33. {_SigThrow, "SIGSYS: bad system call"},
  34. {_SigSetStack + _SigUnblock, "signal 32"},
  35. {_SigSetStack + _SigUnblock, "signal 33"},
  36. ...
  37. }

具体的信号含义可以看这个介绍:Unix 信号 https://zh.wikipedia.org/wiki/Unix 信号。需要注意的是,抢占信号在这里是 _SigNotify + _SigIgn 如下:

  1. {_SigNotify + _SigIgn, "SIGURG: urgent condition on socket"}

下面我们看一下 setsig 函数,这个函数是在 runtime/os_linux.go文件里面:

setsig

  1. func setsig(i uint32, fn uintptr) {
  2. var sa sigactiont
  3. sa.sa_flags = _SA_SIGINFO | _SA_ONSTACK | _SA_RESTORER | _SA_RESTART
  4. sigfillset(&sa.sa_mask)
  5. ...
  6. if fn == funcPC(sighandler) {
  7. if iscgo {
  8. fn = funcPC(cgoSigtramp)
  9. } else {
  10. fn = funcPC(sigtramp)
  11. }
  12. }
  13. sa.sa_handler = fn
  14. sigaction(i, &sa, nil)
  15. }

这里需要注意的是,当 fn 等于 sighandler 的时候,调用的函数会被替换成 sigtramp。sigaction 函数在 Linux 下会调用系统调用函数 sys_signal 以及 sys_rt_sigaction 实现安装信号。

执行抢占信号

到了这里是信号发生的时候进行信号的处理,原本应该是在发送抢占信号之后,但是这里我先顺着安装信号往下先讲了。大家可以跳到发送抢占信号后再回来。

上面分析可以看到当 fn 等于 sighandler 的时候,调用的函数会被替换成 sigtramp,sigtramp 是汇编实现,下面我们看看。

src/runtime/sys_linux_amd64.s:

  1. TEXT runtime·sigtramp<ABIInternal>(SB),NOSPLIT,$72
  2. ...
  3. // We don't save mxcsr or the x87 control word because sigtrampgo doesn't
  4. // modify them.
  5. MOVQ DX, ctx-56(SP)
  6. MOVQ SI, info-64(SP)
  7. MOVQ DI, signum-72(SP)
  8. MOVQ $runtime·sigtrampgo(SB), AX
  9. CALL AX
  10. ...
  11. RET

这里会被调用说明信号已经发送响应了,runtime·sigtramp会进行信号的处理。runtime·sigtramp会继续调用 runtime·sigtrampgo

这个函数在 runtime/signal_unix.go文件中:

sigtrampgo&sighandler

  1. func sigtrampgo(sig uint32, info *siginfo, ctx unsafe.Pointer) {
  2. if sigfwdgo(sig, info, ctx) {
  3. return
  4. }
  5. c := &sigctxt{info, ctx}
  6. g := sigFetchG(c)
  7. ...
  8. sighandler(sig, info, ctx, g)
  9. setg(g)
  10. if setStack {
  11. restoreGsignalStack(&gsignalStack)
  12. }
  13. }
  14. func sighandler(sig uint32, info *siginfo, ctxt unsafe.Pointer, gp *g) {
  15. _g_ := getg()
  16. c := &sigctxt{info, ctxt}
  17. ...
  18. if sig == sigPreempt && debug.asyncpreemptoff == 0 {
  19. doSigPreempt(gp, c)
  20. }
  21. ...
  22. }

sighandler 方法里面做了很多其他信号的处理工作,我们只关心抢占部分的代码,这里最终会通过 doSigPreempt 方法执行抢占。

这个函数在 runtime/signal_unix.go文件中:

doSigPreempt

  1. func doSigPreempt(gp *g, ctxt *sigctxt) {
  2. if wantAsyncPreempt(gp) {
  3. if ok, newpc := isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()); ok {
  4. ctxt.pushCall(funcPC(asyncPreempt), newpc)
  5. }
  6. }
  7. atomic.Xadd(&gp.m.preemptGen, 1)
  8. atomic.Store(&gp.m.signalPending, 0)
  9. }

函数会处理抢占信号,获取当前的 SP 和 PC 寄存器并调用 ctxt.pushCall修改寄存器,并调用 runtime/preempt.go 的 asyncPreempt 函数。

  1. func asyncPreempt()

asyncPreempt 的汇编代码在 src/runtime/preempt_amd64.s中,该函数会保存用户态寄存器后调用 runtime/preempt.go 的 asyncPreempt2 函数中:

asyncPreempt2

  1. func asyncPreempt2() {
  2. gp := getg()
  3. gp.asyncSafePoint = true
  4. if gp.preemptStop {
  5. mcall(preemptPark)
  6. } else {
  7. mcall(gopreempt_m)
  8. }
  9. gp.asyncSafePoint = false
  10. }

该函数会获取当前 G ,然后判断 G 的 preemptStop 值,preemptStop 会在调用 runtime/preempt.go的 suspendG 函数的时候将 _Grunning 状态的 Goroutine 标记成可以被抢占 gp.preemptStop = true,表示该 G 可以被抢占。

下面我们看一下执行抢占任务会调用的 runtime/proc.go的 preemptPark 函数:

preemptPark

  1. func preemptPark(gp *g) {
  2. status := readgstatus(gp)
  3. if status&^_Gscan != _Grunning {
  4. dumpgstatus(gp)
  5. throw("bad g status")
  6. }
  7. gp.waitreason = waitReasonPreempted
  8. casGToPreemptScan(gp, _Grunning, _Gscan|_Gpreempted)
  9. dropg()
  10. casfrom_Gscanstatus(gp, _Gscan|_Gpreempted, _Gpreempted)
  11. schedule()
  12. }

preemptPark 会修改当前 Goroutine 的状态到 _Gpreempted ,调用 dropg 让出线程,最后调用 schedule 函数继续执行其他 Goroutine 的任务循环调度。

gopreempt_m

gopreempt_m 方法比起抢占更像是主动让权,然后重新加入到执行队列中等待调度。

  1. func gopreempt_m(gp *g) {
  2. goschedImpl(gp)
  3. }
  4. func goschedImpl(gp *g) {
  5. status := readgstatus(gp)
  6. ...
  7. casgstatus(gp, _Grunning, _Grunnable)
  8. dropg()
  9. lock(&sched.lock)
  10. globrunqput(gp)
  11. unlock(&sched.lock)
  12. schedule()
  13. }

抢占信号发送

抢占信号的发送是由 preemptM 进行的。

这个函数在runtime/signal_unix.go文件中:

preemptM

  1. const sigPreempt = _SIGURG
  2. func preemptM(mp *m) {
  3. ...
  4. if atomic.Cas(&mp.signalPending, 0, 1) {
  5. signalM(mp, sigPreempt)
  6. }
  7. }

preemptM 这个函数会调用 signalM 将在初始化的安装的 _SIGURG 信号发送到指定的 M 上。

使用 preemptM 发送抢占信号的地方主要有下面几个:

  1. Go 后台监控 runtime.sysmon 检测超时发送抢占信号;
  2. Go GC 栈扫描发送抢占信号;
  3. Go GC STW 的时候调用 preemptall 抢占所有 P,让其暂停;

Go 后台监控执行抢占

系统监控 runtime.sysmon 会在循环中调用 runtime.retake抢占处于运行或者系统调用中的处理器,该函数会遍历运行时的全局处理器。

系统监控通过在循环中抢占主要是为了避免 G 占用 M 的时间过长造成饥饿。

runtime.retake主要分为两部分:

  1. 调用 preemptone 抢占当前处理器;
  2. 调用 handoffp 让出处理器的使用权;

抢占当前处理器

  1. func retake(now int64) uint32 {
  2. n := 0
  3. lock(&allpLock)
  4. for i := 0; i < len(allp); i++ {
  5. _p_ := allp[i]
  6. if _p_ == nil {
  7. continue
  8. }
  9. pd := &_p_.sysmontick
  10. s := _p_.status
  11. sysretake := false
  12. if s == _Prunning || s == _Psyscall {
  13. t := int64(_p_.schedtick)
  14. if int64(pd.schedtick) != t {
  15. pd.schedtick = uint32(t)
  16. pd.schedwhen = now
  17. } else if pd.schedwhen+forcePreemptNS <= now {
  18. preemptone(_p_)
  19. sysretake = true
  20. }
  21. }
  22. ...
  23. }
  24. unlock(&allpLock)
  25. return uint32(n)
  26. }

这一过程会获取当前 P 的状态,如果处于 _Prunning 或者 _Psyscall 状态时,并且上一次触发调度的时间已经过去了 10ms,那么会调用 preemptone 进行抢占信号的发送,preemptone 在上面我们已经讲过了,这里就不再复述。

从源码剖析Go语言基于信号抢占式调度 - luozhiyun - 博客园 - 图4

调用 handoffp 让出处理器的使用权

  1. func retake(now int64) uint32 {
  2. n := 0
  3. lock(&allpLock)
  4. for i := 0; i < len(allp); i++ {
  5. _p_ := allp[i]
  6. if _p_ == nil {
  7. continue
  8. }
  9. pd := &_p_.sysmontick
  10. s := _p_.status
  11. sysretake := false
  12. ...
  13. if s == _Psyscall {
  14. t := int64(_p_.syscalltick)
  15. if !sysretake && int64(pd.syscalltick) != t {
  16. pd.syscalltick = uint32(t)
  17. pd.syscallwhen = now
  18. continue
  19. }
  20. if runqempty(_p_) && atomic.Load(&sched.nmspinning)+atomic.Load(&sched.npidle) > 0 && pd.syscallwhen+10*1000*1000 > now {
  21. continue
  22. }
  23. unlock(&allpLock)
  24. incidlelocked(-1)
  25. if atomic.Cas(&_p_.status, s, _Pidle) {
  26. n++
  27. _p_.syscalltick++
  28. handoffp(_p_)
  29. }
  30. incidlelocked(1)
  31. lock(&allpLock)
  32. }
  33. }
  34. unlock(&allpLock)
  35. return uint32(n)
  36. }

这一过程会判断 P 的状态如果处于 _Psyscall 状态时,会进行一个判断,有一个不满足则调用 handoffp 让出 P 的使用权:

  1. runqempty(_p_) :判断 P 的任务队列是否为空;
  2. atomic.Load(&sched.nmspinning)+atomic.Load(&sched.npidle):nmspinning 表示正在窃取 G 的数量,npidle 表示空闲 P 的数量,判断是否存在空闲 P 和正在进行调度窃取 G 的 P;
  3. pd.syscallwhen+10*1000*1000 > now:判断是否系统调用时间超过了 10ms ;

Go GC 栈扫描发送抢占信号

GC 相关的内容可以看这篇:《Go 语言 GC 实现原理及源码分析 https://www.luozhiyun.com/archives/475》。Go 在 GC 时对 GC Root 进行标记的时候会扫描 G 的栈,扫描之前会调用 suspendG 挂起 G 的执行才进行扫描,扫描完毕之后再次调用 resumeG 恢复执行。

该函数在:runtime/mgcmark.go:

markroot

  1. func markroot(gcw *gcWork, i uint32) {
  2. ...
  3. switch {
  4. ...
  5. default:
  6. var gp *g
  7. if baseStacks <= i && i < end {
  8. gp = allgs[i-baseStacks]
  9. } else {
  10. throw("markroot: bad index")
  11. }
  12. ...
  13. systemstack(func() {
  14. ...
  15. stopped := suspendG(gp)
  16. if stopped.dead {
  17. gp.gcscandone = true
  18. return
  19. }
  20. if gp.gcscandone {
  21. throw("g already scanned")
  22. }
  23. scanstack(gp, gcw)
  24. gp.gcscandone = true
  25. resumeG(stopped)
  26. })
  27. }
  28. }

markroot 在扫描栈之前会切换到 G0 转交给 g0 进行扫描,然后调用 suspendG 会判断 G 的运行状态,如果该 G 处于 运行状态 _Grunning,那么会设置 preemptStop 为 true 并发送抢占信号。

该函数在:runtime/preempt.go:

suspendG

  1. func suspendG(gp *g) suspendGState {
  2. ...
  3. const yieldDelay = 10 * 1000
  4. var nextPreemptM int64
  5. for i := 0; ; i++ {
  6. switch s := readgstatus(gp); s {
  7. ...
  8. case _Grunning:
  9. if gp.preemptStop && gp.preempt && gp.stackguard0 == stackPreempt && asyncM == gp.m && atomic.Load(&asyncM.preemptGen) == asyncGen {
  10. break
  11. }
  12. if !castogscanstatus(gp, _Grunning, _Gscanrunning) {
  13. break
  14. }
  15. gp.preemptStop = true
  16. gp.preempt = true
  17. gp.stackguard0 = stackPreempt
  18. asyncM2 := gp.m
  19. asyncGen2 := atomic.Load(&asyncM2.preemptGen)
  20. needAsync := asyncM != asyncM2 || asyncGen != asyncGen2
  21. asyncM = asyncM2
  22. asyncGen = asyncGen2
  23. casfrom_Gscanstatus(gp, _Gscanrunning, _Grunning)
  24. if preemptMSupported && debug.asyncpreemptoff == 0 && needAsync {
  25. now := nanotime()
  26. if now >= nextPreemptM {
  27. nextPreemptM = now + yieldDelay/2
  28. preemptM(asyncM)
  29. }
  30. }
  31. }
  32. ...
  33. }
  34. }

对于 suspendG 函数我只截取出了 G 在 _Grunning 状态下的处理情况。该状态下会将 preemptStop 设置为 true,也是唯一一个地方设置为 true 的地方。preemptStop 和抢占信号的执行有关,忘记的同学可以翻到上面的 asyncPreempt2 函数中。

Go GC StopTheWorld 抢占所有 P

Go GC STW 是通过 stopTheWorldWithSema 函数来执行的,该函数在 runtime/proc.go:

stopTheWorldWithSema

  1. func stopTheWorldWithSema() {
  2. _g_ := getg()
  3. lock(&sched.lock)
  4. sched.stopwait = gomaxprocs
  5. atomic.Store(&sched.gcwaiting, 1)
  6. preemptall()
  7. _g_.m.p.ptr().status = _Pgcstop
  8. ...
  9. wait := sched.stopwait > 0
  10. unlock(&sched.lock)
  11. if wait {
  12. for {
  13. if notetsleep(&sched.stopnote, 100*1000) {
  14. noteclear(&sched.stopnote)
  15. break
  16. }
  17. preemptall()
  18. }
  19. }
  20. ...
  21. }

stopTheWorldWithSema 函数会调用 preemptall 对所有的 P 发送抢占信号。

preemptall 函数的文件位置在 runtime/proc.go:

preemptall

  1. func preemptall() bool {
  2. res := false
  3. for _, _p_ := range allp {
  4. if _p_.status != _Prunning {
  5. continue
  6. }
  7. if preemptone(_p_) {
  8. res = true
  9. }
  10. }
  11. return res
  12. }

preemptall 调用的 preemptone 会将 P 对应的 M 中正在执行的 G 并标记为正在执行抢占;最后会调用 preemptM 向 M 发送抢占信号。

该函数的文件位置在 runtime/proc.go:

preemptone

  1. func preemptone(_p_ *p) bool {
  2. mp := _p_.m.ptr()
  3. if mp == nil || mp == getg().m {
  4. return false
  5. }
  6. gp := mp.curg
  7. if gp == nil || gp == mp.g0 {
  8. return false
  9. }
  10. gp.preempt = true
  11. gp.stackguard0 = stackPreempt
  12. if preemptMSupported && debug.asyncpreemptoff == 0 {
  13. _p_.preempt = true
  14. preemptM(mp)
  15. }
  16. return true
  17. }

从源码剖析Go语言基于信号抢占式调度 - luozhiyun - 博客园 - 图5

总结

到这里,我们完整的看了一下基于信号的抢占调度过程。总结一下具体的逻辑:

  1. 程序启动时,在注册 _SIGURG 信号的处理函数 runtime.doSigPreempt;
  2. 此时有一个 M1 通过 signalM 函数向 M2 发送中断信号 _SIGURG
  3. M2 收到信号,操作系统中断其执行代码,并切换到信号处理函数runtime.doSigPreempt
  4. M2 调用 runtime.asyncPreempt 修改执行的上下文,重新进入调度循环进而调度其他 G;

从源码剖析Go语言基于信号抢占式调度 - luozhiyun - 博客园 - 图6

Reference

Linux 用户抢占和内核抢占详解 https://blog.csdn.net/gatieme/article/details/51872618

sysmon 后台监控线程做了什么 https://www.bookstack.cn/read/qcrao-Go-Questions/goroutine 调度器 - sysmon 后台监控线程做了什么. md

Go: Asynchronous Preemption https://medium.com/a-journey-with-go/go-asynchronous-preemption-b5194227371c

Unix 信号 https://zh.wikipedia.org/wiki/Unix 信号

Linux 信号 (signal) 机制 http://gityuan.com/2015/12/20/signal/

Golang 大杀器之跟踪剖析 trace https://juejin.cn/post/6844903887757901831

详解 Go 语言调度循环源码实现 https://www.luozhiyun.com/archives/448

信号处理机制 https://golang.design/under-the-hood/zh-cn/part2runtime/ch06sched/signal/#662-

从源码剖析Go语言基于信号抢占式调度 - luozhiyun - 博客园 - 图7
https://www.cnblogs.com/luozhiyun/p/14589730.html