线程模型的三种实现方式:
用户级线程:M:1对应关系,多个用户态线程对应着一个内核线程,用户态线程的创建、终止、切换、同步等线程工作必须由自身来完成。
内核级线程:1:1对应关系,直接调用操作系统的内核线程,所有线程的创建、终止、切换、同步等操作都由内核线程来完成。
两级线程:M:N对应关系,这种线程模型会先创建多个内核级线程,然后用自身的用户级线程去对应创建的多个内核级线程,自身的用户级线程需要本身程序去调度,内核级的线程交给操作系统内核去调度。
go实现了特殊的两级线程模型,用户调度器实现用户线程到KSE(kernel scheduling entity)的调度,内核线程实现KSE到cpu的调度。
go线程模型GPM
G: goroutine, 类似于操作系统中的进程控制块,主要包含以下信息:
- 函数指令及参数
- 栈信息
- 线程上下文信息(goroutine切换时,用于保存 g 的上下文,例如,变量、相关信息等)
- 现场保护和现场恢复需要的寄存器(SP、IP)
- 当前执行的m
- 被阻塞的时间
P: processor, 逻辑处理器,是一个抽象的概念,并不是物理CPU。保存一个队列G, P/M需要进行绑定形成一个执行单元。P决定了可以同时并发的任务数量。可以通过runtime.GOMAXPROCS进行指定(最大256),Go1.5之后GOMAXPROCS被默认设置可用的核数。主要包含以下信息:
- 状态(空闲,运行中)
- 关联的m
- 可运行的goroutine队列 (也称本地队列)
- 下一个g
M: machine, 操作器,是一个线程,用于将一个 G 搬到线程上执行。所有的M是有线程栈的
- g0 *g : 所有调用栈的Goroutine, m专属的线程栈,(普通的goroutine是在heap上分配的可增长的stack,因为 goroutine 可以越开越多)
- curg *g : 当前运行在m上的g
- 状态(空闲,运行中)
- 关联的p
- SP、PC寄存器用于现场保护和现场恢复
LRQ: 本地队列,每个p独有的队列,本地队列是lock-free,没有数据竞争问题,无需加锁,可以提升处理速度。
GRQ:全局队列,所有m共享的全局队列。可以保证p之间任务的平衡。由于有数据竞争问题,需要加锁,处理速度低于本地队列。
go调度器调度过程
首先创建一个g, 保存到p本地队列或者全局队列。然后p去唤醒一个m, p继续执行自己的执行序,m去找是否有空闲的p,找到后将p上面的一个g搬运到本身,最后执行一个调度循环(调用g对象,执行-清理线程-继续寻找新的g对象)
线程清理
Goroutine被调度执行必须保证P/M进行绑定,所以线程清理只需要将P释放(解除绑定)就可以实现线程的清理。什么时候P会释放,保证其它G可以被执行。P被释放主要有两种情况。
- 主动释放:最典型的例子是,当执行G任务时有系统调用,当发生系统调用时M会处于阻塞状态。调度器会设置一个超时时间,当超时时会将P释放。
- 被动释放:如果发生系统调用,有一个专门监控程序,进行扫描当前处于阻塞的P/M组合。当超过系统程序设置的超时时间,会自动将P资源抢走。去执行队列的其它G任务。
上下文切换
简单理解为当时的环境即可,环境可以包括当时程序状态以及变量状态。
对于代码中某个值说,上下文是指这个值所在的局部(全局)作用域对象。相对于进程而言,上下文就是进程执行时的环境,具体来说就是各个变量和数据,包括所有的寄存器变量、进程打开的文件、内存(堆栈)信息等。
Go调度器根据事件进行上下文切换。
Go调度器根据事件进行上下文切换。
- go关键字,创建goroutine
- gc垃圾回收,gc也是goroutine,所以需要时间片
- system call系统调用,block当前G
- sync同步,block当前G
异步调用
Linux可以通过epoll实现网络调用,统称网络轮询器N(Net Poller)。
- G1在M上运行,P的LRQ有其他3个G,N空闲;
- G1进行网络IO,因此被移动到N,M继续从LRQ取其他的G执行。比如G2就被上下文切换到M上;
- G1结束网络请求,收到响应,G1被移回LRQ,等待切换到M执行。
同步调用(如文件I/O)
- G1在M上运行,P的LRQ有其他3个G,G1进行同步调用阻塞M1;
- 调度器接入,将M1与p分离,此时M1只带了一个G1;
- 调度器引入新的M2(新创建或已经存在的),将M2与P进行绑定,继续执行P的LRQ上的G2;
- G1结束堵塞操作,移回LRQ。M1空闲备用。
任务窃取(负载均衡思想)
- P1,P2两个P, P1的LRQ上的队列已执行完,P1的LRQ为空,P1就开始任务窃取;
- 如果P2的LRQ不为空,P1就从P2的LRQ上拿一半移动到自己;
- 如果P2的LRQ也为空,P1从GRQ窃取。
详细流程
基本流程和上面一样。每创建出一个 g,优先创建一个 p或者引入一个空闲的p 进行存储,当 p 达到限制后,则加入状态为 waiting 的队列中。
如果 g 执行时需要被阻塞,则会进行上下文切换,系统归还资源后,再返回继续执行。
当一个G长久阻塞在一个M上时,runtime会新建一个M,阻塞G所在的P会把其他的G 挂载在新建的M上。当旧的G阻塞完成或者认为其已经死掉时,则回收旧的M(抢占式调度)。
P会对自己管理的goroutine队列做一些调度(比如把占用CPU时间较长的goroutine暂停、运行后续的goroutine等等)当自己的队列消费完了就去全局队列里取,如果全局队列里也消费完了会去其他P的队列里抢任务(所以需要单独存储下一个 g 的地址,而不是从队列里获取)。