主题
Go 并发常见坑:用四个小反例找到真正的失误
并发错误常常表现为“偶尔错一次”,但排查时不必先靠压测碰运气。循环闭包、复制锁、等待成环、关闭后发送,都可以缩成很小的例子,把出错的条件明确安排出来。这篇文章重点说明怎样读懂失败,而不只是列出禁忌。
本文用 Go 1.27.2 实际运行。正常示例模块声明 go 1.25.0;另有一个 go 1.21 的独立模块,专门对比循环变量的旧语义。失败程序单独运行并加外部超时,避免混进正常测试后让整批验证中断。
闭包拿到的是变量,先看变量在哪里声明
Go 1.22 起,由循环声明的变量每轮有各自的一份。这里“由循环声明”很重要,不能缩成“Go 1.22 已经修好所有闭包问题”。[1]
先不启动 goroutine,只把函数存起来,等循环结束后调用,可以排除调度对观察结果的干扰:
go
var funcs []func() int
for _, n := range []int{1, 2, 3} {
funcs = append(funcs, func() int { return n })
}正文函数 DeclaredLoop 按顺序执行这些闭包,本次输出:
text
declared: [1 2 3]把声明改成对外部变量赋值,结果就变了:
go
var n int
var funcs []func() int
for _, n = range []int{1, 2, 3} {
funcs = append(funcs, func() int { return n })
}实际输出:
text
assigned: [3 3 3]第二段从始至终只有一个 n。闭包后来读取它时,循环已经把它改成 3。新语义不会为 = 形式凭空创建独立变量。这个反例没有并发,所以没有数据竞争,却已经体现出共享变量带来的逻辑错误;如果启动 goroutine 并让它们与循环同时读写 n,还可能加入数据竞争。[2][3]
| 代码形态 | Go 1.22 及之后的对应语言语义 | 闭包要检查什么 |
|---|---|---|
for _, n := range xs | 每轮声明独立 n | n 若含指针、slice、map,底层对象仍可能共享 |
外部 var n,循环用 n = | 每轮给同一个变量赋值 | 需要显式传值或新建本轮局部变量 |
for i := 0; i < n; i++ | 循环声明的 i 也按每轮规则处理 | 仍需区分循环体自己修改变量的逻辑 |
遍历 []*T | 每轮指针变量可独立 | 多个元素若指向同一个 T,T 没有被复制 |
参数传值是一种清楚的意图表达,但也不是深拷贝。把 *User 作为参数传给 goroutine,只复制指针,两个任务仍可能修改同一个 User。[4]
工具链新,不等于这个包一定使用新循环语义
语言版本还要看模块声明。本文使用同一个 Go 1.27.2 工具链,在单独的 legacy/go.mod 中声明 go 1.21,运行同样的 := range 闭包程序,本次输出:
text
[3 3 3]这不是工具链没升级,而是模块仍使用旧的语言语义。实际项目还可能通过特定构建约束影响文件所用语言版本,所以排查时应同时看 go version、go.mod 和相关文件的构建条件,不只盯着 IDE 显示的 Go 版本。[5][6]
复制锁:复制出来的字段可能仍指向同一份数据
这个方法看起来做了加锁,但接收者按值传递:
go
type Locked struct {
mu sync.Mutex
n int
}
func (v Locked) Value() int {
v.mu.Lock()
defer v.mu.Unlock()
return v.n
}v 是副本,锁也是副本。锁已经使用后再复制,违反 Mutex 文档的要求;而从接口设计看,这个方法无论如何都不能用原对象那把锁来协调。若字段换成 map,复制结构体只复制 map 值,底层 map 仍共享,两个独立锁就可能同时“保护”同一份数据。[7][4:1]
本次把这个文件放在 badcopy 构建标签下,执行 go vet -tags=badcopy ./...,实际输出:
text
copylock_bad.go:13:9: Value passes lock by value: example.com/go-articles/07_pitfalls.Locked contains sync.Mutex命令退出码为 1。通常先把方法改成指针接收者,让调用落到原对象;再继续检查对象有没有在其他地方按值复制。只改方法签名,不能阻止 other := *ptr、把对象 append 进值切片、通过值参数传递或通过 channel 发送副本。[8]
| 使用场景 | 更容易维持的所有权 | 还要检查 |
|---|---|---|
| 共享计数器或缓存对象 | 保存 *Counter、*Cache,方法用指针接收者 | 所有访问用同一个实例和同一把锁 |
| 值切片里含 Mutex 的元素 | 稳定指针指向对象,或在开始使用前固定结构 | 扩容和赋值是否复制已使用同步值 |
| 把对象作为消息传递 | 传不含同步状态的纯数据,或明确转移对象所有权 | 不让两边同时改仍共享的引用 |
| 返回一个已初始化并用过锁的结构体 | 返回指针通常更清楚 | 避免从使用过的对象按值复制出去 |
go vet 是帮助发现问题的静态检查,不是完整的别名分析或业务证明。不能因为没有告警,就断言没有复制锁和状态共享错误。
死锁:沿着“他在等谁”画一个圈
最小的 channel 死锁只要一句接收:
go
<-make(chan struct{})没有发送者,也没有关闭者。本次把它放在独立进程运行,输出开头是:
text
fatal error: all goroutines are asleep - deadlock!进程退出码为 2。完整堆栈指向 main 中的 channel 接收。这个例子说明当前运行时能识别这类全局无法推进的状态,不代表它会找出服务中每一处业务死锁。[9]
更常见的环在锁与任务之间:
正在绘制图示…
查看图源代码
flowchart LR
A[调用方持有 mu] -->|等待 wg.Wait 返回| B[worker 结束]
B -->|结束前必须取得 mu| C[锁被释放]
C -->|调用方在 Wait 后才 Unlock| A
谁都在等另一个先做事。解决方向是打破等待依赖:通常不要持有 worker 必需的锁去等待 worker 结束;多个锁统一获取顺序;channel 的发送和接收不能安排在互相等待之后。
| 等待位置 | 常见循环依赖 | 排查动作 |
|---|---|---|
| 无缓冲 channel 发送 | 接收者要等发送函数返回后才启动 | 检查启动和读取先后 |
| Mutex.Lock | 重复拿不可重入锁,或 A/B 两把锁反向获取 | 列出每条路径的持锁集合和下一把锁 |
| WaitGroup.Wait | 漏 Done,或 Wait 等到了包含自己的任务 | 核对计数登记、所有 return 路径 |
| 结果收集 | 消费者先 Wait,worker 却要发送结果才能结束 | 先持续读取,另由协调者关闭输出 |
| Cond.Wait | 状态变化后没人通知,或醒来后条件仍不可达 | 同时查条件、锁和通知者 |
运行时的死锁检测不是服务健康检查。只卡住一组请求、其他 goroutine 仍能运行,或者还存在可能唤醒的 timer/网络等待时,进程可能不报上述 fatal。需要用测试 timeout、请求超时、goroutine profile 和明确的退出信号判断自己的工作是否还能推进。[9:1][10]
go test -timeout 是测试层面的截止控制,不会替生产程序修复死锁。超时让问题能被观察和定位;修复仍要移除那个等待环。
关闭后发送:不要先猜状态,再做动作
独立反例:
go
c := make(chan int)
close(c)
c <- 1本次输出开头:
text
panic: send on closed channel进程退出码为 2。发送到已关闭 channel 必须 panic,这是语言规定的行为。关闭 nil channel、重复关闭也会 panic;从已关闭且读空的 channel 接收则立即得到零值和 false。[11]
最容易写错的修复是“发送前检查是否关闭”。Go 没有一个能让检查和发送自动成为不可分割操作的 isClosed。即使自己用一个原子 bool 记录状态,另一方仍可在检查之后、真正发送之前 close。加 select 的 default 也不能解决:已关闭 channel 的发送是错误动作,不是一个永远不就绪的分支。[12]
| 想补的措施 | 它解决了什么 | 它没有解决什么 |
|---|---|---|
sync.Once 包住 close | 多个关闭请求中只执行一次 close | close 与仍在进行的发送是否冲突 |
发送前看 len(ch) | 得到某时刻缓冲长度 | 关闭状态与后续发送的原子性 |
| 自己维护 closed 标志 | 记录一个状态 | 检查状态与发送之间仍能插入关闭 |
recover 吃掉 panic | 同 goroutine 可捕获某次 panic | 被丢掉的任务、所有权和并发协议仍不清楚 |
| 等全部发送者结束后 close | 证明不再有发送 | 还需保证发送者本身有可达退出路径 |
合理的修复通常是确定唯一关闭责任:单生产者自己关;多生产者由协调者等齐后关;消费者不想再读时发取消信号,让发送者退出,而不是直接关闭它们正写的 channel。所谓“发送方关闭”是为了让“不再发送”这个条件有一个能证明它的人。[13][14]
还有一种不报错却同样糟糕的关闭问题:循环里只写 v := <-ch,不检查 ok。关闭后不断收到零值,会把空数据重复处理,甚至形成忙循环。单输入读到结束用 range;多个输入 select,在输入关闭后设为 nil 禁用分支,最后结束循环。[11:1][12:1]
把失败从随机变成可解释
本文循环闭包在循环结束后才执行;库存类竞态可以通过屏障安排读写顺序;死锁和关闭后发送放到隔离进程运行。这样做的目的,是让测试明确制造触发条件,而不是反复 sleep 祈祷调度配合。
可以按三个层次检查代码:先用 go vet 找可疑静态模式,再用 go test -race 找实际执行中的数据竞争,最后针对取消、部分成功、失败和关闭时序验证业务约束。前两步通过不代表第三步自动通过。[8:1][3:1]
依据与本次验证
实际环境:Go 1.27.2、Linux/amd64。正常模块的 go run .、go test -race -count=1 ./...、go vet ./... 通过;legacy 模块输出 [3 3 3]。badcopy vet 预期失败,退出码 1;两个独立失败程序都在 5 秒外部超时保护下自然失败,退出码 2,并非被 timeout 杀死。本文摘录错误首行,不把具体文件路径、地址或堆栈行号当成稳定 API。
完整代码与复现方法
以同一目录为根,按下面标出的相对路径保存文件。使用 Go 1.27.2 运行:
sh
go run .
go test -race -count=1 ./...
go vet ./...以下是故意失败的检查,正常运行命令不要加 badcopy 标签:
sh
go vet -tags=badcopy ./...
go build -o /tmp/go-concurrency-pitfalls .
timeout 5s /tmp/go-concurrency-pitfalls deadlock
timeout 5s /tmp/go-concurrency-pitfalls sendclosed
(cd legacy && go run .)timeout 来自 GNU coreutils;反例独立成子进程执行。legacy 是另一个模块,其 go 1.21 用于比较旧循环语义,不会被外层 go test ./... 自动测试。
go.mod
go.mod
module example.com/go-articles/07_pitfalls
go 1.25.0main.go
代码块 · 45 行 · 点击收起 / 展开
go
package main
import (
"fmt"
"os"
)
func DeclaredLoop() []int {
var funcs []func() int
for _, n := range []int{1, 2, 3} {
funcs = append(funcs, func() int { return n })
}
var out []int
for _, f := range funcs {
out = append(out, f())
}
return out
}
func AssignedLoop() []int {
var n int
var funcs []func() int
for _, n = range []int{1, 2, 3} {
funcs = append(funcs, func() int { return n })
}
var out []int
for _, f := range funcs {
out = append(out, f())
}
return out
}
func main() {
if len(os.Args) > 1 {
switch os.Args[1] {
case "deadlock":
<-make(chan struct{})
case "sendclosed":
c := make(chan int)
close(c)
c <- 1
}
return
}
fmt.Println("declared:", DeclaredLoop())
fmt.Println("assigned:", AssignedLoop())
}main_test.go
go
package main
import (
"slices"
"testing"
)
func TestLoopBindings(t *testing.T) {
if got := DeclaredLoop(); !slices.Equal(got, []int{1, 2, 3}) {
t.Fatal(got)
}
if got := AssignedLoop(); !slices.Equal(got, []int{3, 3, 3}) {
t.Fatal(got)
}
}copylock_bad.go
go
//go:build badcopy
package main
import "sync"
type Locked struct {
mu sync.Mutex
n int
}
// Intentionally wrong: copies an already usable lock by value.
func (v Locked) Value() int { v.mu.Lock(); defer v.mu.Unlock(); return v.n }legacy/main.go
go
package main
import "fmt"
func main(){
var fs []func() int
for _,n:=range []int{1,2,3} {fs=append(fs,func()int{return n})}
out:=[]int{};for _,f:=range fs {out=append(out,f())};fmt.Println(out)
}legacy/go.mod
go.mod
module example.com/legacy-loop
go 1.21规范:For statements,For clause 与 Range clause 的声明和赋值规则。 ↩︎
Go 官方 race detector 文档,检测范围与常见竞争。 ↩︎ ↩︎
规范:Assignments,赋值与引用数据共享。 ↩︎ ↩︎
Go 官方:Fixing For Loops in Go 1.22,按模块语言版本采用新语义。 ↩︎
Go Modules Reference:go directive;cmd/go:Build constraints。 ↩︎
sync.Mutex,首次使用后不可复制;RWMutex的递归和升级限制。 ↩︎
Go 1.27.2 runtime/proc.go,
checkdead的检查与 fatal。属于当前运行时实现,不是覆盖全部死锁的语言承诺。 ↩︎ ↩︎runtime/pprof,goroutine profile;cmd/go:Testing flags,测试 timeout。 ↩︎
Go 官方 pipeline 文章,等全部发送者结束后关闭输出。 ↩︎