发布于2026-07-08 阅读(0)
扫一扫,手机访问
在并发编程的演进中,“协程”这个概念越来越被关注,尤其是Go语言中的Goroutine,已经成为很多开发者讨论高性能架构时绕不开的话题。不过,协程到底是怎么工作的?底层调度逻辑长什么样?今天这篇文章,我们就从头开始,一步步把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语言内部,协程是用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(程序计数器,记录当前执行到哪一行)。

协程的结构清楚了,那它在底层是如何与线程关联的?在runtime/runtime2.go中,还有一个m结构体用来表示操作系统线程:
type m struct {
g0 *g // 调度协程而产生的协程
curg *g // 当前运行的协程
id int64 // 线程的id
mOS // 记录不同操作系统底层对线程的额外描述信息
}
Go里的每个线程会从schedule()方法开始运行。这个方法是在g0栈上执行的。g0是专门为调度而生的协程,它有自己的栈空间,用来记录方法调用跳转信息。为什么不直接用普通协程的栈呢?原因有两个:第一,普通协程的栈只能记录业务方法的调用关系;第二,在线程还没拿到协程去运行时,根本没有普通协程栈。所以线程一开始用的就是g0栈——可以理解为在内存的栈区里单独留了一块区域,专门记录线程执行的方法。

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.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指针。它最后又跳转到另一个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), BX和JMP BX则是跳转到程序计数器指示的位置继续执行。
换句话说,流程是这样的:首先在协程栈里插入goexit的栈帧,然后跳转到g结构体里gobuf指示的程序计数器位置执行。业务方法从do1开始执行,do1调用do2,do2调用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)。这个goexit1在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栈记录函数调用和跳转关系;进入业务方法后,使用协程自己的栈记录调用和跳转信息,同时还记录本地变量。业务方法执行完成后,退回到强行插入的goexit栈帧,进入goexit0切换到schedule,然后继续循环。

用一个更抽象的图来表示:M表示系统线程,G表示协程,有一个协程队列或池子。线程会一个个将协程取出来执行,执行完后寻找下一个,如此循环。这个单线程循环的逻辑在Go 0.x版本中就已经实现了,那时候Go甚至还没有正式对外发布。
现在的CPU是多核多线程的,如果还用单线程版本就太浪费资源了。所以从Go 1.0开始引入多线程循环。以下面两个线程为例,它们执行的逻辑完全一样:从协程队列里获取可执行的协程,送入gogo执行其业务方法,然后退出,再取下一个,循环往复。如果有8个线程,那就跑8个这样的循环。

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

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

线程循环:
问题:
关键结论:

为了解决全局锁的竞争问题,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个可执行协程的队列,runqhead和runqtail分别指向队列的头和尾,runnext指向下一个要执行的协程:

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

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

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

窃取式工作分配:
runnext(插队)Go会认为新创建的协程优先级最高,尽量往前排,优先执行。对应的代码在runtime/proc.go的newproc方法中:
func newproc(fn *funcval) {
newg := newproc1(fn, gp, pc) // 新建一个协程
_p_ := getg().m.p.ptr()
runqput(_p_, newg, true) // 寻找p,放到本地队列或全局队列
}
G-M-P模型解决了全局锁竞争问题,但还有一个问题没解决:协程顺序执行,无法并发怎么办?
如果有两个线程,各自运行的协程执行时间特别长,就会导致后面时间敏感的协程迟迟无法得到调度:

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

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

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

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

回到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)
}
}
那超大协程的切换点到底是怎么判断的呢?主要有两种情况:

业务方法中调用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,调度器才会再次调度它。

业务程序难免会做系统调用,比如检查网络状态、硬件状态。系统调用完成后,协程会在exitsyscall()方法中进行切换:
//go:nosplit
//go:nowritebarrierrec
//go:linkname exitsyscall
func exitsyscall() {
......
Gosched()
......
}
Gosched用mcall调用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。只要方法中调用了其他方法,编译器都会插入这个调用:

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.go的newstack方法中:
//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获取新协程执行,从而防止饥饿:

但这种方法并不完美。
看下面这个例子:
func do1() {
i := 0
for true {
i++
}
}
func main() {
go do1()
}
这段代码既没有系统调用,也不会主动挂起,甚至不会被标记抢占——因为它没有任何函数调用,编译器不会插入morestack_noctxt。用汇编一看,确实没有。这个协程会永远占据这个线程。如果多开几个这种协程,其他协程全都要饿死。
为了解决这个问题,大神们想出了基于信号的抢占式调度。这里的信号,指的是线程信号。
这个信号处理函数叫doSigPreempt。当GC发送SIGURG抢占信号后,陷入业务方法执行的线程会立即跳转到doSigPreempt,执行重新调度的循环:

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,由汇编实现:
// asyncPreempt is implemented in assembly. func asyncPreempt()
asyncPreempt再调到asyncPreempt2,里面通过mcall调用preemptPark:
func asyncPreempt2() {
......
mcall(preemptPark)
......
}
preemptPark最终调用schedule(),回到线程循环的起点:
func preemptPark(gp *g) {
schedule()
}
morestack()doSigPreempt()
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
正版软件
正版软件
正版软件
正版软件
正版软件
1
2
3
7
8