Easton Notes
八股Go 面试题

调度与内存管理

GMP 调度器、goroutine 栈、内存分配、逃逸分析和泄漏排查。

GMP 模型是什么?

回答重点

  • G(goroutine):待运行的 Go 函数及其栈、调度状态等信息。
  • M(machine):操作系统线程,实际执行指令。
  • P(processor):执行 Go 代码所需的调度资源,持有本地可运行队列、内存分配缓存等状态。

M 必须关联 P 才能执行普通 Go 代码。P 的数量通常由 GOMAXPROCS 控制,它限制的是同时执行 Go 代码的并行度,不等于 goroutine 数,也不等于进程中操作系统线程的硬上限。

Go 调度器解决了什么问题?

回答重点

调度器负责把大量 goroutine 复用到较少的操作系统线程上,并处理:

  • 创建、阻塞、唤醒和终止 goroutine。
  • 在本地队列、全局队列和网络轮询器之间寻找可运行任务。
  • 工作窃取和负载均衡。
  • 系统调用造成的线程阻塞。
  • 协作式与异步抢占。

它降低了创建并发任务的成本,但不保证 goroutine 的执行顺序、固定线程归属或严格公平。

Go 在什么情况下会调度 goroutine?

回答重点

常见调度点包括:

  • channel、锁、select 等同步操作阻塞。
  • 网络 I/O 等待。
  • 系统调用进入阻塞状态。
  • time.Sleep、runtime.Gosched。
  • goroutine 创建、退出或被运行时抢占。
  • GC 安全点和运行时协作。

Go 1.14 引入了基于信号的异步抢占,缓解了没有函数调用的长循环长期占用 P 的问题。具体信号和实现路径与操作系统、架构及版本有关。

M 如何寻找可运行的 G?

回答重点

调度器会综合多个来源寻找工作,包括当前 P 的本地队列、全局队列、网络轮询器、定时器,以及从其他 P 窃取的任务。

不能把它描述成永远固定的单一路径。运行时会穿插检查全局队列以保证公平,也会根据自旋线程数量、网络事件和 GC 状态调整策略。业务代码只能依赖“可运行 goroutine 最终会被调度”,不能依赖队列选择顺序。

为什么 GMP 中需要 P?

回答重点

P 把可并行执行 Go 代码所需的资源从操作系统线程中抽离出来:

  • 每个 P 有本地可运行队列,减少对全局队列的锁竞争。
  • 每个 P 持有本地内存分配缓存,降低分配竞争。
  • M 因系统调用阻塞时,P 可以与其他 M 结合继续执行 Go 代码。

如果只有 G 和 M,大量线程会争用全局调度状态,也难以高效管理本地缓存和并行度。

P 和 M 什么时候创建?

回答重点

P 在运行时初始化时根据 GOMAXPROCS 建立;调用 runtime.GOMAXPROCS 调整并行度时,P 集合也会相应调整。

M 按运行需要创建,例如现有线程阻塞于系统调用,而仍有 P 和可运行 G 需要执行。空闲 M 可以休眠并被后续复用。线程数量受到运行时限制,可通过 runtime/debug.SetMaxThreads 调整;不存在名为 GOMAXTHREADS 的标准环境变量。

m0 和 g0 分别是什么?

回答重点

  • m0 是运行时启动阶段的初始 M,与程序最初的操作系统线程关联,负责参与早期初始化,之后也可以参与调度。
  • 每个 M 都有自己的 g0。g0 运行调度、栈管理等运行时代码,使用系统栈,不执行普通用户 goroutine 的业务函数。

main 返回后进程会结束,运行时不会等待所有 goroutine,也不会替它们执行尚未运行的 defer。因此“m0 退出时等待所有 goroutine 并执行其 defer”是错误说法。

g0 和用户 goroutine 如何切换?

回答重点

运行时会保存当前 G 的栈指针、程序计数器等调度上下文,再切换到当前 M 的 g0 系统栈执行调度逻辑。选出下一个 G 后,再恢复它保存的上下文。

这些操作由架构相关汇编和运行时代码共同完成。函数名、寄存器布局和 G 结构字段均为实现细节,面试时不宜背诵过时源码结构。

goroutine 的栈有什么特点?

回答重点

goroutine 从较小的栈开始,空间不足时运行时会扩容并移动栈,必要时也能缩小。Go 使用连续可增长栈,不应继续描述成早期版本的“分段栈”实现。

栈增长需要更新栈内指针,因此只有运行时确认安全的位置才能移动。超深递归仍可能导致栈耗尽,goroutine 也不是无限轻量。

Go 内存分配器的基本思路是什么?

回答重点

标准运行时的分配器借鉴 TCMalloc,按对象大小分级管理:

  • P 关联的 mcache 服务常见小对象分配,降低锁竞争。
  • mcentral 管理各 size class 的 span。
  • mheap 管理更大范围的堆页,并向操作系统申请或归还内存。
  • 很小且不含指针的对象可能由 tiny allocator 合并分配。
  • 大对象绕过常规小对象缓存,直接由堆管理。

大小阈值、size class 数量和页布局会随版本变化,应以当前运行时源码和性能分析为准。

什么是逃逸分析?

回答重点

逃逸分析是编译器判断一个值能否安全放在当前 goroutine 栈上的过程。如果编译器无法证明它的生命周期受当前栈帧约束,就可能把相关存储分配到堆上。

常见影响因素包括:

  • 指针或闭包引用在函数返回后仍可能被使用。
  • 值被保存到已经逃逸的对象中。
  • 分配大小在编译期无法确定或过大。
  • 接口调用、动态分派和编译器优化边界使生命周期难以证明。

这些只是可能因素,不是逐条必然规则。是否逃逸应查看当前工具链的诊断:

go build -gcflags='-m=2' ./...

逃逸到堆一定是性能问题吗?

回答重点

不一定。堆分配增加 GC 扫描和回收工作,而栈分配通常更便宜;但一次必要的小分配往往不是瓶颈。

优化顺序应该是:

  1. 用 benchmark、CPU profile 和 allocation profile 找到真实热点。
  2. 优先减少高频路径上的分配次数和存活对象。
  3. 再评估对象复用、预分配或数据结构调整。

不要为了消除逃逸引入复杂对象池。对象池也会增加生命周期、内存占用和维护成本。

Channel 分配在栈上还是堆上?

回答重点

需要区分 channel 变量和 channel 运行时对象:

  • 局部变量 ch 只是一个引用值,它本身可以位于 goroutine 栈上。
  • 当前标准工具链执行 make(chan T) 时,通过运行时 makechan 创建 hchan 和缓冲区,这部分存储位于堆上并由 GC 管理。

这是标准工具链的实现方式,不是 Go 语言规范对所有编译器的强制要求。逃逸分析输出主要解释源码变量和对象是否逃逸,不能据此把局部 channel 变量与它指向的运行时对象混为一谈。

Go 中常见的内存滞留或泄漏场景有哪些?

回答重点

Go 的 GC 只能回收不可达对象。程序不再需要、但仍被引用的数据会继续占用内存:

  • goroutine 永久阻塞,并持有栈或闭包中的对象。
  • 小 slice 长期引用大底层数组。
  • Map 删除大量元素后仍保留较大的内部存储。
  • 全局缓存没有容量、过期或淘汰策略。
  • ticker 未停止,或注册的回调、订阅没有注销。
  • 响应体、文件、数据库行等资源没有关闭,间接拖住缓冲区和连接。

现代 Go 已能回收不再被引用的 timer 和 ticker,因此不能笼统地说“time.After 不手动停止就永久泄漏”。循环中反复创建 timer 仍可能带来不必要的短期分配和延迟。

如何定位内存和 goroutine 泄漏?

回答重点

  1. 先观察 RSS、堆存活量、分配速率和 goroutine 数量,区分堆增长、堆外内存与运行时暂未归还给操作系统的内存。
  2. 对比多个时间点的 heap profile,关注持续增长的存活对象,而不只是累计分配。
  3. 查看 goroutine profile,按阻塞栈聚合,寻找数量不断增加的调用路径。
  4. 使用 block、mutex profile 和 runtime trace 分析等待原因。
  5. 在可重复负载下验证修复前后的存活量,而不是只看一次 GC 后的 RSS。

常用入口:

go tool pprof http://localhost:6060/debug/pprof/heap
go tool pprof http://localhost:6060/debug/pprof/goroutine
go tool trace trace.out

扩展知识

评论

On this page