Goroutine 与调度器
问题
Goroutine 和 OS 线程有什么区别?GMP 调度模型如何工作?如何检测 Goroutine 泄漏?
答案
Goroutine 基础
Goroutine 是 Go 运行时管理的用户态轻量级线程:
// 启动 goroutine
go func() {
fmt.Println("hello from goroutine")
}()
// 带参数的 goroutine
go process(data)
// 启动多个 goroutine
for i := 0; i < 1000; i++ {
go worker(i)
}
Goroutine vs OS Thread
| 维度 | Goroutine | OS Thread |
|---|---|---|
| 栈大小 | 初始 2-8KB,可动态增长到 1GB | 固定 1-8MB |
| 创建开销 | ~0.3μs | ~15μs |
| 切换开销 | ~几十 ns(用户态) | ~1-5μs(内核态) |
| 调度方式 | Go runtime(协作+抢占) | OS 内核 |
| 可创建数量 | 轻松百万 | 通常几千到几万 |
| 通信方式 | Channel(推荐) | 共享内存 + 锁 |
GMP 模型详解
G(Goroutine):
- 包含栈、程序计数器(PC)、状态信息
- 状态:
_Gidle→_Grunnable→_Grunning→_Gwaiting/_Gdead
M(Machine = OS Thread):
- 真正执行代码的操作系统线程
- M 必须绑定一个 P 才能执行 G
- 当 G 进行系统调用阻塞时,M 会释放 P,让其他 M 接管
P(Processor = 逻辑处理器):
- 数量由
GOMAXPROCS控制(默认 = CPU 核数) - 维护本地 G 队列(最多 256 个)
- 是 "M 执行 G" 的中间层,提供执行所需的资源(内存缓存 mcache 等)
调度过程
调度触发时机
| 触发事件 | 说明 |
|---|---|
go 关键字 | 创建新 G,可能触发调度 |
| GC | STW 和并发标记都可能触发 |
| 系统调用 | 阻塞调用导致 M 释放 P |
| Channel 操作 | 阻塞的发送/接收 |
runtime.Gosched() | 主动让出 |
sync.Mutex 等锁 | 竞争失败时挂起 |
| 函数调用 | Go 1.14+ 基于信号的异步抢占 |
| 栈增长检测点 | 函数序言中检查栈空间 |
抢占式调度
Go 1.14 之前:协作式抢占——G 只在函数调用时有机会被抢占。如果一个 G 运行紧密循环(无函数调用),会一直占用 M,影响其他 G。
Go 1.14+:基于信号的异步抢占——runtime 通过 SIGURG 信号中断长时间运行的 G,即使没有函数调用也能被抢占。
// Go 1.14 之前会卡住调度器
go func() {
for { // 紧密循环,没有函数调用
// Go 1.14 之前:其他 goroutine 无法执行
// Go 1.14+:信号抢占,正常调度
}
}()
Goroutine 栈管理
Goroutine 栈动态增长和收缩:
- Go 1.4 之前:分段栈(Segmented Stack)——栈不够时分配新段,可能导致 "hot split" 性能问题
- Go 1.4+:连续栈(Contiguous Stack)——栈不够时分配更大的连续内存,拷贝旧栈
- 收缩:GC 时如果栈使用率低于 25%,缩小到一半
Goroutine 泄漏
Goroutine 泄漏是 Go 最常见的问题之一——goroutine 无法退出,持续占用内存:
// ❌ 泄漏 1:channel 没有接收者
func leak1() {
ch := make(chan int)
go func() {
ch <- 42 // 永远阻塞,没有接收者
}()
// 函数返回,goroutine 泄漏
}
// ❌ 泄漏 2:忘记关闭 channel
func leak2() {
ch := make(chan int)
go func() {
for v := range ch { // 等待 ch 关闭
process(v)
}
}()
ch <- 1
// 忘记 close(ch),goroutine 永远等待
}
// ❌ 泄漏 3:没有退出机制的无限循环
func leak3() {
go func() {
for {
time.Sleep(time.Second) // 永远不退出
}
}()
}
正确做法——用 Context 控制退出:
func noLeak(ctx context.Context) {
go func() {
for {
select {
case <-ctx.Done():
return // 收到取消信号,退出
default:
doWork()
}
}
}()
}
检测泄漏:
// 方法 1:runtime.NumGoroutine()
fmt.Println(runtime.NumGoroutine()) // 对比前后的 goroutine 数
// 方法 2:pprof
import _ "net/http/pprof"
go http.ListenAndServe(":6060", nil)
// 访问 http://localhost:6060/debug/pprof/goroutine?debug=1
// 方法 3:测试中使用 goleak
import "go.uber.org/goleak"
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
常见面试问题
Q1: Goroutine 的初始栈大小是多少?
答案:
Go 1.4+(连续栈)初始栈大小为 2KB(_StackMin = 2048,部分版本为 8KB)。栈可以动态增长,最大默认 1GB(maxstacksize,可调整)。相比之下,OS 线程默认栈大小通常是 1-8MB。
Q2: GOMAXPROCS 设置为多少合适?
答案:
GOMAXPROCS 控制 P 的数量(即最大并行执行的 goroutine 数)。默认等于 CPU 核数,大多数场景这是最优的。
特殊情况:
- CPU 密集型:设为 CPU 核数(默认值即可)
- IO 密集型:默认值即可,因为阻塞时 M 会释放 P
- 容器环境(Docker/K8s):Go 1.19+ 可能需要用
uber/automaxprocs自动适配容器 CPU 限制
Q3: Goroutine 和 Coroutine(协程)有什么区别?
答案:
| 维度 | Go Goroutine | 一般 Coroutine |
|---|---|---|
| 调度方式 | 运行时抢占式调度(Go 1.14+) | 通常协作式(手动 yield) |
| 多核利用 | ✅ M:N 映射,真正并行 | 通常单线程,只能并发 |
| 栈管理 | 运行时动态增长/收缩 | 通常固定大小 |
| 创建方式 | go 关键字 | 语言特定(async/await、yield) |
Goroutine 是 Go 运行时深度管理的特殊协程,支持抢占和多核并行,比一般协程更强大。
Q4: 一个 Goroutine 发生 panic 会影响其他 Goroutine 吗?
答案:
会。如果一个 goroutine 发生 panic 且没有被 recover 捕获,整个进程会崩溃。每个 goroutine 如果可能 panic,应该自己 defer + recover:
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("goroutine recovered: %v", r)
}
}()
riskyWork()
}()
Q5: 如何等待多个 Goroutine 完成?
答案:
// 方法 1:sync.WaitGroup(最常用)
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
doWork()
}()
}
wg.Wait() // 等待所有 goroutine 完成
// 方法 2:errgroup(需要收集错误)
g, ctx := errgroup.WithContext(context.Background())
for i := 0; i < 10; i++ {
g.Go(func() error {
return doWork(ctx)
})
}
if err := g.Wait(); err != nil {
// 处理第一个错误
}
Q6: 什么是工作窃取(Work Stealing)?
答案:
当某个 P 的本地队列为空时,它不会闲着,而是尝试"窃取"其他 P 队列中的 G 来执行。窃取顺序:
- 先检查全局队列(
globalrunqget) - 从全局队列取不到时,随机选择一个其他 P,窃取其本地队列的一半 G
- 如果所有 P 都没有可运行的 G,自旋等待或者 M 进入休眠
这保证了所有 CPU 核心尽可能忙碌,是 GMP 高效调度的关键。