go 进阶 协程相关: 六. 协程问题总结

一. 协程前置概念理解

  1. Go 语言问题集(Go Questions)
  2. GO专家编程
  3. 参考博客

用户态,内核态,进程,线程,协程基本概念

  1. 在了解协程相关问题前首先要简述一下进程,线程,协程之间的关系,在讲述他们之间关系时会设计到用户态与内核态两个概念,先一块简述一下
  2. 进程是一个实体,一个用户程序就是一个进程,每个进程下可能包含一个或多个线程,但是一个cpu同一时间只能调度一个线程,多线程其实是cpu快速的在多个线程之间进行切换,造成多个线程同时执行的假象,提高cpu利用率
  3. 多线程切换是,就需要拿到程序运行时的上下文并保存起来,后续就可以根据这个上下文随时暂停或恢复程序的运行,每个栈帧我们就可以先简单看成一个上下文,停止执行时保存当前的上下文,唤起时拿到这个上下文,基于这个上下文开始运行,会涉及到用户态与内核态的切换
  4. 内核态可以简单理解为主要负责运行系统,硬件交互, 而用户态也叫用户空间,是用户进程/线程所在的区域主要用于执行用户程序

为什么使用协程,协程的特点

  1. 使用线程时存在的问题:
  1. 在使用进程与线程时,实际底层一个cpu同一时间只能调度一个线程,多线程其实是cpu快速的在多个线程之间进行切换,造成多个线程同时执行的假象,主要时提高cpu利用率
  2. 线程是系统级别的重量资源, 线程的创建,销毁, 切换存在用户态到内核态的切换比较消耗资源
  3. Linux和Windows都是分时复用的多任务操作系统,上面跑着很多程序,所以操作系统需要在不同进程之间切换,这时候就产生了CPU上下文切换,但是存在的问题就是切换的时候非常消耗资源,默认情况下Linux只可以创建1024个进程.虽然可以修改,但是进程或线程数过多时,CPU的时间基本上都浪费在上下文切换上面了
  1. 什么是协程: 由用户程序在用户栈分配存储空间,同一线程中的多个协程间的切换只在用户态下,而不涉及核心态,因此这样的协程不存在用户态与核心态的转换,提高了CPU效率
  2. 协程的特点总结
  1. 有独立的栈空间
  2. 共享程序堆空间
  3. 调度由用户(程序)控制
  4. 协程是轻量级的线程
  1. 协程的优点:
  1. 内存消耗更小: Goroutine所需要的内存通常只有2kb,而线程则需要1Mb(500倍)
  2. 创建和销毁消耗小: Goroutine的创建和销毁都是自己管理,线程的创建和销毁是需要内核管理的,消耗更大
  3. 上下文切换:
  1. 线程是抢占式的,一段时间内线程执行完成,其他线程抢占cpu,需要保存很多线程状态,用来再次执行的时候恢复线程
  2. goroutine 的调度是协同式,不需要切入内核,只需要保存少量的寄存器信息需要保存和恢复

MGP线程模型

  1. 上述基础概念了解完毕后,我们要讲一下go中使用的线程模型, 先简述一下常见的几种线程模型
  1. 用户级线程模型(N:1): 一个进程对应一个内核线程(也可以说时多个用户线程基始终在这一个内核线程上跑),问题: 进程内的多线程都无法很好的利用CPU的多核运算优势,只能通过分时复用的方法轮换执行
  2. 内核及线程模型(1:1): 是指进程中的每个线程都会对应一个内核线程,多核时能够充分利用CPU的计算能力,问题: 进程内每创建一个新的线程,都会在内核空间创建一个对应的内核线程,线程的管理和调度由操作系统负责,这将导致每次线程切换上下文时都会从用户态切换至内核态,产生不小的资源消耗
  3. 两级线程模型(M:N): 一个进程对应多个内核线程,由进程内的调度器来决定线程内的线程如何与内核空间的内核线程对应,既能够有效降低线程创建和管理的资源消耗,也能够很好地提供线程并行计算的能力,需要在程序代码中模拟线程调度的细节,比如:线程切换时上下文信息的保存和恢复、栈空间大小的管理等
  4. MPG线程模型(特殊的两级线程模型):
  1. MGP的解释:
  1. M(Machine):操作系统的主线程可以理解为内核线程在用户进程中的映射,真正干活的,一个M代表了一个内核线程,等同于系统线程
  2. P(Processor):协程执行的上下文环境(资源) ,可以看为一个局部调度器,一个P代表了M所需的上下文环境,P的数量可以通过GOMAXPROCS()来设置,代表了真正的并发度,既同一时间内可以运行多少个goroutine
  3. G(Goroutine):协程(轻量级用户线程)
  1. 另外在MPG线程模型中还存在一个Sched成员:代表调度器,它维护有存储M和G的队列以及调度器的一些状态信息等
  2. M,P,G三者之间的关系:
  1. 每一个运行的M都必须绑定一个P, 线程M创建之后会去检查并且执行G,M的数量和P不一定匹配,可以设置很多M,M和P绑定后才可运行,多余的M处于休眠状态
  2. P的个数取决于设置的GOMAXPROCS,go新版本默认使用最大内核数,比如你有8核处理器,那么P的数量就是8,所有的P都在程序启动时创建,并保存在数组中
  3. P中关联了一个LRQ(Local Run Queue)本地运行队列,里面保存着P需要执行的协程G,最多可存放256个Goroutine
  4. Sched调度器还拥有一个全局的G队列GRQ(Global Run Queue)存储的是所有未分配的协程G
  5. G需要绑定在M上才能运行,M需要绑定P才能运行,简单来说一个G的执行需要M和P的支持,一个M在与一个P关联之后形成了一个有效的G运行环境【内核线程 + 上下文环境】,每个P都会包含一个可运行的G的队列
  1. 我们查看一下M, P, G源码

1. M

  1. M 是指OS 内核线程,代表着真正执行计算的资源,在绑定有效的 P 后,进入 schedule 循环;而 schedule 循环的机制大致是从 Global 队列、P 的 Local 队列以及 wait 队列中获取
  2. 默认最大限制为 10000 个。如果一个M工作完成后,找不到可用的P,则需要将自己休眠,并放在空闲线程中,等待下次使用。
  3. M 并不保留 G 状态,这是 G 可以跨 M 调度的基础
  4. 其中几个需要注意的字段
  1. 与goroutine有关的字段有caughtsig 和 curg,其中 curg 这个就是当前m绑定的goroutine
  2. 与p相关的字段的p、nextp、oldp,分别表示当前绑定的P、下次绑定的P和上次绑定的P,nextp 和oldp
  3. 与cgo相关: ncgocall, ncgo, cgoCallersUse, cgoCaller
  4. 与调度相关有关的字段(见gppark()函数): waitlockf、 waitlock 、waittraceev、waittraceskip

2. P

  1. P表示逻辑处理器,对 G 来说,P 相当于 CPU 核,G 只有绑定到 P 才能被调度。对 M 来说,P 提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等。在runtime中使用 p 结构表示。P 的数量决定了系统内最大可并行的 G 的数量(前提:物理 CPU 核数 >= P 的数量)
  2. P 的数量由用户设置的默认是cpu核数,可以 GoMAXPROCS 设置,但是不论 GoMAXPROCS 设置为多大,P 的数量最大为 256
  3. p中字段重点关注部分
  1. runq: 用来保存等待运行的 G 列表,最大256实际再加上runnext 一个P最大的情况下可以有257个goroutine
  2. runnext : 这个字段是用来实现调度器亲和性的,我们知道原来一个G阻塞时,这时P会再获取一个G进行绑定执行,如果这时原来的G执行阻塞结束时,如果想再次执行,就需要重新在P的runq进行排队,如果当前runq里有太多的工作会造成占用线程的话会导致这个解除阻塞的G迟迟无法执行,同时还有可能被其他处理器所窃取。从 Go 1.5 开始得益于 P 的特殊属性,从阻塞 channel 返回的 Goroutine 会优先运行,这里只需要将这个G放在 runnext 这个字段即可
  3. 每个P都有一个自己的runq,除了自身有的runq 还有一个无限长度的全局队列。
  4. 单说每个P下面能够保持的g的数量: runq的允许的最大goroutine 数量为256,再加上runnext 一个P最大的情况下可以有257个goroutine
  5. 每个P中还存在一个gFree 内部保存了空闲的 G 列表,是个结构体,其中n表示空闲g的个数
  1. P的五种状态:
  1. _Pidle: 没有运行用户代码或者调度器
  2. _Prunning: 被线程M持有,正在执行用户代码或者调度器
  3. _Psyscall: 当前p没有执行用户代码,当前线程陷入系统调用
  4. Pgcstop: 被线程M持有,由于垃圾收集被停止
  5. _Pdead: 当前处理器已经不被使用

3. G

  1. 每个 Goroutine 对应一个 g 结构体,它有自己的栈内存, G 存储 Goroutine 的运行堆栈、状态以及任务函数,可重复用, Goroutine数据结构位于 src/runtime/runtime2.go 文件, 当一个 goroutine 退出时,g 对象会被放到一个空闲的 g 对象池中以用于后续的 goroutine 的使用, 以减少内存分配开销
  2. 重点字段:

在g结构体中的stackguard0 字段是出现爆栈前的警戒线。stackguard0的偏移量是16个字节,与当前的真实SP(stack pointer)和爆栈警戒线(stack.lo+StackGuard)比较,如果超出警戒线则表示需要进行栈扩容.先调用runtime·morestack_noctxt()进行栈扩容,然后又跳回到函数的开始位置,此时此刻函数的栈已经调整了。然后再进行一次栈大小的检测,如果依然不足则继续扩容,直到栈足够大为止

  1. 小总结:
  1. 每个G 都有自己的状态,状态保存在 atomicstatus 字段,共有十几种状态值。
  2. 每个 G 在状态发生变化时,即 atomicstatus 字段值被改变时,都需要保存当前G的上下文的信息,这个信息存储在 sched 字段,其数据类型为gobuf,想理解存储的信息可以看一下这个结构体的各个字段
  3. 每个G 都有三个与抢占有关的字段,分别为 preempt、preemptStop 和 premptShrink
  4. 每个 G 都有自己的唯一id, 字段为goid,但此字段官方不推荐开发使用
  5. 每个 G 都可以最多绑定一个m,如果可能未绑定,则值为 nil
  6. 每个 G 都有自己内部的 defer 和 panic。
  7. G 可以被阻塞,并存储有阻塞原因,字段 waitsince 和 waitreason
  8. G 可以被进行 GC 扫描,相关字段为 gcscandone,atomicstatus ( _Gscan 与上面除了_Grunning 状态以外的其它状态组合)
  1. Goroutine 的几种状态
enum{
    //协程创建初始状态
    Gidle,
    //协程在可运行队列等待调度
    Grunnable,
    //协程正在被调度运行
    Grunning,
    //协程正在执行系统调用
    Gsyscall,
    //协程处于阻塞状态,没有在可运行队列
    Gwaiting,
    //协程执行结束,等待调度器回收
    Gmoribund,
    //协程已被回收
    Gdead,
};

在这里插入图片描述
5. 需要注意的是对于 _Gmoribund_unused与_Genqueue_unused 状态并未使用
6. 除了_Grunning 状态以外的其它状态相组合,表示 GC 正在扫描栈。Goroutine 不会执行用户代码,且栈由设置了 _Gscan 位的 Goroutine 所有
在这里插入图片描述

4. schedt 调度器

  1. Go 调度器,它维护有存储 M 和 G 的队列以及调度器的一些状态信息等,全局调度时使用,调度器循环的机制大致是从各种队列、P 的本地队列中获取 G,然后切换到 G 的执行栈上并执行 G 的函数,调用 Goexit 做清理工作并回到 M,如此反复
  2. 重点字段
  1. 调度器会记录当前哪个m在等待工作(midle),一共有多少个空闲m(nmidle),同时还记录等待工作的锁定m的数量(nmidlelocked), 已创建m的数量和允许的最大空间m数量(maxmcount)等信息
  2. 记录了当前空闲的P是哪一个(pidle),共有几少个空闲的P(npidle),还有多少个正在spinning 的M(nmspinning)。另外还有一个全局G的列队(runq)。是不是越来越有点意思了,有了空闲的M(midle)及其数量(nmidle),也有了空闲的P(pidle)及其数量(npidle),还知道有多少个M正在在spinning(nmspinning)拼命在找活干,除了每个P下面自己的runq队列,调度器自身也有存放G的队列runq,所需要的资源GPM都够了,剩下的就是看如何给他们近排活干了
  3. gFree 全局缓存G, 匿名结构体。如果一个G完毕后,并不立即释放它而是先放在一个列表里,以备后续复用,这样可以优先减少创建资源的开销。放入g后再检查一下当前 p 的 p.gFree 长度是否>=64(p.gFree.n >= 64) , 如果大于则会将 p.gFree 一半的 g 迁移到 sched.gFree,源码见 gfput()`
  1. 小总结
  1. 调度器会记录当前是否处于轮训状态以及轮训的时间
  2. 记录有M相关的信息,如当前空闲的M,如果有多个的话,也会记录个数,记录的还有已用过数量,当前有多少个正在spinning,最大允许的M数量。同时也会记录其中持有锁的数量
  3. 记录有P相关的信息,如当前空闲P,空闲的数量。
  4. 持有当前系统 groutine 数量
  5. 有一个G的全局运行队列及其队列大小
  6. 通过gFree 记录有多少个空闲的G,可以被重复利用
  7. sudog缓存 和 deferpool
  8. 都有一个全局锁(lock)和 sysmon (sysmonlock)锁及其它锁(sugoglock)
    10 可以控制是否禁用用户gorutine的调度行为,字段 disable(调用 schedEnableUser)

二. 协程的调度

  1. 协程调度可以分为协程创建,与协程调度执行两部分理解, 通过go关键字启动协程,或者启动go项目,调度器底层等都会调用到一个runtime.newproc函数,该函数内部会创建协程一个G对象,G对象保存到P本地队列或者是全局队列,M寻找是否有空闲的P,如果有则将该G对象移动到它本身。接下来M执行一个调度循环(调用G对象->执行->清理线程→继续找新的Goroutine执行)。
  2. M执行过程中,随时会发生上下文切换。当发生上线文切换时,需要对执行现场进行保护,以便下次被调度执行时进行现场恢复。Go调度器M的栈保存在G对象上,只需要将M所需要的寄存器(SP、PC等)保存到G对象上就可以实现现场保护。当这些寄存器数据被保护起来,就随时可以做上下文切换了,在中断之前把现场保存起来。如果此时G任务还没有执行完,M可以将任务重新丢到P的任务队列,等待下一次被调度执行。当再次被调度执行时,M通过访问G的vdsoSP、vdsoPC寄存器进行现场恢复(从上次中断位置继续执行)

g0 和 m0

  1. g0 和 m0 在runtime中是比较重要的概念这里讲一下,它们到底什么?
  2. m0: 表示进程启动的第一个线程,也叫主线程。它和其其它M主要的区别是,它是进程启动通过汇编直接赋值给m0的,是个全局变量,而其他的m都是runtime内自己创建的。 m0 的赋值过程,可以看前面 runtime/asm_amd64.s 的代码。一个go进程只有一个m0。
  3. g0: 是用于执行调度器的代码的,每个M结构体中都存在一个g0属性,所以每个m都有一个g0,因为每个线程有一个系统堆栈,g0 也是g的结构,与其它g不同的是,g0 上的栈是系统分配的的,在linux上默认为8MB,不能扩展,也不能缩小,而普通g一开始只有2KB大小,可扩展。在 g0 上也没有任何任务函数,也没有任何状态,并且它不能被调度程序抢占。因为调度就是在g0上跑的

协程创建

  1. 查看创建协程的runtime.newproc函数,内部会调用:
  1. newproc1()执行获取协程G
  2. runqput()执行,把获取到的G放到_p_的runnext或本地运行队列或全局队列上

1. runtime·newproc1获取协程G

  1. 先看一下runtime·newproc1实现,在newproc1()中重点完成了以下工作:
  1. 执行getg()获取g0,也就是当前工作线程主线程(g0是用来执行调度器的)
  2. 执行acquirem()获取m,并且设置绑定到当前线程的M实例不可抢占
  3. 执行_p_ := g.m.p.ptr()通过当前m找到p,也就是首先获取到g0系统协程,由g0获取到对应的m,获取到对应的p
  4. 执行gfget(p) 先从p的本地队列获取空闲的g,如果当前P和sched都没有空闲的g,就执行malg(_StackMin)创建g,并且新建一个2k的栈,包括分配结构体G以及协程栈,系统中的每个g都是由该函数创建而来的,然后执行allgadd(newg)将新建的g添加到全局变量allgs中
  5. 判断是否有参数,如果有执行memmove()将参数拷贝到获取的g goroutine栈中
  6. 执行memclrNoHeapPointers()初始化设置sched,后续调度器需要依靠这些字段才能把goroutine调度到CPU上运行,重点将goexit+1设置到sched的pc属性上(为什么要+1参考下面的gostartcallfn()函数,因为当协程执行完函数后,会通过这个+1后的地址继续向下执行)
  7. 执行gostartcallfn(),执行该函数中的gostartcall()
  8. 执行casgstatus()将newg的状态变更为Grunnable
  9. 执行[p.goidcache,p.goidcacheend) 获取goid。 不够用就从sched.goidgen里面批量获取16个
  10. releasem (m.lock–),释放M实例的不可抢占状态,返回新的G实例
  1. 在runtime·newproc1内部会执行到一个gostartcall函数主要作用有两个
  1. 调整newg的栈空间,把goexit函数的第二条指令的地址入栈,伪造成goexit函数调用了fn,从而使fn执行完成后执行ret指令时返回到goexit继续执行完成最后的清理工作;
  2. 重新设置newg.buf.pc 为需要执行的函数的地址,即fn,这里才真正让newg的ip寄存器指向fn函数,等到newg被调度起来运行时,调度器会把buf.pc放入cpu的IP寄存器,从而使newg得以在cpu上真正的运行起来

2. runqput()协程G放到_p_的runnext运行队列或全局队列上

  1. 在前面看P的源码时内部存在几个重要的字段其中就有runnext, 查看runqput()函数,当获取到g后
  1. 首先通过cas判断是否有其它线程在操作runnext ,如果有重试
  2. 取出原runnext上保存的等待运行的g,放入runq队列尾部,将 newg 加入到 P 的 runnext 字段,具有最高优先级,以上防止并发安全问题都是基于cas执行的
  3. 然后执行入列操作,如果 P 的runq本地队列没有满,入队
  4. 如果本地运行队列满了调用runqputslow把g放到"全局队列",注意runqputslow会把本地运行队列中一半的g放到全局队列
  1. 待确认问题点: 在入队时,入的并不是当前新创建的g,好像是runnext上原来等待执行的g,先通过 head,tail,len(p.runq) 来判断队列是否已满,如果没满,则直接写到队列尾部,同时修改队列尾部的指针。

协程调度初始化

  1. 在golang中通过go关键字开启一个协程,协程开启后cpu怎么安排调度的, 这块要由golang程序通过main方法启动去了解,所以一块整理一下main方法启动时做了什么,也可以说成进程启动时都做了什么
  2. 对一个存在main方法的go项目反编译,会发现在启动go项目时,内部会执行一个runtime.rt0_go方法,该方法就是golang的启动入口,在该方法中按照先后顺序完成了:(协程调度由第三步骤开始)
  1. 检查运行平台的CPU,设置好程序运行需要相关标志。
  2. TLS的初始化。
  3. runtime.args、runtime.osinit、runtime.schedinit 三个方法做好程序运行需要的各种变量与调度器。
  4. runtime.newproc 创建新的goroutine用于绑定用户写的main方法。
  5. runtime.mstart 开始goroutine的调度

1. runtime.schedinit(SB) 调度相关初始化

  1. 查看runtime下的schedinit(SB)函数,开始执行调度初始化相关任务, 该方法中主要完成了
  1. 通过"sched.maxmcount = 10000"设置m的最大数量是10000
  2. 调用tracebackinit(),stackinit(),mallocinit()对内存栈等进行初始化分配
  3. 重点调用mcommoninit()初始化m0,将m加入allm
  4. 调用procresize()调整p的数量,也可以通过环境变量 GOMAXPROCS 来控制 P 的数量。_MaxGomaxprocs 控制了最大的 P 数量只能是 1024
  5. 绑定m0和p
  6. 到现在已经有了m0 g0 gsignal和p相互绑定,并且有ncpu个p
mcommoninit()初始化当前M,即全局M0
  1. 该函数中主要完成
  1. 运行runtime.tracebackinit初始化M的traceback函数栈
  2. 随机数
  3. 创建gsignal并且分配32k的栈
  4. 把m加入allm
procresize() 调整p的数量, 并且绑定m0和p
  1. 调用procresize,调整p的数量, 并且绑定m0和p, 也可以通过环境变量 GOMAXPROCS 来控制 P 的数量。_MaxGomaxprocs 控制了最大的 P 数量只能是 1024
  2. 该函数中主要完成的工作
  1. 初始化allp,如果原来的不够就新加一些,如果多了就释放一些(没有真的释放p, 只是释放里面的数据)
  2. runnablePs 获取所有非idle非当前运行的p , 并给他们绑定一个新m

2. runtime.mainPC(SB) 启动监控任务

  1. 在runtime下会启动一个全程运行的监控任务,该任务用于标记抢占执行过长时间的 G,以及检测 epoll 里面是否有可执行的 G

3. runtime.newproc

参考上方的协程创建

4. runtime.mstart(SB)启动调度循环

  1. mstart方法主要的执行路径是:mstart -> mstart1 -> schedule -> execute
  1. mstart做一些栈相关的检查,然后就调用mstart1。
  2. mstart1先做一些初始化与M相关的工作,例如是信号栈和信号处理函数的初始化。最后调用schedule。
  3. schedule逻辑是这四个方法里最复杂的。简单来说,就是要找出一个可运行的G,不管是从P本地的G队列、全局调度器的G队列、GC worker、因IO阻塞的G、甚至从别的P里偷。然后传给execute运行。
  4. execute对传进来的G设置好相关的状态后,就加载G自身记录着的PC、SP等寄存器信息,恢复现场继续执行
  1. 自此协程调度开始, 调度的重要逻辑部分都在mstart()函数中

runtime.mstart(SB)协程调度底层

  1. 首先在启动golang程序时,会执行runtime.schedinit(SB) 初始化调度相关设置,在该函数内通过:
  1. 设置了m的最大数量限制10000
  2. 执行mcommoninit()初始化全局m,既m0,将m添加到allm链表中
  3. 执行procresize()设置p的数量,初始化allp链表, 并且绑定m0和p(默认p的数量等于cpu个数,可以通过 GOMAXPROCS 指定但是还存在_MaxGomaxprocs 控制了最大的 P 数量只能是 1024)
  4. 此时已经有了m0 g0 gsignal和p相互绑定,并且有ncpu个p
  1. 而 P 里面有 2 个管理 G 的链表(runq 存储等待运行的 G 列表,gfree 存储空闲的 G 列表),M 启动后等待可执行的 G,在这里我们就要先了解一下g,m,p几个结构体参考博客
  2. runtime/proc.go下的mstart 只是简单的对 mstart1 的封装,接着看 mstart1
func mstart() {
	// 这里获取的g是g0,在系统堆栈
	_g_ := getg()
	...
 	//
	mstart1(0)
	...
}

1. mstart1() 初始化设置

  1. 在 mstart1()的执行步骤
  1. 调用 g := getg() 获取 g ,然后检查该 g 是不是 g0 ,如果不是 g0 ,直接抛出异常。
  2. 初始化m,主要是设置线程的备用信号堆栈和信号掩码(注意M0 启动是个特殊的启动过程,也是第一个启动的 M,由汇编实现的初始化后启动,而后续的 M 创建以及启动则是 Go 代码实现)
  3. 判断 g 绑定的 m 是不是 m0,如果是,需要一些特殊处理,主要是设置系统信号量的处理函数
  4. 检查 m 是否有起始任务函数?若有,执行它。
  5. 获取一个p,并和m绑定
  6. 执行schedule()进入调度程序

2. schedule() 调度函数

调度器的设计策略
  1. go程序由两层构成:program(用户程序)和runtime(运行时)。Runtime 维护所有的goroutines,并通过 scheduler 来进行调度
  2. 有三种调度策略:
  1. 队列轮转
  2. 协程式调度
  3. 系统调用
  1. 队列轮转(队列轮转中有分为可运行队列与工作窃取)
  1. 可运行队列: P中维护了一个本地队列与全局队列, 在运行时会周期性的将G调度到M中执行,指定时间内将上下文保存下来,然后将G放到队列尾部,然后从队列中重新取出一个G进行调度,防止全局队列中的G被饿死,每个P会周期性的查看全局队列中是否有G待运行并将期调度到M中执行,全局队列中G的来源,主要有从系统调用中恢复的G
  2. 工作量窃取: m在运行时首先会获取到p,然后获取到可运行的g,如果获取不到,空闲的P会查询全局队列,若全局队列也空,则会从其他P中窃取G(一般每次取一半)
  1. 系统调用: 系统调用分为同步与异步,对不同的情况go有不同的策略什么是系统调用
  1. 什么是系统调用:系统调用是程序向操作系统内核请求服务的过程,简单理解为例如执行了syscall包中包含的执行操作系统的接口,系统调用分为同步与异步,对不同的情况go有不同的策略
  2. 同步调用: 假设G1需要进入系统调用时,当前G1对应的M会释放P,在池中或者新建一个新的M,即新M接替原来的继续工作,由新M完成协程的系统调用执行,当执行完毕后,再将绑定解除,如果原M能够获取到空闲P,则有原M继续执行,如果获取不到则将G1放入全局队列中,等待其它P的调用
  3. 异步调用: 当G1需要进入系统调用时,绑定的M不会被阻塞,G 的异步请求会被“代理人” network poller 接手,G 也会被绑定到 network poller,等到系统调用结束,G 才会重新回到 P 上。M 由于没被阻塞,它因此可以继续执行 LRQ 里的其他 G
  1. 协程式调度:考虑到协程复用,阻塞等问题, 采用协程式调度,也就是说,在一下场景时会触发调度
  1. 通过go关键字开启协程
  2. 垃圾收集执行时
  3. 进行系统调用,M阻塞时
  4. 内存同步访问,例如atomic, mutex, channel等
  1. schedule循环是如何启动,如何运转的
结合代码
  1. 在 schedule()中执行了一下逻辑
  1. 首先执行getg()获取到当前系统协程也就是g0,由g0获取到对应的m,获取到对应的p,后续通过这个p去获取待执行协程
  2. 通过sched.gcwaiting != 0判断是否正在gc,如果当前GC需要停止,调用gcstopm休眠当前的M。
  3. 执行globrunqget(g.m.p.ptr(), 1)会每隔61次调度轮回,通过全局队列获取可运行G,避免全局队列中的g被饿死
  4. 然后会执行runqget()函数,先通过 runnext 拿到待运行 G,如果runnext 中没有,则通过 runq 本地队列获取
  5. 如果上一步没有获取到可运行的G,再调用 findrunnable()函数,从全局队列、epoll、别的 P 里获取获取g,找不到的话就调用stop将m休眠,等待唤醒
  6. 当找到一个g后,就会调用 execute()函数,从g0切换到gp 的代码和栈空间去运行
  1. 注意第4步骤调用findrunnable()通过全局队列、epoll、别的 P 里获取获取g时:
  1. 首先调用 runqget():尝试从P本地队列中获取G,获取到返回
  2. 如果本地队列中没有获取到,调用 globrunqget (): 尝试从全局队列中获取G,获取到返回
  3. 从网络IO轮询器中找到就绪的G,把这个G变为可运行的G
  4. 如果不是所有的P都是空闲的,通过遍历最多四次,随机选一个P,执行runqsteal()尝试从这P中偷取一些(一半)G,获取到返回
  5. 上面都找不到G来运行,判断此时P是否处于 GC mark 阶段,如果是,那么此时可以安全的扫描和黑化对象和返回 gcBgMarkWorker 来运行, gcBgMarkWorker 是GC后代标记的goroutine。
  6. 再次从全局队列中获取G,获取到返回
  7. 再次检查所有的P,有没有可以运行的G
  8. 再次检查网络IO轮询器
  9. 注意当M在本地队列、全局运行队列、netpoller中找不到需要运行的G时,首先会判断是否进入自旋状态,当正在自旋的M的个数的2倍大于等于正在执行的P的个数时,不会再次自旋,避免CPU的过多的消耗,一个M未获取到需要运行的G,并且也没有进入自旋状态,那么会将P和M解绑,调用 stopm 来休眠该M
  1. 当获取到等待运行的g时,会执行execute(),该方法中将 gp 的状态改为 _Grunning,将 m 和 gp 相互关联起来。最后调用 gogo 完成从 g0 到 gp 的切换,CPU 的执行权将从 g0 转让到 gp

3. 新建或唤醒休眠的M

  1. 在 schedule() 调度函数中当M获取不到需要运行的G,并且当前M不需要进入自旋状态时,会调用stopm进行休眠,那么休眠的M是如何被唤醒的
  2. 新建一个goroutine或者有个goroutine准备好时,会执行ready()函数,该函数中会调用 wakep 函数,该函数中首先会判断当前是否有正在自旋的M,如果有则跳过,通过自旋的M去获取执行G, 当没有自旋的M时会调用startm()唤醒或者新建一个
  3. 在startm()中首先判断是否有空闲的P,如果没有则返回,如果有空闲的P,判断是否有空闲的m,唤醒该M工作,没有则新建

注意: 这样看起来好像M的个数会被P的个数限制,但其实不一定,因为 p 参数不一定为 nil, 当 p 不为 nil 的时候,是可以新建一个M来服务的,比如cgo调用,阻塞的系统调用等。如果要继续深究, 就看一下 handoffp 函数及相关调用

4. 归还执行完的 G

  1. 在协程调度时会执行runqput(),内部会判断当P 里的待运行 G 超过 256 的时候说明过多了,则执行 runqputslow 方法把一半 G 扔给全局 G 链表,globrunqputbatch 连接全局链表的头尾指针,但可能别的 P 里面并没有超过 256,就不会放到全局 G 链表里,甚至可能一直维持在不到 256 个

5. 对阻塞的处理

  1. 在 Go 里面阻塞主要分为以下 4 种场景:
  1. 由于原子、互斥量或通道操作调用导致 Goroutine 阻塞,调度器将把当前阻塞的 Goroutine 切换出去,重新调度 LRQ 上的其他 Goroutine;
  2. 由于网络请求和 IO 操作导致 Goroutine 阻塞。Go 程序提供了网络轮询器(NetPoller)来处理网络请求和 IO 操作的问题,其后台通过 kqueue(MacOS),epoll(Linux)或 iocp(Windows)来实现 IO 多路复用。通过使用 NetPoller 进行网络系统调用,调度器可以防止 Goroutine 在进行这些系统调用时阻塞 M。这可以让 M 执行 P 的 LRQ 中其他的 Goroutines,而不需要创建新的 M。执行网络系统调用不需要额外的 M,网络轮询器使用系统线程,它时刻处理一个有效的事件循环,有助于减少操作系统上的调度负载。用户层眼中看到的 Goroutine 中的“block socket”,实现了 goroutine-per-connection 简单的网络编程模式。实际上是通过 Go runtime 中的 netpoller 通过 Non-block socket + I/O 多路复用机制“模拟”出来的。
  3. 当调用一些系统方法的时候(如文件 I/O),如果系统方法调用的时候发生阻塞,这种情况下,网络轮询器(NetPoller)无法使用,而进行系统调用的 G1 将阻塞当前 M1。调度器引入 其它M 来服务 M1 的P。
  4. 如果在 Goroutine 去执行一个 sleep 操作,导致 M 被阻塞了。Go 程序后台有一个监控线程 sysmon,它监控那些长时间运行的 G 任务然后设置可以强占的标识符,别的 Goroutine 就可以抢先进来执行。
  1. channel阻塞或网络I/O下的调度如果G被阻塞在某个channel操作或者网络I/O操作上的时候,G会被放入到某个等待队列中,而M会尝试运行P的下一个可运行的G;如果此时P没有可运行的G给M运行,那么M将解绑P,并进入挂起状态。当I/O或者channel操作完成,在等待队列中的G会被唤醒,标记为可运行,并被放入到某个P队列中,绑定一个M后继续运行。(具体可以讲一下channel底层)
  2. 系统调用阻塞情况下,如何调度: 如果G被阻塞在某个系统调用上,那么不仅仅G会阻塞,执行G的M也会解绑P,与G一起进入挂起状态。如果此时有空闲的M,则P和与其绑定并继续执行其他的G;如果没有空闲的M,但还是有其他G去执行,那么会创建一个新M。当系统调用返回后,阻塞在该系统调用上的G会尝试获取一个可用的P,如果没有可用的P,那么这个G会被标记为runnable,之前的那个挂起的M将再次进入挂起状态。
  3. 另外: 有一个sysmon线程做抢占式调度,当一个goroutine占用CPU超过10ms之后,调度器会根据实际情况提供不保证的协程切换机制
  4. 参考博客

6. 协程切换的时机

  1. 哪些情况下会切换
  1. select操作阻塞时
  2. io阻塞
  3. 阻塞在channel:chan 读写出现阻塞时,runtime 会隐式地进行上下文切换
  4. 等待锁
  5. 手动调用runtime.Gosched():
  6. 手动调用timeSleep()
  1. 针对读写channel是否阻塞流程具体参考channle底层
  2. 并且在go中执行 network io, channel, disk io, sleep还有syscall系统调用操作时,这些操作都会导致M跟G的解绑,并且M重新的获取可用G的调度

7. 抢占式调度

  1. runtime.main会创建一个额外的M运行sysmon函数, sysmon函数会进入一个无限循环,第一轮回休眠20us,之后每次休眠时间倍增,最终每一轮都会休眠10ms
  2. sysmon做了些什么:netpool(获取fd事件),retake(抢占),forcegc(按时间强制执行gc),scavenge heap(释放自由列表中多余的项减少内存占用)等处理, 最重要的是会执行到retake()函数
  1. 释放闲置超过5分钟的span物理内存;
  2. 如果超过2分钟没有垃圾回收,强制执行;
  3. 将长时间未处理的netpoll结果添加到任务队列;
  4. 向长时间运行的G任务发出抢占调度;
  5. 收回因syscall长时间阻塞的P;
  1. 在retake()中:
  1. 会遍历所有的p,判断p的状态,根据状态选择执行系统调用抢占逻辑或者运行超时抢占逻辑
  2. 如果P的状态为_Psyscall表示系统调用,且经过了一次sysmon循环(20us~10ms), 则调用handoffp解除M和P之间的关联抢占这个P
  3. 如果P的状态为_Prunning,且经过了一次sysmon循环并且G运行时间超过forcePreemptNS(10ms), 则调用preemptone函数, 设置g.preempt = true, 设置g.stackguard0 = stackPreempt抢占这个P
P的状态为_Psyscall系统调用状态时的抢占
  1. P的状态为_Psyscall时处于系统调用之中,会判断三个是否满足抢占的条件,检查是否需要抢占,然后执行handoffp()
  1. p 的本地运行队列里面有等待运行的 goroutine。这时 p 绑定的 g 正在进行系统调用,无法去执行其他的 g,因此需要接管 p 来执行其他的 g。
  2. sched.nmspinning 和 sched.npidle 都为 0,这就意味着没有“找工作”的 m,也没有空闲的 p,因此要抢占当前的 p,让它来承担一部分工作
  3. 从上一次监控线程观察到 p 对应的 m 处于系统调用之中到现在已经超过 10 毫秒。这说明系统调用所花费的时间较长,需要对其进行抢占,以此来使得 retake 函数返回值不为 0,这样,会保持 sysmon 线程 20 us 的检查周期,提高 sysmon 监控的实时性
  1. handoffp()
  1. 首先会判断p的本地或者全局队列中是否有等待运行的g,如果有调用 startm(p, false) 启动一个 m 来结合 p,继续工作
  2. 如果没有等待运行的g,会判断其它p是否都在工作,为了全局更快地完成工作,调用startm(p, true)启动一个 m,且要使得 m 处于自旋状态,和 p 结合之后,尽快找到工作。
  3. 最后,如果实在没有工作要处理,就当前 p 放入全局空闲队列里
  1. 在handoffp()中会调用startm()函数,查看该函数,
  1. 首先判断如果p为空,会先调用pidleget()从全局空闲队列中获取一个空闲p,如果没找到,则直接返回,在返回前还会判断 spinning 为 true 的话,需要还原全局的处于自旋状态的 m 的数值&sched.nmspinning
  2. 如果p不为空, 调用mget()从 m 空闲队列中获取正处于睡眠之中的m,如果获取不到则调用newm()新建一个m,并且spinning 为 true时会设置一个mspinning()函数
  3. 最后执行mp.nextp.set(p)将m与p绑定, 执行notewakeup()唤醒m
  1. notewakeup() 唤醒m函数中
  1. 首先使用 atomic.Xchg 设置 note.key 值为 1,这是为了使被唤醒的线程可以通过查看该值是否等于 1 来确定是被其它线程唤醒还是意外从睡眠中苏醒了过来, 如果该值为 1 则表示是被唤醒的,可以继续工作,但如果该值为 0 则表示是意外苏醒,需要再次进入睡眠
  2. 然后调用 futexwakeup 来唤醒工作线程
P的状态为_Prunning运行超时时的抢占
  1. 当p的状态为_Prunning 时,表示正在运行,检查运行时间是否超过了forcePreemptNS也就是10ms,如果超过了则调用preemptone()发起抢占
  1. sysmon 扫描每个 p 时,都会记录下当前调度器调度的次数和当前时间,记录到sysmontick结构体变量中: 前面两个字段记录调度器调度的次数和时间,后面两个字段记录系统调用的次数和时间
  2. 在下一次扫描时,对比 sysmon 记录下的 p 的调度次数和时间,与当前 p 自己记录下的调度次数和时间对比,如果一致。说明 P 在这一段时间内一直在运行同一个 goroutine。那就来计算一下运行时间是否太长了
  3. 如果发现运行时间超过了 10 ms,则要调用 preemptone(p) 发起抢占的请求
  1. 在preemptone()中最重要的就是将 stackguard0 设置了一个很大的值stackPreempt
  2. 为什么设置了stackguard= stackPreempt就可以实现抢占: 因为这个值用于检查当前栈空间是否足够, go函数的开头会比对这个值判断是否需要扩张栈.stackPreempt是一个特殊的常量, 它的值会比任何的栈地址都要大, 检查时一定会触发栈扩张.
  3. 栈扩张调用的是morestack_noctxt函数, morestack_noctxt函数清空rdx寄存器并调用morestack函数,该函数中会保存G的状态到g.sched, 切换到g0和g0的栈空间, 然后调用newstack函数。newstack函数判断g.stackguard0等于stackPreempt, 就知道这是抢占触发的, 这时会再检查一遍是否要抢占:
  1. 如果M被锁定(函数的本地变量中有P), 则跳过这一次的抢占并调用gogo函数继续运行G
  2. 如果M正在分配内存, 则跳过这一次的抢占并调用gogo函数继续运行G
  3. 如果M设置了当前不能抢占, 则跳过这一次的抢占并调用gogo函数继续运行G
  4. 如果M的状态不是运行中, 则跳过这一次的抢占并调用gogo函数继续运行G
  1. 如果抢占失败,g.preempt等于true, runtime中的一些代码会重新设置stackPreempt以重试下一次的抢占.如果判断可以抢占, 则继续判断是否GC引起的, 如果是则对G的栈空间执行标记处理(扫描根对象)然后继续运行。如果不是GC引起的则调用gopreempt_m函数完成抢占,gopreempt_m函数会调用goschedImpl函数, goschedImpl函数的流程是:
  1. 把G的状态由运行中(_Grunnable)改为待运行(_Grunnable)
  2. 调用dropg函数解除M和G之间的关联
  3. 调用globrunqput把G放到全局运行队列
  4. 调用schedule函数继续调度
  1. 抢占的优点:保证了不会有一个G长时间的运行导致其他G无法运行的情况发生
  2. sysmon后台监控线程做了什么

8. scheduler的陷阱

  1. 在前面将了抢占式调度, 有一个后台线程在持续监控,一旦发现 goroutine 运行超过 10 ms,会设置 goroutine 的“抢占标志位”,之后调度器会处理。但是设置标志位的时机只有在函数“序言”部分,对于没有函数调用的就没有办法了,如下在死循环里出不来,不会输出最后的那条打印语句:
  1. 示例中启动和机器的 CPU 核心数相等的 goroutine,每个 goroutine 都会执行一个无限循环
  2. 创建完这些 goroutines 后,main 函数里执行一条 time.Sleep(time.Second) 语句
  3. Go scheduler 判断到time.Sleep语句时会执行调度
  4. 但是由于创建的协程个数刚好“一个萝卜一个坑”把 M 和 P 都占满了。
  5. 在这些 goroutine 内部,又没有调用一些诸如 channel,time.sleep 这些会引发调度器工作的事情,所以无限循环了
  6. 解决办法:将协程树减1,主 goroutine 休眠一秒后,被 go schduler 重新唤醒,调度到 M 上继续执行,打印一行语句后,退出。主 goroutine 退出后,其他所有的 goroutine 都必须跟着退出
func main() {
    var x int
    threads := runtime.GOMAXPROCS(0)
    for i := 0; i < threads; i++ {
        go func() {
            for { x++ }
        }()
    }
    time.Sleep(time.Second)
    fmt.Println("x =", x)
}

9. main goroutine的创建

  1. 在启动go项目时底层会执行runtime.rt0_go(),这是go项目的启动入口,在该函数中会执行到schedinit()进行初始化,调用mcommoninit()初始化当前M,即全局M0, 一直调用到newproc() 创建一个新的goroutine用于执行mainPC所对应的runtime·main函数,也就是通过这个goroutine执行我们的main函数, 自此第一个协程创建完毕,接下来等待执行

10. g0栈和用户栈如何切换的

  1. g0 栈用于执行调度器的代码,执行完之后,要跳转到执行用户代码的地方,如何跳转
  2. 首先在调度器执行时会调用schedule()函数,该函数中首先执行getg()获取到当前系统协程也就是g0,由g0获取到对应的m,获取到对应的p,后续通过这个p按照
  1. 每61次通过全局队列获取待执行g,如果没有通过本地队列获取.
  2. 还没有,调用 findrunnable()函数,从全局队列、epoll、别的 P 里获取获取g,
  3. 如果还没有则判断当前m能否进入自旋,对m进行自旋或者休眠,
  4. 如果能获取到,则调用execute(),execute()函数中会调用汇编的汇编的 GoGo 函数,将当前G0切换到对应的 GP 上,继续执行协程
  1. 以上是切换,那么切换前数据是如何设置到用户g协程上去的,这里要看获取协程时底层的newproc1()函数,该函数中也是先调用getg()获取g0,也就是当前工作线程主线程,调用执行acquirem()获取m,找到空闲g,如果没有则调用malg(_StackMin)创建一个,重点:
  1. 点协程创建完毕后,会执行memmove(),将 fn 的参数从 g0 栈上拷贝到 newg 的栈上
  2. 然后调用memclrNoHeapPointers 将 newg.sched 的内存全部清零。接着设置 sched 的 sp 字段,当 goroutine 被调度到 m 上运行时,需要通过 sp 字段来指示栈顶的位置,这里设置的就是新栈的栈顶位置
  3. 会设置pc 字段为函数 goexit 的地址加 1: “newg.sched.pc = funcPC(goexit) + sys.PCQuantum”
  4. 设置newg.sched.g字段为 newg 的地址
  5. 接下来会执行一个
  1. gostartcallfn()函数的主要作用有:
  1. 调整newg的栈空间,把goexit函数的第二条指令的地址入栈,伪造成goexit函数调用了fn,从而使fn执行完成后执行ret指令时返回到goexit继续执行完成最后的清理工作;
  2. 重新设置newg.buf.pc 为需要执行的函数的地址,即fn,这里才真正让newg的ip寄存器指向fn函数,等到newg被调度起来运行时,调度器会把buf.pc放入cpu的IP寄存器,从而使newg得以在cpu上真正的运行起来
  1. 然后执行runqput()将协程g放入放入本地或全局队列中等待运行

11. m是如何找工作的

  1. 在前面说协程调度时我们已经了解到大致经历三个过程:先从本地队列找,定期会从全局队列找,最后实在没办法,就去别的 P 偷,如果偷不到会先进入自旋状态,自旋过后会进入休眠状态
  2. 在源码上看协程调度时,会执行到一个runqget()函数从可运行队列中获取g,可以分四个部分去理解
  1. 第一个 for 循环尝试返回 P 的 runnext 属性中获取g运行,如果为空进入下一个循环,否则,原子操作获取 runnext,并将其值修改为 0也就是空,原子操作的原因是防止在这个过程中,有其他线程过来“偷工作”,导致并发修改 runnext 成员
  2. 第二个 for 循环则是通过runnext 获取失败后,尝试从本地队列中返回队列头的 goroutine,同样先用原子操作获取队列头, 获取到头节点后,还会获取一下队列的尾节点,因为“偷工作”时只会修改队列头,尾部的获取不需要原子操作,然后比较队列头和队列尾,如果两者相等,说明 P 本地队列没有可运行的 goroutine,直接返回空,否则算出队列头指向的 goroutine,再用一个 CAS 原子操作来尝试修改队列头
  3. 当本地队列获取不到时,会调用globrunqget()尝试从全局队列中获取,注意调度器每调度 61 次并且全局队列有可运行 goroutine 的情况下才会调用 globrunqget 函数尝试从全局获取可运行 goroutine。因为从全局获取需要上锁,这个开销可就大了,能不做就不做
  4. 在通过全局队列获取时,首先根据全局队列的可运行 goroutine 长度和 P 的总数,来计算一个数值,表示每个 P 可平均分到的 goroutine 数量, 根据函数参数中的 max 以及 P 本地队列的长度来决定把多少全局队列中的 goroutine 转移到 P 本地,最后,for 循环挨个把全局队列中 n-1 个 goroutine 转移到本地,并且返回最开始获取到的队列头所指向的 goroutine,毕竟它最需要得到运行的机会
  5. 如果全局队列获取不到时调用findrunnable()从其他 P “偷工作”,先获取当前指向的 g,也就是 g0,然后拿到其绑定的 p,即 p,会再次尝试从 p 本地队列获取 goroutine,如果没有获取到,则尝试从全局队列获取。如果还没有获取到就会尝试去“偷”了,并且或判读其它P是否都为空闲,如果是,说明没有任务执行,进入stop,否则开始执行偷的操作
  6. 在偷时会先把自己的自旋状态设置为 true,全局自旋数量加 1,实际上在偷的过程中内层会遍历所有的 P,因此,整体看来,会尝试 4 次扫遍所有的 P,并去“偷工作”
  7. 最终会调用一个runqsteal()从 其它p中偷走一半的工作放到 p 的本地

三. 协程调度总结

  1. 先简述一下协程调度过程,以创建新的G为例
  1. 创建G时,会先判断有没有空闲的P,有的话创建M绑定P,将新的G放到该P队列中。
  2. 如果没有空闲P,判断当前P队列是否满了,没满的话就放到P队列中,如果满了,会将P中的一半连同新的G一起放到全局队列中。
  3. M周期性的从P中取出G来执行,并且定期从全局队列中取出G来执行,防止全局队列中的G被饿死。
  4. 当M因为当前G陷入系统调用或者阻塞时,M会记住当前的P,然后解绑P,P如果没有G了,就会进入空闲状态,如果有G会从M缓存池取空闲的M或者创建一个M来继续执行队列中的G。
  5. 当M结束系统调用时,先找之前的P,没有找到会找到一个空闲的P,找到了就绑定P,将G放到P队列中,继续执行
  6. 如果找不到的话,就将G放到全局队列中,陷入睡眠状态。
  7. 当P中的队列中不存在G,会从全局队列中取G来执行。如果全局队列也没有G。就从其他P中偷取,每次偷一半
  8. 每个M都有一个G0,用来在调度或者系统调用的时候使用其栈空间。
  9. M0除了负责初始化操作和启动第一个G之后跟其他的M一样。
  10. G执行完成后会切换为G0,然后G0负责调度协程切换
  1. 然后讲一下原理,首先要了解进程,线程,协程的区别,协程的优点有哪些,
  2. 接着介绍一下常见的几种线程模型,引出golang中的MPG,参考源码解释一下Go中M,P,G的几个比较重要的属性
  3. 接着结合源码介绍协程调度原理,参考源码协程调度可以分为三个步骤去理解
  1. 协程的创建
  2. go项目启动,调度器初始化
  3. 调度执行

1. M,P,G数量问题

  1. 在启动go项目时底层会执行runtime.rt0_go(),这是go项目的启动入口,在该函数中会执行到schedinit()进行初始化,
  1. 设置m的最大数量是10000
  2. 调用mcommoninit初始化m0
  3. 调用procresize,调整p的数量,默认情况下等于cpu核数,也可以通过环境变量 GOMAXPROCS 来控制 P 的数量。_MaxGomaxprocs 控制了最大的 P 数量只能是 1024
  4. 绑定m0和p
  5. 到现在已经有了m0 g0 gsignal和p相互绑定,并且有ncpu个p
  1. 注意点: 虽然设置了M最大为10000个,但实在实际调度时,会判断当前是否存在空闲,或者自旋的M,如果存在则不会创建
  2. 查看P的源码,在P中分别有: 来保存等待运行的 G 列表的runq属性我们称为本地队列,最大可以存储256个goroutine, 还有一个用来实现调度器亲和性的runnext ,(在外部还存在一个无限长度的全局队列)。单说每个P下面能够保持的g的数量: runq的允许的最大goroutine 数量为256,再加上runnext 一个P最大的情况下可以有257个goroutine,另外P中还存在一个gFree 内部保存了空闲的 G 列表,是个结构体,其中n表示空闲g的个数
  3. 服务能开多少个m由什么决定?开多少个P有什么界定
  1. m和g开多少由内存决定,一个m=2M,一个g=2k
  2. m的个数 > g的个数
  3. p的个数由GOMAXPROC决定,可以设置

2. goroutine泄漏问题

  1. goroutine可能泄漏吗: 可能
  2. 产生泄漏的几种场景:
  1. goroutine由于channel的读/写端退出而一直阻塞,导致goroutine一直占用资源,而无法退出
  2. goroutine进入死循环中,导致资源一直无法释放
  3. 在某个基于协程业务上没有控制协程数量,同一时间创建大量协程
  1. 解决:
  1. 协程创建,特殊场景考虑数量控制
  2. 协程执行,考虑阻塞问题,例如协程操作channel时通过select+time, sync.waitGroup,或者context
  1. goroutine导致的泄漏情况 数组的切片的,其他的呢

3. 控制协程并发数

  1. 参考博客
  2. 参考博客
  3. 几个简单示例
//1.通过channel实现
func TestCount(t *testing.T) {
	//1.创建带缓冲channel,长度为3
	ch := make(chan struct{}, 3)
	for i := 0; i < 10; i++ {

		//2.向channel中写数据,由于设置了缓冲,最大可以写入3个数据
		ch <- struct{}{}

		//3.协程执行
		go func(i int) {
			log.Println(i)
			time.Sleep(time.Second)
			//4.在协程执行完实际业务后通过channel获取数据 ,
			//如果获取不到会在此处阻塞
			<-ch
		}(i)

	}
}

//2.通过sync.WaitGroup实现
func TestCount1(t *testing.T) {
	//1.创建等待组
	var wg = sync.WaitGroup{}

	taskCount := 5 // 指定并发数量
	for i := 0; i < taskCount; i++ {
		//2.等待组设为为1
		wg.Add(1)
		go func(i int) {
			fmt.Println("go func ", i)
			//3.协程中执行完毕后,对等待组进行累减操作
			wg.Done()
		}(i)
	}
	//4.当等待组为0时放行
	wg.Wait()
}

//3.sync.WaitGroup与channel结合使用(WaitGroup不是必须的)
//如果 taskcount 设置的很大超出了限制的,则其还是没有控制到并发数量。
//可以优化下设计,类似池的设计思想,通过允许最大连接数控制量,
//当超出了数量就需要等待释放,有空闲的连接的时候才可以继续执行
func testRoutine() {

	//1.创建缓冲区大小为 3 的 channel,在没有被接收的情况下,
	//至多发送 3 个消息则被阻塞。 通过 channel 控制每次并发的数量。
	//2.开启协程前,设置 task_chan <- true,若缓存区满了则阻塞
	//协程任务执行完成后就释放缓冲区
	//3.等待所有的并发都处理结束后则函数结束。
	//4.其实可以不使用 sync.WaitGroup。
	//因使用 channel 控制并发处理的任务数量可以不用使用等待并发处理结束
	task_chan := make(chan bool, 3) //100 为 channel长度
	wg := sync.WaitGroup{}
	defer close(task_chan)
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		fmt.Println("go func ", i)
		task_chan <- true
		go func() {
			<-task_chan
			defer wg.Done()
		}()
	}
	wg.Wait()
}
  1. 另外还有利用三方库实现,例如Jeffail/tunny 或 panjf2000/ants

4. 协程做超时控制

  1. channel+select +time.After()
func main() {
	ch := make(chan struct{}, 1)
	go func() {
		fmt.Println("do something...")
		time.Sleep(4 * time.Second)
		ch <- struct{}{}
	}()

	select {
	case <-ch:
		fmt.Println("done")
	case <-time.After(3 * time.Second):
		fmt.Println("timeout")
	}
}
  1. Context实现
func test(){
	ch := make(chan string)
	timeout, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()
	go func() {
		time.Sleep(time.Second * 4)
		ch <- "done"
	}()

	select {
	case res := <-ch:
		fmt.Println(res)
	case <-timeout.Done():
		fmt.Println("timout", timeout.Err())
	}
}

5. 保证多协程执行顺序

  1. 基于channel实现
func main() {
	c1 := make(chan struct{})
	c2 := make(chan struct{})
	c3 := make(chan struct{})

	go func() {
		//协程一 不受限制 直接执行 执行结束后关闭通道一
		fmt.Println("this value is 0")
		close(c1)
	}()
	go func() {
		//协程二 需要从通道一中接收值 ,或者通道关闭时,获取到接收失败的结果,否则一直阻塞
		//执行结束后关闭通道二
		<-c1
		fmt.Println("this value is 1")
		close(c2)
	}()
	go func() {
		//协程三 需要从通道二中接收值 ,或者通道关闭时,获取到接收失败的结果,否则一直阻塞
		//执行结束后关闭通道三
		<-c2
		fmt.Println("this value is 2")
		close(c3)
	}()
	
	//主协程 需要从通道三中接收值 ,或者通道关闭时,获取到接收失败的结果,否则一直阻塞
	<-c3
}
  1. 基于多把sync.Mutex互斥锁实现
func main() {
	times := 5
	//创建一个互斥锁数组 多一个给主协程用
	var cc = make([]*sync.Mutex, times+1)
	//往数组中塞入互斥锁,默认直接加锁
	for i := 0; i < len(cc); i++ {
		m := &sync.Mutex{}
		m.Lock()
		cc[i] = m
	}
	for i := 0; i < times; i++ {
		//创建子协程
		go func(index int) {
			//子协程尝试为数组中对应 index 位置的锁加锁,获取不到锁就等待
			//因为初始化的这些互斥锁默认就已经被锁住了,所以这里创建的子协程都会被阻塞
			//一旦获取到锁,就执行逻辑,最后将当前index的锁和index+1的锁释放,这样正在等待 index +1 位置的锁的子协程就可以继续执行了
			cc[index].Lock()
			fmt.Printf("this value is %d \n", index)
			cc[index].Unlock()
			cc[index+1].Unlock()
		}(i)
	}
	//将index 为 0 位置的锁解锁,让第一个子协程可以继续执行
	cc[0].Unlock()
	//为 index 为 times 的锁加锁,只有当最后一个子协程执行完毕后,这个锁才会解锁,主协程才能继续向下走
	cc[times].Lock()
	cc[times].Unlock()
}
  1. 像sync.WaitGroup应该也可以

6. 协程退出

  1. 手动调用退出相关函数

runtime.Goexit(): 协程退出,是对goexit0()的封装, 实际每个堆栈底部都是这个方法,会在协程内的业务代码跑完后被执行到,从而实现协程退出,并调度下一个可执行的G来运行,且结束前还能执行到defer的内容
os.Exit(): 进程退出

  1. 基于 channel 的 close 机制,通知退出
func main() {
	//1.创建channel
	ch := make(chan string, 6)
	//2.创建启动协程
	go func() {
		for {
			//3.协程中在channel中获取数据,
			//当协程关闭时读取到false,执行return跳出协程
			v, ok := <-ch
			if !ok {
				fmt.Println("结束")
				return
			}
			fmt.Println(v)
		}
	}()

	ch <- "写数据1"
	ch <- "写数据2"
	//关闭协程
	close(ch)
	time.Sleep(time.Second)
}
  1. 基于Context+channel+select实现
func main() {
	//1.创建channel
	ch := make(chan struct{})

	//2.创建Context
	ctx, cancel := context.WithCancel(context.Background())

	//3.基于Context+channel+select实现
	go func(ctx context.Context) {
		for {
			select {
			case <-ctx.Done():
				//当context接收到取消操作时,会执行此处
				//跳出协程
				ch <- struct{}{}
				return
			default:
				fmt.Println("业务执行")
			}

			time.Sleep(500 * time.Millisecond)
		}
	}(ctx)

	go func() {
		time.Sleep(3 * time.Second)
		//context取消
		cancel()
	}()

	<-ch
	fmt.Println("结束")
}
  1. 另外还有: 业务执行完毕自动退出, 异常退出,跟随main方法退出
  2. 怎么阻止退出: sync.WaitGroup 或channel
1. 协程退出底层
  1. 协程退出时底层实际会执行goexit0(),实际每个堆栈底部都会调用一个runtime.Goexit(),该函数就是对goexit0的封装,查看源码:
  1. 把 g 的状态从 _Grunning 更新为 _Gdead;
  2. 清空 g 的一些字段;
  3. 调用 dropg 函数解除 g 和 m 之间的关系,其实就是设置 g->m = nil, m->currg = nil;
  4. 把 g 放入 p 的 freeg 队列缓存起来供下次创建 g 时快速获取而不用从内存分配。freeg 就是 g 的一个对象池;
  5. 调用 schedule 函数再次进行调度。到此gp 就完成了它的历史使命
2. 其它问题
  1. go调度为什么说是轻量的,go中怎么让少量的内核线程支撑大量协程并发运行的:该问题实际问的还是协程调度的原理,可以说一下上面总结的协程调度原理,单说这个问题的话:
  1. go调度为什么说是轻量的: go中采用MPG线程模型,通过代码在用户态层面实现了一套线程处理逻辑,其中M就可以理解为内核线程在用户态的映射,减少了用户态到内核态的切换,并且线程栈空间通常是2M, Goroutine栈空间最小是2k节省内存资源
  2. go中怎么让少量的内核线程支撑大量协程并发运行的:这块还是协程调度原理,讲一下go中的M,P,G与调度过程
  1. 为了最大利用计算资源,go调度器是如何处理线程阻塞场景的: 参考对堵塞的处理与抢占式调度
  2. 进程之间如何通信,同一台机器哪种方式最快
  1. 管道:速度慢,容量有限,只有父子进程能通讯
  2. 有名管道(named pipe):任何进程间都能通讯,但速度慢
  3. 消息队列:容量受到系统限制,且要注意第一次读的时候,要考虑上一次没有读完数据的问题
  4. 信号量:不能传递复杂消息,只能用来同步
  5. 共享内存:能够很容易控制容量,速度快,但要保持同步,比如一个进程在写的时候,另一个进程要注意读写的问题,相当于线程中的线程安全,当然,共享内存区同样可以用作线程间通讯,不过没这个必要,线程间本来就已经共享了同一进程内的一块内存
  1. 两个goroutine怎么通信,除了channel还可以用什么
  1. 锁, channel, 共享内存好像不可以,因为go中好像没有内存屏障这一说,并且go中推荐:用通信来共享内存(数据)不要通过共享内存来通信,还有一种go标准库中提供的context?
  1. 说下go什么时候发生协程切换(系统调用、select阻塞时、channel阻塞): 参考对阻塞的处理与抢占式调度
  2. 一个go开启另外一个go,原来的go退出,新的go怎么办 为什么: 当父协程是main协程时,父协程退出,父协程下的所有子协程也会跟着退出;当父协程不是main协程时,父协程退出,父协程下的所有子协程并不会跟着退出(子协程直到自己的所有逻辑执行完或者是main协程结束才结束)
  3. goroutine数量优化: 通过channel, sync.WaitGroup或者semaphore,协程池
  4. 协程用到异常panic怎么办?怎么捕获,具体的语句怎么写: 通过defer +recover()捕获异常
func main() {
	go test()
	for i := 0; i < 10; i++ {
		fmt.Println("ok")
		time.Sleep(time.Second)
	}
}

func test() {
	//这里可以使用错误处理机制defer + recover来解决
	defer func() {
		//使用匿名函数捕获test抛出的panic
		//是一个内建的函数,可以让进入令人恐慌的流程中的 goroutine 恢复过来。
		// recover仅在延迟函数中有效。
		//在正常的执行过程中,调用 recover 会返回 nil 并且没有其他任何效果。
		//如果当前的 goroutine 陷入恐慌,调用 recover 可以捕获到 panic 的输入值,
		//并且恢复正常的执行。recover只有在defer调⽤的函数中有效。
 
		if err := recover(); err != nil {
			fmt.Println("test 发生错误", err)
		}
	}()
	//定义了一个map
	var myMap map[int]string
	myMap[0] = "golang" //error
	//panic: assignment to entry in nil map
}
  1. 如果goroutine之间传递信息,除了channel还可以用什么: 锁, channel, 不知道context算不算
  2. GMP,m和g什么时候会阻塞,阻塞休眠之后怎么做,获取资源之后呢: 阻塞休眠之后参考对阻塞的处理,获取到资源后参考协程调度
  3. 本地队列的数量多少个: 256
  4. golang是否支持抢占式调度,哪几种场景会进行抢占式调度: 支持,参考抢占式调度,
  5. 怎么保持cpu一直不被协程占用的呢,满足什么条件会退出呢:不知道问的是不是抢占式调度
  6. g0线程(协程)和m0线程一般都担当什么职责
  7. 怎么保持全部协程执行完毕,主协程再退出
//1. sync.WaitGroup
func main() {
     var wg sync.WaitGroup
 
     for i := 0 ; i > 5 ; i = i + 1 {
         wg.Add( 1 )
         go func(n int ) {
             // defer wg.Done(),注意这个Done的位置,是另一个函数
             defer wg.Done()
             EchoNumber(n)
         }(i)
     }
     wg.Wait()
}
 
func EchoNumber(i int ) {
     time.Sleep(3e9)
     fmt.Println(i)
}

//2.chennel+select
func Count(ch chan int) {
    ch <- 1
    fmt.Println("Counting")
}

func main() {
	//1.创建channel用来控制协程的执行
    chs := make([] chan int, 10)
    for i:=0; i<10; i++ {
        chs[i] = make(chan int)
        //2.子协程中向channel中添加数据
        go Count(chs[i])
    }
	
	//3.主协程基于channel一直取数据,当取不到时会阻塞
    for _, ch := range(chs) {
        <-ch
    }
}
  1. GMP中本地队列有256个,一个G绑定一个P的时候,跟它的M不是配对的嘛?当他们都绑定之后,在本地最多可以执行多少个g?
  1. p的数量默认等于cpu核数,运行g的前提时有对应的M跟P绑定,那么能够同时运行的g的个数等于p的数量等于cpu核数
  2. 如果问能给存储都少个g, 一个p下面有本地队列256+有一个用来实现调度器亲和性的runnext, 再加上无序的全局队列
  1. 可以说在p中最多可以存储多少个g来执行?一个p中最多可以存储多少个g?肯定是高于256个,至于多几个,多在什么地方知道吗: 257个,其中256个存储在runq本地队列中,在P中还有一个实现调度亲和性的runnext存储1个
  2. 本地存储到多少个队列的时候会全局队列里放?:
  1. 创建G时,会先判断有没有空闲的P,有的话创建M绑定P,将新的G放到该P队列中。
  2. 如果没有空闲P,判断当前P队列是否满了,没满的话就放到P队列中,如果满了,会将P中的一半连同新的G一起放到全局队列中
  1. 当我在执行本地队列里的goroutine的时候,比如说本地队列的256是满的,这个时候我去输出我执行的goroutine,他是全部从本地队列去拿还是会夹杂着全局队列?(限定一下,当我在执行的时候,把goMaxProxy限定为1,打300个goroutine进去,输出的时候会输出到全局队列吗?前256会有全局队列吗)?没太明白问啥,跟上一题是同一个问题吗
  2. 一个进程在fork()的时候会复制什么信息
  3. fork一个子进程?父进程垮掉了会影响子进程吗?父进程获取不到子进程的id会怎么样?父进程会等待子进程运行完毕吗
  4. 子进程垮掉会影响父进程吗?然后问到了孤儿进程和僵尸进程?
  5. 子进程变成孤儿进程了发生死循环会怎么样?
  6. goroutine默认栈空间多少: 默认栈为2KB,考虑是否聊一下栈扩容
  7. 你认为goroutine是语言机制还是系统机制: 语言机制, 通过代码在用户态实现了一套协程调度机制,MGP,其中m可以认为是内心线程在用户态的映射
  8. gmp模型、每个P的缓存队列和全局缓存队列,局部饥饿问题、全局饥饿问题
  1. 聊一下调度策略,根据调度策略中的 队列轮转模式: P中维护了一个本地队列与全局队列, 在运行时会周期性的将G调度到M中执行,指定时间内将上下文保存下来,然后将G放到队列尾部,然后从队列中重新取出一个G进行调度,防止全局队列中的G被饿死,每个P会周期性的查看全局队列中是否有G待运行并将期调度到M中执行,全局队列中G的来源,主要有从系统调用中恢复的G
  2. 工作量窃取: m在运行时首先会获取到p,然后获取到可运行的g,如果获取不到,空闲的P会查询全局队列,若全局队列也空,则会从其他P中窃取G(一般每次取一半)
  3. 聊一下抢占式调度, runtime.main会创建一个额外的M运行sysmon函数, sysmon函数会进入一个无限循环,第一轮回休眠20us,之后每次休眠时间倍增,最终每一轮都会休眠10ms,在该函数中会调用retake()遍历所有的P,如果一个P处于执行状态, 且已经连续执行了较长时间,就会被抢占,具体流程:
  4. P在系统调用状态时_Psyscall.且经过了一次sysmon循环(20us~10ms), 则抢占这个P: 调用handoffp解除M和P之间的关联
  5. P在运行状态时_Prunning,且经过了一次sysmon循环并且G运行时间超过forcePreemptNS(10ms), 则抢占这个P, 调用preemptone函数, 设置g.preempt = true, 设置g.stackguard0 = stackPreempt
  6. 但是有人说如果cpu密集型的如果协程内部没有再次执行函数调用,栈容量始终不变,不会有栈阔张,抢占式调度不会触发,也有人说stackPreempt是一个专门的常量,比任何的栈地址都要大,即使实际栈没有变,也会执行栈阔张触发抢占式调度
  1. 线程池是如何配置的
  2. 线程池是用来处理啥的、使用的业务场景、解决什么业务问题
  3. 协程池 每一个worker对应的一个是切片还是个管道
  4. 数据来了之后 写哪个worker的管道
  5. 一个调度相关的陷阱
  6. M:N模型: go runtime负责管理goroutine,Runtime会在程序启动的时候,创建M个线程(CPU执行调度的单位),之后创建的N个goroutine都会依附在这M个线程上执行。在同一时刻,一个线程上只能跑一个goroutine。当goroutine发生阻塞时,runtime会把当前goroutine调度走,让其他goroutine来执行,不让一个线程闲着
    在这里插入图片描述

7. 协程与线程的区别

  1. 在内存占用角度: 协程初始会占用4k的堆栈内存,然后随着程序的执行自动增加或减少是不固定的,而线程是固定的通常以M为单位
  2. 在创建数量上,由占用空间也可以看出协程可以轻松创建百万级别,线程通常不超过一万
  3. 从切换上,以java为例,Java线程的切换依赖于操作系统的线程调度器和硬件中断,而Golang协程的切换由Golang自己实现的调度器完成,更为轻量级和高效,并且在java中一个用户线程会对应一个内核线程,切换时会设置到用户态到内核态的切换,而协程之间的切换只需要在用户态进行,切换成本更低
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值