Golang GMP 实现原理

文章导读

本文是对go语言的gmp模型的一个技术分享。

本文一共分成四个部分对gmp进行学习:

  1. 前置知识:操作系统的一些概念,协程、线程等知识。
  2. golang的gmp模型:这是go语言的核心拼图,理解这块底层知识,后面的学习才会得心应手,这部分是偏原理性的介绍。
  3. 核心数据结构:这部分,基于源码,对go语言的标准库runtime这个包中gmp涉及到的核心的数据结构的一些定义进行认识。
  4. 动态的调度流程:对整个gmp的调度的链路,源码进行走读。

一. 前置知识

1.1 线程

首先是线程,总所周知,线程是操作系统的最小调度单元。

通义中的线程,指的是内核线程,其核心特点如下:

  1. 操作系统本身有用户态和内核态的区分,线程作为最小的调度单元,是在内核态的视角之下的;
  2. 创建、销毁、调度均由内核完成,cpu需要完成用户态和内核态之间的切换
  3. 可充分利用多核实现并行;

1.2 协程

前文已经强调,线程是内核态视角下的昂贵资源,那么协程和线程就相对,是用户态视角下的一个产物。

这两者并非平级,协程比线程低一级,是线程的子集,是在先有线程的基础之上,在用户态的视角之下,进行二次加工,二次开发的衍生产物,更像是一个逻辑上的概念,最终底层物理意义上,本质还是一个线程,还是一个最小的调度单元。

协程有以下核心特点:

  1. 与线程存在映射关系,为M:1,可以理解为依附与某一个线程而生的,可以理解为用户态基于某些操作,依附与某一个始终存活可运行的线程之上的一个子单元;
  2. 创建、销毁、调度都是在用户态完成,更像一种逻辑处理,所以更轻;
  3. 从属于某一个内核级线程,无法并行,也就是说,当一个协程阻塞,从属于同一个线程的所有协程将无法运行;

也就是说,从属于同一个线程 的协程组,在用户态视角下,实际上是只能做到并发。(缺陷)

1.3 goroutine

前文提到的协程主流叫法叫:Coroutine

而goroutine其实是经过go语言优化后的特殊"Coroutine",是特指go世界中,优化改良后的一种协程。

将goroutine和线程之间的强依附性,强关联性解绑了。

加了一个go调度器,在中间动态的维护goroutine和线程之间的关系。

核心特点:

  1. 将映射关系变成M:N,可利用多个线程;
  2. 创建、销毁、调度也是在用户态完成,极轻,初始化才2kb,无需内核态的介入;
  3. 能够真正的做到并行;
  4. 通过go调度器的斡旋,实现和线程之间的动态绑定,从而更加灵活的调度;
  5. goroutine栈空间大小可以动态扩展,因地制宜(区别于线程栈);

综上,可以看出,go语言在调度协程上做了优化,实现了"灵活调度",但是,这其中,针对"如何减少加锁的行为","如何避免资源分配不均"等问题到底是怎么解决的呢?

二. golang的gmp模型

gmp=goroutine+machine+processor

下面单独说一下各个组件

2.1 g

g即是goroutine,在go语言中,是对协程的一种抽象。

每一个g都有自己的栈、状态和执行的函数(通过go func指定,在堆上创建)

由操作系统线程(M)执行,通过(P)获取运行资源。g只知道自己在哪个M上运行,不知道也不关心是哪个P在提供资源。

2.2 m

m就是machine,是go语言对线程一个抽象

M直接执行并记录当前G的信息,P为M提供运行资源。G在生命周期内可以被不同M执行,但同一时刻只能在一个M上运行。

2.3 p

p即是processor,是go语言中,抽象为对协程的一个调度器,g只有进入p队列,才能得以执行

P为M提供G队列和运行资源。P的数量限制同时执行的G数量

三. 核心数据结构

3.1 g

源码位置:$(go env GOROOT)/src/runtime/runtime2.go

3.1.1 g的结构定义

go 复制代码
type g struct {
    m         *m      // 当前交与的m的指针(操作系统线程),可变,也可空
    sched     gobuf   // 专门用于切换 Goroutine 时保存和恢复执行上下文
    atomicstatus atomic.Uint32  // G status 存储生命状态
    ...
}

// Goroutine Buffer(Go运行时上下文缓冲区)
type gobuf struct {
    sp   uintptr   // 保存cpu的rsp寄存器的值,指向函数调用栈的栈顶
    pc   uintptr   // 保存cpu的rip寄存器的值,指向程序下一条执行指令的地址
    g    guintptr  // 当前 Goroutine 的指针(指向自己)
    ret  uintptr   // 保存系统调用的返回值
    bp   uintptr   // 保存cpu的rbp寄存器的值,存储函数栈帧的起始位置
    ...
}

这里的m应该怎么来理解?

这里的m不是具体绑定到某一个m,而是一个指针,是动态可变的,是调度器分配的

很明显,g.sched是专供调度器使用的,调度器需要这个字段来执行 g 的切换

因为Goroutine需要被暂停和恢复 (比如等待I/O时让出CPU)。调度器暂停一个g时,就把CPU的sp、pc等寄存器值保存到g.sched里,恢复时再从g.sched读出来放回CPU寄存器,g就能接着执行了

3.1.2 g的生命周期状态

go 复制代码
// g status
const (
        _Gidle = iota // 0    // 刚创建,还没初始化
        _Grunnable // 1       // 就绪态,随时可以被调度
        _Grunning // 2        // 运行态,正在 CPU 上执行
        _Gsyscall // 3        // 系统调用态,正在执行系统调用
        _Gwaiting // 4        // 等待态,被阻塞
        _Gdead // 6           // 死亡态,已退出,还没被清理
        _Gcopystack // 8      // 栈复制态,正在扩容/收缩栈
        _Gpreempted // 9      // 被抢占态,被强制让出m
        ...
        // 这里有个有意思的点:为什么变量的命名要使用下划线和大写字母
        // 明明如果不想被外部包访问,直接写成gidle = iota不就可以了吗?
        // 主要是因为:
        // 可读性: _开头=运行时私有,大写=关联G结构体,包外不可访问
        // 历史习惯:早期C/Unix风格,系统级代码用 `_` 前缀表示内部/系统
        // 分组归类:运行时有很多私有常量,需要分类,例如,_Gidle表示G状态,_Pidle就可以表示P状态了,很清晰
)

g.atomicstatus用 atomic.Uint32 就是为了保证:原子性、可见性和不用锁。

例如:g 正从 _Grunning 切换到 _Gwaiting,同时垃圾回收器在检查所有 G 的状态,用原子操作能确保不会看到中间的不一致状态

3.2 m

3.2.1 m的结构定义

go 复制代码
type m struct {
    g0     *g                // goroutine with scheduling stack
    curg   *g                // current running goroutine
    tls  [tlsSlots]uintptr // thread-local storage
    
    p      puintptr // attached p for executing go code (nil if not executing go code)
    nextp  puintptr
    oldp   puintptr
    ...
}

这里的g0要怎么理解,怎么看起来还是m直接管理g?

g0其实是一类特殊的协程,称为始祖协程,是调度器的工作协程 ,用操作系统线程的原生栈,专门处理协程切换,与m的关系是1:1,这里的g0虽然也是一个指针,但是却是绑定的关系

  • p:该m为了执行go代码所依附的p,没有执行时,就为nil
  • nextp:临时存放的P(准备绑定),也就是下一个可以依附的p
  • oldp:g退出_Grunning前保存的P,也就是p和解绑时保存,直到恢复时,p = oldp(恢复绑定,如果该p没有新的m的话)
特点 g g0
谁创建 go func() 创建 运行时创建(线程启动时)
用途 执行用户代码 执行调度、垃圾回收等系统任务
数量 成千上万 1:1 对应每个m
栈在哪 堆上分配的普通栈 操作系统线程栈
能否被抢占 不能(必须一次性执行完)
特权 用户态 和普通g一样在用户态运行

3.3 p

3.3.1 p的结构定义

go 复制代码
type p struct {
    runqhead uint32         // 队列头地址
    runqtail uint32         // 队列尾地址
    runq     [256]guintptr  // 本地goroutine队列,固定最大长度256
    runnext  guintptr       // 下一个可执行的goroutine地址
    
    status   uint32         // p的生命状态
    m        muintptr       // 该p获取到的m
    ...
}

p.runq就是p本身私有的一个g队列,但是这个队列是有一个最大的容量的

go 复制代码
type schedt struct {
    lock     mutex
    // Global runnable queue.
    runq     gQueue
    runqsize int32
    ...
}

schedt是对全局goroutine队列的封装

lock是一把操作全局队列时使用的锁,runq、runqsize即为队列和队列的容量

go 复制代码
const (
    _Pidle     = iota  // 空闲,等待M绑定
    _Prunning          // 已被M绑定,正在运行
    _Psyscall          // 绑定M在系统调用,可被其他M偷
    _Pgcstop           // 暂停(GC时)
    _Pdead             // 销毁
)

P 的状态决定谁可以绑定

3.4 三者的关系

从以上的结构定义可以看到:

  • m中似乎有最全的信息,两种g(g0和curg),有p,nextp,oldp的地址;
  • p中有g的队列,也有*m;
  • 而g中只有*m,和自己的相关信息(sched、status)之外,没有p;

这种设计,既体现了g和m之间,协程和线程之间本质的关系(协程依托于线程才能执行),又证明p是go语言中的一个优化存在,P事实上是一个抽象理解的 "资源管理者",是g的"管理者",也作为m获取g的"工作台"

css 复制代码
启动顺序:
1. 进程启动 → 创建 M0(静态分配,后续动态创建)
2. runtime初始化 → 创建 P(GOMAXPROCS个)
3. 创建主goroutine(main方法)
4. M0的g0开始调度,P绑定到M

时间线:
M0存在 → P创建 → M0绑定P → 开始调度

从g的结构体定义可以看到,g中没有p啊,那g被创建出来后,此时没有*m,第一次是怎么入队列的,入的什么队列?

新创建的g既没有m也没有p,它被放入创建它的g的p的本地队列的队尾

那main这个主goroutine是谁创建的,是不是g0?因为g0是使用的线程栈,那么是创建在堆上,还是在线程栈上?

runtime 自己创建的,不是 g0 直接创建的

调用链:runtime.mainnewproc() → 创建主goroutine → 放入队列,堆上!所有用户goroutine的栈都在堆上分配,包括main的主goroutine

直到main方法结束,exit(0) 是粗暴的进程终止,操作系统回收所有资源。

3.5 sysmon

sysmon 不是 m 结构体的字段,而是一个运行在专用M上的goroutine函数。这个M不绑定P,专门执行系统监控任务。运行时不需要在 m 中标记 sysmon,因为它是通过启动专用m来运行的独立监控实体。专用m,也不参与p的绑定

  1. 不参与普通调度
  2. 可以看见所有P的状态
  3. 不会被系统调用阻塞影响

四. 动态的调度流程

核心的部分,追溯gmp模型的调用链路

4.1 两种g的转换

前面3.2中,对m的结构定义学习时,m中不止有g0的指针,也有curg的指针,很明显curg指向的就是当前正在被m处理的g

一个完整的goroutine的调用链路,其实就是会有一个从g0到g,g再回归g0的这样的一个闭环,而在这个过程中会有一组对偶的函数,分别是func gogo()func mcall()

接口被定义在runtime包的stubs.go中

4.2 几种调度类型

源码在proc.go文件中,以下方法也都是用户g来调用

4.2.1 主动调度

这里的主动,是指用户的主动,也就是用户在代码中使用runtime包对外暴露的Gosched方法,使当前的g出让执行权,主动进入全局队列 等待下次被调度,调用它的 g 变为 _Grunnable 状态

go 复制代码
// Gosched yields the processor, allowing other goroutines to run. It does not
// suspend the current goroutine, so execution resumes automatically.
//
//go:nosplit
func Gosched() {
    checkTimeouts()     // 检查是否有超时的timer
    mcall(gosched_m)
}

意思就是,Gosched会让出处理器,允许其他协程运行。它不会挂起当前的协程,因此执行会自动恢复。

值得注意的是这里的checkTimeouts(),这是go14引入的安全机制,因为 mcall 切换到 g 后,g 执行调度逻辑也需要时间!如果一直 mcall,g0 会一直占用CPU,没机会调度timer goroutine。

4.2.2 被动调度

这个就不是用户主动的让g出让执行权了,而是 g 本身遇到了没有必要继续占用 m 的情况

1) _Gsyscall

entersyscall(),这个方法会让当前运行的g进入_Gsyscall:

  1. m会保存g当前的信息(g.sched)
  2. m会带着g进入内核态去执行
  3. 并记录此时的p到m.oldp
  4. 此时p就会和m解绑,去寻找其他的m(如果有的话)
  5. g就会留在_Gsyscall,直到内核态结束,调用exitsyscall()

exitsyscall(), 这个方法,如果原来的 P 还空闲(没有绑定新的m),就可以直接恢复运行态,否则就是进入就绪态等待新的调度

2) _Gwaiting

gopark(),这个方法在g遇到阻塞时就会调用,目的是为了让g进入_Gwaiting,例如channer、select和同步锁等情况,和前者不同的是,m立即执行其他g ,与P保持绑定,此时的g进入的是一个等待队列,直到有"唤醒者"执行goready(*g),被唤醒的g会优先放入唤醒者的p的本地队列,这样才可能更快被m执行

这是为了利用CPU缓存局部性,当前CPU核心的缓存很可能已经有这个P的数据,如果放到其他P的队列,需要同步缓存,开销大,减少调度延迟,提高性能。

如果正好遇到唤醒者本地队列满的情况,则会调用runqputslow()

  1. 移动128个G到全局队列
  2. 把新G(被唤醒者)放入本地队列空位
  3. 本地队列现在有 256-128+1 = 129个G

这是为了避免频繁操作全局队列,如果每次满都只放一个G到全局,频繁加锁 → 性能差

总之被唤醒的g只能是回到_Grunable

而值得注意得是,执行goready的g自己并不会进入_Gwaiting,因为这样设计可以避免死锁。举个例子:

go 复制代码
ch := make(chan int)  // 无缓冲

// G1: 接收者(先启动)
go func() {
    val := <-ch  // 阻塞!gopark()到_Gwaiting,并将G1自己的信息带给ch.recvq,因为期待ch接收数据时唤醒自己
    fmt.Println("收到:", val)
}()

// G2: 唤醒者(后启动)
go func() {
    time.Sleep(time.Second)
    ch <- 42     // 1. 发现ch.recvq有等待的G1
                 // 2. 调用 goready(G1) 唤醒G1
                 // 3. 唤醒者继续执行下一行代码!
    fmt.Println("发送完成")  // ← 立即执行,不会阻塞!
}()

如果 goready(G1) 会阻塞,那么 fmt.Println("发送完成") 就永远不会执行,形成死锁

3) 抢占调度

抢占调度 = 强制打断正在运行的Goroutine,不让它霸占CPU太久。

Go 1.14+:基于异步信号的抢占,任何地方都可能被中断!才是真正的抢占

抢占如何工作?

  1. 标记阶段(sysmon负责)
go 复制代码
func retake(now int64) {
    for 每个P {
        if pp.status == _Prunning {
            // G运行超过10ms?
            if now-pp.schedtick > 10*1000*1000 {
                preemptone(pp)  // 标记这个P上的G应该被抢占
            }
        }
    }
}

func preemptone(pp *p) bool {
    gp := pp.curg
    if gp == nil || gp == pp.g0 {
        return false
    }
    gp.preempt = true  // ← 设置抢占标志
    
    // 告诉G:"你该让出了!"
    gp.stackguard0 = stackPreempt
    return true
}
  1. 执行阶段(G自己检查或信号中断)

异步信号(Go 1.14+)

scss 复制代码
// 操作系统信号(如SIGURG)中断G
// 在信号处理程序中切换到调度器
func sighandler(sig int32) {
    if sig == sigPreempt {
        // 被抢占!
        doasyncPreempt()  // 切换到调度去找下一个g
    }
}

抢占完整链路

makefile 复制代码
时间线(详细版):
t0: G1在CPU上执行
    ↓
t1: sysmon调用 signalM(M1, SIGURG)
    ↓
t2: 内核向CPU发送中断信号
    ↓
t3: CPU中断当前指令,保存寄存器
    ↓
t4: 跳转到内核信号处理代码(C)
    ↓
t5: 内核调用Go注册的sigtramp(汇编)
    ↓
t6: sigtramp保存完整上下文,调用sighandler(Go)
    ↓  
t7: sighandler识别是抢占信号,调用doasyncPreempt
    ↓
t8: doasyncPreempt调用mcall()切换到g0
    ↓
t9: g0执行调度,选G2

抢占的局限:即使有抢占,Go仍不是完全实时

arduino 复制代码
// 这些情况可能延迟抢占:
1. G在执行不可抢占的系统调用
2. G在修改运行时关键数据结构(持锁)
3. 信号被阻塞或延迟传递

// 所以Go文档说:
// "Goroutines没有超时概念"
// 不能依赖抢占做超时控制

正确的超时控制方式:

错误控制

scss 复制代码
go func() {
    doWork()  // 可能运行很久
    // 期望1秒超时?不保证!
}()

正确控制

go 复制代码
// 方法1:context.WithTimeout
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()

go func() {
    select {          // goroutine启动,监听两个channel
    case <-ctx.Done():       // 1秒后ctx.Done()可读
        return  
    case result := <-doWorkAsync(): // 如果doWorkAsync()先返回结果
        // 正常完成
    }
}()

// 方法2:带超时的channel操作
select {
case result := <-ch:
    // 正常
case <-time.After(1 * time.Second):
    // 超时
}

// 方法3:定期检查退出标志
stop := make(chan bool)
go func() {
    for {
        select {
        case <-stop:
            return
        default:
            doChunkOfWork()   // doChunkOfWork() 执行后立即再次检查stop
        }
    }
}()
time.Sleep(1 * time.Second)
close(stop)  // 请求停止
// 关闭channel的效果:
// 1. 所有从这个channel接收的操作立即返回零值
// 2. 不再阻塞
// 3. 可以通知多个接收者

五. work-stealing

Work-Stealing是Go调度器的负载均衡算法:空闲的P从忙碌的P的本地队列偷一半Goroutine过来执行,实现自动负载均衡,减少空闲,提高CPU利用率。

5.1 工作原理:

1) 什么时候发生?

go 复制代码
func findRunnable() {
    // 当P自己的队列为空时...
    if 自己队列空 && 全局队列空 {
        // 尝试从其他P偷工作
        gp := runqsteal(pp)
        if gp != nil {
            return gp  // 偷到工作了!
        }
    }
}

2) 怎么偷?

go 复制代码
func runqsteal(pp *p) *g {
    // 1. 随机选一个其他P
    victim := 随机选择一个P
    
    // 2. 偷它本地队列**一半**的G
    //    比如:victim有10个G,偷5个过来
    half := victim队列长度 / 2
    
    // 3. 把偷来的G放到自己队列
    for i := 0; i < half; i++ {
        g := victim.pop()  // 从victim队列取
        pp.push(g)         // 放到自己队列
    }
}

5.2 为什么需要Work-Stealing?

makefile 复制代码
// P3、P4从P1、P2偷工作
P1: 10 → 5个G  (被偷走5个)
P2: 10 → 5个G  (被偷走5个)  
P3: 0 → 5个G  (偷了P1的5个)
P4: 0 → 5个G  (偷了P2的5个)
// 现在4个P都有5个G,负载均衡!

5.3 Work-Stealing的优势:

1) 减少锁竞争

arduino 复制代码
// 每个P操作自己的队列(无锁)
// 只有偷窃时才需要锁其他P的队列
// 大部分时间无竞争

2) 利用局部性

arduino 复制代码
// G通常在自己的P上执行
// 只有P空闲时才去偷
// 保持CPU缓存热度

3) 自适应的负载均衡

less 复制代码
/ 忙的P不会被过度干扰
// 闲的P自动找活干
// 无需中央调度器

六. p到底干了什么?

P其实不调用任何方法,它只是个被操作的"资源包"。P什么也不干,只是被g/g0读写的数据结构。

P就像"工作台":工作台不会自己干活,工人(g)用工具(m)在工作台上干活

虽然P不调用方法,但它是关键的资源管理者P提供本地队列、缓存等资源,但所有操作都是G/g0在对P进行读写。这是Go调度器"G中心"设计哲学的体现。

既然,p这个结构体,只是一个被g和g0操作的数据结构,那为啥,都说processor是gmp中核心的调度器,明明它啥也没干?

6.1 说P是"核心调度器"是从架构和性能角度说的

  1. 并行度控制中心
go 复制代码
// 系统能同时运行多少个G?由P的数量决定!
GOMAXPROCS = 4  // ← 创建4个P
// 最多4个G真正并行执行
// P是并行度的"闸门"
  1. 本地化调度中心
go 复制代码
// 没有P:所有m竞争一个全局队列 → 锁竞争严重
// 有P:每个P有本地队列 → 减少90%锁竞争

// 调度性能提升10倍+的关键!
  1. CPU亲和性锚点
go 复制代码
M是流动的(线程可能被OS调度到不同CPU核心)
P是固定的(每个P尽量绑定到固定CPU核心)

G → 绑定到 → P → 亲和到 → CPU核心
      (调度关系)   (缓存优化)
  1. 工作窃取(Work Stealing)的枢纽
css 复制代码
// 当P1空闲时,它偷谁的工作?
// 偷其他P的队列!P是窃取的单位

func stealWork(pp *p) {
    // 遍历其他P,偷它们的G
    for 每个其他P {
        从其他P.runq偷一半G
    }
}

6.2 P的"被动核心"设计哲学

Go调度器的创新:

传统线程池: Worker(主动) + Queue(被动)

Go的GMP: G(主动) + P(被动+智能) + M(执行)

P的"智能被动":

  1. 本地队列(256) ← 智能缓冲:减少竞争
  2. runnext槽位 ← 智能缓存:下一个立即执行
  3. 各种内存池 ← 智能复用:减少分配
  4. 状态机管理 ← P.status: _Pidle, _Prunning, _Psyscall, _Pdead,通过状态知道P在干嘛

没有P(Go 1.0之前) :Benchmark调度-4核: 100万次切换/秒

有P(Go 1.1引入P后):Benchmark调度-4核: 5000万次切换/秒 提升50倍!

6.3 调度器真正的工作流

vbnet 复制代码
真正的"调度大脑"是:g0 + P的数据

g0(决策) + P(数据) = 完整调度器
      ↓           ↓
    "做什么"     "用什么做"

比如:
g0: "该执行下一个G了"
    ↓
g0: 查看当前P.runq(数据在P中)
    ↓  
g0: 取出G(操作P的字段)
    ↓
g0: 执行G

P提供了调度需要的所有数据,g0提供决策逻辑

6.4 所以,不应该说"P是核心调度器"

更准确的说法:

  • g0 :调度器的逻辑核心(做决策)
  • P :调度器的数据核心(提供资源)
  • M :调度器的执行核心(跑代码)
  • G :调度器的任务核心(要执行的工作)

6.5 源码中的体现

在runtime包的proc.go文件中:

go 复制代码
// One round of scheduler: find a runnable goroutine and execute it.
// Never returns.
func schedule() {
	mp := getg().m

	if mp.locks != 0 {
		throw("schedule: holding locks")
	}

	if mp.lockedg != 0 {
		stoplockedm()
		execute(mp.lockedg.ptr(), false) // Never returns.
	}

	if mp.incgo {
		throw("schedule: in cgo")
	}

top:
	pp := mp.p.ptr()
	pp.preempt = false

	if mp.spinning && (pp.runnext != 0 || pp.runqhead != pp.runqtail) {
		throw("schedule: spinning with local work")
	}

	gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available

	if debug.dontfreezetheworld > 0 && freezing.Load() {
		lock(&deadlock)
		lock(&deadlock)
	}

	if mp.spinning {
		resetspinning()
	}

	if sched.disable.user && !schedEnabled(gp) {
		lock(&sched.lock)
		if schedEnabled(gp) {
			unlock(&sched.lock)
		} else {
			sched.disable.runnable.pushBack(gp)
			sched.disable.n++
			unlock(&sched.lock)
			goto top
		}
	}

	if tryWakeP {
		wakep()
	}
	if gp.lockedm != 0 {
		startlockedm(gp)
		goto top
	}

	execute(gp, inheritTime)
}

整个调度循环都围绕着"当前P"的数据操作!

这个方法是g0调用,在go run之后,就开始循环,其中的getg()就是取得g0本身

具体到代码中的循环路径:

  1. 普通调度循环
scss 复制代码
func schedule() {
    gp := findRunnable()      // 1. 找个可运行的G
    execute(gp, inheritTime)  // 2. 执行它(永不返回!)
}

func execute(gp *g, inheritTime bool) {
    // M设置当前G
    _g_.m.curg = gp  // M ← G
    gp.m = _g_.m     // G ← M    g.m和m.gcurg的赋值是在execute中完成的

    // M开始执行G的代码
    gogo(&gp.sched)  // 3. 切换到用户G(不返回!)事实上gogo只负责数据恢复
}

// 用户G执行...
// 用户G调用 runtime.Gosched() 或阻塞时:
func gosched_m(gp *g) {
    // 4. 用户G通过mcall()切换到g0执行这个函数
    schedule()  // 5. 重新调度!回到步骤1
}

这里execute() 调用 gogo()不返回 !所以 schedule()不返回

findRunnable() 是调度器的"找活干"函数,它按优先级从多个地方寻找可运行的G:

markdown 复制代码
findRunnable() 的查找顺序:
1. 当前P的runnext(最高优先级)
2. 当前P的本地队列
3. 全局队列  
4. 网络轮询器(netpoll)
5. 从其他P偷工作(work stealing)
6. 如果还找不到 → 休眠等待
  1. 两种 goto top 的情况

情况1:G被锁定到特定的M(gp.lockedm != 0

scss 复制代码
if gp.lockedm != 0 {
    // 这个G必须运行在特定的M上
    // 比如:cgo回调、某些系统调用
    startlockedm(gp)  // 把P交给那个M,自己阻塞
    goto top          // 重新找其他G执行
}

情况2:用户调度被禁用(sched.disable.user

scss 复制代码
if sched.disable.user && !schedEnabled(gp) {
    // 用户G调度被临时禁用(比如在STW垃圾回收)
    // 但GC worker等系统G可以运行
    sched.disable.runnable.pushBack(gp)  // 放回特殊队列
    goto top  // 跳过这个用户G,重新找
}
相关推荐
古城小栈1 天前
Golang Gin+Gorm :SQL注入 防护
sql·安全·go·gin
郑州光合科技余经理2 天前
同城系统海外版:一站式多语种O2O系统源码
java·开发语言·git·mysql·uni-app·go·phpstorm
喵个咪2 天前
初学者入门:用 go-kratos-admin + protoc-gen-typescript-http 快速搭建企业级 Admin 系统
后端·typescript·go
ん贤2 天前
高可靠微服务消息设计:Outbox模式、延迟队列与Watermill集成实践
redis·微服务·云原生·架构·消息队列·go·分布式系统
百锦再2 天前
.NET到Java的终极迁移指南:最快转型路线图
android·java·开发语言·python·rust·go·.net
喵个咪3 天前
初学者导引:在 Go-Kratos 中用 go-crud 实现 Ent ORM CRUD 操作
后端·go
喵个咪3 天前
初学者导引:在 Go-Kratos 中用 go-crud 实现 GORM CRUD 操作
后端·go
lpfasd1233 天前
Wails介绍
go·跨平台
Coding君3 天前
每日一Go-6、Go语言结构体(Struct)与面向对象的实现方式
go