1. Go 并发基础:goroutine 与调度
1. Go 并发基础:goroutine 与调度
Go 的并发模型很直接:用 goroutine 表示并发执行的任务,用 channel 和同步原语协调任务之间的关系。goroutine 的语法简单到只有一个 go 关键字,但真正写好并发代码,重点不在”怎么启动”,而在”谁负责结束、谁负责等待、谁负责收尾”。
并发与并行
并发(concurrency)是同时处理多件事的结构能力,并行(parallelism)是真的在同一时刻执行多件事的运行状态。
并发:一个服务同时管理多个请求、多个任务、多个 I/O 等待
并行:多个 CPU 核心同时跑多个 goroutine
Go 鼓励先把程序组织成并发结构,再由运行时决定能不能并行执行。也就是说,goroutine 不是线程的轻量拼写,而是一种更便宜的任务抽象。
启动 goroutine
在函数调用前加 go,这个函数就会在新的 goroutine 中执行:
func say(msg string) {
fmt.Println(msg)
}
func main() {
go say("hello")
say("world")
}
这里有一个重要细节:主 goroutine 结束,整个进程就结束。如果 main 返回得太快,后台 goroutine 可能还没来得及运行。
func main() {
go func() {
time.Sleep(100 * time.Millisecond)
fmt.Println("done")
}()
// main 立即返回,"done" 可能永远不会打印
}
所以真实代码里不能靠 time.Sleep 等 goroutine,要用 sync.WaitGroup、channel 或 context 明确等待。
用 WaitGroup 等待结束
sync.WaitGroup 用来等待一组 goroutine 完成:
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf("worker %d start\n", id)
time.Sleep(100 * time.Millisecond)
fmt.Printf("worker %d done\n", id)
}(i)
}
wg.Wait()
fmt.Println("all done")
}
三个关键点:
- 先
Add,再启动 goroutine。如果在 goroutine 内部Add,主 goroutine 可能先执行到Wait,造成竞态。 defer wg.Done()放在 goroutine 开头,保证异常返回时也能计数归零。- 不要复制 WaitGroup,它内部有状态,通常传指针或在闭包里引用外层变量。
goroutine 很便宜,但不是免费
goroutine 的初始栈很小,会按需增长;调度也由 Go runtime 管理,比系统线程轻量得多。但它仍然占用内存、调度成本、文件句柄、网络连接等资源。
// ❌ 错误:每个请求都无限制启动后台任务,流量一大就失控
func handler(w http.ResponseWriter, r *http.Request) {
for _, item := range loadItems() {
go process(item)
}
}
并发数量必须被控制。常见方式有三种:
- worker pool:固定数量 goroutine 消费任务
- semaphore:用带 buffer 的 channel 限流
- errgroup:管理一组任务的生命周期和错误
调度模型:G、M、P
Go runtime 的调度模型常被概括为 G-M-P:
G = goroutine,要执行的任务
M = machine,操作系统线程
P = processor,调度上下文,负责把 G 分配给 M 执行
可以把它理解成:
goroutine 很多 → 放在运行队列里
P 挑选可运行的 goroutine
M 拿到 P 后真正执行 goroutine
GOMAXPROCS 决定同时有多少个 P,也就是最多有多少个 goroutine 可以并行执行 Go 代码。默认值通常等于可用 CPU 核心数。
fmt.Println(runtime.GOMAXPROCS(0)) // 查看当前值
大多数时候不需要手动调它。只有 CPU 密集型任务、容器 CPU 配额、性能压测这类场景,才值得专门关注。
阻塞不会总是阻塞线程
goroutine 在等待 channel、定时器、网络 I/O、锁时,runtime 会尽量把当前线程让出来,去执行其他可运行 goroutine。
go func() {
resp, err := http.Get("https://example.com")
_ = resp
_ = err
}()
这个 goroutine 等网络响应时,并不意味着一个系统线程被白白占住。Go 的网络轮询器会帮忙等待 I/O 就绪,再把 goroutine 放回可运行队列。
但有些阻塞可能真的占住线程,比如某些 cgo 调用、长时间系统调用、CPU 密集循环。CPU 密集代码如果没有函数调用和抢占点,也可能影响调度公平性。
生命周期:启动容易,退出更重要
每个 goroutine 都应该能回答三个问题:
- 它什么时候启动?
- 它什么时候退出?
- 谁负责等待它退出?
下面是一个没有退出路径的 goroutine:
// ❌ 泄漏:如果 ch 永远没有数据,这个 goroutine 永远阻塞
go func() {
value := <-ch
fmt.Println(value)
}()
应该加上取消信号:
go func() {
select {
case value := <-ch:
fmt.Println(value)
case <-ctx.Done():
return
}
}()
经验法则:只要 goroutine 的生命周期可能超过当前函数,就要认真设计退出路径。HTTP 请求、后台 worker、定时任务、订阅消费都属于这种场景。
闭包变量捕获
循环里启动 goroutine 时,要把循环变量作为参数传进去:
for _, url := range urls {
go func(url string) {
fetch(url)
}(url)
}
这样每个 goroutine 拿到的是自己的 url 副本。这个写法即使在较新的 Go 版本已经改善了 range 变量语义,仍然清晰、兼容、少歧义。
另外一个常见坑是闭包里直接写外层错误变量:
// ❌ 多个 goroutine 同时写 err,会产生数据竞争
var err error
for _, job := range jobs {
go func(job Job) {
err = process(job)
}(job)
}
应该把结果通过 channel 返回,或者用互斥锁保护共享变量。
panic 不会跨 goroutine 捕获
recover 只能捕获同一个 goroutine 栈上的 panic:
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
}
}()
go func() {
panic("boom") // main 里的 recover 捕不到
}()
time.Sleep(time.Second)
}
如果 goroutine 里可能 panic,要在 goroutine 内部 recover:
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("worker panic: %v", r)
}
}()
runWorker()
}()
生产代码里更推荐让函数返回 error,再统一收集错误。panic 适合不可恢复的程序错误,不适合普通业务失败。
小结
goroutine 的语法很轻,工程约束要更重:
- 启动 goroutine 前先想退出路径
- 需要等待就用 WaitGroup、channel 或 errgroup
- 需要取消就传 context
- 需要限流就控制 goroutine 数量
- 不要让多个 goroutine 无保护地读写同一份数据
Go 并发的难点不是”开多快”,而是”收得住”。