简介
Go中的协程叫做goroutine,Go通过协作式的调度机制,将一组可复用的函数运行在一组系统线程之上,实现高效的并发。
GMP模型的组成
Go中goroutine的调度由运行时负责,Go运行时包含一个协作式调度器,GMP模型是这个协作式调度器的核心机制。GMP分别代表模型中的三种成员:
- G:goroutine,Go中的轻量级线程,每个G包含要执行的函数、栈等信息。
- M:machine,内核线程,Go运行时的工作线程,由操作系统调度。
- P:processor,处理器,负责执行Go调度器的逻辑,调度G到M上运行,每个P维护一个可运行G的队列。
除此之外,GMP模型中有一个全局队列:
- 全局队列:维护一个G队列,存放等待运行的G。新建G时,优先存放到P的本地队列,如果P的队列满了,会把P的本地队列的一半的G移动到全局队列。
GMP模型设计思想
先了解设计策略,再理解工作流程和原理,GMP有5个比较重要的设计策略:
- work stealing机制:本线程M没有可运行的G时,尝试从其它线程绑定的P偷取G,而不是销毁线程。复用线程,避免频繁创建。
- hand off机制:本线程M因为某个G进行系统调用阻塞时,线程M会释放绑定的P,把P转移给其它空闲的线程进行。线程M就可以执行其队列中的其它G,避免阻塞。
- 并行:GOMAXPROCS可以设置P的数量,最多由GOMAXPROCS个线程在CPU上运行,既利用了并行,又限制了并发程度。
- 抢占:Go的调度器是协作式的,为了防止其它goroutine被饿死,调度器添加了抢占的思想,一个goroutine最多占用CPU10ms。
- 全局队列:存放多出来的G,并且M的work stealing从其他P偷不到G,可以从全局队列获取G。
GMP模型工作流程
先弄清楚P和M的数量和创建时机,然后分析一个多协程Go程序的工作流程。
P和M的数量和创建时机
Go的二进制程序启动时会创建固定数量的P,这个固定数量由3个因素确定,优先级从大到小依次为
- runtime的GOMAXPROCS方法:在Go程序中指明P的数量为n,二进制运行时会默认创建n个P。
- 环境变量GOMAXPROCS:有Go编程环境,或者主动设置这个环境变量的话,二进制运行时会默认创建$GOMAXPROCS个P。
- CPU核心数:自动检测CPU核心数n,二进制运行时默认创建n个P。(比如构建的二进制文件到无Go环境的服务器上运行)
M的数量有上限的,通过GOMAXTHREADS设置,但是一般不会达到这个限制。M是按需创建、动态分配的,我们需要重点关注M的创建时机,有两个原则:
- 复用原则:Go运行时会维护一个线程池。如果一个线程M执行完了队列中的G,变为空闲状态,不会立即销毁,而是回到线程池中。程序需要新的线程的时候,优先复用线程池中空闲的M,如果线程池中没有足够的M,才会真正的创建M。
- 创建原则:如果没有足够的M关联P并且运行其中可运行的G,线程池中也没有空闲线程,这时候会创建新的线程M。
主程序启动
在描述主程序启动前,需要了解G0的作用,它是每次启动一个M都会第一个创建的goroutine,用于执行调度器相关的任务。每一个M都有一个专属的G0,不会在其它M上运行,而且只会在调度器需要时被切换出来运行。
一个多协程主程序启动包含以下步骤:
- runtime 启动:入口函数是runtime.main(),会进行一系列初始化操作
- 创建主线程M0和对应的goroutine G0,M0负责执行初始化和启动第一个G,之后就和普通的M一样。
- 调度器初始化:初始化M0、垃圾回收和所有P列表。
- 为runtime.main创建一个goroutine,加入到一个P本地队列中,然后和M0绑定。
- M0从P的本地队列获取G:即runtime.main对应的goroutine。M根据G中的栈信息和调度信息设置运行环境,运行G。
- main.main(我们写的主函数)被调用:runtime.main会调用main.main,这个runtime.main对应的goroutine会执行main.main。
- main.main创建协程:我们写的主函数会创建goroutine,即新的G,会优先放到当前P队列,满了的话会放入全局队列。如果其它的P中没有G,这个G会被work stealing到其它P里面,寻找或者创建新的线程M运行。
- main.main退出:runtime.main对应的goroutine继续执行Defer和Panic处理,或者runtime.exit退出程序。如果没有使用适当的同步机制,例如WaitGroup、channel、sleep等,即使还有没执行完的G,程序也会退出。