前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Go调度系列--调度器实现原理(二)

Go调度系列--调度器实现原理(二)

原创
作者头像
小许code
发布2023-03-20 09:31:47
3640
发布2023-03-20 09:31:47
举报
文章被收录于专栏:小许code小许code

前言

Go 语言在并发编程方面有强大的能力,这离不开语言层面对并发编程的支持,Go调度的本质就是将 Goroutine (G)按照一定算法放到CPU上去执行。在上一篇我们已经知道了GMP各自代表的含义,三者之间的关系,今天从调度的角度去看Go是如何将三者之间进行协作的。

进程、线程、协程

讲Go的调度之前,我们对进程、线程、协程这些概念做个简单了解。

  1. 进程是系统分配系统资源基本单位
  2. 线程是CPU调度时的最基本单元
  3. 协程是一种用户态的轻量级线程,协程的调度完全由用户控制

多个线程可以属于同一个进程并共享内存空间。因为多线程不需要创建新的虚拟内存空间,所以它们也不需要内存管理单元处理上下文的切换,线程之间的通信也正是基于共享的内存进行的,与重量级的进程相比,线程显得比较轻量。

正如一个进程可以拥有多个线程一样,一个线程可以拥有多个协程。Go 语言的调度器通过使用与 CPU 数量相等的线程减少线程频繁切换的内存开销,同时在每一个线程上执行额外开销更低的 Goroutine。

编辑

添加图片注释,不超过 140 字(可选)

Go调度器原理

调度模型演化

Go调度其实本质就是将 Goroutine (G)按照一定算法放到CPU上去执行。因为线程是CPU调度的基本单位,而不是协程,所以Go调度器需要将Goroutine放到内核线程上去(M),然后操作系统调度器将内核线程放到CPU上去执行(这块其实是操作系统层的工作了)。

Go调度器也是经历了多次演化才有现在的版本:

编辑切换为居中

GMP模型演化

设计思想

  • 线程复用(work stealing机制和hand off机制)
  • 利用并行(利用多核CPU)
  • 抢占调度(解决公平性问题) – 基于信号的抢占,避免极端情况下造成的饥饿问题

编辑切换为居中

GMP设计思想

被调度对象

被调度对象其实就是GMP,它们的来源如下:

G

M

P

P的runnext(只有一个G,局部性原理,永远会被最先调度执行)

休眠线程队列(未绑定P,长时间休眠会等待GC回收销毁)

全局P队列(可手动设置,最多GOMAXPROCSG个P)

P的本地队列,数组,最多256个Goroutine

运行线程(绑定P,指向P中的G)

全局G队列,链表,无限制

自旋线程(绑定P,指向M的G0)

网络轮询器network poller(存放网络调用被阻塞的G)

调度器启动

从编译的角度看调度器启动过程有以下几步:

代码语言:javascript
复制
// The bootstrap sequence is:
//	call osinit
//	call schedinit
//	make & queue new G
//	call runtime·mstart
TEXT runtime·rt0_go(SB),NOSPLIT|TOPFRAME,$0
 ...
 CALL	runtime·osinit(SB)
 CALL	runtime·schedinit(SB)

 // create a new goroutine to start program
 MOVQ	$runtime·mainPC(SB), AX  // entry
 PUSHQ	AX
 CALL	runtime·newproc(SB)
 POPQ	AX
 // start this M
 CALL	runtime·mstart(SB)
 ...

1. 调用 runtime·osinit 来获取系统的cpu个数。

2. 调用 runtime·schedinit 来初始化调度系统,会进行p的初始化,也会把m0和某个p绑定。

3. 调用 runtime·newproc 新建一个goroutine,也叫main goroutine,它的任务函数是 runtime.main 函数,建好后插入到m0绑定的p的本地队列。

4. 调用 runtime·mstart 来启动m,进入启动调度系统。

调度策略

调度策略也叫做调度循环,进入调度系统后调用 mstart1 --> schedule()函数(都在src/runtime/proc.go) ,实际的调度逻辑就在schedule()函数中,它就是不断的获取G,然后执行G,而P充当了中间层,维护了P的本地队列,让M尽量能执行到G,源码如下:

代码语言:javascript
复制
// One round of scheduler: find a runnable goroutine and execute it
// 一个环形调度器:找到一个可运行的goroutine并执行它。
func schedule() {
    _g_ := getg()
 ...
 //gp是一个g的结构
    var gp *g
    var inheritTime bool
    ...
    if gp == nil {
        // 每执行61次调度循环会看一下全局队列。为了保证公平,避免全局队列一直无法得到执行的情况,当全局运行队列中有待执行的G时,通过schedtick保证有一定几率会从全局的运行队列中查找对应的Goroutine;
        if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
            lock(&sched.lock)
            gp = globrunqget(_g_.m.p.ptr(), 1)
            unlock(&sched.lock)
        }
    }
    if gp == nil {
        // 先尝试从P的runnext和本地队列查找G
        gp, inheritTime = runqget(_g_.m.p.ptr())
    }
    if gp == nil {
        // 仍找不到,去全局队列中查找。还找不到,要去网络轮询器中查找是否有G等待运行;仍找不到,则尝试从其他P中窃取G来执行。
        gp, inheritTime = findrunnable() // blocks until work is available
        // 这个函数是阻塞的,执行到这里一定会获取到一个可执行的G
    }
    ...
    // 调用execute,继续调度循环
    execute(gp, inheritTime)
}

从上面GMP的来源可以知道,由于 P 中的 G 分布可能在 runnext、本地队列、全局队列、网络轮询器中,则需要挨个判断是否有可执行的 G,大体逻辑如下:

  1. 每执行61次调度循环,从全局队列获取G
  2. 若有则直接返回从P 上的 runnext 看一下是否有 G
  3. 若有则直接返回 从P 上的 本地队列 看一下是否有 G
  4. 若有则直接返回 上面都没查找到时,则去全局队列、网络轮询器查找或者从其他 P 中窃取,一直阻塞直到获取到一个可用的 G 为止

而从全局队列队列获取G也有规则,实现代码如下:

代码语言:javascript
复制
func globrunqget(_p_ *p, max int32) *g {
   ...
   // gomaxprocs = p的数量
   // sched.runqsize是全局队列长度
   // 这里n = 全局队列的G平分到每个P本地队列上的数量 + 1
   n := sched.runqsize/gomaxprocs + 1
   if n > sched.runqsize {
      n = sched.runqsize
   }
   if max > 0 && n > max {
      n = max
   }
   // 平分后的数量n不能超过本地队列长度的一半,也就是128
   if n > int32(len(_p_.runq))/2 {
      n = int32(len(_p_.runq)) / 2
   }

   // 执行将G从全局队列中取n个分到当前P本地队列的操作
   sched.runqsize -= n

   gp := sched.runq.pop()
   n--
   for ; n > 0; n-- {
      gp1 := sched.runq.pop()
      runqput(_p_, gp1, false)
   }
   return gp
}

所有 P 平分全局队列中的 G,每个 P 要分得多少个,这里假设会分得 n 个。然后把这 n 个 G,转移到当前 G 所在 P 的本地队列中去。但是最多不能超过 P 本地队列长度的一半(即 128)。而从其它P获取G时,会偷一半的G过来放到当前P的本地队列。

hand off机制

hand off从字面上看是移交,就是把当前线程绑定的P移交。当线程M运行的G进行系统调用阻塞时,线程M释放绑定的P,把P转移给其他空闲的线程进行绑定。

编辑

hand off机制

work stealing机制

当前线程无可用G时,尝试“从其他线程绑定的P,偷取一半P的本地队列G“,work stealing机制就是图中的第四步。

编辑切换为居中

work stealing 机制

触发调度时机

只要调用了runtime.schedule()函数地方我们就可以任务是调度触发的地方,有以下时间点也调用了schedule()。

编辑切换为居中

添加图片注释,不超过 140 字(可选)

除了上图中可能触发调度的时间点,运行时还会在线程启动 runtime.mstart 和 Goroutine 执行结束 runtime.goexit 触发调度,也有以下几个调度路径。

•主动挂起 — runtime.gopark -> runtime.park_m

•系统调用 — runtime.exitsyscall -> runtime.exitsyscall0

•协作式调度 — runtime.Gosched -> runtime.gosched_m -> runtime.goschedImpl

•系统监控 — runtime.sysmon -> runtime.retake -> runtime.preemptone

总结

Go调度器实现原理,多看看也能理解个7788。

参考资料:

【调度器(详细介绍)】

【Go语言设计与实现】

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 进程、线程、协程
  • Go调度器原理
    • 调度模型演化
      • 设计思想
        • 被调度对象
          • 调度器启动
            • 调度策略
              • hand off机制
                • work stealing机制
                  • 触发调度时机
                  • 总结
                  • 参考资料:
                  领券
                  问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档