并发问题的解决思路以及Go语言调度器工作原理( 三 )


在大多数情况下 , 我们都会使用 Go 的默认设置 , 也就是活跃线程数等于 CPU 个数 , 在这种情况下不会触发操作系统的线程调度和上下文切换 , 所有的调度都会发生在用户态 , 由 Go 语言调度器触发 , 能够减少非常多的额外开销 。
操作系统线程在 Go 语言中会使用私有结构体 runtime.m 来表示
type m struct {g0*gcurg *g...}其中 g0 是持有调度栈的 goroutine  ,  curg 是在当前线程上运行的用户 goroutine  , 用户 goroutine 执行完后 , 线程切换回 g0 上 ,  g0 会从线程 M 绑定的 P 上的等待队列中获取 goroutine 交给线程 。
P调度器中的处理器 P 是线程和 goroutine 的中间层 , 它能提供线程需要的上下文环境 , 也会负责调度线程上的等待队列 , 通过处理器 P 的调度 , 每一个内核线程都能够执行多个 goroutine  , 它能在 goroutine 进行一些 I/O 操作时及时切换 , 提高线程的利用率 。因为调度器在启动时就会创建 GOMAXPROCS 个处理器 , 所以 Go 语言程序的处理器数量一定会等于 GOMAXPROCS  , 这些处理器会绑定到不同的内核线程上并利用线程的计算资源运行 goroutine。
此外在调度器里还有一个全局等待队列 , 当所有P本地的等待队列被占满后 , 新创建的 goroutine 会进入全局等待队列 。P 的本地队列为空后 ,  M 也会从全局队列中拿一批待执行的 goroutine 放到 P 本地的等待队列中 。
GMP模型图示

并发问题的解决思路以及Go语言调度器工作原理

文章插图
 
GMP模型图示
  • 全局队列:存放等待运行的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 , 不断重复下去 。
  • goroutine 调度器和OS调度器是通过M结合起来的 , 每个M都代表了1个内核线程 , OS调度器负责把内核线程分配到CPU上执行 。
调度器的策略调度器的一个策略是尽可能的复用现有的活跃线程 , 通过以下两个机制提高线程的复用:
  1. work stealing机制 , 当本线程无可运行的G时 , 尝试从其他线程绑定的P偷取G , 而不是销毁线程 。
  2. hand off机制 , 当本线程因为G进行系统调用阻塞时 , 线程释放绑定的P , 把P转移给其他空闲的线程执行 。
Go 的运行时并不具备操作系统内核级的硬件中断能力 , 基于工作窃取的调度器实现 , 本质上属于先来先服务的协作式调度 , 为了解决响应时间可能较高的问题 , 目前运行时实现了协作式调度和抢占式调度两种不同的调度策略 , 保证在大部分情况下 , 不同的 G 能够获得均匀的 CPU 时间片 。
协作式调度依靠被调度方主动弃权 , 系统监控到一个 goroutine 运行超过10ms会通过 runtime.Gosched 调用主动让出执行机会 。抢占式调度则依靠调度器强制将被调度方被动中断 。




推荐阅读