背景
最近组会上组长检查线上项目功能的时候发现我负责的模块的一个列表的数据有问题,有两个字段逻辑对不上,然后我来检查代码找 bug。
这是一个列表展示的业务,并发调用 3 个 rpc 接口获取数据,再组合返回给前端。
逻辑复现
下面这个 demo 复现原始错误的逻辑(线上环境是Go 1.19)
go
type Foo struct {
ID string `json:"id"`
Bar string `json:"bar"`
}
func main() {
ids := []string{"5", "6", "7", "8", "9"}
bars := []string{"a", "b", "c", "d", "e"}
result := make([]Foo, len(ids))
wg := sync.WaitGroup{}
for i, id := range ids {
wg.Add(2)
go func(i int, id string) {
defer wg.Done()
fmt.Printf("任务1的索引号:%d\n", i)
// 业务逻辑 ......
result[i].ID = id
return
}(i, id)
go func(int) { // 错误的闭包
defer wg.Done()
fmt.Printf("任务2的索引号:%d\n", i)
// 业务逻辑 ......
result[i].Bar = bars[i]
return
}(i)
}
wg.Wait()
fmt.Printf("%+v", result)
}
输出结果:
bash
任务1的索引号:0
任务1的索引号:2
任务2的索引号:4
任务1的索引号:1
任务1的索引号:3
任务1的索引号:4
任务2的索引号:4
任务2的索引号:4
任务2的索引号:4
任务2的索引号:4
[{ID:5 Bar:} {ID:6 Bar:} {ID:7 Bar:} {ID:8 Bar:} {ID:9 Bar:e}]
在第二个匿名函数中,由于错误的闭包导致它访问了外部的循环变量i
。在内层goroutine开始执行之前,主协程的 for 循环已经在执行了,导致i
的值发生了变化。
这就导致了只为一个元素重复赋值。
修改方案
上面 demo 的 main 函数中的代码替换为一下内容
go
ids := []string{"5", "6", "7", "8", "9"}
bars := []string{"a", "b", "c", "d", "e"}
result := make([]Foo, len(ids))
wg := sync.WaitGroup{}
for it, uid := range ids {
wg.Add(2)
go func(it int, uid string) {
defer wg.Done()
fmt.Printf("任务1的索引号:%d\n", it)
// 业务逻辑 ......
result[it].ID = uid
return
}(i, id)
go func(it int) { // 修改后
defer wg.Done()
fmt.Printf("任务2的索引号:%d\n", it)
// 业务逻辑 ......
result[it].Bar = bars[it]
return
}(i)
}
wg.Wait()
fmt.Printf("%+v", result)
在每个 goroutine 中创建一个局部变量it
,并将当前迭代的i
的值赋给这个局部变量。这样,每个 goroutine 都会持有自己的i
的副本,避免了竞争条件。
输出结果:
bash
任务1的索引号:2
任务2的索引号:1
任务2的索引号:2
任务2的索引号:0
任务1的索引号:3
任务1的索引号:0
任务2的索引号:3
任务1的索引号:4
任务2的索引号:4
任务1的索引号:1
[{ID:5 Bar:a} {ID:6 Bar:b} {ID:7 Bar:c} {ID:8 Bar:d} {ID:9 Bar:e}]
IDE 提示修复
其实在 Goland 里,第二个匿名函数内部赋值时变量i
背景颜色被标黄了,鼠标悬停上去会显示 由 'go' 语句中的 'func' 文字捕获的循环变量可能有意外值
点击创建中间变量后,IDE 给出的修复方案如下
go
type Foo struct {
ID string `json:"id"`
Bar string `json:"bar"`
}
func main() {
ids := []string{"5", "6", "7", "8", "9"}
bars := []string{"a", "b", "c", "d", "e"}
result := make([]Foo, len(ids))
wg := sync.WaitGroup{}
for i, id := range ids {
wg.Add(2)
go func(i int, id string) {
defer wg.Done()
fmt.Printf("任务1的索引号:%d\n", i)
// 业务逻辑 ......
result[i].ID = id
return
}(i, id)
i := i // IDE 生成的修正代码
go func(int) {
defer wg.Done()
fmt.Printf("任务2的索引号:%d\n", i)
// 业务逻辑 ......
result[i].Bar = bars[i]
return
}(i)
}
wg.Wait()
fmt.Printf("%+v", result)
}
这么做也是可行的,在go func(int)
函数之前添加了一行代码i := i
。这实际上是创建了一个新的局部变量i
,并将当前循环迭代中的i
的值复制给它。这样,每个goroutine都有自己的i
副本,可以安全地使用它来访问bars
切片并将值赋给result
切片的相应项的Bar
字段。输出结果也符合预期。
总结
在 Go 语言并发编程时,要注意竞争条件。注意go func()
匿名函数的传参。