Golang GMP解读

概念梳理

1. 1 线程

通常语义中的线程,指的是内核级线程,核心点如下:

  1. 是操作系统最小调度单元;
  2. 创建、销毁、调度交由内核完成,cpu 需完成用户态与内核态间的切换;
  3. 可充分利用多核,实现并行.

1.2 协程

协程又称为用户级线程核心点如下:

  1. 与线程存在映射关系,为 M:1,即多个协程对应一个线程
  2. 创建、销毁、调度在用户态完成,对内核透明,所以更轻;
  3. 从属同一个内核级线程,无法并行;一个协程阻塞会导致从属同一线程的所有协程无法执行.

1.3 Goroutine

Goroutine,经 Golang 优化后的特殊“协程”,核心点如下:

  1. 与线程存在映射关系,为 M:N,即 goroutine 既有协程M对1的特性,也存在1对1的可能,甚至1对N
  2. 创建、销毁、调度在用户态完成,对内核透明,足够轻便;
  3. 可利用多个线程,实现并行;
  4. 通过调度器的斡旋,实现和线程间的动态绑定和灵活调度;
  5. 栈空间大小可动态扩缩,因地制宜.

1.4 三种模型的能力对比

模型 依赖内核 可并行 可应对阻塞 栈可动态扩缩
线程 X
协程 X X X X
goroutine X

goroutine更像是一个博采众长的存在。实际上,“灵活调度” 一词概括得实在过于简要,Golang 在调度 goroutine 时,针对“如何减少加锁行为”,“如何避免资源不均”等问题都给出了精彩的解决方案,这一切都得益于经典的 “gmp” 模型

GMP模型

gmp = goroutine + machine + processor (+ 一套有机组合的机制),下面先单独拆出每个组件进行介绍,最后再总览全局,对 gmp 进行总述

2.1 g(goroutine)

  1. g 即goroutine,是 golang 中对协程的抽象;
  2. g 有自己的运行栈、状态、以及执行的任务函数(用户通过 go func 指定);
  3. g 需要绑定到 p 才能执行,在 g 的视角中,p 就是它的 cpu.

2.2 p(processor)

  1. p 即 processor ,是golang中的调度器
  2. p 是 gmp 的中枢,借由 p 承上启下,实现 g 和 m 之间的动态有机结合
  3. 对于 g 而言,p 是其cpu,g 只有被 p 调度才得以执行
  4. 对于 m 而言,p 是其执行代理,为其提供必要信息的同时(可执行的 g,内存分配情况等),并隐藏了复杂的调度细节
  5. p 的数量决定了 g 最大的并行数量。可以由用户通过 GoMaxProcs 设置(但是超过了CPU的核心数则无意义了)

2.3 m(machine)

  1. m 即 machine ,是golang中线程的抽象
  2. m 不直接执行 g,而是先和 p 绑定,由其代理实现
  3. 借由 p 的存在,m 无需和 g 绑死,也无需记录 g 的状态信息,因此 g 在全生命周期可以实现跨 m 执行

2.4 GMP(线程-- 使用调度器 --> 使用协程 goroutine)

GMP宏观模型

  1. M 是线程的抽象;G 是 goroutine;P 是承上启下的调度器;
  2. M调度G前,需要和P绑定;
  3. 全局有多个M和多个P,但同时并行的G的最大数量等于P的数量;
  4. G的存放队列有三类:P的本地队列;全局队列;和wait队列(图中未展示,为io阻塞就绪态goroutine队列);
  5. M调度G时,优先取P本地队列,其次取全局队列,最后取wait队列;这样的好处是,取本地队列时,可以接近于无锁化,减少全局锁竞争;
  6. 为防止不同P的闲忙差异过大,设立work-stealing机制,本地队列为空的P可以尝试从其他P本地队列偷取一半的G补充到自身队列.

核心数据结构

gmp 数据结构定义为 runtime/runtime2.go 文件中

3.1 g

type g struct {
   
    // ...
    // m:在 p 的代理,负责执行当前 g 的 m;
    m         *m      
    // ...
    sched     gobuf
    // ...
}
type gobuf struct {
   
    sp   uintptr
    pc   uintptr
    ret  uintptr
    bp   uintptr // for framepointer-enabled architectures
}
  1. m:在 p 的代理,负责执行当前 g 的 m;
  2. sched.sp:保存 CPU 的 rsp 寄存器的值,指向函数调用栈栈顶;
  3. sched.pc:保存 CPU 的 rip 寄存器的值,指向程序下一条执行指令的地址;
  4. sched.ret:保存系统调用的返回值;
  5. sched.bp:保存 CPU 的 rbp 寄存器的值,存储函数栈帧的起始位置.
g 的生命周期

生命周期

const(
  _Gidle = itoa // 0
  _Grunnable // 1
  _Grunning // 2
  _Gsyscall // 3
  _Gwaiting // 4
  _Gdead // 6
  _Gcopystack // 8
  _Gpreempted // 9
)
  1. _Gidle 值为 0,为协程开始创建时的状态,此时尚未初始化完成;
  2. _Grunnable 值 为 1,协程在待执行队列中,等待被执行;
  3. _Grunning 值为 2,协程正在执行,同一时刻一个 p 中只有一个 g 处于此状态;
  4. _Gsyscall 值为 3,协程正在执行系统调用;
  5. _Gwaiting 值为 4,协程处于挂起态,需要等待被唤醒. gc、channel 通信或者锁操作时经常会进入这种状态;
  6. _Gdead 值为 6,协程刚初始化完成或者已经被销毁,会处于此状态;
  7. _Gcopystack 值为 8,协程正在栈扩容流程中;
  8. _Greempted 值为 9,协程被抢占后的状态.

3.2 m

type m struct {
   
    g0      *g     // goroutine with scheduling stack
    // ...
    tls           [tlsSlots]uintptr // thread-local storage (for x86 extern register)
    // ...
}
  1. g0:一类特殊的调度协程,不用于执行用户函数,负责执行 g 之间的切换调度. 与 m 的关系为 1:1;
  2. tls:thread-local storage,线程本地存储,存储内容只对当前线程可见. 线程本地存储的是 m.tls 的地址,m.tls[0] 存储的是当前运行的 g,因此线程可以通过 g 找到当前的 m、p、g0 等信息.

3.3 p

type p struct {
   
    // ...
    runqhead uint32
    runqtail uint32
    runq     [256]guintptr
    
    runnext guintptr
    // ...
}
  1. runq:本地 goroutine 队列,最大长度伟大256
  2. runqhead:队列头部
  3. runqtail:队列尾部
  4. runnext:下一个可执行的 goroutine

3.4 schedt

sched 是全局队列的封装

type schedt struct {
   
    // ...
    lock mutex
    // ...
    runq     gQueue
    runqsize int32
    // ...
}
  1. lock 操作全局对列的锁
  2. runq 全局 goroutine 队列
  3. runqsize 全局队列的长度

调度流程解析

4.1 两种 g 的转换

即 普通任务 g 和调度查找任务 g0 之间的转换
goroutine 的类型可以分为两类:

  1. 负责调度普通 g 的 g0,执行固定的调度流程,与 m 的关系为一对一;
  2. 负责执行用户函数的普通 g.
    m 通过 p 调度执行的 goroutine 永远在普通 g 和 g0 之间进行切换,当 g0 找到可执行的 g 时,会调用 gogo 方法,调度 g 执行用户定义的任务;当 g 需要主动让渡或被动调度时,会触发 mcall 方法,将执行权重新交还给 g0.
    gogo 和 mcall 可以理解为对偶关系,其定义位于 runtime/stubs.go 文件中.
func gogo(buf *gobuf)
// ...
func mcall(fn func(*g))

4.2 调度类型

通常,调度指的是由 g0 按照特定策略找到下一个可执行 g 的过程. 而本小节谈及的调度类型是广义上的“调度”,指的是调度器 p 实现从执行一个 g 切换到另一个 g 的过程.

这种广义“调度”可分为几种类型:

  1. 主动调度
    一种用户主动执行让渡的方式,主要方式是,用户在执行代码中调用了 runtime.Gosched 方法,此时当前 g 会当让出执行权,主动进行队列等待下次被调度执行.
    代码位于 runtime/proc.go
func Gosched() {
   
    checkTimeouts()
    mcall(gosched_m)
}
  1. 被动调度
    因当前不满足某种执行条件,g 可能会陷入阻塞态无法被调度,直到关注的条件达成后,g才从阻塞中被唤醒,重新进入可执行队列等待被调度.
    常见的被动调度触发方式为因 channel 操作或互斥锁操作陷入阻塞等操作,底层会走进 gopark 方法(例如http的IO多路复用,epoll方式使用的就是gopark来进行挂起操作)
    代码位于 runtime/proc.go
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {
   
    // ...
    mcall(park_m)
}

通常 goready 与 gopark 成对出现,能够将 g 从阻塞状态恢复过来的,重新进入等待执行的状态
源码位于 runtime/proc.go

func goready(gp *g, traceskip int) {
   
    systemstack(func() {
   
        ready(gp, traceskip, true)
    })
}
  1. 正常调度
    g 中的任务执行完后,g0 会将当前 g 置于死亡状态,发起新一轮的调度

  2. 抢占调度:
    如果 g 执行系统调度时间过长,超过了指定的市场,且全局的 p 资源比较紧缺,此时将 p 和 g 解绑,抢占出来用于其他 g 调度。等 g 完成系统调用后,会重新进入可执行队列中等待被调度
    但是跟前三种调度方式不同的是,其余三个调度方式都是在 m 下的 g0 完成的,抢占调度则不同
    因为发起系统调度时需要打破用户态的边界进入内核,此时 m 也会因系统调用而陷入僵直,无法主动完成抢占调度的行为
    所以Golang进程会有一个全局监控协程 monitor g 的存在,这个 g 会越过 p 直接跟 m 进行绑定,不断轮询对所有的 p 的执行状况进行监控,倘若发现满足抢占调度的条件,则从第三方角度出手干预。主动发起抢占调度动作

宏观调度流程串联

调度流程

  1. 以 g0 -> g -> g0 的一轮循环为例进行串联
  2. g0 执行 schedule() 函数,寻找到用于执行的 g
  3. g0 执行 execute() 方法,更新当前 g、p 的状态信息,并调用 gogo 方法,将执行权交给 g
  4. g 因主动让渡(goshce_m())、被动调度( park_m() )、正常结束( goexit0() )等原因,调用 m_call 函数,执行权重新回到 g0手中
  5. g0 执行 schedule() 函数,开启新一轮的循环

解析 schedule() 搜索可执行 g 的函数

调度流程的主干方法是位于 runtime/proc.go 中的 schedule 函数,此时的执行权位于 g0 手中:

func schedule() {
   
    // ...
    gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available
    // ...
    execute(gp, inheritTime)
}
findRunable()

调度流程中,一个非常核心的步骤,就是为 m 寻找到下一个执行的 g,这部分内容位于 runtime/proc.go 的 findRunnable 方法中:

func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
   
    _g_ := getg()

top:
    _p_ := _g_.m.p.ptr()
    // ...
    // 判断执行查找到 61 次没有
    if _p_.schedtick%61 == 0 && sched.runqsize > 0 {
   
    	// 加锁向全局队列进行查找
        lock(&sched.lock)
        gp = globrunqget(_p_, 1)
        // 释放锁
        unlock(&sched.lock)
        if gp != nil {
   
            // 返回可执行的 g
            return gp, false, false
        }
    }
    
    // ...
    // 尝试从 p 本地队列中进行查找
    if gp, inheritTime := runqget(_p_); gp != nil {
   
        return gp, inheritTime, false
    }
    
    // ...
    // 判断全局队列长度,尝试从全局队列中进行查找
    if sched.runqsize != 0 {
   
        lock(&sched.lock)
        gp := globrunqget(_p_, 0)
        unlock(&sched.lock)
        if gp != nil {
   
            return gp, false, false
        }
    }

	// 尝试获取就绪的网络协议 --> 向 epoll 就绪队列中进行查找
    if netpollinited()</
### Golang 中使用 GMP 大数运算库的方法 #### 安装依赖包 为了在 Go 语言中使用 GMP 库,需要安装 `gmp` 和对应的 Go 绑定库。可以通过以下命令获取并安装所需的软件包: ```bash go get github.com/ncw/gmp ``` 这一步骤确保了开发环境具备必要的工具链支持[^1]。 #### 导入 GMP 包 接下来,在 Go 文件头部导入该第三方库以便后续调用其功能接口: ```go import "github.com/ncw/gmp" ``` 通过这种方式引入外部资源可以简化程序逻辑结构的同时增强代码可读性。 #### 创建大整型实例 利用 `NewInt()` 函数创建一个新的大整数值对象用于存储任意大小的整数数据: ```go a := gmp.NewInt(0).SetString("123456789", 10) b := gmp.NewInt(0).SetUint64(uint64(math.MaxUint64)) c := gmp.NewInt(0).Mul(a, b) // 计算乘法结果 fmt.Println(c.String()) // 输出计算后的字符串表示形式 ``` 上述代码片段展示了如何初始化两个不同类型的初始值以及执行基本四则运算操作。 #### 执行复杂运算 除了简单的加减乘除外,还可以借助于更高级别的 API 实现诸如模幂、最大公约数等特殊需求下的高效处理过程: ```go base := gmp.NewInt(0).SetString("7", 10) exp := gmp.NewInt(0).SetString("65537", 10) modulus := gmp.NewInt(0).SetString("65537", 10) result := new(gmp.Int).Exp(base, exp, modulus) // 模幂运算 fmt.Printf("%s ^ %s mod %s = %s\n", base.Text(10), exp.Text(10), modulus.Text(10), result.Text(10)) ``` 此部分说明了当面对特定领域内的密集型任务时,GMP 提供的强大能力能够显著提升应用程序性能表现。
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值