商城首页欢迎来到中国正版软件门户

您的位置: 首页 > 文章列表 > 编程开发 > Go协程的底层原理及分析

Go协程的底层原理及分析

  发布于2026-07-08 阅读(0)

扫一扫,手机访问

在并发编程的演进中,“协程”这个概念越来越被关注,尤其是Go语言中的Goroutine,已经成为很多开发者讨论高性能架构时绕不开的话题。不过,协程到底是怎么工作的?底层调度逻辑长什么样?今天这篇文章,我们就从头开始,一步步把Go协程的底层原理拆清楚。这不算一篇新手入门帖,更像一份带着源码分析路径的深度解析。

为什么要有协程

什么是进程

Go协程的底层原理及分析

  • 操作系统里“程序”的最小单位
  • 进程用于占用内存空间
  • 打个比方,进程像是工厂的一个厂房,占用了工厂的空间

什么是线程

如果把进程比作厂房,线程就是厂房里的生产线:

Go协程的底层原理及分析

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

CPU会在线程之间来回切换:

Go协程的底层原理及分析

  • 线程用于占用CPU时间
  • 线程的调度依赖操作系统,开销比较大
  • 线程像厂房里的生产线,占用的是工人(CPU)工时
  • 线程里跑的代码就是生产流程

线程的问题

  • 线程本身占用的资源不小
  • 线程的创建和销毁开销大
  • 线程切换需要陷入内核,开销也很大

什么是协程

协程的思路很有意思:把一段程序的运行状态打包,让它可以在线程间灵活调度:

Go协程的底层原理及分析

  • 协程本质上是将一段程序的运行状态打包,可以在线程之间调度
  • 就好比把生产流程打包,让流程不固定在某一条生产线上
  • 注意,协程并不是要取代线程,它仍然需要在线程上运行
  • 线程是协程的资源,协程利用线程来执行

协程的优势

  • 资源利用率更高
  • 调度速度更快
  • 能够支撑超高并发

总结

  • 进程负责分配内存空间
  • 线程负责分配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还没有被调用进来:

Go协程的底层原理及分析

在Go语言内部,协程是用g结构体表示的。路径是runtime/runtime2.go

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

这里面的stack就是协程栈,也就是上面调试时看到的栈信息。我们再看看stack结构体内部的细节:

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

可以简单理解,这个栈有两个指针——高指针和低指针,指向栈在内存中的占用区域,用来存储栈帧信息。接着看g结构体里的另一个关键参数:

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

sched的类型是gobuf,它里面有两个重要的成员:sp(栈指针)指向当前运行到的方法栈帧,pc(程序计数器)记录当前运行到了哪行代码:

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结构体,内部有lo和hi两个指针分别指向栈的低地址和高地址,用来记录协程的执行位置;shed是gobuf结构体,包含sp(栈指针,指向当前运行到的栈帧)和pc(程序计数器,记录当前执行到哪一行)。

Go协程的底层原理及分析

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

协程的结构清楚了,那它在底层是如何与线程关联的?在runtime/runtime2.go中,还有一个m结构体用来表示操作系统线程:

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

协程是如何执行的

单线程循环(Go 0.x)

Go里的每个线程会从schedule()方法开始运行。这个方法是在g0栈上执行的。g0是专门为调度而生的协程,它有自己的栈空间,用来记录方法调用跳转信息。为什么不直接用普通协程的栈呢?原因有两个:第一,普通协程的栈只能记录业务方法的调用关系;第二,在线程还没拿到协程去运行时,根本没有普通协程栈。所以线程一开始用的就是g0栈——可以理解为在内存的栈区里单独留了一块区域,专门记录线程执行的方法。

Go协程的底层原理及分析

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实现,说明这个方法的逻辑是平台相关的。我们看看runtime/asm_amd64.sgogo的实现:

// 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指针。它最后又跳转到另一个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方法的栈帧;MOVQ gobuf_pc(BX), BXJMP BX则是跳转到程序计数器指示的位置继续执行。

换句话说,流程是这样的:首先在协程栈里插入goexit的栈帧,然后跳转到g结构体里gobuf指示的程序计数器位置执行。业务方法从do1开始执行,do1调用do2do2调用do3,依次执行。这个过程中使用的是协程自己的栈(g stack),目的是让每个g结构体都能记录自己的执行现场。业务代码执行到do3后打印文本,然后逐步返回,最终退到goexit方法。

goexit也只有声明:

func goexit(neverCallThisFunction)

看看runtime/asm_amd64.s中的实现:

// 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)。这个goexit1runtime/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栈记录函数调用和跳转关系;进入业务方法后,使用协程自己的栈记录调用和跳转信息,同时还记录本地变量。业务方法执行完成后,退回到强行插入的goexit栈帧,进入goexit0切换到schedule,然后继续循环。

Go协程的底层原理及分析

用一个更抽象的图来表示:M表示系统线程,G表示协程,有一个协程队列或池子。线程会一个个将协程取出来执行,执行完后寻找下一个,如此循环。这个单线程循环的逻辑在Go 0.x版本中就已经实现了,那时候Go甚至还没有正式对外发布。

多线程循环(Go 1.0)

现在的CPU是多核多线程的,如果还用单线程版本就太浪费资源了。所以从Go 1.0开始引入多线程循环。以下面两个线程为例,它们执行的逻辑完全一样:从协程队列里获取可执行的协程,送入gogo执行其业务方法,然后退出,再取下一个,循环往复。如果有8个线程,那就跑8个这样的循环。

Go协程的底层原理及分析

但问题也来了:所有线程都从同一个全局协程队列获取,会引发并发冲突。需要保证一个协程不会同时被多个线程取走,所以全局队列必须加锁:

Go协程的底层原理及分析

用抽象图来表示:M是系统线程,G是协程,下方是一个全局队列。每个线程都要从这个全局队列里抓取协程执行,执行完后丢弃,再抓取新的。抢锁成为性能瓶颈:

Go协程的底层原理及分析

总结

线程循环:

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

问题:

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

关键结论:

  • 协程的本质是一个g结构体
  • g结构体记录了协程栈和PC信息
  • 最简场景下,线程执行标准调度循环,依次执行协程

G-M-P调度模型

本地队列

Go协程的底层原理及分析

为了解决全局锁的竞争问题,Go引入了本地队列:线程不再一次只拿一个协程,而是先拿一批放到本地队列里执行,执行完后再拿下一批。这样能大大降低全局锁的冲突频率。

这个本地队列在Go底层由p结构体表示,路径同样是runtime/runtime2.go

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

用图来表示p结构体:m指向它服务的线程,runq是一个可容纳256个可执行协程的队列,runqheadrunqtail分别指向队列的头和尾,runnext指向下一个要执行的协程:

Go协程的底层原理及分析

G-M-P模型

有了G、M和P,就构成了经典的G-M-P调度模型。每个P服务于一个M,职责是维护一个本地协程队列。M每次获取协程时,优先从本地队列取。只有当本地队列都执行完了,M才会尝试从全局队列拿一批放到本地,从而再次获得无锁执行的能力。这就是最朴素的G-M-P模型。

Go协程的底层原理及分析

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

窃取式工作分配机制

  • P是M和G之间的中间层(类似送料器)
  • P持有G的队列,M每次获取G时不用每次都去全局找
  • 大大减少了并发冲突

这部分逻辑在schedule方法中体现,我们之前省略了获取协程的具体过程:

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

如果本地拿不到协程,就调用runqget方法,从可执行队列获取一个协程。入参_g_是当前正在运行的协程,.m到当前线程结构体,.p到本地队列指针。runqget的逻辑很简单:先看runnext,如果有就返回:

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

如果本地队列也没有了,就尝试从全局队列获取。对应schedule里的findrunnable

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内部调用了stealWork

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

stealWork的作用是从其他P的队列里偷一些协程过来:

// 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上偷。这就是窃取式工作分配:

Go协程的底层原理及分析

左侧线程的本地和全局队列都空了,但右侧线程对应的P队列上还有任务,左侧线程就会偷一些过来执行:

Go协程的底层原理及分析

窃取式工作分配:

  • 本地和全局队列都找不到G时
  • 去其他P里“偷”
  • 有效提升线程利用率

新建协程的放置

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

Go会认为新创建的协程优先级最高,尽量往前排,优先执行。对应的代码在runtime/proc.gonewproc方法中:

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

如何实现协程并发

G-M-P模型解决了全局锁竞争问题,但还有一个问题没解决:协程顺序执行,无法并发怎么办?

协程饥饿问题

如果有两个线程,各自运行的协程执行时间特别长,就会导致后面时间敏感的协程迟迟无法得到调度:

Go协程的底层原理及分析

解决办法是什么?回到单线程循环模型,如果遇到超长协程,可以引入某种轮换机制:执行一段时间后将这个协程暂停,切换执行后面的协程,防止时间敏感的协程饿死。

Go协程的底层原理及分析

所以,当协程执行到业务轮换点时,保存现场(业务数据和代码执行行数),放到自己的协程结构体中,然后放回队列或进入休眠,让出线程去处理优先级更高的协程:

Go协程的底层原理及分析

用抽象图表示,这就是通过本地队列的小循环来解决本地队列中协程的饥饿问题:

Go协程的底层原理及分析

但问题还没完:如果本地队列的协程循环多次还没执行完,全局队列的协程也会被饿死。所以本地小循环在适当的时候,会从全局队列中拿一些协程进来一起执行:

Go协程的底层原理及分析

回到schedule方法,可以看到这个逻辑的具体实现——每执行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)

Go协程的底层原理及分析

业务方法中调用gopark(),会直接从线程循环跳到schedule(),重新从队列取协程执行:

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

park_m中进行状态维护后调用schedule()

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

这里有两个点值得留意:第一,gopark是小写开头的,我们自己不能直接调用,但业务中使用的逻辑如锁、channel、sleep等会主动调用它。第二,调用gopark后协程会进入waiting状态,不能立即被再次调度,比如sleep结束后系统才会把状态改为runnable,调度器才会再次调度它。

系统调用完成时

Go协程的底层原理及分析

业务程序难免会做系统调用,比如检查网络状态、硬件状态。系统调用完成后,协程会在exitsyscall()方法中进行切换:

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

Goschedmcall调用gosched_m

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

gosched_m调用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前,do2在调用do3前,都调用了runtime.morestack_noctxt。只要方法中调用了其他方法,编译器都会插入这个调用:

Go协程的底层原理及分析

runtime.morestack_noctxt

runtime.morestack_noctxt的本意是检查协程栈是否有足够空间,不够就扩容,本身和协程调度没有直接关系。但因为每次函数调用时都会被编译器插入,我们可以在这个方法里下个钩子。

标记抢占

怎么钩?系统监控到某个Goroutine运行超过10ms时,认为它是一个大协程,会引发其他协程饥饿。于是系统会把g结构体里的stackguard0字段值设为0xfffffade——这个值就是抢占标志。

抢占

runtime.morestack_noctxt被调用时,会检查是否被抢占。如果是,就直接回到schedule方法。在runtime/stubs.go中只有声明,实现是汇编的:

func morestack_noctxt()

查看runtime/asm_amd64.s中的实现:

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

跳转到runtime.morestack,它也由汇编实现,最终调用runtime.newstack

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

runtime/stack.gonewstack方法中:

//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就是抢占的意思。如果stackguard0被标记为0xfffffade,就会调用gopreempt_m,里面最终调用goschedImpl,再调用schedule,回到线程循环起点。

总结一下:每次函数调用都会经过morestack,它会判断协程是否被标记了抢占。如果是,就回到schedule获取新协程执行,从而防止饥饿:

Go协程的底层原理及分析

但这种方法并不完美。

基于信号的抢占式调度

基于协作抢占的缺陷

看下面这个例子:

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

这段代码既没有系统调用,也不会主动挂起,甚至不会被标记抢占——因为它没有任何函数调用,编译器不会插入morestack_noctxt。用汇编一看,确实没有。这个协程会永远占据这个线程。如果多开几个这种协程,其他协程全都要饿死。

为了解决这个问题,大神们想出了基于信号的抢占式调度。这里的信号,指的是线程信号。

线程信号

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

实现

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

这个信号处理函数叫doSigPreempt。当GC发送SIGURG抢占信号后,陷入业务方法执行的线程会立即跳转到doSigPreempt,执行重新调度的循环:

Go协程的底层原理及分析

doSigPreemptruntime/signal_unix.go中:

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

核心是调用asyncPreempt,由汇编实现:

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

asyncPreempt再调到asyncPreempt2,里面通过mcall调用preemptPark

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

preemptPark最终调用schedule(),回到线程循环的起点:

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

总结

  • 基于系统调用和主动挂起,协程可能无法正常调度
  • 基于协作的抢占式调度:业务主动调用morestack()
  • 基于信号的抢占式调度:强制线程调用doSigPreempt()
本文转载于:https://www.jb51.net/jiaoben/363191acb.htm 如有侵犯,请联系zhengruancom@outlook.com删除。
免责声明:正软商城发布此文仅为传递信息,不代表正软商城认同其观点或证实其描述。

热门关注