本文不讨论通过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个线程。