跳到主要内容

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

维度GoroutineOS 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,可能触发调度
GCSTW 和并发标记都可能触发
系统调用阻塞调用导致 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 来执行。窃取顺序:

  1. 先检查全局队列(globalrunqget
  2. 从全局队列取不到时,随机选择一个其他 P,窃取其本地队列的一半 G
  3. 如果所有 P 都没有可运行的 G,自旋等待或者 M 进入休眠

这保证了所有 CPU 核心尽可能忙碌,是 GMP 高效调度的关键。

相关链接