Skip to content

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每轮声明独立 nn 若含指针、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
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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多个关闭请求中只执行一次 closeclose 与仍在进行的发送是否冲突
发送前看 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.0

main.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

  1. Go 1.22 发布说明:语言变化。 ↩︎

  2. 规范:For statements,For clause 与 Range clause 的声明和赋值规则。 ↩︎

  3. Go 官方 race detector 文档,检测范围与常见竞争。 ↩︎ ↩︎

  4. 规范:Assignments,赋值与引用数据共享。 ↩︎ ↩︎

  5. Go 官方:Fixing For Loops in Go 1.22,按模块语言版本采用新语义。 ↩︎

  6. Go Modules Reference:go directive;cmd/go:Build constraints。 ↩︎

  7. sync.Mutex,首次使用后不可复制;RWMutex的递归和升级限制。 ↩︎

  8. cmd/vet,copylocks 等静态检查。 ↩︎ ↩︎

  9. Go 1.27.2 runtime/proc.go,checkdead 的检查与 fatal。属于当前运行时实现,不是覆盖全部死锁的语言承诺。 ↩︎ ↩︎

  10. runtime/pprof,goroutine profile;cmd/go:Testing flags,测试 timeout。 ↩︎

  11. 规范:Close、Send、Receive。 ↩︎ ↩︎

  12. 规范:Select statements。 ↩︎ ↩︎

  13. Go Tour:Range and Close。 ↩︎

  14. Go 官方 pipeline 文章,等全部发送者结束后关闭输出。 ↩︎

保留各书来源与许可 · 示例运行条件以正文为准