Go goroutine 与调度器

笔记/Go/Go核心编程/Go goroutine 与调度器

背景与动机#

Go 并发编程的入口是 goroutine。它看起来只是一个 go 关键字,但背后真正值得理解的是:为什么它比线程轻、谁负责调度它、阻塞时会不会拖住整个程序。

这一篇只聚焦 goroutine 和运行时调度器,不展开 channel、selectWaitGroupMutex 的完整用法。它们分别放在后续笔记里:

  • 07.Go Channel 通道:负责 goroutine 之间的通信和同步。
  • 08.Go select 多路复用:负责同时等待多个 channel 操作。
  • 09.Go sync 同步原语:负责共享状态保护和任务等待。

本文默认按 Go 1.22+ 理解;涉及版本差异的地方会单独标注。调度器内部细节可能随版本演进,但 G-M-P 这条主线足够解释大多数日常并发现象。

核心原理拆解#

1. goroutine 是由 Go 运行时管理的轻量任务#

启动 goroutine 的语法很简单:

go fn()

这行代码不会创建一个“用户可直接管理的线程”,而是把 fn 包装成一个由 Go runtime 调度的任务。它有自己的栈、执行状态和调度信息,但并不和操作系统线程一一绑定。

goroutine 轻的原因主要有三个:

  • 初始栈很小,并且可以按需增长。
  • 调度主要由 Go runtime 完成,不需要每次都直接交给操作系统线程调度。
  • 阻塞在 channel、锁、网络 I/O 等位置时,runtime 可以让当前线程去跑别的 goroutine。

所以 Go 能轻松创建成千上万个 goroutine,但这不等于 goroutine 没有成本。每个 goroutine 仍然需要栈、调度状态和 GC 扫描成本。

2. G-M-P 模型:任务、线程、调度上下文分离#

Go 运行时调度器常用 G-M-P 模型解释:

  • G:goroutine,本质是待执行任务。
  • M:machine,对应操作系统线程,真正被 CPU 调度。
  • P:processor,Go 代码执行所需的调度上下文,持有本地运行队列。

M 必须拿到 P 才能执行 Go 代码。GOMAXPROCS 控制的是 P 的数量,也就是同一时刻最多有多少个 goroutine 并行执行 Go 代码;它不是 goroutine 总数,也不是线程总数。

【并发编程 - G-M-P 调度拓扑与阻塞流转】

image.png
image.png

调度器大致按这个方向工作:

  • 每个 P 有本地运行队列,优先执行本地队列里的 G
  • 本地队列没有任务时,会尝试从全局队列取任务。
  • 如果全局队列也没有任务,会从其他 P 的本地队列窃取一部分任务,这就是 work stealing。
  • 这样可以减少全局锁竞争,同时避免某个 P 很忙、另一个 P 空闲。

3. 阻塞时要分清是 G 阻塞,还是 M 阻塞#

理解 goroutine 调度时,最容易混淆的是“谁被阻塞了”。

如果 goroutine 阻塞在 channel 收发、Mutex.LockWaitGroup.Wait 这类 runtime 能识别的同步点上,通常是当前 G 被挂起。MP 可以继续执行别的 G

如果 goroutine 发起可能长期阻塞的系统调用,阻塞的可能是 M。这时 runtime 会尽量把 P 从当前 M 手里拿走,交给其他可用 M 继续执行 Go 代码。

网络 I/O 又是另一类常见场景。标准库网络 I/O 通常会结合 netpoller,让 goroutine 等待事件通知,而不是让线程一直卡在阻塞 I/O 上。

因此,下面这句话更准确:

goroutine 阻塞不一定等于线程阻塞;线程阻塞也不一定让整个 Go 程序停住。

4. 抢占:长时间运行的 goroutine 也需要被让出#

早期 Go 调度更依赖函数调用、channel 操作、系统调用等位置作为调度点。如果一个 goroutine 长时间运行纯计算循环,可能导致其他 goroutine 难以及时运行。

Go 1.14 起引入异步抢占,Go 1.22+ 中这是默认能力。它改善了长时间运行 goroutine 的调度公平性,但不能把坏代码变成好代码。

下面这种循环仍然不值得鼓励:

for {
// 没有退出条件,也没有任何阻塞点
}

即使 runtime 能抢占它,它仍会持续消耗 CPU。正确做法通常是提供退出条件,或者在需要等待时使用 channel、context.Context、timer 等机制。

5. goroutine 生命周期必须有人负责#

goroutine 最大的优点是启动容易,最大的问题也是启动太容易。只要 goroutine 没有退出条件,它就会一直占用资源。

常见泄漏来源:

  • 接收方一直等 channel,但发送方已经不会再发送。
  • 发送方一直往 channel 发,但接收方提前退出。
  • 后台 goroutine 没有监听取消信号。
  • 循环里反复启动 goroutine,却没有限制并发数量。

判断 goroutine 是否安全,重点不是“能不能启动”,而是:

  • 谁负责通知它退出。
  • 它阻塞时还能不能被唤醒。
  • 上游失败或请求取消时,它是否会一起停止。

最小可运行代码示例#

下面这个例子只演示 goroutine 的启动、等待和取消,不把 channel 细节展开成主角:

package main
import (
"context"
"fmt"
"sync"
"time"
)
func worker(ctx context.Context, id int, wg *sync.WaitGroup) {
defer wg.Done()
for {
select {
case <-ctx.Done():
fmt.Println("worker exit:", id)
return
default:
fmt.Println("worker running:", id)
time.Sleep(100 * time.Millisecond)
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
for i := 1; i <= 2; i++ {
wg.Add(1)
go worker(ctx, i, &wg)
}
time.Sleep(300 * time.Millisecond)
cancel()
wg.Wait()
fmt.Println("all workers stopped")
}

这个例子的重点有三点:

  • go worker(...) 启动的是 goroutine,不是手动创建线程。
  • context.Context 负责传递退出信号。
  • WaitGroup 只负责等待 goroutine 结束,不负责取消 goroutine。

常见陷阱与错误示例#

1. 主 goroutine 退出后,其他 goroutine 也没机会继续跑#

错误写法:

func main() {
go func() {
fmt.Println("background task")
}()
}

main 返回后进程结束,后台 goroutine 不会因为“还没执行完”就自动保活。需要等待结果时,应使用 WaitGroup、channel 或其他同步方式。

2. 循环里启动 goroutine,但没有并发上限#

for _, item := range items {
go handle(item)
}

如果 items 很大,这会瞬间创建大量 goroutine。更稳妥的方式通常是 worker pool、带缓冲 channel 控制并发,或者使用上层任务编排。

3. 以为 Go 1.22 以后所有闭包捕获问题都消失了#

Go 1.22 修复的是常见 for/range 迭代变量声明场景:每次迭代会创建新变量。但如果你复用的是外部变量,仍然可能踩坑。

var v int
for _, v = range []int{1, 2, 3} {
go func() {
fmt.Println(v) // 仍然是同一个外部变量
}()
}

版本说明:Go 1.22 的循环变量语义改动见官方 Go 1.22 Release Notes。

4. goroutine 只启动,不设计退出路径#

go func() {
for msg := range ch {
handle(msg)
}
}()

这段代码能不能退出,完全取决于 ch 是否会被关闭。如果没有明确的关闭者,这个 goroutine 就可能永久等待。

5. 用 time.Sleep 当同步手段#

go doSomething()
time.Sleep(time.Second)

这只是“猜”后台任务一秒内能跑完。机器慢、调度延迟、任务变复杂时都会出问题。需要等待完成就显式同步,需要超时就显式设置超时。

性能影响或设计取舍#

goroutine 很轻,但不是零成本。大量 goroutine 会带来栈内存、调度队列、GC 扫描和上下文切换成本。短时间创建大量 goroutine 也可能造成突发资源压力。

GOMAXPROCS 影响的是并行执行 Go 代码的上限。CPU 密集任务通常受它影响明显;I/O 密集任务则可能创建远多于 GOMAXPROCS 的 goroutine,因为大部分时间都在等待。

是否使用 goroutine,要看任务之间是否真的可以并发推进。把一段严格串行逻辑拆成多个 goroutine,不会自动变快,反而会增加同步成本和错误面。

排查 goroutine 泄漏时,常用方向是:

  • 看 goroutine 数量是否持续增长。
  • pprof 查看 goroutine 堆栈停在哪些 channel、锁或 I/O 上。
  • 检查请求取消、channel 关闭、错误返回时,后台 goroutine 是否能退出。

面试高频问题#

  1. goroutine 和线程是什么关系? goroutine 由 Go runtime 调度,最终运行在操作系统线程上,但两者不是一一对应关系。

  2. GOMAXPROCS 控制的是什么? 控制 P 的数量,也就是同一时刻并行执行 Go 代码的上限。

  3. G-M-P 分别代表什么? G 是 goroutine,M 是操作系统线程,P 是执行 Go 代码需要的调度上下文。

  4. goroutine 阻塞会不会阻塞整个线程? 要看阻塞类型。channel、锁等待通常挂起的是 goroutine;系统调用可能阻塞线程,但 runtime 会尽量把 P 交给其他线程继续执行。

  5. goroutine 泄漏通常怎么发生? 常见原因是没有退出信号、channel 永远等不到发送或关闭、发送方没人接收、上游取消后后台任务还在跑。

一句话总结#

goroutine 解决的是“任务如何被轻量并发执行”,而调度器解决的是“这些任务如何映射到有限线程和 CPU 上高效运行”。

文章目录

文章目录