背景与动机
Go 并发编程的入口是 goroutine。它看起来只是一个 go 关键字,但背后真正值得理解的是:为什么它比线程轻、谁负责调度它、阻塞时会不会拖住整个程序。
这一篇只聚焦 goroutine 和运行时调度器,不展开 channel、select、WaitGroup、Mutex 的完整用法。它们分别放在后续笔记里:
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 调度拓扑与阻塞流转】

调度器大致按这个方向工作:
- 每个
P有本地运行队列,优先执行本地队列里的G。 - 本地队列没有任务时,会尝试从全局队列取任务。
- 如果全局队列也没有任务,会从其他
P的本地队列窃取一部分任务,这就是 work stealing。 - 这样可以减少全局锁竞争,同时避免某个
P很忙、另一个P空闲。
3. 阻塞时要分清是 G 阻塞,还是 M 阻塞
理解 goroutine 调度时,最容易混淆的是“谁被阻塞了”。
如果 goroutine 阻塞在 channel 收发、Mutex.Lock、WaitGroup.Wait 这类 runtime 能识别的同步点上,通常是当前 G 被挂起。M 和 P 可以继续执行别的 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 intfor _, 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 是否能退出。
面试高频问题
-
goroutine 和线程是什么关系? goroutine 由 Go runtime 调度,最终运行在操作系统线程上,但两者不是一一对应关系。
-
GOMAXPROCS控制的是什么? 控制P的数量,也就是同一时刻并行执行 Go 代码的上限。 -
G-M-P 分别代表什么?
G是 goroutine,M是操作系统线程,P是执行 Go 代码需要的调度上下文。 -
goroutine 阻塞会不会阻塞整个线程? 要看阻塞类型。channel、锁等待通常挂起的是 goroutine;系统调用可能阻塞线程,但 runtime 会尽量把
P交给其他线程继续执行。 -
goroutine 泄漏通常怎么发生? 常见原因是没有退出信号、channel 永远等不到发送或关闭、发送方没人接收、上游取消后后台任务还在跑。
一句话总结
goroutine 解决的是“任务如何被轻量并发执行”,而调度器解决的是“这些任务如何映射到有限线程和 CPU 上高效运行”。