Go协程的底层原理(图文详解)

news2024/11/23 20:07:40

为什么要有协程

什么是进程

![[什么是进程.png]]

  • 操作系统“程序”的最小单位
  • 进程用来占用内存空间
  • 进程相当于厂房,占用工厂空间

什么是线程

进程如果比作厂房,线程就是厂房里面的生产线:
![[什么是线程.png]]

  • 每个进程可以有多个线程
  • 线程使用系统分配给进程的内存,线程之间共享内存

CPU在线程之间来回切换:
![[cpu在线程之间来回切换.png]]

  • 线程用来占用CPU时间
  • 线程的调度需要由系统进行,开销较大
  • 线程相当于工厂的生产线,占用工人的工时
  • 线程里跑的程序就是生产流程

线程的问题

  • 线程本身占用资源大
  • 线程的操作开销大
  • 线程切换开销大

什么是协程

通过将程序的运行状态打包,使其可以在线程中调度运行多个程序:
![[协程在线程中运行图示.png]]

  • 协程就是将一段程序的运行状态打包,可以在线程之间调度
  • 将生产流程打包,使得流程不固定在生产线上
  • 协程并不取代线程,协程也需要在线程上运行
  • 线程是协程的资源,协程使用线程这个资源

协程的优势

  • 资源利用率高
  • 快速调度
  • 超高并发

总结

  • 进程用分配内存空间
  • 线程用来分配cpu时间
  • 协程用来精细利用线程
  • 协程的本质是一段包含了运行状态的程序

协程的本质

func do1() {  
   func2()  
}  
  
func do2() {  
   func3()  // 在这里打个断点
}  
  
func do3() {  
   fmt.Print("dododo")  
}  
  
func main() {  
   go func1()  
   time.Sleep(time.Minute)  
}

运行调试,打开debugger,看输出的栈信息,协程从do1调用到do2,接下来还会调用do3,但是由于打了断点,所以do3还没有调用进来:
![[协程调用栈信息.png]]

协程在go语言的内部结构:g结构体,路径runtime/runtime2.go

type g struct {  
   stack       stack   // offset known to runtime/cgo
}

g结构体中的stack就是协程栈,这个协程栈就是我们上面调试输出看到的栈信息。进入stack结构体,有两个指针参数:

type stack struct {  
   lo uintptr  // 栈的上限
   hi uintptr  // 栈的下限
}

姑且先认为这个栈有两个指针,一个高指针,一个低指针,它是指向了我们栈在内存的栈区里面占用的这块内存,用来存储我们的栈帧信息。继续看g结构体里面的其它重要参数:

type g struct {  
   stack     stack   // offset known to runtime/cgo
   sched     gobuf
}

还有一个叫sched的成员,类型是gobuf结构体,进入gobuf结构体,里面有两个重要的成员,一个是sp,stack pointer 栈指针,指向当前运行到的do2这个栈帧;还有一个是pc,program counter 程序计数器 记录当前程序运行到了哪行代码:

type gobuf struct {  
   sp   uintptr
   pc   uintptr
   g    guintptr  
   ctxt unsafe.Pointer  
   ret  uintptr  
   lr   uintptr  
   bp   uintptr // for framepointer-enabled architectures  
}

再回过头来看g结构体还有哪些重要成员,atomicstatus,这个协程的状态;goid 协程的id号:

type g struct {  
   stack     stack   // offset known to runtime/cgo
   sched     gobuf
   atomicstatus uint32 
   goid         int64
}

根据上面的分析,绘出协程的底层结构,协程的底层结构是一个g结构体,它里面有很多个变量,一个是stack,表示的是协程栈,指向的是stack结构体,stack里面有两个重要元素,一个是lo,指向的是协程栈的低地址,还有一个是hi,指向的是协程栈的高地址,这个栈是为了记录现在这个协程执行到哪里了。g结构体里面还有个sched的字段,这个字段是个gobuf结构体,这个结构体有两个核心的字段,一个是sp,叫栈指针,指向的是当前运行到的栈帧(目前是do2方法),还有一个是pc,叫程序计数器,记录当前程序运行到了do2中的哪行代码:
![[协程的底层结构图示.png]]

  • runtime中,协程的本质是一个g结构体
  • stack:堆栈地址
  • gobuf:目前程序运行现场
  • atomicstatus:协程状态

协程的结构我们知道了,但协程不是还要放到线程上面去执行,那go底层是如何表示线程的底层结构的呢,在runtime/runtime2.go中还有一个m的结构体,这个结构体就是用来记录操作系统线程的信息:

type m struct {  
   g0      *g     // 调度协程而产生的协程
   curg    *g     // 当前运行的协程
   id      int64  // 线程的id
   mOS            // 记录不同操作系统底层对线程的额外描述信息
}
  • runtime中将操作系统线程抽象为m结构体
  • g0:g0协程,操作调度器
  • curg:current g,目前线程运行的g
  • mOS:操作系统线程信息

协程是如何执行的

单线程循环(Go 0.x)

每个go里面的线程会从schedule()方法开始运行,schedule()方法是从g0栈上开始执行的,g0栈就是给g0这个协程在栈空间里面分配的一段内存地址,用来记录你的函数调用跳转的信息,为什么不用一个普通协程的栈记录呢,有两个原因:1.普通协程的栈只能记录业务方法的函数跳转调用信息2.当我们这个线程还没有拿到协程去运行的时候,是没有普通协程栈的。所以线程循环一开始使用的就是g0这个栈,可以理解为在内存的栈区里面有单独的一块区域用来记录线程所执行的方法。
![[单线程循环调度协程图示.png]]

这个schedule()的方法的路径是runtime/proc.go,下面简单分析下这个方法:

func schedule() {
    var gp *g  // 新建了一个变量叫gp,就是即将要运行的协程
    ...        // 在全局的各种队列或本地的各种队列尝试去拿一个可以运行的协程
    execute(gp, inheritTime)  // 调用了execute方法
}

继续看execute方法的逻辑:

func execute(gp *g, inheritTime bool) {
    ... // 给即将要执行的gp协程里面的一些字段赋了值
    gogo(&gp.sched)  // 调用了gogo方法
}

继续看gogo方法,发现只有一个函数声明:

func gogo(buf *gobuf)

凭经验可以判断出gogo方法是由汇编实现的,我们用Ctrl+Shift+F全局搜索这个gogo方法,发现每个平台都实现了对应的gogo方法,说明这个方法的逻辑是平台相关的,我们找一个runtime/asm_amd64.s文件下的gogo方法来分析:

// func gogo(buf *gobuf) 
// restore state from Gobuf; longjmp  
TEXT runtime·gogo(SB), NOSPLIT, $0-8  
   MOVQ   buf+0(FP), BX     // gobuf  
   MOVQ   gobuf_g(BX), DX  
   MOVQ   0(DX), CX     // make sure g != nil  
   JMP    gogo<>(SB)

入参传了一个gobuf指针,回顾下,gobuf结构体有两个核心的字段,一个是sp,叫栈指针,指向的是当前运行到的栈帧(目前是do2方法),还有一个是pc,叫程序计数器,记录当前程序运行到了do2中的哪行代码。继续往下看,它最后又跳转到了下面这个gogo方法,这个gogo方法里面有两个比较核心的地方:

TEXT gogo<>(SB), NOSPLIT, $0  
   get_tls(CX)  
   MOVQ   DX, g(CX)  
   MOVQ   DX, R14       // set the g register  
   MOVQ   gobuf_sp(BX), SP   // 人为插入了一个goexit方法的栈帧
   MOVQ   gobuf_ret(BX), AX  
   MOVQ   gobuf_ctxt(BX), DX  
   MOVQ   gobuf_bp(BX), BP  
   MOVQ   $0, gobuf_sp(BX)   // clear to help garbage collector  
   MOVQ   $0, gobuf_ret(BX)  
   MOVQ   $0, gobuf_ctxt(BX)  
   MOVQ   $0, gobuf_bp(BX)  
   MOVQ   gobuf_pc(BX), BX  // 将我们协程运行到了哪行代码的位置取出来 
   JMP    BX // 跳转到那个位置进行执行

MOVQ gobuf_sp(BX), SP:往我们的协程栈里面插入了一个栈帧,就是goexit这个方法。也就是从gogo这个方法第一次拿到了我们的普通协程栈,通过gobuf这个结构体里面指示的协程栈的高地址和低地址,它就知道了我们协程栈的范围,拿到这个协程栈后它首先将goexit这个方法的栈帧插入了进来。
MOVQ gobuf_pc(BX), BX JMP BX`:跳转到我们现场正在执行的程序计数器。

分析后逻辑就很清晰了,首先给我们的协程栈插入了一个goexit的栈帧,然后跳转到我们g结构体里面的gobuf里面的程序计数器的那一行进行执行。执行就是进入到了业务方法里面,它从do1开始执行,do1调到do2然后调到do3,它就这样执行。执行的时候用的是我们的g stack,也就是用的我们协程自己的协程栈,用自己的协程栈是为了每个g结构体记录自己的执行现场,记录自己执行到了哪个位置。然后看我们的业务代码,执行到do3打印了一条文本,然后do3要退后do2do2要退回do1,然后最终会退到goexit这个方法里面。

看下goexit这个方法的逻辑,双击shift,打开在文件中查找runtime.goexit,发现只有声明,说明是由汇编语言编写的:

func goexit(neverCallThisFunction)

每个平台都实现了对应的goexit方法,说明这个方法的逻辑是平台相关的,我们找一个runtime/asm_amd64.s文件下的goexit方法来分析:

// The top-most function running on a goroutine  
// returns to goexit+PCQuantum.  
TEXT runtime·goexit(SB),NOSPLIT|TOPFRAME,$0-0  
   BYTE   $0x90  // NOP  
   CALL   runtime·goexit1(SB)    // does not return  
   // traceback from goexit1 must hit code range of goexit   BYTE   $0x90  // NOP

里面比较核心的是CALL runtime·goexit1(SB)这行,它调用了runtime·goexit1这个go方法,看下这个方法的逻辑,位于runtime/proc.go中:

// Finishes execution of the current goroutine.  
func goexit1() {  
   mcall(goexit0) 
}

看下mcall这个方法的定义,它会切换到g0栈上执行里面的方法,后面的函数调用关系就用g0栈来记录:

// mcall switches from the g to the g0 stack and invokes fn(g)
func mcall(fn func(*g))

继续看下goexit0这个方法,里面最终会重新回到schedule()方法:

// goexit continuation on g0.  
func goexit0(gp *g) {
    ... // 设置协程中的各项参数
    schedule() 
}

执行go里面的业务逻辑或业务代码都是在线程上执行的,只是说线程里面在执行一个循环,在业务方法之外也就是我们写的协程逻辑之外它是用了g0栈来记录了函数调用和跳转的关系,进入到业务方法之后,就我们写的do1调到do2然后调到do3,它使用协程自己的栈来记录函数调用和跳转的关系,这个栈里面还记录了本地临时变量,业务方法执行完成后会回退到人为强行插入的goexit这个栈帧,进入这个栈帧就会调用goexit0这个方法,他会切换到schedule,不断往复循环。
![[单线程循环抽象GM.png]]

用一个更抽象的图来表示这种过程,M用来表示系统线程的信息,G表示协程,有一个协程队列或协程池子,线程就会一个个将协程从队列里面取出来执行,执行完了后就开始寻找下一个进行执行,不断循环。这么一个单线程循环的逻辑是在0点几版的go里面实现的,当时go还没有正式发布。

多线程循环(Go 1.0)

现在的cpu都是多核多线程,应用也是多线程的应用,如果用上面的go单线程版本那也太浪费系统资源了,所以go从1.0开始引入多线程循环。以下面的两个线程为例,这两个线程执行的是一模一样的逻辑,就是走线程循环。它们会不断地从协程队列里面获取可执行协程拿过来然后送入到gogo里面去执行g协程里面的业务方法,然后再退出,退出后再从队列里面拿,循环往复。如果有8个线程的话就有8个这样的循环,不断从协程队列里面抓取可执行的协程。
![[多线程循环调度协程图示.png]]

那这样就带来一个问题,所有线程都从全局的协程队列里面去获取,就有并发问题。要保证这个协程只被抓到一个线程上面去执行,执行完成后就被丢弃,所以这个全局的协程队列需要加锁,不然业务就会产生冲突。
![[多线程循环调度协程加锁.png]]

用一个更抽象的图来表示这种过程,M用来表示系统线程,G表示协程,下面还有一个全局的队列,每个线程都要从全局的队列里面去抓取协程执行,执行完了后丢弃,再抓取一个新的,这样就会有并发问题,所以全局的协程队列需要加锁,三个线程或更多线程就要竞争这一把锁,竞争成功了就能拿到一个协程去执行:
![[多线程循环抽象GM.png]]

总结

线程循环:

  • 操作系统并不知道Goroutine的存在
  • 操作系统线程执行一个调度循环,顺序执行Goroutine
  • 调度循环非常像线程池

问题:

  • 协程顺序执行,无法并发
  • 多线程并发时,会抢夺协程队列的全局锁

总结:

  • 协程的本质时一个g结构体
  • g结构体记录了协程栈、PC信息
  • 最简情况下,线程执行标准调度循环,执行协程

G-M-P调度模型

本地队列

![[多线程一次从全局队列拿一批协程抽象图示.png]]

线程由原来一次只从全局队列拿一个协程转变为一次拿一批协程,先抓取一批放到本地队列里面执行,将本地队列里的协程执行完后再拿下一批,而从降低全局锁的冲突。

这个队列在go底层是如何表示的呢,和前面的g一样,也是一个p结构体,路径是runtime/runtime2.go,来看下它里面的重要参数:

type p struct {
    m        muintptr   // 指向它服务的那个线程
    // 它是可执行的协程的队列,可以无锁进行访问
    runqhead uint32  // 队列的头
    runqtail uint32  // 队列的尾
    runq     [256]guintptr  // 256长度的指针,每个指针会指向一个g结构体
    runnext guintptr  // 指向下一个可用的协程的指针
}

根据上面的分析,用一个图来表示p结构体,m指向它所要服务的线程,runq说明它是一个可容纳256个可执行协程的队列,runqhead指向的是队列的头,runqtail指向的是队列的尾,还有runnext指向下一个要执行的协程的地址。
![[p结构体图示.png]]

G-M-P模型

根据前面对p结构体的分析,结合前面的G和M,就凑够了G-M-P模型,每个P服务于一个M,P的职责是它要构建一个本地的协程队列,M每次要获取一个协程就直接从本地队列获取,如果M将它本地队列里的协程都执行完了,那就只能从全局队列里获取,先抢到锁,然后再拿一批到本地队列,M就又可以无锁地执行协程了。这就是最朴素最简单的G-M-P模型。
![[G-M-P模型.png]]

进一步对P的作用及底层逻辑进行分析

窃取式工作分配机制

  • M与G之间的中介(送料器)
  • P持有一些G,使得每次获取G的时候不用从全局找
  • 大大减少了并发冲突的情况

那p这部分的逻辑在go源码哪个位置体现的呢,回到前面多次提到的schedule方法,方法的路径是runtime/proc.go,他是我们线程循环的第一个方法,之前分析的时候忽略了里面大量的逻辑,其中一个逻辑就是gp,它要执行的这个协程是如何获取的:

func schedule() {
    var gp *g  // 新建了一个变量叫gp,就是即将要运行的协程
      
    if gp == nil {  
        gp, inheritTime = runqget(_g_.m.p.ptr())  
   }
}

如果前面没有拿到要执行的这个协程,就执行了一个runqget的方法,意思是通过可执行队列获取一个协程,先看下它的入参,_g_是我们现在正在运行的这个协程,也就是还没有切换之前,这个线程正在跑的这个协程,.m就到了我们现在的这个线程的结构体,.p就到了我们现在这个线程对应的本地队列,然后取了它的指针。传入到了runqget中。看下runqget的逻辑,正常情况下nextrunnext,runnext指向的是下一个可执行的协程,这里将这个地址取了出来然后return了回去:

func runqget(_p_ *p) (gp *g, inheritTime bool) {
    next := _p_.runnext
    if next != 0 && _p_.runnext.cas(next, 0) {  
        return next.ptr(), true  
    }
}

如果本地的队列没有了,那要从全局的队列获取一批,这个逻辑在哪里呢,回到schedule方法,继续往下看:

func schedule() {
    var gp *g  // 新建了一个变量叫gp,就是即将要运行的协程
      
    if gp == nil {  
        gp, inheritTime = runqget(_g_.m.p.ptr())  
    }
    if gp == nil {  
        gp, inheritTime = findrunnable()
    }
}

如果从本地的队列没有取到可执行的协程,它会执行findrunnable方法:

func findrunnable() (gp *g, inheritTime bool) {
    if sched.runqsize != 0 {  
        lock(&sched.lock)  
        gp := globrunqget(_p_, 0)  
        unlock(&sched.lock)  
        if gp != nil {  
            return gp, false  
        }  
    }
}

findrunnable里面如果确实从本地队列获取不到可执行的协程,它就会执行globrunqget方法,尝试从全局队列获取:

// 从全局的协程队列里拿一批协程
func globrunqget(_p_ *p, max int32) *g {
    
}

再往回退,如果本地队列和全局队列都没有获取到可执行的协程,那是不是线程就闲着,什么也不干了,当然不是,它还能从别的地方拿到,从findrunnable中调用globrunqget方法的位置往下翻,找到下面这行代码:

gp, inheritTime, tnow, w, newWork := stealWork(now)  

点击跳转到stealWork这个方法,查看注释,意思是stealWork它的作用是从别的队列上偷一些协程过来:

// stealWork attempts to steal a runnable goroutine or timer from any P
func stealWork(now int64) (gp *g, inheritTime bool, rnow, pollUntil int64, newWork bool) {
    
}

本地也没有,全局也没有,就只能从别的p上偷一些协程过来,进行任务窃取:
![[任务窃取1.png]]

左边这个线程的本地和全局都没有协程可获取,但是隔壁的线程对应的队列上还有,它就会偷一些过来执行,帮助隔壁的线程分担工作压力:
![[任务窃取2.png]]

窃取式工作分配机制:

  • 如果在本地或者全局队列中都找不到G
  • 去别的P中“偷”
  • 增强了线程的利用率

新建协程的放置

  • 随机寻找一个P
  • 将新协程放入P的runnect(插队)
  • 若P本地队列满,放入全局队列

go会认为你新建的这个协程优先级是最高的,尽量往前排,优先执行。这一部分的代码逻辑是在runtime/proc.gonewproc方法中,这个方法就是用来新建协程:

func newproc(fn *funcval) {
    newg := newproc1(fn, gp, pc)  // 新建一个协程
    _p_ := getg().m.p.ptr()  
    runqput(_p_, newg, true) // 寻找p,放到本地队列或全局队列
}

寻找p,放到本地队列或全局队列的逻辑都在runqput方法里面,有兴趣的可以下去分析下。

如何实现协程并发

上面的G-M-P调度模型解决了多线程并发时,会抢夺协程队列的全局锁问题,那协程顺序执行,无法并发的问题又是如何处理的呢。

协程饥饿问题

两个线程正在运行的协程的执行时间特别长,会导致后面对时间敏感的协程无法得到切换执行,造成异常。

![[协程饥饿问题图示.png]]

如何解决呢,回到之前的单线程循环模型,如果在执行超长协程或业务的时候,引入某种轮换机制,超长协程执行一段时间后将其暂停,切换执行一下后面的协程,以防后面有一些时间敏感的协程得不到执行会导致异常。
![[单线程循环调度协程图示.png]]

所以这种超大协程,执行到业务轮换点时,保存现场,将执行中的业务数据和代码执行的行数,保存到自己的协程结构体中,放回队列或休眠,让出线程从队列取一个优先级较高的协程进行执行。
![[解决协程饥饿问题-触发切换.png]]

用下面一个更抽象的图来描述,那就是通过本地队列的小循环来解决本地队列的协程饥饿问题。
![[解决协程饥饿问题-本地队列小循环.png]]

又来了一个新问题,如果本地队列的协程循环了很多次还没有执行完,势必会造成全局队列的协程饥饿。所以在本地小循环的时候,会在适当的时候从全局队列中拿一些上来进行执行,也参与一下本地的小循环,这样就可以解决全局的协程队列得不到运行的问题。
![[解决协程饥饿问题-全局队列循环.png]]

回到之前的schduler方法,我们来看下,全局队列大循环的逻辑在哪里体现的,找到下面这行代码,意思是每执行61次线程循环,会从全局的协程队列中拿一个进我们的本地队列:

func schedule() {
    if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {  
        lock(&sched.lock)  
        gp = globrunqget(_g_.m.p.ptr(), 1)  
        unlock(&sched.lock)  
    }
}

切换的时机

超大协程的业务轮换点是如何判断的呢,会在什么情况下进行一个切换:

  • 主动挂起(runtime.gopark)
  • 系统调用完成时

主动挂起(runtime.gopark)

![[协程切换-主动挂起图示.png]]

业务方法中如果调用了gopark()方法,会直接从线程循环中跳转到线程循环的开头,执行schedule()方法,重新从协程队列取协程进行执行,这样就可以解决后面小的任务饥饿的问题,这个方法在runtime/proc.go中:

// 让我们现在正在运行的这个协程进入等待状态
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {  
   ......
   mcall(park_m)  
}

主要的逻辑就是维护了我们的协程和m的一些状态,最后用mcall调用了park_m这个方法,mcall的调用之前讲过,它会切换到g0栈上执行里面的方法,后面的函数调用关系就用g0栈来记录。接着看下park_m这个方法:

// park continuation on g0.  
func park_m(gp *g) {  
   ......
   schedule()  
}

这个方法主要就是进行了一些状态的维护和一些逻辑检查,最重要的是后面调用了schedule这个方法,回到了线程循环的起始点。

关于gopark这个方法还有两点需要注意,那就是这个方法是小写开头的,说明我们自己是调用不了的,调用不了为什么还能叫做主动挂起呢,虽然我们自己调用不了,但是我们在业务中使用的一些逻辑会主动调用gopark,例如涉及到锁、channel、sleep。第二点就是调用了gopark这个方法后,我们的协程会进入waiting状态,是没有办法被再次立即调用的,例如sleep只有当sleep结束后,系统逻辑会自动地将协程的状态改为operable,这样我们的调度器会再次调度这个协程。

系统调用完成时

![[协程切换-系统调用完成时.png]]

我们的业务程序不可避免会做一些系统调用,比如说检查网络状态、硬件状态,这类系统底层命令的调用,一旦有这种系统调用,在完成后,我们的协程会在exitsyscall()方法中进行协程切换。关于exitsyscall这个方法也在runtime/proc.go中:

//go:nosplit  
//go:nowritebarrierrec  
//go:linkname exitsyscall  
func exitsyscall() {
    ......
    Gosched()
    ......
}

它会在里面进入一个Gosched的方法,会在里面用mcall去调用gosched_m

func Gosched() {  
   checkTimeouts()  
   mcall(gosched_m)  
}

gosched_m里面会调用goschedImpl方法:

func gosched_m(gp *g) {  
   if trace.enabled {  
      traceGoSched()  
   }  
   goschedImpl(gp)  
}

goschedImpl这个方法里面就再次调用了schedule这个方法,回到了线程循环的起始点

func goschedImpl(gp *g) {  
   ......
   schedule()  
}

关于系统调用我们不用特别在意,只要知道涉及到系统调用,他会进入到exitsyscall方法,我们的线程就会停止这个协程,返回到线程循环的起始位置,执行别的协程。至于为什么会这样,其实也没有别的什么原因,就是想让它在执行完系统调用后找一个时机切换下,防止它永远不切换。

总结

  • 如果协程顺序执行,会有饥饿问题
  • 协程执行中间,将协程挂起,执行其它协程
  • 完成系统调用时挂起,也可以主动挂起
  • 防止全局队列饥饿,本地队列随机抽取全局队列

抢占式调度

如果有一个超大业务的协程,既不主动挂起也不系统调用,那这种情况下就势必会引发其它协程的饥饿问题,这该怎么办?

基于协作的抢占式调度

思路

有没有一个地方,经常会被调用?能不能在这个地方做一些工作呢,是有的,这个方法叫做runtime.morestack_noctxt

用之前介绍协程的本质的这个例子,用汇编看下,将其编出来后,有没有什么特点。

func do1() {  
   func2()  
}  
  
func do2() {  
   func3()  // 在这里打个断点
}  
  
func do3() {  
   fmt.Print("dododo")  
}  
  
func main() {  
   go func1()  
   time.Sleep(time.Minute)  
}

go build -gcflags -S main.go上面的代码,一个个看这些方法编出来后的内容,do1在调用do2之前会调用runtime.morestack_noctxt方法,do2在调用do3之前也会调用runtime.morestack_noctxt方法,只要我们的方法中有调用其它的方法,编译器在编译的时候都会给它插入一个runtime.morestack_noctxt方法,也就是在调用其它方法之前,都要调用一下runtime.morestack_noctxt方法。

![[协程切换-标记抢占.png]]

runtime.morestack_noctxt

runtime.morestack_noctxt的本意是检查协程栈是否有足够空间,如果没有足够的空间会进行扩容操作,这个业务本身与我们的协程调度没有任何关系。既然每次业务在函数调用时,总会被编译器插入这个方法,我们就可以在这个方法里面下个钩子。

标记抢占

这个钩子就是标记抢占,怎么个标记抢占法呢,就是当系统监控到Goroutine运行超过10ms时,会认为这是一个大协程,这个协程会引发其它协程产生饥饿问题,系统会将g结构体里面的stackguard0字段的值置为0xfffffade,这个值有个解释含义叫抢占标志。

抢占

在调用runtime.morestack_noctxt方法时会判断是否被抢占,如果被抢占,就回到schedule方法。

进入go源码,我们看下runtime.morestack_noctxt这个方法,在runtime/stubs.go这个文件中,发现只有声明,没有实现,说明是用汇编实现的:

func morestack_noctxt()

每个平台都实现了对应的morestack_noctxt方法,说明这个方法的逻辑是平台相关的,我们找一个runtime/asm_amd64.s文件下的morestack_noctxt方法来查看:

// morestack but not preserving ctxt.  
TEXT runtime·morestack_noctxt(SB),NOSPLIT,$0  
   MOVL   $0, DX  
   JMP    runtime·morestack(SB)

不保留上下文,跳转到了runtime.morestack方法,进入这个方法,发现也只有声明,没有实现,说明是用汇编实现的:

func morestack()

我们找一个runtime/asm_amd64.s文件下的morestack方法来查看:

TEXT runtime·morestack(SB),NOSPLIT,$0-0  
   // Cannot grow scheduler stack (m->g0).  
   ......
   CALL   runtime·newstack(SB)  
   ......

这里面大量的逻辑是处理栈深度不足的问题,可以先不用管,在最后调用了一个runtime.newstack方法,可以进入这个方法看下,在runtime/stack.go文件中:

//go:nowritebarrierrec  
func newstack() {
    stackguard0 := atomic.Loaduintptr(&gp.stackguard0)
    preempt := stackguard0 == stackPreempt
    if preempt {  
        // Act like goroutine called runtime.Gosched
       gopreempt_m(gp) // never return  
    }
}

这个方法里面会处理很多新生成一个栈的逻辑,与我们的内容没有多大关系。注意这个地方,preempt就是抢占的意思,他会判断一下g结构体stackguard0这个值是否被标记成了抢占这个值,就是0xfffffade,如果发现抢占的标记是true,就会调用gopreempt_m方法,在这个方法里面会调用goschedImpl方法,goschedImpl最终会调用schedule方法,回到线程循环的起始点。

业务方法在执行函数调用时,会在morestack方法中判断该协程是否标记了抢占,如果被标记了抢占,就会回到schedule方法,获取新的协程进行运行,防止协程饥饿问题。
![[协程切换-基于协作的抢占式调度.png]]

那上面这种方法是不是非常完美了呢,非也非也,继续往下看

基于信号的抢占式调度

基于协作抢占的缺陷

上面基于协作的抢占式调度有什么样的问题呢,看下面这段代码:

func do1() {  
   i := 0  
   for true {  
      i++  
   }  
}  
  
func main() {  
   go do1()  
}

这段代码,既没有系统调用,也不会主动挂起(没有涉及到锁、channel、sleep等逻辑),更不会被标记抢占,这个协程会永远占据这个线程。不信可以用汇编看下,里面没有插入morestack_noctxt这个方法,因为没有函数调用。如果真的是这样,多开几个这样的协程,那不是后面的其它协程都执行不了,全部都饥饿。有大神想出了一招,那就是基于信号的抢占式调度,先看下什么是信号,这里的信号,指的是线程信号

线程信号

  • 操作系统中,有很多基于信号的底层通信方式
  • 比如Linux系统有SIGPIPE、SIGURG、SIGHUP信号
  • 线程可以注册对应信号的处理函数

实现

  • 注册SIGURG信号的处理函数(这个信号不常用)
  • GC工作时,向目标线程发送信号(GC时,很多线程业务都停了,适合做抢占)
  • 线程收到信号,触发调度

原理图如下,注册的这个信号处理函数就叫doSigPreempt,做信号抢占。当垃圾回收器GC发送SIGURG抢占信号后,陷入到业务方法中的线程会立即跳转到doSigPreempt方法,这个方法其实就是做重新调度循环。
![[协程切换-基于信号的抢占式调度.png]]

看下这个doSigPreempt方法的源码,在runtime/signal_unix.go这个文件中:

// doSigPreempt handles a preemption signal on gp.  
func doSigPreempt(gp *g, ctxt *sigctxt) {
    ctxt.pushCall(abi.FuncPCABI0(asyncPreempt), newpc)
}

这个方法最核心的就是会调用asyncPreempt这个方法,这个方法是由汇编实现的,在runtime/preempt.go这个文件中:

// asyncPreempt is implemented in assembly.
func asyncPreempt()

asyncPreempt方法会调回来,调到它下面这个asyncPreempt2方法,这个方法里面会通过mcall调用preemptPark方法。

func asyncPreempt2() {   
    ......
    mcall(preemptPark) 
    ...... 
}

preemptPark这个方法,最终会调用schedule()这个方法,回到线程循环的起始点。

func preemptPark(gp *g) {
    schedule()
}

总结

  • 基于系统调用和主动挂起,协程可能无法调度
  • 基于协作的抢占式调度:业务主动调用morestack()
  • 基于信号的抢占式调度:强制线程调用doSigPreempt()

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1640900.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

OpenWRT有线桥接部署教程

前言 之前咱们讲到OpenWRT部署WAN实现PPPoE拨号上网和自动获取IP模式上网的办法&#xff1a; OpenWRT设置PPPoE拨号教程 OpenWRT设置自动获取IP&#xff0c;作为二级路由器 这一次&#xff0c;咱们尝试用OpenWRT有线桥接上一级路由器的教程。 可能有小伙伴敏锐地发现了&am…

15、ESP32 Wifi

ESP32 的 WIFI 功能是模块内置的&#xff0c;通过 ESP32 的基础库调用一些函数就可以轻松使用它。 Wifi STA 模式&#xff1a; 让 ESP32 连接附近 WIFI&#xff0c;可以上网访问数据。 // 代码显示搜索连接附近指定的 WIFI // 通过 pin 按键可断开连接#include <WiFi.h>…

Docker - 修改服务的端口

1. 测试 新建一个httpd服务 docker run -itd -p 1314:80 --name test -h test httpd 2. 先停止容器和 docke r服务 docker stop test #停止容器3. 修改配置 cd /var/lib/docker/containers ls 找到需要修改的 cd 1fc55f0d24014217cff68c9a417ca46cf50312caa5c9e6bb24085126…

全栈开发之路——前端篇(4)watch监视、数据绑定和计算属性

全栈开发一条龙——前端篇 第一篇&#xff1a;框架确定、ide设置与项目创建 第二篇&#xff1a;介绍项目文件意义、组件结构与导入以及setup的引入。 第三篇&#xff1a;setup语法&#xff0c;设置响应式数据。 辅助文档&#xff1a;HTML标签大全&#xff08;实时更新&#xff…

抖音 通用交易系统 下单 密钥生成

已PHP为例 前提提条件 必须在 linux 系统中 生成 准备工作 接下来打开命令 执行命令即可 openssl genrsa -out private_key.pem 2048 rsa -in private_key.pem -pubout -out public_key.pem exit 会生成 公匙和 私匙 在小程序中 将 生成应用公匙 复制到小程序后台 在执行…

数据结构——循环结构:for循环

今天是星期五&#xff0c;明天休息&#xff0c;后天补课&#xff0c;然后就是运动会&#xff0c;接着是放假。&#xff08;但这些都和我没关系啊&#xff0c;哭死&#xff01;&#xff09;今天脑袋难得清醒一会儿&#xff0c;主要是醒的比较早吧&#xff0c;早起学了一会&#…

苹果CEO对未来一代人工智能投资持乐观态度

尽管在动荡的第二季度&#xff0c;苹果的收入和iPhone销量有所下降&#xff0c;但其新兴的人工智能技术可能会带来急需的提振。 在5月2日的电话财报会议上&#xff0c;苹果公布季度收入为908亿美元&#xff0c;比去年下降4%。iPhone的收入也下降了10%&#xff0c;至460亿美元。…

向量体系结构(4):多条车道内存组

笔记来源《计算机体系结构 量化研究方法》。 接着向量体系结构(2)讲&#xff0c;解决最后留下的问题中的两个问题 向量体系结构&#xff1a;向量执行时间-CSDN博客 &#xff08;1&#xff09;向量处理器如何实现每个时钟周期处理多于一个元素的能力? &#xff08;2&#x…

【大语言模型LLM】-基于大语言模型搭建客服助手(2)

&#x1f525;博客主页&#xff1a;西瓜WiFi &#x1f3a5;系列专栏&#xff1a;《大语言模型》 很多非常有趣的模型&#xff0c;值得收藏&#xff0c;满足大家的收集癖&#xff01; 如果觉得有用&#xff0c;请三连&#x1f44d;⭐❤️&#xff0c;谢谢&#xff01; 长期不…

json文件的读取

&#x1f4da;博客主页&#xff1a;knighthood2001 ✨公众号&#xff1a;认知up吧 &#xff08;目前正在带领大家一起提升认知&#xff0c;感兴趣可以来围观一下&#xff09; &#x1f383;知识星球&#xff1a;【认知up吧|成长|副业】介绍 ❤️感谢大家点赞&#x1f44d;&…

公考学习|基于SprinBoot+vue的公考学习平台(源码+数据库+文档)

公考学习平台目录 目录 基于SprinBootvue的公考学习平台 一、前言 二、系统设计 三、系统功能设计 5.1用户信息管理 5.2 视频信息管理 5.3公告信息管理 5.4论坛信息管理 四、数据库设计 五、核心代码 六、论文参考 七、最新计算机毕设选题推荐 八、源码获取&…

JavaScript:Web APIs(三)

本篇文章的内容包括&#xff1a; 一&#xff0c;事件流 二&#xff0c;移除事件监听 三&#xff0c;其他事件 四&#xff0c;元素尺寸与位置 一&#xff0c;事件流 事件流是什么呢&#xff1f; 事件流是指事件执行过程中的流动路径。 我们发现&#xff0c;一个完整的事件执行…

117篇 | 3D Gaussian Splatting论文

本论文集划分为4个部分&#xff1a;综述&基础&#xff08;14篇&#xff09;、NeRF在AIGC&#xff08;54篇&#xff09;、NeRF在SLAM&#xff08;自动驾驶&#xff09;&#xff08;25篇&#xff09;、NeRF之场景建模&#xff08;25篇&#xff09; https://t.zsxq.com/3ATyE…

021、Python+fastapi,第一个Python项目走向第21步:ubuntu 24.04 docker 安装mysql8、redis(二)

系列文章目录 pythonvue3fastapiai 学习_浪淘沙jkp的博客-CSDN博客https://blog.csdn.net/jiangkp/category_12623996.html 前言 安装redis 我会以三种方式安装&#xff0c; 第一、直接最简单安装&#xff0c;适用于测试环境玩玩 第二、conf配置安装 第三、集群环境安装 一…

详细介绍如何使用YOLOv9 在医疗数据集上进行实例分割-含源码+数据集下载

深度学习彻底改变了医学图像分析。通过识别医学图像中的复杂模式,它可以帮助我们解释有关生物系统的重要见解。因此,如果您希望利用深度学习进行医疗诊断,本文可以成为在医疗数据集上微调YOLOv9 实例分割的良好起点。 实例分割模型不是简单地将区域分类为属于特定细胞类型,…

解决wget报错:ERROR 403: Forbidden.

原因&#xff1a; 服务器正在检查引用者&#xff0c;部分 HTTP 请求会得到错误响应。不以 Mozilla 开头或不包含 Wget 的用户代理的请求会被拒绝。 解决方案&#xff1a; wget --user-agent“Mozilla” 要下载的链接 如&#xff1a; wget --user-agent"Mozilla" …

global IoT SIM解决方案

有任何关于GSMA\IOT\eSIM\RSP\业务应用场景相关的问题&#xff0c;欢迎W: xiangcunge59 一起讨论, 共同进步 (加的时候请注明: 来自CSDN-iot). Onomondo提供的全球IoT SIM卡解决方案具有以下特点和优势&#xff1a; 1. **单一全球配置文件**&#xff1a;Onomondo的SIM卡拥…

一份工作 6 年前端的 Git 备忘录

前言 熟练的使用 git 指令&#xff0c;是一个程序员的基本功&#xff0c;本文记录了我这些年常用的一些 git 操作。 进入新团队需要做的一系列 git 操作 高频使用的指令 1. 注册内网 gitLab 账户 2. 项目管理员拉我进项目 3. 有了权限后&#xff0c;git clone url 项目到本…

顺序表经典算法

顺序表经典算法 1.移除元素 题目&#xff1a; 给你一个数组 nums 和一个值 val&#xff0c;你需要 原地 移除所有数值等于 val 的元素&#xff0c;并返回移除后数组的新长度。 不要使用额外的数组空间&#xff0c;你必须仅使用 O(1) 额外空间并 原地 修改输入数组。 元素的…

华为OD机试 - 会议室占用时间段(Java 2024 C卷 100分)

华为OD机试 2024C卷题库疯狂收录中&#xff0c;刷题点这里 专栏导读 本专栏收录于《华为OD机试&#xff08;JAVA&#xff09;真题&#xff08;A卷B卷C卷&#xff09;》。 刷的越多&#xff0c;抽中的概率越大&#xff0c;每一题都有详细的答题思路、详细的代码注释、样例测试…