GMP

1.GMP的设计思想

go func(){} 调度流程

1、通过go func() 创建一个goroutine;

2、有两个存储G的队列,一个是局部调度器的本地队列、一个是全局队列,新创建的G会先保存在P的本地队列,如果P的本地队列已经满了就会保存到全局队列中;

3、G只能运行在M中,一个M必须持有一个P,M与P是1:1关系,M会从P的本地队列中获得一个可执行状态的G来执行,如果P的本地队列为空,那么就会向其他的MP组合偷取一个可执行的G来执行;

4、一个M调度G执行的过程是一个循环机制;

5、当M执行到某一个G如果发生了syscall或其他阻塞操作,M会阻塞,如果当前有一些G在执行,runtime会把这个线程的M从P中摘除,然后在创建一个新的M(如果有空闲的M就复用空闲的M)来复用这个P;

6、当M系统调用结束,这个G会尝试获取一个空闲的P执行,并放入到这个P的本地队列。如果获取不到P,那么这个M就变成休眠状态,加入到空闲线程,然后这个G就放入全局队列。

M的自旋状态下获取满足的数量的公式:min(len(globbrunqsize)/GOMAXPROCS+1,len(localrunsize/2))

2.GMP的数据结构

G的数据结构

type g struct {
stack       stack   // 描述当前goroutine的栈内存范围[stack.lo,stack.hi]
stackguard0 uintptr // 用于调度器抢占式调度
stackguard1 uintptr // offset known to liblink
_panic    *_panic // innermost panic - offset known to liblink
_defer    *_defer // innermost defer
m         *m      // 当前 G 占用的 M,可能为空
sched     gobuf   // 存储 G 的调度相关的数据
atomicstatus atomic.Uint32// G 的状态
goid         uint64  // G 的ID
waitreason   waitReason // 当状态status==Gwwaiting时等待的原因
preempt       bool // 抢占信号
preemptStop   bool // 抢占时将状态修改成 _Gpreempted
preemptShrink bool // 在同步安全点收缩栈
lockedm       muintptr// G被锁定只能在这个M上运行
waiting       *sudog         // 这个G当前正在阻塞的sudog结构体
   ...
}

G的主要字段有:

stack:描述了当前goroutine的栈内存范围[stack.lo,stack.hi);

stackguard0:可以用于调度器抢占式调度;preempt,preemptStop,preemptShrink跟抢占相关;

defer和panic:分别记录这个G最内侧的panic和_defer结构体;

m:记录当前G占用的线程M,可能为空;

atomicstatus:表示G的状态;

sched:存储G的调度相关的数据;

goid:表示G的ID,对开发者不可见;

sched字段的runtime.gobuf结构体:

runtime.g的atomicstatus字段存储了当前G的状态:

以下是主要的六种状态:

M的数据结构

M的字段众多,其重要的有:

g0:Go运行时系统在启动之处创建的,用来调度其他G到M上;

mstartfn:表示M的起始函数,其实就是Go语句携带的那个函数;

curg:存放当前正在运行的G的指针;

p:指向当前与M关联的P;

nextp:用于暂时存放当前M有潜在关联的P;

spinning:表示当前M是否在寻找G,在寻找过程中M处于自旋状态;

lockedg:表示当前与M锁定的那个G,运行时系统会将M和G锁定,一旦锁定就只能相互作用,不接受第三者;

P的数据结构

最主要的数据结构:

status:表示P的不同状态;

runqhead、runqtail、runq三个字段表示处理器持有的运行队列,是一个长度为256的环形队列,其中存储者等待运行的G列表;

runnext:是线程下一个需要执行的G;

gFree存储P本地的状态为_Gdeed的空闲的G,可重新初始化;

schedt的数据结构

除了这些结构体,也有一些全局变量:

var (
allm *m // 所有的 M
gomaxprocs int32 // P的个数,默认为ncpu的个数
ncpu   int32
.....
sched   schedt   // schedt 全局结构体
newprocs   int32
allplock   mutex    // 全局p队列的锁
allp       []*p     // 全局p队列,个数为gomaxprocs
)

面试题

1.什么是GMP?GMP调度流程?(必问)

GMP模型是Go语言中实现并发调度的重要机制。G代表goroutine,M代表操作系统线程,P代表一个调度器,管理调度所需的上下文。G的数量可以很大,不受限于物理线程数;M负责调度工作,主要是将G安排到可用的P上执行;P的数量一般默认为物理CPU核心数,但也可以自己通过GOMAXPROCS。

调度器的启动

Go程序的真正启动函数runtime.rt0_go

1.初始化g0和m0,并将二者互相绑定,m0是程序启动后的初始线程,g0是m0的系统栈代表的G结构体,负责普通G在M上的调度切换;

2.schedinit:进行各种运行时组件初始化工作,这包括我们的调度器与内存分配器、回收器的初始化;

3.newproc:负责根据主函数main的入口地址创建可被运行时调度的执行单元;

4.mstart:开始调度器的调度循环;

首先,启动调度器的启动函数schedinit():会设置M的最大数量10000,实际不会达到;会分别调用stackinit()、mallocinit()、gcinit()等执行goroutine栈初始化、进行内存分配器初始化、进行系统线程M0的初始化、进行GC垃圾回收器的初始化;然后将p的个数设置为GOMAXPROCS,最后会调用runtime.procresize()函数初始化P列表。

schedinit()函数负责M、P、G的初始化过程。M、P、G彼此的初始化顺序遵循:mcommoninit、procresize、newproc,分别负责初始化M资源池(allm)、P资源池(allp)、G的运行现场(g.sched)以及调度队列(p.runq)。

mcomoninit()函数主要负责对m0进行一个初步的初始化,并将其添加到schedt全局结构体中,这里访问schedt会加锁。

runtime.procresize()函数的执行过程如下:

1.如果全局变量allp切片中的P数量少于期望数量,会对切片进行扩容;

2.使用new创建新的P结构体并调用runtime.p.init初始化刚刚扩容的P;

3.通过指针将线程m0和处理器allp[0]绑定到一起;

4.通过runtime.p.destroy释放不再使用的P结构;

5.通过切片截断改变全局变量allp的长度,保证它与期望P数量相等;

6.将除allp[0]之外的处理器P全部设置为_Pidle并加入到全局schedt的空闲P队列中;

runtime.newproce()函数主要是调用runtime.newproc1()获取新的goroutine结构,将新的G放入P的运行队列,M启动时唤醒新的P执行G。

runtime.newproc1()函数主要执行:

1.获取或者创建新的goroutine结构体,会从处理器gFree列表中查找空闲的goroutine,如果不存在空闲的goroutine,会通过runtime.malg创建一个栈大小足 够的新结构体,新创建的G状态为_Gdead;

2.将传入的参数callergp、callerpc、fn更新到G的栈上,初始化G的相关参数;

3.将G的状态设置为_Grunnable状态,返回;

runtime.newproc1()函数主要调用了runtime.gfget()函数获取G:

runtime.gfget()函数的主要逻辑是:当P的空闲列表gFree为空时,从sched持有的全局空闲列表gFree中移动最多32G到当前的P的空闲列表上,然后从P的 gFree列表头返回一个G,如果还是没有,则返回空,说明获取不到空闲的G。

在runtime.newproc1()函数中,如果不存在空闲的G,会通过runtime.malg()创建一个栈大小足够的新结构体。

回到runtime.newproce()函数,在获取到G后,调用runtime.runqput()函数将G放入到P本地队列或全局队列。

runtime.runqput()函数的主要逻辑是:

1.保留一定的随机性,设置next为false,即不将当前G设置为P的下一个执行的G;

2.当next为true是,将G设置到P的runnext作为P执行的下一个执行的任务;

3.当next为false并且本地运行队列还有剩余空间时,将goroutine加入处理器持有的本地运行队列;

4.当处理器的本地运行队列已经没有剩余空间时,就会把本地队列中的一部分G和待加入的G通过runtime.runqputslow添加到调度器持有的全局运行队列上;

runtime.runqputslow()函数会把P本地环形队列的前一半G获取出来,跟传入的G组成一个列表,打乱顺序,再放入全局队列。

调度循环

runtime.rt0_go函数在调用runtime.schedinit()初始化了调度器、调用了runtime.newproc()创建了main函数的G后,会调用runtime.mastrt函数启动M去执行G。

runtime.mstart()会直接调用runtime.mstart0()函数。

runtime.mstart0()函数主要调用runtime.mstart1(),runtime.mstart1()保存调度信心后,会调用runtime.schedule()进入调度循环,寻找一个可执行的G并执行。

runtime.schedule()函数会查找待执行的goroutine:

1.当全局队列中有待执行的G时,通过schedtick对61取模,表示每61次会有一次从全局队列中查找对应的G,这样可以避免两个G在P本地队列互换一直占有本地队列;

2.调用runtime.runqget()函数从P本地的运行队列中获取待执行的G;

3.如果前两种都没找到,那么会通过runtime.findrunnable()进行阻塞地查找G;

runtime.schedule函数从全局队列中获取G的函数是runtime.globrunqget():会从全局队列获取n个G,第一个G返回给调度器去执行,剩下的n-1个G放入到本地队列,其中,n一般为全局队列的长度/P处理器个数+1,含义是平均每个P应该从全局队列中承担的G数量,且不能超过P本地长度的一半。

runtime.schedul函数从本地队列中获取G的函数是runtime.runqget():本地队列的获取从P的runnext字段获取,如果不为空则直接返回;如果runnext为空,那么从本地环形队列头指针遍历本地队列,取到则返回。

阻塞式获取G的runtime.findrunnable看起来很繁琐,其实是按 local->global->netpoll->local->global->netpoll 的顺序获取。

runtime.findrunnable的主要工作:

1.首先检查是否正在进行GC,如果是则休眠当前的M;

2.尝试从本地队列中获取G,如果取到,则直接返回,否则继续从全局队列中找G,如果找到则直接返回;

3.检查netpoll网络轮询器是否有G,如果有,就直接返回;

4.如果此时仍然无法找到G,则从其他P的本地队列中偷取,从其他P的本地队列偷取的工作会执行四轮,如果找到G,则直接返回;

5.所有的可能性尝试过了,在准备休眠M之前,还要进行额外的检查;

6.首先检查此时是否GC mark阶段,如果是,则直接返回mark阶段的G;

7.如果仍然没有,则对当前的P进行快照,准备对调度器进行加锁;

8.当调度器被锁住后,仍然还需要再次检查这段时间里是否有进入GC,如果已经进入了GC,则回到第一步,阻塞M并休眠;

9.如果又在全局队列中发现了G,则直接返回;

10.当调度器被锁住后,我们彻底找不到任务了,则归还释放当前的P,将其放入idle链表中,并解锁调度器;

11.当M、P 已经解绑后,我们需要将M的状态切换出自旋状态,并减少nmspinning;

12.此时仍然需要重新检查所有的队列,如果我们又在全局队列中发现了g,则直接返回;

13.还需要再检查是否存在poll网络的G,如果有,则直接返回;

14.什么也没找到,休眠当前的M;

在 Go 语言的调度器中,runtime.schedule() 是负责调度 G(goroutine)的核心函数。它首先通过 runtime.globrunqget() 尝试从全局队列获取一个可运行的 G。如果全局队列没有 G,则调用 runtime.runqget() 从当前 P(Processor,本地队列)中获取。如果本地队列也没有找到 G,调度器将调用 runtime.findrunnable(),从其他地方(例如其他 P 的队列、网络轮询器等)尝试获取一个可执行的 G。

当成功获取到一个可执行的 G 后,调度器将调用 runtime.execute(),该函数负责将 G 绑定到 M(OS 线程)上执行。在 runtime.execute() 中,会调用 runtime.gogo(),将 G 的上下文切换到当前的 M 上,开始真正的执行。

当 G 的执行即将结束时,会进入 runtime.goexit() 函数,该函数负责清理 G 的状态,最终调用 runtime.goexit1()runtime.goexit0() 进行进一步的收尾工作。最后,在 runtime.goexit0() 的结尾,调度器会再次调用 runtime.schedule(),重新进入下一次的调度循环。

这样,整个调度流程形成了一个循环:从选择 G 到执行 G,再到结束 G,并进入下一次调度。

2.什么是Workstealing机制?Handoff机制?

Workstealing:M优先执行其绑定的P的本地队列的G,如果本地队列为空,可以从全局队列获取G运行,也可以从其它M绑定的P的运行队列偷取G运行。

Handoff:当M因为G进行系统调用阻塞时,M释放绑定的P,把P转移给其他空闲的M执行。

3.基于协作式的抢占式调度?基于信号的抢占式调度?

每个真正运行的G,如果不被打断,将会一直运行下去,为了保证公平,防止新创建的G一直获取不到M执行造成饥饿问题,Go程序会保证每个G运行10ms就要让出M,交给其他G去执行。

为了避免长时间占用CPU的goroutine影响程序的并发性,从Go1.14开始引入了抢占式调度,他通过给运行时间过长的goroutine发送抢占信号,让它强制交出CPU以便调度器能够调度其他goroutine,避免长时间运行造成的阻塞。

4.GMP调度过程存在那些阻塞?

系统调用导致的阻塞:当 Goroutine 执行系统调用(如文件 I/O、网络 I/O 等)时,整个操作会阻塞,这会导致与该 Goroutine 绑定的 M 也被阻塞。Go 运行时会识别这种情况,并尝试将该 M 解绑,从而将其他的 P 重新分配给没有被阻塞的 Goroutine。

同步原语导致的阻塞:当 Goroutine 使用如 sync.Mutexsync.WaitGroupsync.Cond 等同步原语时,若某个 Goroutine 在这些同步操作上被阻塞,它将进入等待队列,直到锁或条件满足。这种阻塞会影响 Goroutine 的调度。

Goroutine 数量过多或工作窃取失败:当 Goroutine 数量激增时,Go 运行时会通过工作窃取机制在不同的 P 之间平衡 Goroutine 的分配。如果某个 P 窃取不到足够的工作,也可能导致 P 处于空闲状态,等待其他 P 的任务。

垃圾回收 (GC) 暂停:Go 的垃圾回收机制虽然优化了暂停时间(通过并发 GC),但仍然会有短暂的 STW(Stop-The-World)时段,这也会导致 Goroutine 的调度短暂阻塞。

5.Sysmon有什么作用?

在 Go 语言的运行时系统中,sysmon 是一个关键的后台系统监控线程,它的作用是监控和维护 Go 运行时的一些核心机制,确保调度器的正常运行。具体功能如下:

  1. 检测空闲的 P 和 Msysmon 线程会周期性地检查是否有空闲的 P(Processor)和 M(Machine)。如果有长时间空闲的 M,sysmon 会回收这些 M 以减少资源浪费。
  2. 处理网络轮询和定时器sysmon 负责管理网络轮询器和定时器的触发。当 Goroutine 由于 I/O 操作被阻塞时,sysmon 通过网络轮询器检查 I/O 的就绪状态,并唤醒相关的 Goroutine 继续执行。此外,它也负责处理定时器,当定时器到期时唤醒相关的 Goroutine。
  3. 检测和处理长时间运行的 Goroutinesysmon 线程会检测是否有 Goroutine 长时间占用 CPU 而不主动让出。通过检测 Goroutine 是否长时间没有触发调度,它可以强制性地挂起这些 Goroutine 并重新调度,确保调度器的公平性。
  4. 触发垃圾回收 (GC)sysmon 会在内存使用或分配达到一定阈值时,主动触发垃圾回收机制,释放内存并确保程序的平稳运行。
  5. 唤醒系统:当系统进入空闲状态时,sysmon 会定期唤醒并检查是否有需要执行的任务或 Goroutine,保持系统活跃。

总的来说,sysmon 是 Go 运行时中的一个守护线程,负责监控系统资源的使用、任务调度以及一些系统级的操作,如网络轮询和定时器处理。

6.P的数量有限制吗?M的数量有限制吗?G的数量有限制吗?

P的数量由启动时环境变量GOMAXPROCS或者runtime.GOMAXPROCS()决定,数量不能超过可用的CPU核心数量。

M的数量设置为10000个,但是达不到。

G的数量受内存和系统资源限制。

7.进程线程协程有什么区别?

进程线程、和协程是并发编程中的三个不同概念,它们在操作系统级别和编程模型中的作用不同。下面是它们的区别:

进程 (Process)

  • 定义:进程是操作系统中资源分配的基本单位。每个进程都有自己独立的内存空间、文件描述符、堆栈等系统资源,操作系统通过进程进行资源管理和隔离。
  • 特点
    • 独立性:进程间彼此独立,内存空间互不干扰。一个进程的崩溃不会影响其他进程。
    • 上下文切换代价大:因为进程有独立的内存空间,所以进程之间的切换需要保存和恢复大量上下文信息,切换代价相对较高。
    • 进程间通信复杂:由于不同进程的内存空间隔离,进程间通信(IPC)需要借助操作系统提供的机制,如管道、消息队列、共享内存等。
    • 创建代价大:进程的创建和销毁需要操作系统进行大量的资源分配和回收操作。

线程 (Thread)

  • 定义:线程是 CPU 调度的基本单位。一个进程可以包含多个线程,线程之间共享进程的内存空间和资源。
  • 特点
    • 共享资源:同一进程中的线程共享相同的内存空间、文件句柄等资源,所以线程间通信(例如通过共享内存)非常高效。
    • 轻量级:线程比进程轻量得多,创建和销毁的代价较小,线程间的上下文切换也比进程快,因为线程共享同一个内存空间,不需要进行复杂的内存操作。
    • 独立执行:每个线程有自己的栈、寄存器等执行上下文,能够独立调度和执行。
    • 多线程问题:由于共享内存,线程间通信虽然高效,但容易出现竞争条件、死锁等问题,因此需要小心使用同步机制(如互斥锁、信号量等)来管理资源访问。

协程 (Coroutine)

  • 定义:协程是比线程更轻量的执行单元,也叫作”用户态线程”。协程的调度通常是在用户态完成的,不需要操作系统内核的参与。协程通过主动让出控制权进行调度,通常用于实现非阻塞式并发编程。
  • 特点
    • 轻量级:协程的创建和销毁非常廉价,相比于线程,协程不需要操作系统提供上下文切换机制,协程的切换发生在用户态,因此切换代价极低。
    • 非抢占式调度:协程的调度是协作式的,协程主动放弃 CPU,程序员需要显式地指定何时让出执行权(例如使用 yieldawait 或其他机制)。这减少了并发竞争,但可能导致长时间运行的协程阻塞整个系统。
    • 栈切片:协程通常使用更小的栈(有的语言会使用分段栈或堆栈切片),因此它可以运行大量协程,而不会像线程那样消耗大量内存。
    • 并发而非并行:协程通常是并发执行的,但不一定并行执行(即同一时间只有一个协程运行)。多核并行通常需要协程与线程或进程结合使用。

对比总结

特性进程线程协程
调度方式由操作系统内核调度由操作系统内核调度由用户态调度
资源占用独立资源,占用较大共享资源,较轻量更轻量,极少的资源占用
切换代价切换代价高(需要切换整个进程的上下文)切换代价中等(切换线程上下文)切换代价低(用户态内调度)
并发并行并发和并行并发和并行并发(通常),可结合多线程实现并行
通信方式通过 IPC 机制,如管道、消息队列共享内存,通信简单通过共享变量或消息传递,高效
阻塞系统调用会阻塞整个进程系统调用会阻塞整个线程系统调用可避免阻塞,异步非阻塞
常见应用场景独立应用程序,多任务处理多线程并发任务,如 Web 服务器高并发异步任务处理,如网络 I/O 密集型应用

实际应用

  • 进程通常用于需要完全隔离的独立任务,比如浏览器的多个标签页或后台服务。
  • 线程用于同一应用程序中需要并发执行的任务,如 Web 服务器处理多个请求。
  • 协程多用于异步编程和高并发场景,尤其是在 I/O 密集型任务中(如网络编程、数据库操作),如 Go 的 Goroutine、Python 的 asyncio 等。

8.抢占式调度是如何抢占的?

抢占式调度是一种操作系统级别的调度机制,用于确保系统内

9.为什么GMP需要P?

P是一个管理上下文,它持有待运行的goroutine队列,在Go调度器中协调G、M之间的关联和分配。这种设计可以提高Go的并发性能和调度效率。P的引入解决了goroutine的有效执行和线程的可伸缩性。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇