协程
协程的概念已经出来很久了。但相比进程/线程,它可能没有那么耳熟。在学习一些新概念前,我喜欢用一些我熟知的概念去类比学习。我们可以把协程和线程以及函数去对比:
- 协程和函数对比,可以把协程理解为保存了运行上下文的函数。我们知道函数的调用是一次性的,当函数运行结束返回,它的栈帧也随之销毁,除非我们再一次调用该函数。而协程就是保存了运行上下文的函数,它可以随时保存上下文并退出运行开始执行其它协程
- 协程和函数对比,可以把协程理解为用户态轻量级线程,协程的创建、销毁调度都是在用户态去执行的,避免了从用户态到内核态的上下文切换,自然显得轻量,且协程栈相对来说也更小。
协程也分为好几种:
- 按照协程是否有单独的栈可以分为有栈协程/无栈协程
- 按照协程是否可以在任意的协程中调度可以分为对称协程/非对称协程
go是有栈对称协程,并且它的栈是动态可调整的。 当然我知道即使这么说了,你依然觉得不够直观。感兴趣的可以看一下这个源码,就能理解 C++实现的IO协程服务器框架sylar
被废弃的GM模型
groutine是N:M的线程模型。这里的G就是一个协程(用户态线程),M是一个内核线程。我们可以看到GM模型中有多个协程和多个内核线程。这是为了避免一对多的线程模型中,因为内核是不知道协程(用户态线程)的存在的,只要有一个协程阻塞了,该线程对应的其它协程也会无法运行。而N:M的线程模型就解决了这个问题。如果该线程阻塞,完全可以把其他的协程绑定到其它的线程上运行。但它也有它的缺点:
- M想要执行、放回G都必须访问全局G队列,并且M有多个,即多线程访问同一资源需要加锁进行保证互斥/同步,所以全局G队列是有互斥锁进行保护的。
- 创建、销毁、调度G都需要每个M获取锁,这就形成了激烈的锁竞争。
- M转移G会造成延迟和额外的系统负载。比如当G中包含创建新协程的时候,M创建了G’,为了继续执行G,需要把G’交给M’执行,也造成了很差的局部性,因为G’和G是相关的,最好放在M上执行,而不是其他M'。
- 系统调用(CPU在M之间的切换)导致频繁的线程阻塞和取消阻塞操作增加了系统开销。
GMP调度模型
面对之前调度器的问题,Go设计了新的调度器。在新调度器中,除了M(thread)和G(goroutine),又引进了P(Processor)。
- G: Goroutine,即我们在 Go 程序中使用
go
关键字创建的执行体; - M: Machine,或 worker thread,即传统意义上进程的线程;
- P: Processor,即一种人为抽象的、用于执行 Go 代码被要求局部资源。只有当 M 与一个 P 关联后才能执行 Go 代码。除非 M 发生阻塞或在进行系统调用时间过长时,没有与之关联的 P,可以把它当作一个逻辑上的处理器。
- 全局队列(Global Queue):存放等待运行的G。
- P的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建G'时,G'优先加入到P的本地队列,如果队列满了,则会把本地队列中一半的G移动到全局队列。
- P列表:所有的P都在程序启动时创建,并保存在数组中,最多有
GOMAXPROCS
(可配置)个。 - M:线程想运行任务就得获取P,从P的本地队列获取G,P队列为空时,M也会尝试从全局队列拿一批G放到P的本地队列,或从其他P的本地队列偷一半放到自己P的本地队列。M运行G,G执行之后,M会从P获取下一个G,不断重复下去。
GMP模型通过加入了逻辑处理器P,M只有获取到P之后才能运行G。自然的,它会优先从P的本地队列中获取G去执行,这样就避免了多个M访问全局队列造成的激烈锁的竞争。并且新创建的G'也会优先加到本地队列中,这样也考虑到了执行的局部性。
调度
用户态线程或者说协程往往是协助式调度。为什么呢?如果协程想要实现抢占式调度,由谁来实现呢?那必然会有一个调度协程。我们目标是某个线程下的协程阻塞之后由调度协程让它让出cpu,但问题是该线程已经阻塞了,又如何切换到调度协程来调度呢?这就变成了一个鸡生蛋蛋生鸡的问题。 go语言不仅有协作式调度,它还实现了抢占式调度。它是怎么实现的呢?我们知道内核是通过中断来实现抢占式调度的,避免一个线程占用了太多的时间片。而在用户空间,我们可以通过进程之间的异步通知机制:信号来实现。