Go并发调度

作者: 链人成长chainerup | 来源:发表于2019-09-18 21:02 被阅读0次

    本文是《循序渐进Go语言》的第六篇-Go并发调度。本文是学习《Go语言学习笔记》的并发调度一章,然后结合阅读源码的总结,希望对你有所帮助~

    1 概述

    Go语言在操作系统的进程、线程基础上又进一步做了更高层次的抽象。通过三种基本对象相互协作,来实现在用户空间管理和调度并发任务。

    三种对象分别是:(为了概念准确,直接引用《Go语言学习笔记》的原文)

    • Processor (简称P),用来控制可同时并发执行的任务数量。每个工作线程都必须绑定一个有效P才被允许执行。P还为线程提供执行资源,比如内存分配、本地任务队列等。线程独享所绑定的P资源,可在无锁状态下执行高效操作
    • 进程内的一切都已goroutine(简称G) 方式运行,包括运行时相关服务,以及main.main入口函数。G并非执行体,它仅仅**保存并发任务状态,为任务执行提供所需的栈内存空间。G任务创建后,要放入P队列或者全局队列,等待工作线程调度执行。
    • 工作线程(简称M)。和P绑定,以调度循环方式不停地执行G并发任务。M通过修改寄存器,将执行栈指向G自带的栈内存,并在此空间内分配堆栈帧,执行任务函数。当需要中途切换时,只需要将相关寄存器值保存回G空间即可维持状态。任何M都可以据此恢复执行。线程仅仅负责执行,不再持有状态。这是并发任务跨线程调度实现多路复用的根本所在

    上面文字看明白了吗?我第一遍看的时候,也没啥概念。等到看完全章再回头来整理,发现句句宝贵。
    为了更明白一些,我来画个图说明一下三者的关系:


    G-P-M.png

    2 调度器初始化 (schedInit)

    主要经历如下步骤:

    1) 设置M的最大数量
    2) 初始化当前的M
    3) 设置P的数量,一般为cpu核数
    4)调整P的数量(这个过程中会新建一批P,放入空闲链表;另外调整过程也会发生在startTheWorld阶段,如果P之前太多,则会回收一部分)
    

    调度器初始化之后,才会执行main 函数,main函数也是一个用户应用程序,也是启动一个goroutine。

    3 任务

    每次我们执行

    go func (){}
    

    其实编译之后,就会发现这是调用了系统的runtime.newproc操作。
    那么这个函数主要执行了什么呢?

    1) 获取队列中的空闲G。P队列中的G 是会复用的,如果队列中没有空闲P,则调用malg 创建新的G。
    2) 为空闲的G设置栈空间
    3) 保留现场
    4) 设置调用者的PC(方便任务执行完毕之后,回到调用处)以及任务函数。
    5)放入P队列,等待唤醒线程M执行。P队列按照优先级分了3级。先放本地,如果本地满了,则一次性将一半的任务迁移到全局队列。其目的是方便让其他空闲的P执行。
    

    讲到这儿,我们应该看出,G一般会经过
    “新建” -->"初始化前"-->"初始化"-->"调度执行"-->"执行完毕" 这几个状态。
    如果任务执行完毕,G是可以继续放入P队列,作为空闲G,迎接新的任务的到来。

    4 线程M

    上一节我们提到,新任务创建之后,放入P队列,然后去唤醒线程M执行。
    那么线程M从哪儿来呢,在存储在sched结构中。
    M的获取也有两种,一种是获取空闲的M,另外一种是新建M(每个新建的M都会自带一个分配了8m空间的G0。G0主要是用来执行非用户任务,运行时的管理指令)。
    被唤醒的M会陷入执行循环,从各个场所获取任务,并执行。有些时候甚至偷取其他P的任务。只有M被剥夺了P (比如因为执行时间太长,被抢占了)才会进入休眠状态。

    5 线程执行

    线程启动,会执行mstart这个函数。启动时必须绑定一个有效的P,P为M提供cache,以便为工作线程提供对象内存分配。每一个P都有一个mcache的结构。我们看下mcache的部分解释

    Per-thread (in Go, Per-P) cache for small objects。
    

    我们之前讲内存分配时,有提到说每个线程独占一个cache, 当时的cache就是这儿的cache。
    mcache 中还有一个span的数组结构 “alloc [67]*mspan” 。是不是越看关系越大了,哈哈。
    其执行流程:

    1) 获取任务:要么从P本地获取,要么从其他地方获取(比如全局队列,网络队列,偷其他P的任务)
    2)执行 主要是execute函数。这个函数中主要是通过汇编gogo函数来执行。
    3)执行完毕,清理现场,然后跳回到调用处。
    

    6 连续栈

    其实栈的分配、回收、释放跟内存策略都一样。就不展开讲了。
    G中有个stackguard0的指针,可以通过它跟SP比较,用于判断是否需要扩充栈了。如果扩充,就找一个原来两倍大小的栈,然后将数据copy过去,然后替代旧栈即可。

    7 系统调用与监控

    7.1 系统调用

    为了支持并发,Go对syscall 进行了包装,以便在长时间阻塞时能切换执行其他任务。

    监控线程sysmon负责将因系统调用而长时间阻塞的P抢回,将P用于执行其他任务。

    7.2 监控

    监控线程sysmon 保障了内存分配、垃圾回收、并发调度的有效执行。
    除了上文提到的抢占调度,sysmon还提供如下的功能:

    释放闲置超过5分钟的span物理内存
    如果超过2分钟没有垃圾回收,则强制执行
    将长时间未处理的netpoll结果添加到任务队列

    其实sysmon 就是个for循环,会定期对上述的几个功能进行检查,如果到了特定时间,就执行预定的方法。

    如果遇到GC,sysmon 会进入休眠状态。

    7.3 抢占调度

    所谓的抢占调度,只是在目标G上设置一个抢占标志,当该任务调用某个函数时,会检查这个标志,从而决定暂停这个任务。

    8 stopTheWorld

    stopTheWorld 会循环等待目标任务进入一个安全点后主动暂停。其核心流程就是向所有正在运行的G发送抢占调度,然后等。。。如果还没停,就继续发抢占调度。

    9 总结

    本文讲解了Go并发调度的机制。从G-P-M的关系入手,然后讲解了系统初始化时,调度器做的一系列操作。接着从一个任务创建、启动,到执行 讲解了G、P、M 都做了什么事情。 最后讲了系统调用、监控以及stopTheWorld这种暂停的机制。

    10 参考文献

    《Go学习笔记》
    Go源码 1.8 版本。

    11 其他

    本文是《循序渐进go语言》的第六篇-《Go并发调度》。
    如果有疑问,可以直接留言,也可以关注公众号 “链人成长chainerup” 提问留言,或者加入知识星球“链人成长” 与我深度链接~

    相关文章

      网友评论

        本文标题:Go并发调度

        本文链接:https://www.haomeiwen.com/subject/djgkuctx.html