go 协程调度

本文不讨论通过channel、sync等实现协程调度的具体写法,只是简单聊聊协程调度这件事儿。

非抢占式调度

go语言的协程调度是一种非抢占式的调度,何为非抢占式?我们先说抢占式。

我们知道操作系统的线程调度,会使用时间片轮转的方式调度各个线程,当时间片需要从线程A交给线程B时,不管线程A处于什么状态,都会先停掉。这种调度方式下会经常出现一种情况,就是线程A正在做一件事做到一半的时候,时间到了,为了保证下一次时间片轮到它的时候,可以继续做刚才的事儿,线程A需要把当前的状态存到寄存器里,那么问题也就很明显了,存寄存器这个操作很费时间,这就是抢占式的一个缺点。

而go的非抢占式呢,指的就是不会有人强行停掉一个协程去执行另一个协程,协程会主动让出CPU。怎么个让法呢?自然不是随意让,让的目的就是避免切换协程的时候需要把一堆状态存入寄存器,所以协程会在干完一件事儿的时候再让出CPU,这样就不需要存什么东西了。

编译时调度

那么怎么知道协程什么时候干完一件事儿呢?如果运行时去确定这件事儿,显然也需要额外的代价,所以go语言是在编译的时候确定的,也就是说程序运行的时候,协程之间怎么切换,在编译的时候就计划好了。这就是go从语言层面实现协程所带来的好处。

切换点

下面给出几种协程可能切换的情况:

  • io,比如print,print完,就可以认为干完了一件事儿
  • 函数调用,可以理解为调用函数的时候是要去做另一件事儿了,所以这时候可以切换
  • channel,sync,runtime.Gosched(),这些本来就是多协程用的东西,就不说了

注意上面几种,只是可能切换,不是一定切换,也就是说不是每次print完就一定切换到另一个线程去,go会根据实际代码决定更合适的切换时机。

这块其实可以看个小例子,下面这段代码,开了10个协程,但其实是11个,我们一定不能忘了主协程也是一个协程,这点很重要,因为主协程一旦退出,其他协程也都会被杀死,这也是为什么下面的程序中,我们在最后让主协程sleep了1秒,如果不sleep,还不等主协程把时间片让出去,主协程就直接运行完退出了,其他10个协程也就没有登场机会了。

package main

import (
	"fmt"
	"time"
)

func main()  {
	for i := 0; i < 10; i++ {
		go func(i int) {
			for {
				fmt.Println(i)
			}
		}(i)
	}
	time.Sleep(time.Second)
}

程序的运行结果很多,我们只留取最后一小段,就足以说明问题了。

可以看到,截取的这段,发生了一次协程切换,这次切换是在打印3的协程打印完多个3之后发生的,也就是说,协程切换确实发生在了print之后,但并不是每一次print都会切换。其实也很好理解,虽然go的协程切换比线程切换开销要小,但终归还是有开销的,所以还是不能说切就切。

我们换种写法再试一次。

package main

import (
	"time"
)

func main()  {
	for i := 0; i < 10; i++ {
		go func(i int) {
			for {
				i++
			}
		}(i)
	}
	time.Sleep(time.Second)
}

这段程序同样开了10个协程,不同的时,协程里没有了print,或者说没有了任何可以被go语言编译器认为是协程切换的点,因此这段的运行结果就是,永远不会退出,因为只要进了某一个协程,就会一直i++,不会切换。实际写代码的时候,我们自然要避免写出这种情况。

线程

最后,想说一下go语言默认的线程使用方式,我们知道一个线程里可以有多个协程,那么当我们真的同时运行多个协程的时候,到底会用几个线程呢?

答案是,CPU有几个核,就用几个线程。我们知道,虽然单核可以运行多线程,但是多个线程并不是真正的并发,而是在CPU的调度下依次运行。对于go来说,线程调度是没有意义的,因为go已经从语言层面对多个协程做好了合适的调度。因此,即使一个程序有1000个并发协程,如果CPU只有4核,那么程序运行起来最多也只会用4个线程。

发布了39 篇原创文章 · 获赞 25 · 访问量 12万+

猜你喜欢

转载自blog.csdn.net/u013536232/article/details/104892205