启动 goroutine
在函数调用前加上 go,这次调用就会并发运行。这条语句会立即返回,调用方不会等待。
四个 goroutine 同时运行 shout。它们完成的先后顺序是不确定的,但输出始终保持原来的顺序,因为每个 goroutine 都写入自己的索引位置,而 main 只在 wg.Wait() 返回之后才读取切片。
这个例子已经包含了几乎所有 goroutine 程序都需要的三样东西:启动工作的方式(go)、等待工作完成的方式(sync.WaitGroup),以及在两个 goroutine 不碰同一块内存的前提下取回结果的方式(每个 goroutine 一个切片元素)。
main 不会等待
main 返回时,程序就退出了。仍在运行的 goroutine 无论执行到哪里都会被停止。没有任何东西会等待它们。
这段代码通常只会打印 from main。有时 goroutine 恰好及时得到调度,你就会看到两行。问题就出在这个“通常”上:代码在你的机器上能跑,到了负载很高的服务器上就会出错。
在 main 的末尾加一个 time.Sleep 可以让演示打印出两行,但这是错误的修复方式。它只是在猜工作需要多长时间。应该等待工作本身完成,可以用 WaitGroup(WaitGroup 一页有详细介绍),也可以用 channel。
取回结果
go 语句会丢弃函数的返回值。x := go f() 无法编译。返回数据有两种标准做法。
每个 goroutine 一个位置,就像第一个例子那样。预先分配好切片大小,把索引交给每个 goroutine,在 Wait 之后读取。顺序得以保留,也不需要加锁,因为没有两个 goroutine 会写同一个元素。
channel。 每个 goroutine 发送自己的结果,接收方负责收集。结果按完成的顺序到达,而不是按启动的顺序。
恰好接收 len(nums) 个值,同时也起到了等待的作用:在每个 goroutine 都发送完之前,main 无法走出这个循环。每次运行的到达顺序都不同,所以程序在打印任何依赖顺序的内容之前都要先排序。channel 一页介绍了带缓冲的 channel、关闭 channel,以及用 range 遍历 channel。
循环变量与闭包(Go 1.22 的改动)
从 Go 1.22 开始,for 循环的每次迭代都会得到循环变量的全新副本。在 goroutine 中启动的闭包捕获的是该次迭代的值,所以下面的写法是正确的:
for i, w := range words {
go func() {
results[i] = shout(w) // Go 1.22+: i and w belong to this iteration
}()
}
在 Go 1.22 之前,所有迭代共用同一个 i 和同一个 w,每个 goroutine 往往都只看到最后一个值。旧代码的变通办法是把值作为参数传入,即 go func(i int, w string) { ... }(i, w),或者用同名变量遮蔽,即 i := i。这两种写法在 Go 1.22 及以后的版本中都无害,你在现有代码中仍然会看到它们。当模块的 go.mod 写的是 go 1.22 或更高版本时,新行为才会生效。
goroutine 很廉价
goroutine 启动时只有一个很小的栈(几 KB),运行时会按需扩大或缩小它。Go 调度器在一个操作系统线程池上运行 goroutine,同一时刻最多有 GOMAXPROCS 个线程在执行 Go 代码,而 GOMAXPROCS 默认等于 CPU 数量。阻塞在 channel、互斥锁、sleep 或网络 I/O 上时,goroutine 会被挂起,让出线程给其他 goroutine。
所以即使数量很大,为每个任务启动一个 goroutine 也没有问题:
十万个 goroutine 不到一秒就能全部完成。总和始终是 4999950000,因为 atomic.Int64 让每次加法都不可分割。不过,廉价并不等于免费:每个仍处于阻塞状态的 goroutine,都会让它的栈以及它引用的所有东西继续占用内存。
| 操作系统线程 | goroutine | |
|---|---|---|
| 由谁创建 | 内核 | Go 运行时 |
| 初始栈 | 固定大小,通常 1 MB 或更大 | 几 KB,按需增长 |
| 切换 | 内核上下文切换 | Go 调度器,在用户空间完成 |
| 标识 | 有线程 ID | 刻意不提供可读取的 ID |
| 常见数量 | 几百个 | 几千到几百万个 |
数据竞争
两个 goroutine 同时访问同一个变量,并且其中至少一个在写入,这就是数据竞争。结果是不可预测的,而不只是“稍微有点偏差”:更新会丢失,而对字符串、切片、map 或接口值的竞争可能会让程序崩溃或破坏内存。
在多核机器上,这段代码大多数时候都会打印一个小于 10000 的不同数字,因为两个 goroutine 读到了同一个旧值,然后都把这个值加一写回去。在单核上它可能会打印 10000,这反而更糟:bug 通过了你的测试,却在生产环境中冒出来。
Go 自带竞争检测器。用 -race 运行你的程序或测试:
go run -race main.go
go test -race ./...
==================
WARNING: DATA RACE
Read at 0x00c000090038 by goroutine 8:
main.main.func1()
/tmp/race/main.go:16 +0x94
Previous write at 0x00c000090038 by goroutine 6:
main.main.func1()
/tmp/race/main.go:16 +0xa4
...
Found 2 data race(s)
exit status 66
它会准确指出出问题的那一行(counter++)以及涉及的两个 goroutine。它只报告运行期间实际发生的竞争,所以要在覆盖并发路径的测试上运行它。它会让程序慢上好几倍,所以只用于测试和预发布环境,而不是生产环境。
修复方法从最简单到最通用依次是:
- 不共享。给每个 goroutine 各自的数据,最后再合并(每个 goroutine 一个位置的模式)。
- 对单个计数器或标志使用
sync/atomic:var n atomic.Int64; n.Add(1)。 - 对更大的东西(比如 map 或有多个字段的结构体),用
sync.Mutex保护起来。互斥锁一页还介绍了RWMutex和sync.Once。 - 通过 channel 传递数据,让同一时刻只有一个 goroutine 拥有它。
goroutine 中的 panic 会让整个程序崩溃
如果一个 goroutine 发生 panic,而在同一个 goroutine 内部没有任何代码 recover,整个程序都会崩溃,包括 main 和其他所有 goroutine。在 main 中 recover 也无济于事,因为 recover 只能捕获它自己所在 goroutine 中的 panic。
在长时间运行的服务器的边界处,这样 recover 是合理的,因为一个坏请求不能拖垮其他请求。而在普通代码中,panic 通常意味着 bug,大声崩溃才是正确的结果。
goroutine 泄漏
一个永远阻塞的 goroutine 永远不会退出,也永远不会释放它的内存。典型的原因是一次永远没有人接收的发送:
func firstResult(urls []string) string {
ch := make(chan string) // unbuffered
for _, u := range urls {
go func() { ch <- fetch(u) }()
}
return <-ch // takes the first result; the other senders block forever
}
每次调用都会泄漏 len(urls) - 1 个 goroutine。在一个要处理成千上万次这种请求的服务器中,内存会不断攀升,直到进程挂掉。有两种修复方法:让 channel 足够大,使每个发送方都能完成(make(chan string, len(urls)));或者给 goroutine 一个放弃的途径,通常是 context.Context 加上对 ctx.Done() 的 select。你可以在测试中用 runtime.NumGoroutine() 监测泄漏。
限制同时运行的数量
对于 10,000 个廉价的计算,“每个任务一个 goroutine”没有问题。但对于向同一台服务器发出 10,000 个 HTTP 请求,或者同时打开 10,000 个文件,就不行了。用一个带缓冲的 channel 充当信号量,来限制并发数:
这个带缓冲的 channel 最多容纳 3 个令牌,所以任何时刻最多只有 3 个 goroutine 越过 sem <- 这一行。峰值永远不会超过 3,而对于十二个各自 sleep 一会儿的任务,实际上会达到 3。另一种常见的结构是由固定数量的 worker goroutine 组成的池,从一个任务 channel 中读取任务;WaitGroup 一页实现了一个。
在标准库之外,golang.org/x/sync/errgroup 把 WaitGroup、第一个错误、context 取消和并发限制(g.SetLimit(n))合并到了一个类型中。在需要这四样功能的生产代码中,它是常见的选择。
常见错误
- 忘了等待。
main返回了,工作就悄无声息地没做成。每条go语句都需要一个对应的方式来得知它已完成。 - 在 goroutine 内部调用
wg.Add。Wait可能在Add之前执行,看到计数器为零就提前返回了。要在go语句之前调用Add。 - 不加同步地共享变量。 最常见的是 map:并发写 map 通常会被运行时检测到,并以
fatal error: concurrent map writes让程序崩溃,而且recover无法捕获。 - 假定执行顺序。 goroutine 按调度器选择的任意顺序运行。如果输出必须有序,就先收集再排序,或者写入带索引的位置。
- 用
time.Sleep做同步。 它会让测试变慢,而且仍然不稳定。要等待事件本身,而不是靠猜。 - 启动 goroutine 却没有办法停止它。 任何会循环或等待 I/O 的代码,都应该接收一个
context.Context,让调用方可以取消它。
常见问题
Go 中的 goroutine 是什么?
goroutine 是与程序其余部分并发运行的一次函数调用。在调用前加上 go 就能启动一个:go work()。goroutine 由 Go 运行时而不是操作系统管理,运行时会把大量 goroutine 复用到少数几个操作系统线程上,所以启动成千上万个 goroutine 是很正常的。
在 Go 中怎样等待 goroutine 执行完毕?
使用 sync.WaitGroup:在每条 go 语句之前调用 wg.Add(1),在 goroutine 开头写 defer wg.Done(),在需要所有 goroutine 都完成的地方调用 wg.Wait()。如果 goroutine 会产生值,从 channel 中为每个 goroutine 接收一个值,也能起到等待的作用。
goroutine 和线程有什么区别?
操作系统线程有一个固定大小的栈(通常 1 MB 或更大),由内核调度。goroutine 一开始只有几 KB 的栈,按需增长,Go 调度器在用户空间中切换 goroutine。运行时最多同时在 GOMAXPROCS 个线程上运行 goroutine(默认等于 CPU 数量)。
怎样从 goroutine 中获取返回值?
go 语句会丢弃函数的返回值。可以把结果发送到 channel(results <- compute(x)),或者写入一个预先分配好大小的切片中属于自己的位置(out[i] = compute(x)),然后在 wg.Wait() 之后读取。
为什么 goroutine 还没打印任何内容,我的 Go 程序就退出了?
main 返回时程序就结束了,其他所有 goroutine 都会被停止,剩下的代码不会再执行。没有任何东西会自动等待 goroutine。用 WaitGroup 或从 channel 接收,让 main 阻塞到工作完成为止。加上 time.Sleep 只是把问题藏了起来。