go语言为什么能变的如此受欢迎,很大一个原因是其原生支持高并发,而这核心来自goroutine的设计,而对goroutine的管理则离不开go的调度器。

调度的定义

要理解调度,就必要先理解操作系统、进程、线程等调度的基本概念。

  • 操作系统:一种计算机程序,管理硬件与软件资源,计算机系统的"底层软件",如windows,macOS,Linux
  • 进程:操作系统下程序运行单位,有独立的内存,由内核调度
  • 线程:操作系统调度最小单元,一般属于进程,共享进程的内存与资源,由内核调度(比进程轻量)
  • 协程:用户级线程,不由内核调度,通过程序员显示的调度(比线程轻量)
  • 调度:调度本质上就是一个资源分配算法。

Go 调度设计

goroutine 和线程的关系即一个main函数创建一个操作线程——主线程,之后会由go的调度模型来负责创建和管理 goroutine 分配到具体哪个线程.

历史

  1. 最简单的调度器(Go 0.x):单线程 G-M:缺陷是程序只能存在一个活跃线程
  2. 改进为多线程(Go 1.0):G-M:互斥锁带来的开销
  3. 进阶版本(Go 1.2 至今):G-P-M:多了一层P抽象

G-P-M

  • 定义:src/runtime/runtime2.go

    • G:goroutine,需要绑定一个 m 来运行

      type g struct {
        ...
        m           *m      // current m; offset known to arm liblink
        preempt     bool    // 抢占标记
        stackguard0 uintptr // 
        ...
      }
      
    • P:processor, a resource that is required to execute Go code.中间层,G绑定到P才能被M调度

      type p struct {
        ...
        runq     [256]guintptr // 环形队列
        ...
      }
      
    • M:worker thread, or machine.操作系统抽象线程,负责调度,从P获取G执行

      type m struct {
      g0      *g     // goroutine with scheduling stack
      ...
      curg          *g       // current running goroutine
      p             puintptr // attached p for executing go code (nil if not executing go code)
      nextp         puintptr
      oldp          puintptr // the p that was attached before executing a syscall
      
      ...
      }
      
  • 调度模型

调度模型

  • 调度流程:src/runtime/proc.go
  1. 宏观白话文:M和P绑定,不断地从P的队列里面取出G运行,当P的队列没有G了,工作狂M还会从global队列获取G(转移一部分到P的队列)来执行,如果global也没有,M还会从其他的P窃取。
  2. show me the code: 核心代码就是如何获取g,findRunnable
// 找到一个可运行的 goroutine 来执行。
// 尝试从其他 P 窃取,从本地或全局队列中获取 g,轮询网络。
// tryWakeP 表示返回的goroutine不正常(GC worker, trace reader) 所以调用者应该尝试唤醒一个 P。
func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
  _g_ := getg()
top:
	_p_ := _g_.m.p.ptr()
  ... // 忽略 gc 相关
  // local runq
	if gp, inheritTime := runqget(_p_); gp != nil {
		return gp, inheritTime, false
	}
  // global runq
	if sched.runqsize != 0 {
		lock(&sched.lock)
		gp := globrunqget(_p_, 0)
		unlock(&sched.lock)
		if gp != nil {
			return gp, false, false
		}
	}
  ... // 忽略网络轮询器相关
  // Spinning Ms:窃取其他 Ps 的工作。
  // 限制旋转 Ms 的数量为忙碌 Ps 数量的一半。
  // 这是防止 CPU 消耗过多的必要条件
  // GOMAXPROCS>>1 但程序并行度低。
	procs := uint32(gomaxprocs)
	if _g_.m.spinning || 2*atomic.Load(&sched.nmspinning) < procs-atomic.Load(&sched.npidle) {
		if !_g_.m.spinning {
			_g_.m.spinning = true
			atomic.Xadd(&sched.nmspinning, 1)
		}

		gp, inheritTime, tnow, w, newWork := stealWork(now)
		now = tnow
		if gp != nil {
			// Successfully stole.
			return gp, inheritTime, false
		}
		if newWork {
			// There may be new timer or GC work; restart to
			// discover.
			goto top
		}
		if w != 0 && (pollUntil == 0 || w < pollUntil) {
			// Earlier timer to wait for.
			pollUntil = w
		}
	}
  ... // 实在无事可做,drop P
}

阻塞与抢占

阻塞是指当前的G因为一些操作,如系统调用,需要等待,此时M未被利用情况

抢占是指一个G在没有阻塞情况下执行占用M的时间过长,其他G得不到执行,其他G需要抢占M来获得执行

抢占式调度

操作系统的调度是时间片,而go没有该设计,go是通过sysmon的监控程序来实现的。

  • sysmon: src/runtime/proc.go:5134

    // 不需要绑定P运行
    func sysmon() {
      ...
      for {
        // 20us - 10ms 唤醒一次
        usleep(delay)
        ...
        // 重新获取在系统调用中阻塞的 P
        // 并抢占长时间运行的 G
    		if retake(now) != 0 {
    			idle = 0
    		} else {
    			idle++
    		}
        ...
      }
    }
    
    func retake(now int64) uint32 {
      ...
        s := _p_.status
    		sysretake := false
    		if s == _Prunning || s == _Psyscall {
    			// 如果P运行时间过长则抢占
    			t := int64(_p_.schedtick)
    			if int64(pd.schedtick) != t {
    				pd.schedtick = uint32(t)
    				pd.schedwhen = now
    			} else if pd.schedwhen+forcePreemptNS <= now {
    				preemptone(_p_) // 在 goroutine 上标记 gp.preempt = true
    				// In case of syscall, preemptone() doesn't
    				// work, because there is no M wired to P.
    				sysretake = true
    			}
    		}
        ...
    }
    

阻塞情况下的调度

  • syscall

    如果G被阻塞在某个system call操作上,那么不光G会阻塞,执行该G的M也会解绑P(实质是被sysmon抢走了),与G一起进入sleep状态。如果此时有idle的M,则P与该M绑定继续执行其他G;如果没有idle M,但仍然有其他G要去执行,那么就会创建一个新M。当阻塞在syscall上的G完成syscall调用后,G会去尝试获取一个可用的P,如果没有可用的P,那么G会被标记为runnable,之前的那个sleep的M将再次进入sleep。

  • channel 或 network I/O

    如果G被阻塞在某个channel操作或network I/O操作上时,G会被放置到某个wait队列中,而M会尝试运行下一个runnable的G;如果此时没有runnable的G供m运行,那么m将解绑P,并进入sleep状态。当I/O available或channel操作完成,在wait队列中的G会被唤醒,标记为runnable,放入到某P的队列中,绑定一个M继续执行。

其他的调度方式

  • NUMA调度提议

###调度器在Go中如何运行

要理解调度器,仅仅知道G-P-M还不够,还需要了解完整的调度流程,调度器的初始化、goroutine的添加等等,这一块会再去读一读源码,理解一下。

在工作中的使用

工作中总是充满了CURD,我常常迷惑于了解了这些知识有什么作用,我又该如何应用。

  1. 了解调度,我们才能知道该如何在应用中去使用 goroutine,goroutine 虽然轻量,但也不是越多越好。

  2. 遇到一些问题时,我们可以通过查看调度来思考代码的优化方案。

  3. 如何查看go调度器:通过GODEBUG运行时环境变量的schedtrace=1000参数,可以每隔1000ms查看一次调度器状态。

    GODEBUG=schedtrace=1000 ./main
    打印
    SCHED 1007ms: gomaxprocs=4 idleprocs=4 threads=9 spinningthreads=0 idlethreads=5 runqueue=0 [0 0 0 0]
    SCHED 2011ms: gomaxprocs=4 idleprocs=4 threads=9 spinningthreads=0 idlethreads=5 runqueue=0 [0 0 0 0]
    
    参数含义
    gomaxprocs: P的数量;
    idleprocs: 处于idle状态的P的数量;通过gomaxprocs和idleprocs的差值,我们就可知道执行go代码的P的数量;
    threads: os threads的数量,包含scheduler使用的m数量,加上runtime自用的类似sysmon这样的thread的数量;
    spinningthreads: 处于自旋状态的os thread数量;
    idlethread: 处于idle状态的os thread的数量;
    runqueue: go scheduler全局队列中G的数量;
    [0 0 0 0]: 分别为4个P的local queue中的G的数量。
    

总结

  1. go 调度经历过几个版本,很巧妙的地方就是中间层P的抽象,很多设计的核心都很简单,但是其最迷人的地方就是抽象。
  2. 很多文章都讲过G-P-M调度,内容其实大同小异,在写这篇博文的时候,去翻看了源码,其实这些东西都在代码里,且有很好的注释。

相关资料