Go Goroutine 底层源码深度学习
文章目录
- Go Goroutine 底层源码深度学习
- 1. 面试前置总览:Goroutine定位与GMP整体架构
- 2. G(goroutine)核心结构体完整源码解析
- 3. M(OS线程)与P(逻辑处理器)核心原理
- 4. goroutine创建newproc/newproc1底层完整流程
- 5. GMP核心调度循环schedule源码深度拆解
- 6. goroutine栈动态扩容与缩容
- 7. Go抢占式调度底层完整实现
- 8. goroutine阻塞与唤醒gopark/goready
- 9. 系统调用GOMAXPROCS调度切换逻辑
- 10. goroutine内存模型、gFree缓存池与GC关联
- 11. 大厂goroutine高频面试题源码级解析
- 12. 全文面试总结
Go Goroutine 底层源码深度学习
本文档基于 Go 1.27 源码,面向后端大厂面试,深入解析 goroutine 底层实现原理。
1. 面试前置总览:Goroutine定位与GMP整体架构
1.1 用户态协程 vs OS内核线程差异
| 特性 | OS内核线程 | Goroutine协程 |
|---|---|---|
| 调度者 | 操作系统内核 | Go运行时调度器 |
| 上下文切换 | 模式切换(用户态→内核态),耗时~1-2μs | 用户态切换,耗时~0.2μs |
| 栈大小 | 固定(通常8MB) | 动态(初始2KB,最大1GB) |
| 内存占用 | 栈+内核数据结构~MB级 | 栈(2KB)+g结构体~KB级 |
| 并发数量 | 千级受限(内存和调度开销) | 百万级轻松支持 |
| 调度开销 | 高(涉及内核调度器) | 低(用户态M:N调度) |
面试核心考点:为什么Go不直接使用内核线程?
- 内核线程内存开销大,百万级并发会耗尽系统内存
- 内核线程调度开销大,上下文切换需要陷入内核
- Go采用M:N调度,将多个goroutine复用到少量内核线程上
1.2 GMP三大核心对象全局架构图
三者职责总结:
- G (Goroutine):代表一个goroutine,存储栈信息、状态、执行上下文等
- M (Machine):代表一个内核线程,负责执行goroutine
- P (Processor):代表执行Go代码所需的资源,包括本地运行队列、内存分配缓存等
1.3 大厂高频面试提问汇总
| 问题 | 快速答题思路 |
|---|---|
| 为什么Go不使用内核线程? | 内核线程开销大,百万级并发内存不足;上下文切换慢;Go采用M:N调度更高效 |
| M、P、G分别是什么? | M是内核线程,P是逻辑处理器资源,G是goroutine |
| P的作用是什么?为什么需要P? | 解耦M和G,提供本地运行队列、内存分配缓存,支持工作窃取 |
| GOMAXPROCS是什么?默认值? | 逻辑处理器数量,默认等于CPU核心数 |
| M可以多于P吗?为什么? | 可以,当G执行系统调用时,P会让给其他M,而原M阻塞在syscall上 |
1.4 核心runtime函数清单
| 函数 | 文件 | 功能 |
|---|---|---|
newproc | proc.go:5334 | 创建goroutine入口 |
newproc1 | proc.go:5352 | 创建goroutine核心实现 |
schedule | proc.go:4150 | 调度器主循环 |
gopark | proc.go:457 | 阻塞goroutine |
goready | proc.go:493 | 唤醒goroutine |
preemptM | signal_unix.go:369 | 抢占M |
stackalloc | stack.go:344 | 分配栈 |
stackfree | stack.go:463 | 释放栈 |
copystack | stack.go:898 | 复制/扩容栈 |
2. G(goroutine)核心结构体完整源码解析
2.1 面试考察点
- g结构体核心字段含义
- goroutine栈布局
- goroutine状态流转
2.2 源码文件路径
src/runtime/runtime2.go
2.3 完整源码片段 + 逐行注释
// src/runtime/runtime2.go:471
type g struct {
// ========== 栈相关字段 ==========
// stack描述实际的栈内存: [stack.lo, stack.hi)
stack stack
// stackguard0是Go栈增长序言中比较的栈指针
// 通常是 stack.lo + StackGuard
// 但可以是 StackPreempt 来触发抢占
stackguard0 uintptr
// stackguard1是//go:systemstack栈增长序言中比较的栈指针
// 对于g0和gsignal是 stack.lo + StackGuard
// 对于其他goroutine是 ~0 来触发调用morestackc
stackguard1 uintptr
// ========== panic/defer链 ==========
_panic *_panic // 最内层的panic
_defer *_defer // 最内层的defer
// ========== 调度相关 ==========
m *m // 当前绑定的M (可能为nil)
sched gobuf // 调度上下文: 保存pc/sp等寄存器信息
// ========== 系统调用相关 ==========
syscallsp uintptr // 如果status==Gsyscall,保存sched.sp用于GC
syscallpc uintptr // 如果status==Gsyscall,保存sched.pc用于GC
syscallbp uintptr // 如果status==Gsyscall,保存sched.bp
stktopsp uintptr // 栈顶的sp,用于traceback校验
// param是一个通用指针参数字段
// 用途1: channel唤醒阻塞的goroutine时,指向完成的sudog
// 用途2: gcAssistAlloc1向调用者传递信号
// 用途3: debugCallWrap向新goroutine传参
// 用途4: panic恢复时指向savedOpenDeferState
param unsafe.Pointer
// ========== 状态与标识 ==========
atomicstatus atomic.Uint32 // goroutine状态 (原子操作)
stackLock uint32 // sigprof/scang锁
goid uint64 // goroutine唯一ID
schedlink guintptr // 调度队列链表指针
waitsince int64 // 变为waiting的大致时间
waitreason waitReason // 为什么在waiting状态
// ========== 抢占相关 ==========
preempt bool // 抢占信号,与stackguard0 = stackPreempt重复
preemptStop bool // 抢占时转为_Gpreempted,而不是只是调度
preemptShrink bool // 在同步安全点收缩栈
asyncSafePoint bool // 在异步安全点停止
// ========== 其他标识 ==========
paniconfault bool // 遇到错误地址时panic而不是crash
gcscandone bool // g已扫描栈
throwsplit bool // 禁止栈分裂
activeStackChans bool // 有未加锁的channel指向这个栈
parkingOnChan atomic.Bool // 即将park在channel上
inMarkAssist bool // 在mark assist中
coroexit bool // coroutine切换参数
// ========== 杂项 ==========
raceignore int8
nocgocallback bool // 禁用C回调
tracking bool // 是否正在跟踪调度延迟
trackingSeq uint8 // 用于决定是否跟踪
trackingStamp int64 // 上次开始跟踪的时间
runnableTime int64 // 在runnable状态的时长
lockedm muintptr // 锁定的M
fipsIndicator uint8
fipsOnlyBypass bool
ditWanted bool // g想要在启用DIT的情况下执行
syncSafePoint bool // 在同步安全点停止
runningCleanups atomic.Bool
sig uint32
secret int32 // runtime/secret.Do调用的嵌套层级
writebuf []byte
sigcode0 uintptr
sigcode1 uintptr
sigpc uintptr
parentGoid uint64 // 创建此goroutine的goid
gopc uintptr // 创建此goroutine的go语句pc
ancestors *[]ancestorInfo // 创建此goroutine的祖先信息
startpc uintptr // goroutine函数的pc
racectx uintptr
waiting *sudog // 此g正在等待的sudog
cgoCtxt []uintptr // cgo traceback上下文
labels unsafe.Pointer // pprof标签
timer *timer // time.Sleep的缓存timer
sleepWhen int64 // 睡到何时
selectDone atomic.Uint32 // 参与select并且有人赢了?
// goroutineProfiled表示当前进行中的goroutine profile的状态
goroutineProfiled goroutineProfileStateHolder
coroarg *coro // coroutine传输期间的参数
bubble *synctestBubble
// xRegs存储扩展寄存器状态,如果此G被异步抢占
xRegs xRegPerG
// Per-G tracer状态
trace gTraceState
// ========== GC相关 ==========
gcAssistBytes int64 // 此G的GC assist额度
// valgrind栈ID
valgrindStackID uintptr
}
// 栈内存范围定义
type stack struct {
lo uintptr // 栈底 (低地址)
hi uintptr // 栈顶 (高地址)
}
// 调度上下文,保存用于恢复goroutine执行的寄存器
type gobuf struct {
sp uintptr // 栈指针
pc uintptr // 程序计数器
g guintptr // 指向当前g
ctxt unsafe.Pointer // 上下文
lr uintptr // 链接寄存器(返回地址)
bp uintptr // 帧指针 (启用framepointer的架构)
}
2.4 g/m/p三者关联内存结构
2.5 goroutine五种核心状态状态机
状态常量定义 (src/runtime/runtime2.go:36-119):
const (
_Gidle = iota // 0: 刚分配,未初始化
_Grunnable // 1: 在运行队列中
_Grunning // 2: 正在运行
_Gsyscall // 3: 执行系统调用
_Gwaiting // 4: 阻塞等待
_Gmoribund_unused // 5: 未使用
_Gdead // 6: 未使用/退出
_Genqueue_unused // 7: 未使用
_Gcopystack // 8: 栈正在被移动
_Gpreempted // 9: 被suspendG抢占
_Gleaked // 10: GC发现的泄漏goroutine
_Gdeadextra // 11: 额外M上的dead goroutine
// _Gscan与其他状态组合表示GC正在扫描栈
_Gscan = 0x1000
_Gscanrunnable = _Gscan + _Grunnable
_Gscanrunning = _Gscan + _Grunning
_Gscansyscall = _Gscan + _Gsyscall
_Gscanwaiting = _Gscan + _Gwaiting
_Gscanpreempted = _Gscan + _Gpreempted
_Gscanleaked = _Gscan + _Gleaked
_Gscandeadextra = _Gscan + _Gdeadextra
)
2.6 面试标准答题总结
问题1:goroutine的栈初始多大?栈布局是怎样的?
- 初始2KB (
stackMin = 2048) - 栈从高地址向低地址增长
stack.hi是栈顶(高地址),stack.lo是栈底(低地址)stackguard0=stack.lo + StackGuard,用于栈溢出检测
问题2:goroutine切换时保存什么上下文?
- 保存到
g.sched(gobuf结构体) - 核心寄存器:
sp(栈指针)、pc(程序计数器)、lr(链接寄存器)、bp(帧指针) - 还保存当前g的指针用于恢复验证
问题3:_Gscan状态的作用是什么?
- GC扫描栈时设置
_Gscan位 - 此时goroutine状态被"锁定",防止栈被移动
- 例如
_Gscanrunning表示正在运行的goroutine的栈被扫描
3. M(OS线程)与P(逻辑处理器)核心原理
3.1 面试考察点
- M结构体核心字段
- P结构体核心字段
- M-P绑定与解绑场景
- GOMAXPROCS含义
3.2 源码文件路径
src/runtime/runtime2.go
3.3 M(内核线程)结构体完整解析
// src/runtime/runtime2.go:616
type m struct {
// ========== 特殊goroutine ==========
g0 *g // 拥有调度栈的goroutine,用于执行调度代码
morebuf gobuf // morestack的参数
// ========== 线程标识 ==========
procid uint64 // 调试用的进程/线程ID
// ========== 信号处理 ==========
gsignal *g // 信号处理goroutine
goSigStack gsignalStack // Go分配的信号处理栈
sigmask sigset // 保存的信号掩码
// ========== TLS线程本地存储 ==========
tls [tlsSlots]uintptr // 线程本地存储
// ========== 调度相关 ==========
mstartfn func() // M启动时执行的函数
curg *g // 当前正在运行的goroutine
caughtsig guintptr // fatal signal时正在运行的g
signalSecret bool // 在secret模式下收到信号
// p是当前绑定的P,用于执行Go代码,不执行用户代码时为nil
// 非nil意味着独占拥有P,除非curg处于_Gsyscall
// 在_Gsyscall时,调度器可能修改p
p puintptr
nextp puintptr // 即将切换到的P
oldp puintptr // 执行syscall前的P
id int64 // M的唯一ID
// ========== 状态标志 ==========
mallocing int32 // 是否正在分配内存
throwing throwType // 是否正在throw
preemptoff string // 非空时保持curg在这个m运行
locks int32 // 持有锁的数量
dying int32 // 正在 dying
profilehz int32 // cpu profiling频率
spinning bool // M正在寻找工作
blocked bool // M被阻塞在note上
newSigstack bool // C线程上minit调用了sigaltstack
printlock int8 // 打印锁
incgo bool // M正在执行cgo调用
isextra bool // M是额外的(用于cgo回调)
isExtraInC bool // 额外的M且没有Go帧
isExtraInSig bool // 额外的M在信号处理中
freeWait atomic.Uint32 // 是否可以安全释放g0和删除M
needextram bool
g0StackAccurate bool // g0栈边界是否准确
traceback uint8
allpSnapshot []*p // findRunnable中保存的allp快照
// ========== cgo相关 ==========
ncgo uint64 // 总的cgo调用次数
ncgo int32 // 当前进行中的cgo调用
cgoCallersUse atomic.Uint32 // cgoCallers是否正在使用
cgoCallers *cgoCallers // crash时的cgo traceback
// ========== 同步与链表 ==========
park note // 休眠用的note
alllink *m // allm链表
schedlink muintptr // sched链表
idleNode listNodeManual // 空闲链表节点
lockedg guintptr // 锁定的g
createstack [32]uintptr // 创建此线程的栈
lockedExt uint32 // LockOSThread外部锁计数
lockedInt uint32 // lockOSThread内部锁计数
mWaitList mWaitList // runtime锁等待列表
ditEnabled bool // DIT是否在此M上启用
// ========== profile相关 ==========
mLockProfile mLockProfile // runtime锁竞争相关
profStack []uintptr
// ========== gopark参数 ==========
waitunlockf func(*g, unsafe.Pointer) bool
waitlock unsafe.Pointer
waitTraceSkip int
waitTraceBlockReason traceBlockReason
// ========== syscall相关 ==========
syscalltick uint32
freelink *m // sched.freem链表
// ========== trace ==========
trace mTraceState
// ========== 这些放在这里避免在调用期间使用G的栈 ==========
libcallpc uintptr
libcallsp uintptr
libcallg guintptr
winsyscall winlibcall
// ========== vdso ==========
vdsoSP uintptr
vdsoPC uintptr
// ========== 抢占相关 ==========
preemptGen atomic.Uint32 // 已完成的抢占信号计数
signalPending atomic.Uint32 // 此M上有待处理的抢占信号
// ========== pcvalue缓存 ==========
pcvalueCache pcvalueCache
// ========== debug ==========
dlogPerM
// ========== 操作系统特定字段 ==========
mOS
// ========== 随机数 ==========
chacha8 chacha8rand.State
cheaprand uint32
cheaprand64 uint64
// ========== 持有的锁 ==========
locksHeldLen int
locksHeld [10]heldLockInfo
// ========== 自我引用 ==========
self mWeakPointer
}
3.4 P(逻辑处理器)结构体完整解析
// src/runtime/runtime2.go:774
type p struct {
// ========== 标识与状态 ==========
id int32 // P的唯一ID
status uint32 // P的状态: _Pidle/_Prunning/_Pgcstop/_Pdead
link puintptr // 链表指针
schedtick uint32 // 每次调度递增
syscalltick uint32 // 每次系统调用递增
sysmontick sysmontick // sysmon上次观察到的tick
// ========== 关联的M ==========
m muintptr // 回指关联的M (idle时为nil)
// oldm是此P上次运行的M
oldm mWeakPointer
// ========== defer缓存 ==========
deferpool []*_defer // 可用defer结构体池
deferpoolbuf [32]*_defer // deferpool的后备存储
// ========== goid缓存 ==========
goidcache uint64 // goid缓存
goidcacheend uint64 // goid缓存结束 (cache到cacheend-1可用)
// ========== 本地运行队列 ==========
runqhead uint32 // 队列头
runqtail uint32 // 队列尾
runq [256]guintptr // 环形队列,可存储256个goroutine
runnext guintptr // 下一个要运行的G (优先于runq,继承时间片)
// ========== 空闲G缓存 ==========
gFree gList // 状态为_Gdead的G列表
// ========== sudog缓存 ==========
sudogcache []*sudog
sudogbuf [128]*sudog
// ========== mspan缓存 ==========
mspancache struct {
len int
buf [128]*mspan
}
// ========== pinner缓存 ==========
pinnerCache *pinner
// ========== trace ==========
trace pTraceState
// ========== 持久化分配 ==========
palloc persistentAlloc
// ========== GC相关 ==========
gcAssistTime int64 // assistAlloc中的纳秒数
gcFractionalMarkTime atomic.Int64 // 分阶mark worker中的纳秒数
limiterEvent limiterEvent
gcMarkWorkerMode gcMarkWorkerMode // 下一个mark worker的模式
gcMarkWorkerStartTime int64 // 最近一个mark worker的启动时间
nextGCMarkWorker *gcBgMarkWorkerNode // 下一个要运行的GC mark worker
gcw gcWork // GC工作缓冲区缓存
wbBuf wbBuf // 写屏障缓冲区
runSafePointFn uint32 // 如果为1,在下一个安全点运行sched.safePointFn
statsSeq atomic.Uint32 // 指示是否正在写stats
// ========== 定时器 ==========
timers timers
// ========== cleanup ==========
cleanups *cleanupBlock
cleanupsQueued uint64
// ========== 栈扫描 ==========
maxStackScanDelta int64 // 累积的活动goroutine的栈空间
scannedStackSize uint64 // 此P扫描的栈大小
scannedStacks uint64 // 此P扫描的栈数量
// ========== 抢占标志 ==========
preempt bool // 表示此P应尽快进入调度器
// ========== GC停止时间 ==========
gcStopTime int64
// ========== 统计 ==========
goroutinesCreated uint64
// ========== 扩展寄存器 ==========
xRegs xRegPerP
}
// P的状态常量
const (
_Pidle = iota // 0: 空闲,在pidle链表
_Prunning // 1: 正在运行,被某个M持有
_Psyscall_unused // 2: 已废弃
_Pgcstop // 3: STW停止
_Pdead // 4: 不再使用
)
3.5 M-P-G绑定与解绑完整场景
3.6 面试考点详解
问题1:P的数量为什么默认等于GOMAXPROCS?
- GOMAXPROCS决定可以并行执行Go代码的P的数量
- 默认值等于CPU核心数,充分利用多核
- 每个P同一时间只能被一个M持有执行Go代码
- 可以通过
runtime.GOMAXPROCS(n)动态调整
问题2:M可以多于P吗?为什么?
- 可以,M的数量可以多于P
- 场景:当G执行阻塞系统调用时,M会阻塞,但P可以被释放给其他M
- 这就是为什么Go可以有很多G在系统调用,但同时只有GOMAXPROCS个G在并行执行
- sched.maxmcount限制最多10000个M
问题3:P的本地运行队列大小是多少?满了怎么办?
- 本地队列大小256 (
runq [256]guintptr) - 本地队列满了时,会把本地队列的一半和新G一起放入全局队列
- 全局队列没有大小限制(仅受内存限制)
4. goroutine创建newproc/newproc1底层完整流程
4.1 面试考察点
go func()编译器转换- newproc1完整流程
- 初始栈分配
- gFree缓存池复用
4.2 源码文件路径
src/runtime/proc.go:5334-5477
4.3 完整源码片段 + 逐行注释
// src/runtime/proc.go:5334
// go语句的编译器实现入口
func newproc(fn *funcval) {
gp := getg()
pc := sys.GetCallerPC()
// 在系统栈上执行,避免栈分裂影响
systemstack(func() {
// 实际创建goroutine
newg := newproc1(fn, gp, pc, false, waitReasonZero)
// 放入当前P的本地运行队列
pp := getg().m.p.ptr()
runqput(pp, newg, true)
// main已启动则尝试唤醒其他P
if mainStarted {
wakep()
}
})
}
// src/runtime/proc.go:5352
// newproc1创建一个新的goroutine
// fn: 要执行的函数
// callergp: 创建此goroutine的g
// callerpc: go语句的返回地址
// parked: 是否初始为Gwaiting状态
// waitreason: 如果parked为true,等待原因
func newproc1(fn *funcval, callergp *g, callerpc uintptr, parked bool, waitreason waitReason) *g {
if fn == nil {
fatal("go of nil func value")
}
// 获得当前M,禁止抢占
mp := acquirem()
pp := mp.p.ptr()
// ========== 步骤1:尝试从gFree缓存获取 ==========
newg := gfget(pp)
if newg == nil {
// 缓存中没有,分配新的G和初始栈(2KB)
newg = malg(stackMin)
casgstatus(newg, _Gidle, _Gdead)
allgadd(newg) // 发布到allg列表,状态为Gdead,GC扫描会跳过
}
if newg.stack.hi == 0 {
throw("newproc1: newg missing stack")
}
if readgstatus(newg) != _Gdead {
throw("newproc1: new g is not Gdead")
}
// ========== 步骤2:初始化栈 ==========
// 计算栈空间大小
totalSize := uintptr(4*goarch.PtrSize + sys.MinFrameSize) // 额外空间避免略超帧边界
totalSize = alignUp(totalSize, sys.StackAlign)
sp := newg.stack.hi - totalSize
// 处理LR寄存器(某些架构需要)
if usesLR {
*(*uintptr)(unsafe.Pointer(sp)) = 0
prepGoExitFrame(sp)
}
// 处理ARM64的FP
if GOARCH == "arm64" {
*(*uintptr)(unsafe.Pointer(sp - goarch.PtrSize)) = 0
}
// ========== 步骤3:初始化调度上下文 ==========
memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched))
newg.sched.sp = sp
newg.stktopsp = sp
newg.sched.pc = abi.FuncPCABI0(goexit) + sys.PCQuantum // +PCQuantum使得前一条指令在同一函数
newg.sched.g = guintptr(unsafe.Pointer(newg))
// 设置为执行fn,返回时会跳到goexit
gostartcallfn(&newg.sched, fn)
// ========== 步骤4:设置各种元数据 ==========
newg.parentGoid = callergp.goid
newg.gopc = callerpc
newg.ancestors = saveAncestors(callergp)
newg.startpc = fn.fn
newg.runningCleanups.Store(false)
// 系统goroutine或用户goroutine?
if isSystemGoroutine(newg, false) {
sched.ngsys.Add(1)
} else {
// 继承synctest组和pprof标签
newg.bubble = callergp.bubble
if mp.curg != nil {
newg.labels = mp.curg.labels
}
if goroutineProfile.active {
// 并发profile运行中,标记新g不需要profile
newg.goroutineProfiled.Store(goroutineProfileSatisfied)
}
}
// 随机决定是否跟踪调度延迟
newg.trackingSeq = uint8(cheaprand())
if newg.trackingSeq % gTrackingPeriod == 0 {
newg.tracking = true
}
// 为GC更新可扫描栈大小
gcController.addScannableStack(pp, int64(newg.stack.hi - newg.stack.lo))
// ========== 步骤5:获取goid并设置状态 ==========
trace := traceAcquire()
var status uint32 = _Grunnable
if parked {
status = _Gwaiting
newg.waitreason = waitreason
}
// 从P的goid缓存获取goid
if pp.goidcache == pp.goidcacheend {
// 本地缓存用完,从全局拿一批
pp.goidcache = sched.goidgen.Add(_GoidCacheBatch)
pp.goidcache -= _GoidCacheBatch - 1
pp.goidcacheend = pp.goidcache + _GoidCacheBatch
}
newg.goid = pp.goidcache
pp.goidcache++
// 状态转换:_Gdead -> _Grunnable
casgstatus(newg, _Gdead, status)
newg.trace.reset()
if trace.ok() {
trace.GoCreate(newg, newg.startpc, parked)
traceRelease(trace)
}
// ========== 步骤6:fips140和dit继承 ==========
newg.fipsOnlyBypass = callergp.fipsOnlyBypass
newg.ditWanted = callergp.ditWanted
// ========== 步骤7:secret模式继承 ==========
if goexperiment.RuntimeSecret && callergp.secret > 0 {
newg.secret = 1
}
// ========== 步骤8:设置race detector上下文 ==========
if raceenabled {
newg.racectx = racegostart(callerpc)
newg.raceignore = 0
if newg.labels != nil {
racereleasemergeg(newg, unsafe.Pointer(&labelSync))
}
}
// 更新统计
pp.goroutinesCreated++
releasem(mp)
return newg
}
4.4 gfget - 从缓存池获取G
// src/runtime/proc.go:5557
func gfget(pp *p) *g {
retry:
// P本地gFree空了,从全局拿一批
if pp.gFree.empty() && (!sched.gFree.stack.empty() || !sched.gFree.noStack.empty()) {
lock(&sched.gFree.lock)
for pp.gFree.size < 32 {
// 优先拿有栈的G
gp := sched.gFree.stack.pop()
if gp == nil {
gp = sched.gFree.noStack.pop()
if gp == nil {
break
}
}
pp.gFree.push(gp)
}
unlock(&sched.gFree.lock)
goto retry
}
// 从P本地gFree拿一个
gp := pp.gFree.pop()
if gp == nil {
return nil
}
// 如果栈大小不是初始大小,释放重新分配
if gp.stack.lo != 0 && gp.stack.hi - gp.stack.lo != uintptr(startingStackSize) {
systemstack(func() {
stackfree(gp.stack)
gp.stack.lo = 0
gp.stack.hi = 0
gp.stackguard0 = 0
if valgrindenabled {
valgrindDeregisterStack(gp.valgrindStackID)
gp.valgrindStackID = 0
}
})
}
// 如果没有栈,分配初始栈(2KB)
if gp.stack.lo == 0 {
systemstack(func() {
gp.stack = stackalloc(startingStackSize)
if valgrindenabled {
gp.valgrindStackID = valgrindRegisterStack(unsafe.Pointer(gp.stack.lo), unsafe.Pointer(gp.stack.hi))
}
})
gp.stackguard0 = gp.stack.lo + stackGuard
} else {
// 有栈,设置race/msan/asan
if raceenabled {
racemalloc(unsafe.Pointer(gp.stack.lo), gp.stack.hi - gp.stack.lo)
}
if msanenabled {
msanmalloc(unsafe.Pointer(gp.stack.lo), gp.stack.hi - gp.stack.lo)
}
if asanenabled {
asanunpoison(unsafe.Pointer(gp.stack.lo), gp.stack.hi - gp.stack.lo)
}
}
return gp
}
4.5 goroutine创建入队执行全流程
4.6 面试标准答题总结
问题1:goroutine的初始栈大小是多少?
- 初始2KB (
stackMin = 2048) - 不是固定大小,会动态扩容缩容
问题2:gFree缓存池的作用是什么?
- 复用已退出的goroutine对象,避免频繁分配释放
- P本地gFree满了(64个)会转移一批到全局
- 优先复用有栈的G,避免重新分配栈
问题3:go func() 返回地址为什么设置为goexit?
- 这样当fn执行完返回时,会自动跳转到goexit
- goexit负责清理goroutine,放回gFree缓存
- 对用户代码透明,无需显式调用
5. GMP核心调度循环schedule源码深度拆解
5.1 面试考察点
- schedule完整流程
- 工作窃取(work stealing)机制
- findRunnable查找顺序
- 全局队列平衡
5.2 源码文件路径
src/runtime/proc.go:4150-4360
5.3 完整源码片段 + 逐行注释
// src/runtime/proc.go:4150
// schedule是调度器的主循环
// 查找可运行的goroutine并执行它
// 永不返回
func schedule() {
mp := getg().m
if mp.locks != 0 {
throw("schedule: holding locks")
}
// 如果G被锁定到M,直接运行那个G
if mp.lockedg != 0 {
stoplockedm()
execute(mp.lockedg.ptr(), false) // 永不返回
}
// 不能在cgo调用中调度,因为cgo在用g0栈
if mp.incgo {
throw("schedule: in cgo")
}
top:
pp := mp.p.ptr()
pp.preempt = false
// 安全检查:如果spinning,本地运行队列应该是空的
if mp.spinning && (pp.runnext != 0 || pp.runqhead != pp.runqtail) {
throw("schedule: spinning with local work")
}
// ========== 核心步骤:找到一个可运行的G ==========
gp, inheritTime, tryWakeP := findRunnable() // 阻塞直到找到工作
// 可能在新的P上了
pp = mp.p.ptr()
// 清除allp快照
mp.clearAllpSnapshot()
// 如果P有nextGCMarkWorker但没选它,释放它
gcController.releaseNextGCMarkWorker(pp)
// debug模式:检查是否需要冻结
if debug.dontfreezetheworld > 0 && freezing.Load() {
lock(&deadlock)
lock(&deadlock)
}
// 如果是spinning状态,现在要停止spinning了
if mp.spinning {
resetspinning()
}
// 检查是否禁用了用户goroutine调度
if sched.disable.user && !schedEnabled(gp) {
lock(&sched.lock)
if schedEnabled(gp) {
unlock(&sched.lock)
} else {
sched.disable.runnable.pushBack(gp)
unlock(&sched.lock)
goto top
}
}
// 如果选的是特殊G(GC worker/trace reader),尝试唤醒其他P
if tryWakeP {
wakep()
}
// 如果G锁定到其他M,移交P
if gp.lockedm != 0 {
startlockedm(gp)
goto top
}
// ========== 执行这个G ==========
execute(gp, inheritTime)
}
// src/runtime/proc.go:4346
// 执行gp
// inheritTime表示是否继承当前时间片
func execute(gp *g, inheritTime bool) {
mp := getg().m
if goroutineProfile.active {
tryRecordGoroutineProfile(gp, nil, osyield)
}
// 设置gp.m,然后才能进入_Grunning
mp.curg = gp
gp.m = mp
gp.syncSafePoint = false
// 状态转换:_Grunnable → _Grunning
casgstatus(gp, _Grunnable, _Grunning)
gp.waitsince = 0
gp.preempt = false
gp.stackguard0 = gp.stack.lo + stackGuard // 重置stackguard0取消抢占标志
if !inheritTime {
mp.p.ptr().schedtick++
}
// DIT(Data Independent Timing)处理
if sys.DITSupported && debug.dataindependenttiming != 1 {
if gp.ditWanted && !mp.ditEnabled {
sys.EnableDIT()
mp.ditEnabled = true
} else if !gp.ditWanted && mp.ditEnabled {
sys.DisableDIT()
mp.ditEnabled = false
}
}
// 检查profiler是否需要开关
hz := sched.profilehz
if mp.profilehz != hz {
setThreadCPUProfiler(hz)
}
// trace事件
trace := traceAcquire()
if trace.ok() {
trace.GoStart()
traceRelease(trace)
}
// ========== 核心:切换到G执行 ==========
gogo(&gp.sched) // 永不返回,除非从signal返回
}
5.4 findRunnable - 查找可运行G的核心逻辑
findRunnable的查找顺序(面试必背):
- P本地runnext
- P本地runq
- 全局runq (每61次调度检查一次,保证公平)
- 从其他P窃取
- poll网络
- GC标记工作
- trace reader
- 定时器检查
5.5 工作窃取机制详解
工作窃取的核心思想:
- 当P空闲时,从其他P的队列"偷"一半过来
- 平衡各P的负载,充分利用CPU
- 减少锁竞争(大多时候操作本地队列)
5.6 面试标准答题总结
问题1:调度器查找G的顺序是什么?
- P的
runnext(下一个要运行的,优先级最高) - P的本地
runq - 全局队列(每61次调度检查一次,保证公平)
- 从其他P窃取
- poll网络、GC工作、定时器等
问题2:为什么每61次调度检查一次全局队列?
- 保证公平性,防止全局队列的G饿死
- 61是个质数,避免固定模式
- 如果每次都检查,会有锁竞争(全局队列有锁)
问题3:工作窃取的优点是什么?
- 负载均衡:空闲的P可以帮其他P分担
- 低锁竞争:大部分时间操作本地队列
- 高效利用多核CPU
问题4:runnext的作用是什么?
- 存放被当前G唤醒的G
- 优先级最高,下一个就执行它
- 继承时间片,提高通信G的局部性
6. goroutine栈动态扩容与缩容
6.1 面试考察点
- stackguard0栈溢出检测原理
- 扩容触发条件与流程
- 缩容时机
- 栈复制细节
6.2 源码文件路径
src/runtime/stack.go
6.3 栈布局与溢出检测原理
栈相关常量 (src/runtime/stack.go:70-103):
const (
// 操作系统特定的额外栈空间
stackSystem = goos.IsWindows*4096 + goos.IsPlan9*512 + goos.IsIos*goarch.IsArm64*1024
// Go代码使用的最小栈
stackMin = 2048 // 2KB
fixedStack0 = stackMin + stackSystem
fixedStack1 = fixedStack0 - 1
fixedStack2 = fixedStack1 | (fixedStack1 >> 1)
fixedStack3 = fixedStack2 | (fixedStack2 >> 2)
fixedStack4 = fixedStack3 | (fixedStack3 >> 4)
fixedStack5 = fixedStack4 | (fixedStack4 >> 8)
fixedStack6 = fixedStack5 | (fixedStack5 >> 16)
fixedStack = fixedStack6 + 1 // 对齐到2的幂
// nosplit函数链可用的最大栈空间
stackNosplit = abi.StackNosplitBase * sys.StackGuardMultiplier
// stackGuard是栈底以上的安全距离
// 逻辑: stackGuard = stackNosplit + stackSystem + abi.StackSmall
stackGuard = stackNosplit + stackSystem + abi.StackSmall
// 特殊的stackguard0值
stackPreempt = uintptrMask & -1314 // 触发抢占
stackForceMove = uintptrMask & -275 // 强制移动栈
)
6.4 copystack - 栈扩容/缩容核心实现
// src/runtime/stack.go:898
// copystack复制gp的栈到新栈
// 调用者必须将gp状态设为_Gcopystack
func copystack(gp *g, newsize uintptr) {
if gp.syscallsp != 0 {
throw("stack growth not allowed in system call")
}
old := gp.stack
if old.lo == 0 {
throw("nil stackbase")
}
used := old.hi - gp.sched.sp // 已使用的栈空间大小
// 更新GC的可扫描栈大小
gcController.addScannableStack(getg().m.p.ptr(), int64(newsize) - int64(old.hi - old.lo))
// ========== 步骤1:分配新栈 ==========
new := stackalloc(uint32(newsize))
if stackPoisonCopy != 0 {
fillstack(new, 0xfd) // 调试用:填充毒药
}
if stackDebug >= 1 {
print("copystack gp=", gp, " [", hex(old.lo), " ", hex(old.hi - used), " ", hex(old.hi), "]",
" -> [", hex(new.lo), " ", hex(new.hi - used), " ", hex(new.hi), "]/", newsize, "\n")
}
// ========== 步骤2:计算调整量 ==========
var adjinfo adjustinfo
adjinfo.old = old
adjinfo.delta = new.hi - old.hi // 新栈和旧栈的地址差
// ========== 步骤3:处理channel sudog (同步) ==========
ncopy := used
if !gp.activeStackChans {
if newsize < old.hi - old.lo && gp.parkingOnChan.Load() {
throw("racy sudog adjustment due to parking on channel")
}
adjustsudogs(gp, &adjinfo)
} else {
// sudog可能指向栈,gp已释放channel锁,其他goroutine可能在写栈
// 找到最高的这样的指针,小心处理
adjinfo.sghi = findsghi(gp, old)
// 同步channel操作并复制那部分栈
ncopy -= syncadjustsudogs(gp, used, &adjinfo)
}
// ========== 步骤4:复制栈内容 ==========
memmove(unsafe.Pointer(new.hi - ncopy), unsafe.Pointer(old.hi - ncopy), ncopy)
// ========== 步骤5:调整各种指向栈的指针 ==========
adjustctxt(gp, &adjinfo) // 调整g.sched.ctxt
adjustdefers(gp, &adjinfo) // 调整defer链
adjustpanics(gp, &adjinfo) // 调整panic链
if adjinfo.sghi != 0 {
adjinfo.sghi += adjinfo.delta
}
// ========== 步骤6:切换到新栈 ==========
gp.stack = new
gp.stackguard0 = new.lo + stackGuard // 重置,取消抢占标志
gp.sched.sp = new.hi - used
gp.stktopsp += adjinfo.delta
// ========== 步骤7:调整栈内指针 ==========
var u unwinder
for u.init(gp, 0); u.valid(); u.next() {
adjustframe(&u.frame, &adjinfo) // 调整每个栈帧内的指针
}
// valgrind处理
if valgrindenabled {
if gp.valgrindStackID == 0 {
gp.valgrindStackID = valgrindRegisterStack(unsafe.Pointer(new.lo), unsafe.Pointer(new.hi))
} else {
valgrindChangeStack(gp.valgrindStackID, unsafe.Pointer(new.lo), unsafe.Pointer(new.hi))
}
}
// ========== 步骤8:处理secret模式 ==========
if goexperiment.RuntimeSecret && gp.secret > 0 {
memclrNoHeapPointers(unsafe.Pointer(old.lo), old.hi - old.lo)
secretEraseRegisters()
}
// 调试用:旧栈填充毒药
if stackPoisonCopy != 0 {
fillstack(old, 0xfc)
}
// ========== 步骤9:释放旧栈 ==========
stackfree(old)
}
6.5 栈扩容/缩容完整流程
6.6 内存变化对比图
6.7 面试标准答题总结
问题1:栈扩容的触发条件是什么?
- 函数序言检查:SP > stackguard0
- stackguard0 = stack.lo + StackGuard
- 此时需要调用morestack扩容
问题2:新栈大小是多少?
- 扩容:旧栈大小 * 2
- 初始2KB → 4KB → 8KB → 16KB → …
- 最大:64位系统1GB,32位系统250MB
问题3:栈缩容在什么时候发生?
- GC时检查
- 如果栈使用量 < 1/4,缩容到1/2
- 同步安全点也可能触发缩容
问题4:复制栈时如何处理指向栈的指针?
- 调整gp.sched中的sp/bp/pc
- 调整defer/panic链
- 遍历栈帧,调整帧内指针
- 调整channel sudog的elem指针(需要加锁同步)
问题5:为什么goroutine可以动态扩缩栈,而线程不行?
- goroutine是用户态的,runtime完全控制
- 线程栈由操作系统管理,只能预留虚拟地址
- goroutine栈可以复制,因为runtime知道哪些是指针
- 线程栈如果移动,不知道如何调整内核中的指针
7. Go抢占式调度底层完整实现
7.1 面试考察点
- 协作式调度vs抢占式调度
- 基于信号的异步抢占机制
- 安全点
- 何时会抢占/不会抢占
7.2 协作式调度(旧版本)的缺陷
Go 1.14之前是协作式调度,主要问题:
- 依赖goroutine主动让出
- 长时间循环无法抢占
- GC STW需要等待所有G到达安全点
// 旧版本的"忙循环"问题示例
func busyLoop() {
// 没有函数调用,就没有栈检查,无法被抢占
for {
// 一直占着CPU
}
}
7.3 基于信号的异步抢占机制
7.4 抢占相关源码
// src/runtime/proc.go:6917
// 抢占一个P
func preemptone(pp *p) bool {
mp := pp.m.ptr()
if mp == nil || mp == getg().m {
return false
}
gp := mp.curg
if gp == nil || gp == mp.g0 {
return false
}
gp.preemptStop = true // 抢占后暂停,不只是调度
gp.preempt = true // 设置抢占标志
// 每个函数序言都会检查stackguard0
// 设置为stackPreempt将导致morestack被调用
gp.stackguard0 = stackPreempt
// 发送SIGURG信号,异步抢占
preemptM(mp)
return true
}
// src/runtime/proc.go:4383
// preemptPark将gp暂停并置为_Gpreempted
// 在系统栈上运行
func preemptPark(gp *g) {
status := readgstatus(gp)
if status&^_Gscan != _Grunning {
dumpgstatus(gp)
throw("bad g status")
}
if gp.asyncSafePoint {
f := findfunc(gp.sched.pc)
if !f.valid() {
throw("preempt at unknown pc")
}
if f.flag&abi.FuncFlagSPWrite != 0 {
println("runtime: unexpected SPWRITE function", funcname(f), "in async preempt")
throw("preempt SPWRITE")
}
}
// 状态转换:_Grunning → _Gscan|_Gpreempted
casGToPreemptScan(gp, _Grunning, _Gscan|_Gpreempted)
// 发出trace事件(在持有_Gscan时,保证所有权)
trace := traceAcquire()
if trace.ok() {
trace.GoPark(traceBlockPreempted, 0)
}
dropg()
// 释放_Gscan位
casfrom_Gscanstatus(gp, _Gscan|_Gpreempted, _Gpreempted)
if trace.ok() {
traceRelease(trace)
}
schedule() // 继续调度
}
// src/runtime/signal_unix.go:369
// 发送抢占信号
func preemptM(mp *m) {
// 原子检查是否已有pending信号
if atomic.Load(&mp.signalPending) != 0 {
return
}
if !atomic.Cas(&mp.signalPending, 0, 1) {
return
}
// 发送SIGURG信号
signalM(mp, sigURG)
}
7.5 安全点 - 什么时候可以抢占/不能抢占
可以安全抢占的点:
- 函数序言/尾声(有栈检查)
- 调用morestack时
- 同步安全点(调用runtime函数)
不能抢占的场景:
// src/runtime/proc.go:4383附近
if gp.m.locks != 0 {
// 持有锁,不能抢占
}
if gp.m.preemptoff != "" {
// preemptoff非空,不能抢占
}
if gp.m.incgo {
// 在cgo调用中,不能抢占
}
if gp.m.mallocing != 0 {
// 正在分配内存,不能抢占
}
SPWrite函数: 函数会修改SP,不能在中间抢占
f := findfunc(gp.sched.pc)
if f.flag&abi.FuncFlagSPWrite != 0 {
// 在修改SP的函数中,不安全,不能抢占
throw("preempt SPWRITE")
}
7.6 面试标准答题总结
问题1:Go的抢占式调度是怎么实现的?
- sysmon监控线程定期检查
- P超过10ms没调度,标记抢占
- 设置g.stackguard0 = stackPreempt
- 发送SIGURG信号给M
- 下一次函数序言检查时触发异步抢占
- 调用asyncPreempt → preemptPark
问题2:什么时候会发生抢占?
- goroutine运行超过10ms
- GC需要STW时
- 同步抢占点(如runtime.Gosched)
问题3:什么场景下无法抢占?
- 持有runtime锁
- 在malloc中
- 在cgo调用中
- preemptoff非空
- 在写SP的函数中(SPWrite标志)
问题4:stackPreempt的作用是什么?
- 特殊的stackguard0值
- 函数序言检查时,SP > stackguard0(因为stackPreempt很大)
- 触发morestack → asyncPreempt路径
- 巧妙复用了已有的栈检查机制
8. goroutine阻塞与唤醒gopark/goready
8.1 面试考察点
- gopark的作用与参数
- goready的流程
- gopark为什么释放P
- 常见阻塞场景
8.2 源码文件路径
src/runtime/proc.go:457-532
8.3 gopark - 阻塞goroutine
// src/runtime/proc.go:457
// gopark将当前goroutine置于waiting状态
// unlockf: 状态切换后调用的解锁函数,返回false则恢复goroutine
// lock: 传给unlockf的参数
// reason: 等待原因,用于trace/debug
// traceReason: trace原因
// traceskip: 跳过的栈帧数
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceReason traceBlockReason, traceskip int) {
if reason != waitReasonSleep {
checkTimeouts() // 两个goroutine忙调度器时,超时可能到期
}
mp := acquirem()
gp := mp.curg
status := readgstatus(gp)
if status != _Grunning && status != _Gscanrunning {
throw("gopark: bad g status")
}
mp.waitlock = lock
mp.waitunlockf = unlockf
gp.waitreason = reason
mp.waitTraceBlockReason = traceReason
mp.waitTraceSkip = traceskip
releasem(mp)
mcall(park_m) // 在g0栈上执行park_m
}
// src/runtime/proc.go:4268
// park_m是gopark的延续,在g0栈上执行
func park_m(gp *g) {
mp := getg().m
trace := traceAcquire()
bubble := gp.bubble
if bubble != nil {
bubble.incActive()
}
if trace.ok() {
trace.GoPark(mp.waitTraceBlockReason, mp.waitTraceSkip)
}
// 状态转换:_Grunning → _Gwaiting
casgstatus(gp, _Grunning, _Gwaiting)
if trace.ok() {
traceRelease(trace)
}
dropg() // 解绑M和G
// 调用解锁函数
if fn := mp.waitunlockf; fn != nil {
ok := fn(gp, mp.waitlock)
mp.waitunlockf = nil
mp.waitlock = nil
if !ok {
// 解锁函数返回false,恢复goroutine
trace := traceAcquire()
casgstatus(gp, _Gwaiting, _Grunnable)
if bubble != nil {
bubble.decActive()
}
if trace.ok() {
trace.GoUnpark(gp, 2)
traceRelease(trace)
}
execute(gp, true) // 继承时间片,不返回
}
}
if bubble != nil {
bubble.decActive()
}
schedule() // 继续调度其他G
}
8.4 goready - 唤醒goroutine
// src/runtime/proc.go:493
// goready将gp置为runnable状态
func goready(gp *g, traceskip int) {
systemstack(func() {
ready(gp, traceskip, true)
})
}
// src/runtime/proc.go:1133
// ready是goready的实际实现
func ready(gp *g, traceskip int, next bool) {
status := readgstatus(gp)
mp := acquirem() // 禁止抢占
if status&^_Gscan != _Gwaiting {
dumpgstatus(gp)
throw("bad g status in ready")
}
trace := traceAcquire()
casgstatus(gp, _Gwaiting, _Grunnable)
if trace.ok() {
trace.GoUnpark(gp, traceskip)
traceRelease(trace)
}
// 放入运行队列
pp := mp.p.ptr()
runqput(pp, gp, next)
// 尝试唤醒其他P
wakep()
releasem(mp)
}
8.5 阻塞与唤醒完整流程
8.6 常见阻塞场景
| 场景 | waitReason | 说明 |
|---|---|---|
| channel发送 | waitReasonChanSend | ch <- x 阻塞 |
| channel接收 | waitReasonChanReceive | x := <-ch 阻塞 |
| select | waitReasonSelect | select阻塞 |
| sync.Mutex | waitReasonSyncMutexLock | 锁竞争 |
| sync.Cond | waitReasonSyncCondWait | cond.Wait() |
| time.Sleep | waitReasonSleep | 睡眠 |
| GC等待 | waitReasonGCWorkerIdle | GC worker空闲 |
| panic等待 | waitReasonPanicWait | panic中等待其他G |
8.7 面试标准答题总结
问题1:gopark为什么要释放P?
- 阻塞时不需要占用P资源
- P可以让给其他M运行其他G
- 提高并发度
- M可能阻塞,但P不能浪费
问题2:unlockf的作用是什么?为什么在park之后调用?
- 在状态切换到_Gwaiting后解锁,避免竞态
- 如果先解锁再park,可能在park前被唤醒,唤醒就丢了
- unlockf返回false表示不需要park了,恢复执行
问题3:goready放入哪个队列?
- 优先放入当前P的本地队列
next=true放入runnext位置(最高优先级)- 如果本地队列满了,放入全局队列
9. 系统调用GOMAXPROCS调度切换逻辑
9.1 面试考察点
- entersyscall/exitsyscall流程
- 系统调用时P的处理
- sysmon监控线程作用
9.2 entersyscall - 进入系统调用
9.3 exitsyscall - 系统调用返回
// 核心逻辑简化
func exitsyscall() {
gp := getg()
mp := gp.m
pp := mp.oldp.ptr()
// 尝试获取原来的P
if pp != nil && pp.status == _Pidle && atomic.Cas(&pp.status, _Pidle, _Prunning) {
// 成功获取旧P
mp.setp(pp)
casgstatus(gp, _Gsyscall, _Grunning)
return
}
// 旧P不在了,尝试获取其他P
if schedEnabled(gp) {
pp = pidleget(0)
if pp != nil {
acquirep(pp)
casgstatus(gp, _Gsyscall, _Grunning)
return
}
}
// 没有P可用,放入全局队列
lock(&sched.lock)
casgstatus(gp, _Gsyscall, _Grunnable)
globrunqput(gp)
unlock(&sched.lock)
// M进入休眠
stopm()
schedule()
}
9.4 sysmon - 系统监控线程
9.5 面试标准答题总结
问题1:系统调用时P会怎样?
- G进入_Gsyscall状态
- M阻塞在系统调用
- P被释放,可以被其他M获取
- sysmon监控,如果syscall超过20us,强制handoffp
问题2:系统调用返回时会发生什么?
- 先尝试获取原来的P
- 如果P还在空闲,直接用
- 如果不在,尝试获取其他空闲P
- 没有P,把G放入全局队列,M休眠
问题3:sysmon的作用是什么?
- 监控系统调用超时,handoff P
- 监控抢占超时,发送抢占信号
- 强制GC(如果2min没GC)
- 检查死锁
- 网络poller readiness
10. goroutine内存模型、gFree缓存池与GC关联
10.1 面试考察点
- gFree链表结构
- goroutine退出流程
- GC如何扫描goroutine栈
10.2 gFree缓存池结构
10.3 goroutine退出流程
// src/runtime/proc.go:4490
// goexit是goroutine的返回地址
func goexit1() {
if raceenabled {
if gp := getg(); gp.bubble != nil {
racereleasemergeg(gp, gp.bubble.raceaddr())
}
racegoend()
}
trace := traceAcquire()
if trace.ok() {
trace.GoEnd()
traceRelease(trace)
}
mcall(goexit0)
}
// src/runtime/proc.go:4506
func goexit0(gp *g) {
// secret模式清除栈
if goexperiment.RuntimeSecret && gp.secret > 0 {
memclrNoHeapPointers(unsafe.Pointer(gp.stack.lo), gp.stack.hi - gp.stack.lo)
}
gdestroy(gp) // 销毁goroutine
schedule() // 继续调度
}
// src/runtime/proc.go:4518
func gdestroy(gp *g) {
mp := getg().m
pp := mp.p.ptr()
casgstatus(gp, _Grunning, _Gdead)
gcController.addScannableStack(pp, -int64(gp.stack.hi - gp.stack.lo))
if isSystemGoroutine(gp, false) {
sched.ngsys.Add(-1)
}
gp.m = nil
locked := gp.lockedm != 0
gp.lockedm = 0
mp.lockedg = 0
gp.preemptStop = false
gp.paniconfault = false
gp._defer = nil
gp._panic = nil
gp.writebuf = nil
gp.waitreason = waitReasonZero
gp.param = nil
gp.labels = nil
gp.timer = nil
gp.bubble = nil
gp.fipsOnlyBypass = false
gp.secret = 0
if gcBlackenEnabled != 0 && gp.gcAssistBytes > 0 {
assistWorkPerByte := gcController.assistWorkPerByte.Load()
scanCredit := int64(assistWorkPerByte * float64(gp.gcAssistBytes))
gcController.bgScanCredit.Add(scanCredit)
gp.gcAssistBytes = 0
}
dropg()
if GOARCH == "wasm" {
gfput(pp, gp)
return
}
if locked && mp.lockedInt != 0 {
print("runtime: mp.lockedInt = ", mp.lockedInt, "\n")
if mp.isextra {
throw("runtime.Goexit called in a thread that was not created by the Go runtime")
}
throw("exited a goroutine internally locked to the OS thread")
}
gfput(pp, gp)
}
10.4 GC与goroutine栈
GC扫描goroutine栈的时机:
- STW时扫描所有G
- 并发标记时,G到达安全点扫描
栈扫描要点:
- G状态必须是_Gscan*,防止栈被移动
- 根据栈帧的go:nowritebarrierrec信息找到指针
- 调用adjustframe调整栈指针(如果有copystack)
10.5 百万goroutine为什么不会OOM?
假设有100万goroutine:
每个goroutine开销:
- g结构体:~几KB
- 初始栈:2KB
- 其他元数据:少量
总计:
100万 * (2KB + 几KB) ≈ 3-5GB
如果是线程:
- 每个线程栈:8MB
- 100万 * 8MB = 8TB!
Go的优势:
1. 小初始栈(2KB)
2. 动态扩缩容
3. gFree复用
4. 栈内存来自堆,不是连续预留
10.6 面试标准答题总结
问题1:goroutine退出后会被立即销毁吗?
- 不会,放入gFree缓存池
- 优先P本地缓存,满了移到全局
- 下次newproc时优先复用
问题2:GC如何处理goroutine的栈?
- 扫描栈上的根指针
- G状态加上_Gscan位,防止栈被移动
- 根据栈位图精确找到指针
问题3:百万级goroutine为什么不会OOM?
- 初始栈只有2KB,不是线程的8MB
- 栈动态扩缩,按需分配
- gFree复用,避免频繁分配
- G结构体本身很小
11. 大厂goroutine高频面试题源码级解析
11.1 GMP调度模型完整口述答题框架
面试官:说一下GMP调度模型?
回答思路:
G = Goroutine,协程,类似于用户态线程
M = Machine,内核线程
P = Processor,逻辑处理器
整体架构:
- 每个P有本地运行队列,还有全局运行队列
- M需要绑定P才能执行G
- M从P的队列获取G来执行
- 没有G时,M可以从其他P窃取(work stealing)
关键流程:
1. go func() → newproc创建G → 放入队列
2. schedule循环找G → 本地runnext → 本地队列 → 全局队列 → 窃取
3. execute执行G → 可能gopark阻塞 → 可能系统调用释放P
4. goready唤醒 → 重新入队 → 被调度
核心组件作用:
P:提供资源,本地队列,内存缓存,解耦M和G
M:执行实体,真正在CPU上跑
G:代码和栈
优势:
- 利用多核
- 工作窃取负载均衡
- 减少内核态切换
- 轻量,支持百万并发
11.2 抢占式调度实现原理
面试官:Go的抢占式调度是怎么实现的?
回答思路:
旧版本(1.14前)是协作式:
- 依赖函数调用时的栈检查让出
- 忙循环无法抢占
- GC STW慢
新版本是异步抢占:
1. sysmon监控线程,每10ms检查一次
2. 如果G运行超过10ms没有调度,标记抢占
3. 设置gp.stackguard0 = stackPreempt
4. 发送SIGURG信号给M
5. 下次函数序言检查时,发现stackPreempt
6. 调用asyncPreempt保存上下文
7. preemptPark将G置为_Gpreempted
8. 进入schedule调度其他G
不能抢占的场景:
- 持有runtime锁
- 在malloc中
- 在cgo调用中
- 写SP的函数(SPWrite标志)
11.3 goroutine栈动态扩缩容原理
面试官:goroutine栈是怎么扩容的?
回答思路:
初始栈:2KB
栈布局:
stack.hi (高地址) → 栈顶
已使用栈
stackguard0 = stack.lo + StackGuard → 溢出检查点
stack.lo (低地址) → 栈底
扩容触发:
函数序言检查 SP > stackguard0 → 调用morestack
扩容流程:
1. 计算新大小 = oldsize * 2
2. 检查不超过maxstacksize (1GB)
3. stackalloc分配新栈
4. copystack复制旧栈内容
5. adjustframe调整所有指向栈的指针
- defer/panic链
- sudog的elem指针
- 栈帧内指针
6. 更新gp.stack/gp.sched.sp
7. stackfree释放旧栈
缩容时机:
GC时检查,如果栈使用量 < 1/4,缩容到1/2
11.4 工作窃取机制优缺点
面试官:说一下工作窃取(work stealing)?
回答思路:
机制:
- 当P空闲,自己本地队列为空
- 随机选择另一个P
- 窃取它的队列的一半过来
- 一个自己执行,其他放入自己队列
优点:
- 负载均衡:空闲P帮助繁忙P
- 低锁竞争:大多时候操作本地队列
- 高效:多核利用率高
缺点:
- 窃取需要加锁(目标P的队列)
- 缓存局部性被破坏(偷过来的G是别的P的)
- 实现复杂(需要随机选择,处理边界)
11.5 系统调用时P如何转移
面试官:G执行系统调用时,GMP怎么变化?
回答思路:
1. entersyscall:
- G状态 → _Gsyscall
- 保存syscallsp/syscallpc用于GC
- 标记P可以被转移
2. 系统调用阻塞中:
- M阻塞在kernel
- sysmon监控到超过20us
- handoffp释放P
- 其他M可以获取这个P执行其他G
3. exitsyscall返回:
- 先尝试获取原来的P
- 如果P还在空闲,直接用
- 否则找其他空闲P
- 没P就把G放入全局队列,M休眠
11.6 goroutine泄漏场景
面试官:什么情况下会goroutine泄漏?
回答思路:
1. channel永久阻塞:
ch := make(chan int)
go func() { ch <- 1 }() // 没人接收,永远阻塞
2. select没有default且所有case永久阻塞:
go func() {
select {
case <-ch1:
case <-ch2:
}
}()
3. mutex忘记解锁,死锁:
mu.Lock()
go func() { mu.Lock() }() // 永远拿不到锁
4. time.After泄漏:
for {
select {
case <-time.After(1 * time.Hour): // 每个循环创建一个timer,1小时后才释放
}
}
5. goroutine被遗忘在某个wait队列:
比如context没有cancel,goroutine等在ctx.Done()
如何检测:
- pprof看goroutine profile
- go.uber.org/goleak库
11.7 GOMAXPROCS调优影响
面试官:GOMAXPROCS应该设多少?调大有什么影响?
回答思路:
默认值:CPU核心数,通过nprocs获取
调大:
- 优点:更多并行度?不,P多了但M多了上下文切换增加
- 缺点:更多的P竞争,更多M创建,更多上下文切换
- 场景:如果很多G在系统调用,可以略大于CPU数
调小:
- 优点:减少竞争,更好缓存局部性
- 缺点:并行度降低,CPU利用不足
最佳实践:
- 一般保持默认,runtime会动态调整
- 容器环境注意CPU quota
- 可通过go test -cpu测试不同值的性能
- runtime.GOMAXPROCS(n) 可以动态调整
11.8 协程vs内核线程对比表
| 特性 | Goroutine | 内核线程 |
|---|---|---|
| 调度者 | Go runtime | OS内核 |
| 上下文切换 | 用户态,~0.2μs | 内核态,~1-2μs |
| 栈大小 | 动态,初始2KB,最大1GB | 固定,8MB |
| 内存开销 | ~2KB+g结构体 | ~8MB栈+内核数据 |
| 并发数量 | 百万级 | 千级 |
| 创建销毁 | 快速,用户态 | 慢速,系统调用 |
| 同步原语 | channel/mutex | futex等 |
| 调度开销 | 低(协作+抢占) | 高(完全抢占) |
12. 全文面试总结
12.1 GMP核心调度流程背诵版
12.2 goroutine底层核心runtime函数速查表
| 函数 | 文件 | 作用 | 面试频度 |
|---|---|---|---|
| newproc | proc.go:5334 | go语句入口 | ⭐⭐⭐⭐⭐ |
| newproc1 | proc.go:5352 | 创建G实现 | ⭐⭐⭐⭐ |
| malg | proc.go:567 | 分配G和栈 | ⭐⭐⭐ |
| gfget | proc.go:5557 | 从gFree获取G | ⭐⭐⭐ |
| gfput | proc.go:5516 | 把G放入gFree | ⭐⭐⭐ |
| schedule | proc.go:4150 | 调度主循环 | ⭐⭐⭐⭐⭐ |
| findRunnable | proc.go:4360 | 找可运行G | ⭐⭐⭐⭐⭐ |
| runqget | proc.go:6087 | 获取本地队列G | ⭐⭐⭐ |
| runqput | proc.go:6041 | 放入本地队列 | ⭐⭐⭐ |
| globrunqget | proc.go:6132 | 获取全局队列G | ⭐⭐⭐ |
| execute | proc.go:4346 | 执行G | ⭐⭐⭐⭐ |
| gogo | asm_amd64.s | 切换G上下文 | ⭐⭐ |
| gopark | proc.go:457 | 阻塞G | ⭐⭐⭐⭐⭐ |
| goready | proc.go:493 | 唤醒G | ⭐⭐⭐⭐⭐ |
| park_m | proc.go:4268 | gopark的mcall实现 | ⭐⭐⭐ |
| entersyscall | proc.go:4642 | 进入系统调用 | ⭐⭐⭐⭐ |
| exitsyscall | proc.go:4715 | 退出系统调用 | ⭐⭐⭐⭐ |
| casgstatus | proc.go:3270 | 原子改变G状态 | ⭐⭐⭐ |
| morestack | asm_amd64.s | 栈溢出入口 | ⭐⭐⭐ |
| copystack | stack.go:898 | 复制/扩容栈 | ⭐⭐⭐⭐⭐ |
| stackalloc | stack.go:344 | 分配栈 | ⭐⭐⭐ |
| stackfree | stack.go:463 | 释放栈 | ⭐⭐⭐ |
| preemptone | proc.go:6917 | 抢占一个P | ⭐⭐⭐⭐ |
| preemptM | signal_unix.go:369 | 发送抢占信号 | ⭐⭐⭐ |
| asyncPreempt | asyncPreempt.s | 异步抢占入口 | ⭐⭐⭐ |
| preemptPark | proc.go:4383 | 抢占后暂停 | ⭐⭐⭐⭐ |
| wakep | proc.go:6762 | 唤醒/创建M | ⭐⭐⭐ |
| startm | proc.go:3050 | 启动M | ⭐⭐⭐ |
| stopm | proc.go:3007 | 停止M | ⭐⭐⭐ |
| handoffp | proc.go:3146 | 移交P | ⭐⭐⭐⭐ |
| sysmon | proc.go:6974 | 监控线程 | ⭐⭐⭐⭐ |
| retake | proc.go:7043 | sysmon中收回P | ⭐⭐⭐ |
12.3 面试答题分层思路
第1层:概念
- 简明扼要回答是什么
- 用类比让面试官知道你理解
第2层:结构
- 画出G/M/P等结构体的关系
- 核心字段的作用
第3层:流程
- 时间线/状态机/流程图
- 先主流程,再分支
第4层:源码
- 关键函数名,文件位置
- 关键实现点(不背整段,背逻辑)
第5层:优化/踩坑
- 为什么这样设计?tradeoff?
- 实际使用中的问题?如何调优?
更多推荐

所有评论(0)