golang 如何调度你的程序的

1,888 阅读5分钟

当你写下如下程序的时候,电脑的是如何执行你的程序的?

carbon (1).png 是执行第一个go func的死循环,还是打印"hello go"?

goroutine

go可以轻轻松松并发上万,这个得益于goroutine。在最初的计算机时代,只有进程,进程的缺点比较明显,太重,fork一个进程40M内存,消耗的资源比较多。后来出现了线程,初始化一个线程应该是比一个进程要少一个数据量级的内存,轻量的多。相比进程,线程并发要好的多,但是线程的切换也是要陷入内核的,频繁切换的话,这个开销还是比较大的,cpu要保护现场,暂存当下这个线程的运行状态等等。如果线程很多,那么cpu浪费在上下文切换的时间就太多了。假设线程A在用户态执行a+b,也没发生系统调用,这时切到线程B,也是要由用户态切到内核态的。

image.png 后来出现coroutine协程,用户协作。相比线程的好处就是多个协程可以关联一个线程,避免创建过多的线程,并且一个协程执行完后,可以通知执行下一个协程,这样的切换是不需要切到内核态的,在用户态即可完成。

image.png

goroutine起源也是来自协程,自然拥有协程的一切好处,当然有比常规的协程更好的地方,创建goroutine只需要2k的内存,线程的切换大概会消耗1000-1500纳秒,一个纳秒可以执行12-18条指令。goroutine的切换只需要200纳秒左右,相比线程可以至少节约5倍的时间。一个os级线程可以绑定多个goroutine,当要发生goroutine切换时,只需要把剔走当前正在执行的goroutine,不需要线程切换。在go1.14后,goroutine支持信号抢占式调度(一个goroutine不会一直占用cpu,在执行超过一段时间后,让出)。

image.png

GMP

image.png

  • G:一个goroutine
  • P:处理器,go程序启动时设定,默认gomaxprocs个,多少个p,就决定同时能并行执行的goroutine
  • M:os线程,真正执行代码的线程
    三者关系:当M拥有P,且P拥有G,那么M就能执行G。

调度算法

image.png

  • 程序启动时,先初始化和核数一样的P。
  • 当创建一个goroutine时,优先尝试放在本地队列,如果本地队列满了,则会把本地队列的前半部分和这个新的goroutine一起移到全局队列中。
  • 如果没有可用的P的时候,新goroutine加入全局队列中。
  • 如果获取到空闲的P,那么尝试去唤醒一个M,没有可用的M的时候新建一个M。
  • 当M关联上P时,且local队列有任务时,可以一直从p的local队列中取goroutine执行。
  • 当P的local队列中没有goroutine时,则会尝试从全局队列中拿一部分放在本地队列中,这个过程是加锁的。
  • 当从全局对列没取到时,会尝试从其他的P的local队列偷取一半放在自己的本地队列中
  • 当一个G发生系统调用的时候,P会断开与当前的M的关系,尝试从M的空闲队列中获取一个M来继续执行剩下的goroutine。
  • 当上面的G系统调用结束后,M尝试获取一个P来继续执行,如果没获取到,则会把这个g放到全局队列中,并且自己进入M的空闲队列。这里不是销毁M,避免后面又要创建M,造成不必要的开销。

goroutine的好处在于每个g不会一直占有m。
go在1.14版本后,做了个很大的改进,基于信号抢断式的调度。
每个M在初始化的时候,都会注册一个可以接收sigurg信号的handler。这个sigurg信号是由一个sysmon的监视器发出的,sysmon单独占用一个M,sysmon每隔一段时间会去检查goroutine是否执行超过10ms 或者是否执行GC(STW),如果满足条件sysmon就会给对应的M发送sigurg信号,对应的handler开始执行,将正在执行的G打上标识,然后在检查当前栈是否溢出的地方(morestack)判断,符合条件后M会保存当前G的上下文(如果下次这个G还能被这个M执行,通过上下文就可以迅速回复到上次执行的位置),并且当前G会被丢到全局G队列中,同时M继续执行下一个G。

image.png

如何执行?

我们回到一开始的那道题

func main() {
  runtime.GOMAXPROCS(1)
     go func() {
	 for {
           }
	}()
    time.Sleep(time.Second)
    fmt.Println("hello go!")
}

程序的一开始设置p=1,那么就不存在并行的问题,同一时刻只能有一个goroutine执行,要么是go func的死循环,要么是主goroutine的time.sleep及以后。

  • go1.14以下的版本会死循环,卡住,因为,即使主goroutine抢到了P,也会因为sleep,让出cpu,继而go func开始无限执行。
  • go1.14及以上版本,因为有sysmon抢占,即使第一个go func抢到了P,也会因为执行超过10ms,被踢出P的local队列。执行主goroutine的打印,然后退出,不会卡住。