goroutine和线程的区别
从三个角度分析:内存消耗、创建和销毁、切换
内存消耗
- 创建一个goroutine的栈消耗为2KB或4KB,在实际运行中,如果栈空间不够,会自动进行扩容。
- 创建一个线程需要1MB
创建和销毁
- goroutine是用户级的,是由go runtime控制创建和销毁的,代价非常小
- 线程要和操作系统打交道,是内核级的,线程创建和销毁都会产生巨大的消耗。所以我们要是使用线程,一般都会使用线程池,尽量复用
切换
- goroutine是用户级的,切换时只涉及到CPU上下文的切换,所谓的CPU上下文,就是一堆寄存器,里面保存了CPU运行任务需要的信息。所以,go协程切换非常简单,只需要把当前CPU寄存器状态保存起来,然后把切换进来的CPU寄存器加载上就行了。
- 线程是内核级的,线程的切换涉及到用户空间和内存空间的切换,然后需要操作系统调度模块完成线程调度。谢忱刚切换时除了要保存和协程切换时的CPU上下文时,还要保存线程私有的栈和寄存器。也就是说,要保存的上下文比协程更多。
- 一般协程切换只需要200ns,而协程需要1000 - 1500ns
M:N模型
当程序执行后,按需创建N个线程,之后创建M个协程依附在这N个线程上。这样做协程可以再用户态就完成切换,不会进入内核态。
G、M、P分别代表什么
- G取的goroutine的首字母,保存了goroutine的一些状态信息和CPU的寄存器状态
- M去machine首字母。就是工作进程,G只有调度到M上才能执行,M是真正工作的实体
- P就是Processor首字母。为M的执行提供上下文,保存M执行G的一些资源,拥有调度Goroutine的能力
它们的关系是互相依赖的,M必须拥有P才能执行G中的代码,P中有一个包含多个G的队列,P可以调度G交给M执行
Goroutine调度策略
队列轮转
- 每个p都维护一个G队列,在不考虑G进入系统调用或者IO的情况下,P会周期性的将G调度到M中执行。执行一段时间,将上下文保存,然后将这个G放到队列尾部,从队列重新取出一个G进行调度
- 除了P维护的一个G队列之外,p还会周期性的查看队列中是否有G待运行并调度到M中执行。全局队列中的G,大部分是从系统调用中恢复的G,之所以p会周期性的查看全局队列,是为了防止全局队列中的G饿死
系统调用
- G发生系统调用或者阻塞时,M会释放自己绑定的P。让某个空闲的M1去获取p,继续执行P队列中剩下的G。这个M1的来源可能是来自M的缓冲池,也可能是新建的
- 当G的系统调用结束时,M会尝试获取一个P
- 如果有空闲的P,将会尝试获取,继续执行这个G
- 如果没有空闲的P,将Go放入全局队列中,等待被其他的P调用,然后这个M会进入缓冲池休眠
工作量窃取
- 当线程绑定的P中的G全部执行完,就会尝试从全局队列获取G
- 或者从其他的P中偷取P,一次偷一半
M0和g0
- M0就是系统创建的第一个系统线程,也叫作主线程。是在进程启动时系统创建的,其他后续的M都是go Runtime自行创建的。负责的就是初始化和启动的工作
- g0就是M第一个创建的goroutine,g0仅仅负责调度g,不指向任何函数。每一个M都只有一个g0
怎么控制M和P的数量
- P的数量通过 runtime.GOMAXPROCS()控制
- M的数量通过 runtime/debug包的,SetMaxThread()控制