【每日八股】Golang篇(四):GMP

发布于:2025-03-13 ⋅ 阅读:(13) ⋅ 点赞:(0)

GMP 模型?

G:Goroutine

G 是 golang 中 goroutine 的缩写。goroutine,也称作协程,它的行为类似于 OS 当中的一个进程控制块,不过协程是完全运行在用户态的,不需要与内核态交互。一个 goroutine 通过结构体 g 进行管理,g 中存放着 goroutine 运行时的栈信息,CPU 一些寄存器的值以及要执行的函数指令。sched 字段的类型是 gobuf,它保存着 goroutine 的上下文。goroutine 在切换的时候,不依赖 OS 提供上下文保存,而是直接将上下文保存在了 gobuf 这个结构当中:

type g struct {
  stack       stack   		// 描述真实的栈内存,包括上下界

  m              *m     	// 当前的 m
  sched          gobuf   	// goroutine 切换时,用于保存 g 的上下文      
  param          unsafe.Pointer // 用于传递参数,睡眠时其他 goroutine 可以设置 param,唤醒时该goroutine可以获取
  atomicstatus   uint32
  stackLock      uint32 
  goid           int64  	// goroutine 的 ID
  waitsince      int64 		// g 被阻塞的大体时间
  lockedm        *m     	// G 被锁定只在这个 m 上运行
}

gobuf 类型保存了当前栈的指针、计数器以及 g 本身,此处对 g 的指针进行保存的目的是为了能够快速访问到 goroutine 中的信息。gobuf 的结构如下:

type gobuf struct {
	sp   uintptr
    pc   uintptr
    g    guintptr
    ctxt unsafe.Pointer
    ret  sys.Uintreg
    lr   uintptr
    bp   uintptr // for goEXPERIMENT=framepointer
}

M:Machine

M 代表的是操作系统的主线程,是对内核级线程的封装,其运行的数量对应着运行机器上真实的 CPU 数。一个 M 直接关联一个 OS 的内核线程,用于执行 G。M 会优先从关联的 P 的本地队列中直接获取待执行的 G。M 保存了其自身要使用的栈信息、当前正在 M 上执行的 G 的信息,以及与之绑定的 P 的信息。

在 M 的结构定义中,curg 代表结构体 M 当前绑定的结构体 G。g0 是带有调度栈的 goroutine,普通的 goroutine 的栈是在堆上分配的可增长的栈,但 g0 的栈式 M 对应的线程的栈。

type m struct {
    g0      *g     				// 带有调度栈的goroutine

    gsignal       *g         	// 处理信号的goroutine
    tls           [6]uintptr 	// thread-local storage
    mstartfn      func()
    curg          *g       		// 当前运行的goroutine
    caughtsig     guintptr 
    p             puintptr 		// 关联p和执行的go代码
    nextp         puintptr
    id            int32
    mallocing     int32 		// 状态

    spinning      bool 			// m是否out of work
    blocked       bool 			// m是否被阻塞
    inwb          bool 			// m是否在执行写屏蔽

    printlock     int8
    incgo         bool
    fastrand      uint32
    ncgocall      uint64      	// cgo调用的总数
    ncgo          int32       	// 当前cgo调用的数目
    park          note
    alllink       *m 			// 用于链接allm
    schedlink     muintptr
    mcache        *mcache 		// 当前m的内存缓存
    lockedg       *g 			// 锁定g在当前m上执行,而不会切换到其他m
    createstack   [32]uintptr 	// thread创建的栈
}

P:Processor

Processor 代表了 M 所需的上下文环境,代表着 M 运行 G 所需要的资源。P 是处理用户级代码逻辑的处理器,可以将其视作一个局部调度器,使得 go 的代码可以在线程上运行。当 P 有任务时,需要创建或唤醒一个系统线程来执行其队列当中的任务,所以 P 和 M 是互相绑定的(回忆一下,M 对应的是 golang 代码所运行机器的操作系统的线程,而 P 是 M 运行所需要的上下文,因此二者必然是相互绑定的)。

P 可以根据实际情况去开启协程工作,它包含了运行 goroutine 的资源,如果线程像运行 goroutine,必须先获取 P,P 中还包含了可运行的 G 队列。

type p struct {
    lock mutex

    id          int32
    status      uint32 		// 状态,可以为pidle/prunning/...
    link        puintptr
    schedtick   uint32     // 每调度一次加1
    syscalltick uint32     // 每一次系统调用加1
    sysmontick  sysmontick 
    m           muintptr   // 回链到关联的m
    mcache      *mcache
    racectx     uintptr

    goidcache    uint64 	// goroutine的ID的缓存
    goidcacheend uint64

    // 可运行的goroutine的队列
    runqhead uint32
    runqtail uint32
    runq     [256]guintptr

    runnext guintptr 		// 下一个运行的g

    sudogcache []*sudog
    sudogbuf   [128]*sudog

    palloc persistentAlloc // per-P to avoid mutex

    pad [sys.CacheLineSize]byte
}

GMP 的调度流程?

请添加图片描述

  • 每个 P 有一个局部队列,局部队列保存待执行的 goroutine,当 M 绑定的 P 的局部队列已满时,goroutine 会被追加到全局队列当中;
  • 每个 P 和一个 M 绑定,M 是真正执行 P 中 goroutine 的实体,M 会从其绑定的 P 的局部队列取 G 来执行;
  • 当 M 所绑定的 P 为空时,会从全局队列取 G,当全局队列中无 G 时,M 会从其它 P 队列中偷取 G 来执行,这种从其它 P 截获 G 来提前执行的行为称作 work stealing;
  • 当 G 因系统调用阻塞时会阻塞 M,此时 P 和 M 解绑,并寻找新的理想的 M,若没有理想的 M 则会新创建一个 M【goroutine 中的系统调用会阻塞承载它的进程,此时与进程(M)绑定的资源及 goroutine 队列(一并打包为 P)会寻找一个新的线程(M)或是新建一个线程(M)来绑定】;
  • 当 G 因 channel 或其他 network I/O 阻塞时,不会阻塞 M,M 会寻找其它可运行的 G 并执行;当阻塞的 G 恢复后,会重新进入其对应的 P 队列等待执行【goroutine 中的 channel 或 network I/O 不会阻塞线程(M),goroutine 自身陷入阻塞态,而 M 继续执行 P 中其它 G。当阻塞的 G 就绪后,重新插入 P 队列】。

P 和 M 的个数?

P:由启动时环境变量$GOMAXPROCSruntimeGOMAXPROCS()方法决定。这意味着程序在执行的任意时刻都只有$GOMAXPROCS个 goroutine 在同时运行【当然,等待被调度执行的 goroutine 可能跟多,它们都在 P 的队列中等待】;

M:

  • Golang 本身的限制:Golang 程序启动时,会设置 M 的最大数量,默认是 10000,但是内核很难支持这么多的线程同时运行。不过这条限制本身可以忽略;
  • runtime/debug 中的 SetMaxThreads 设置 M 的最大数量;
  • 一个 M 阻塞后,会创建新的 M。

M 与 P 的数量没有绝对关系,如果一个 M 阻塞,其承载的 P 就会创建一个新的 M,所以,即使 P 的默认数量为 1,也可能对应多个 M。

P 和 M 何时创建?

  • P:在确定了 P 的最大数量 n 后,运行时系统会根据这个数量创建 n 个 P;
  • M:当没有足够的 M 来关联 P 时,M 会创建。比如当前时刻所有 M 都阻塞了,而 P 中还有很多就绪的任务,此时就会去寻找空闲的 M,如果没有空闲的 M,就创建新的 M。

goroutine 创建流程?

调用 go func() 时,golang 会调用 runtime.newproc 来创建一个 goroutine,这个 goroutine 会新建一个自己的栈空间,同时在 G 的 sched(用于维护 goroutine 上下文的字段)中维护栈地址及程序计数器这些上下文信息。

创建好的 goroutine 会被放到其所对应内核线程 M 所绑定的上下文 P 的 run_queue 当中,等待调度器决定何时取出这个 goroutine 并执行,通常的调度顺序是按时间顺序来调度,即 rune_queue 是一个先进先出的队列。

goroutine 何时被挂起?

  • waitReasonChanReceiveNilChan:对未初始化的 channel 进行读操作;
  • waitReasonChanSendNilChan:对未初始化的 channel 进行写操作;
  • 在 main goroutine 发生 panic 时,会触发;
  • 在调用关键字 select 时会触发;
  • 在调用关键字 select 时,若一个 case 都没有,会直接触发;
  • 在 channel 进行读操作,会触发;
  • 在 channel 进行写操作,会触发;
  • sleep 行为,会触发;
  • IO 阻塞等待时,例如:网络请求等;
  • 在垃圾回收时,主要场景是 GC 标记终止和标记阶段时触发;
  • GC 清扫阶段中的结束行为,会触发;
  • 信号量处理结束时,会触发;

同时开启了一万个 goroutine,应该如何调度?

一万个 G 会按照 P 的设定个数,尽可能地平摊到每个 P 的 run_queue 当中。如果 run_queue 都满了,那么剩余的 G 会分配到 GMP 的全局队列上。完成将 G 分配到 P 的队列之后,便开始 GMP 模型的调度策略:

  • 本地队列轮转:每一个 P 维护着一个包含 G 的队列 run_queue,也称本地队列,不考虑 G 进入系统调用或 I/O 的情况下,P 会周期性地将 G 调度到 M 中执行,执行一小段时间后(即并发),保存上下文并将这个 G 追加到队尾,再从对头取一个 G 调度。
  • 系统调用:P 的个数默认等于 CPU 的核数,每个 M 必须持有一个 P 才能执行 G,一般 M 的个数会略大于 P,多出的 M 会在 G 产生系统调用时发挥作用。当 G 即将进入系统调用时,对应的 M 陷入系统调用而被阻塞,这将会使 M 释放 P,进而某个空闲的 M1 将获取 P,继续执行 P 队列剩下的 G。
  • 工作量窃取:多个 P 的本地队列中维护的 G 的个数可能是不均衡的,当某个 P 已经执行完其队列中的 G 时,会去全局队列查找 G;如果全局队列也没有新的 G,而其它的某个 P 的本地队列还有很多 G 待运行,此时空闲的 P 会从其它 P 中偷一些 G 来执行,一般每次偷取一半。

goroutine 内存泄漏和处理?

原因
goroutine 又称作协程,它是轻量级线程,由于 goroutine 完全运行在用户态,需要维护 goroutine 运行的上下文信息。因此,如果一个程序持续不断地产生新的 goroutine,且不结束已经创建的 goroutine 并复用这部分内存,就会产生内存泄露现象。产生内存泄露的原因大致分为以下三种:

  • goroutine 内正在进行 channel/mutex 读写操作,但由于逻辑产生了问题,某些情况下 goroutine 被一直阻塞;
  • goroutine 业务逻辑进入 dead loop,资源一直无法释放;
  • goroutine 内的业务逻辑长时间等待,而此时又有不断新增的 goroutine 进入等待。

解决办法

  • 使用 channel:使用 channel 接收业务完成的通知;业务执行阻塞超过设定的时间(可以通过 select 和 time.After() 来实现),就触发超时退出;
  • 使用 pprof 排查;