Go for-range 循环变量陷阱:goroutine 里全打印同一个值(Go 1.22 前后差异)

Go for-range 循环变量陷阱:goroutine 里全打印同一个值(Go 1.22 前后差异)

你大概率写过这样的代码:开一批 goroutine 并发处理切片里的元素,结果打印出来的值全是最后一个,或者干脆乱序重复。这不是并发调度的锅,而是 for-range 循环变量的作用域问题。这个坑在 Go 1.22 之前坑了无数人,Go 1.22 改了语义之后又出现了「同一份代码在两个版本跑出不同结果」的新问题。这篇把来龙去脉和正确写法一次讲清。

先复现这个经典 bug

go 复制代码
package main

import (
	"fmt"
	"sync"
)

func main() {
	nums := []int{1, 2, 3}
	var wg sync.WaitGroup

	for _, n := range nums {
		wg.Add(1)
		go func() {
			defer wg.Done()
			fmt.Println(n) // 期望 1 2 3(乱序),实际可能全是 3
		}()
	}
	wg.Wait()
}

Go 1.21 及更早 版本里,这段代码大概率打印三个 3(顺序还不定)。很多人第一反应是「goroutine 调度有问题」,其实跟调度没关系。

问题根源:循环变量是「复用」的

在 Go 1.22 之前,for _, n := range nums 里的 n 在整个循环中只有一个实例 。每次迭代不是新建一个 n,而是把新值赋给同一个 n

关键在于:goroutine 里的闭包捕获的是 n 这个变量的地址 ,不是它某一刻的值。等 goroutine 真正被调度执行时,循环往往早就跑完了,n 的最终值停在最后一个元素 3,于是三个 goroutine 读到的都是 3

用一句话记:闭包捕获的是变量,不是值。 循环共用一个变量,闭包自然共用同一份数据。

不止 goroutine,把地址存进切片也一样中招:

go 复制代码
var ptrs []*int
for _, n := range nums {
	ptrs = append(ptrs, &n) // 三个指针指向同一个 n
}
for _, p := range ptrs {
	fmt.Println(*p) // Go 1.21: 全是 3
}

Go 1.21 及之前的正确写法

写法一:循环内部重新声明(最常用)

go 复制代码
for _, n := range nums {
	n := n // 关键:在循环体内新建一个同名局部变量,遮蔽外层的 n
	wg.Add(1)
	go func() {
		defer wg.Done()
		fmt.Println(n) // 每个 goroutine 捕获的是各自的副本
	}()
}

n := n 看着别扭,但它的作用是:每次迭代都创建一个新的 局部 n,把当前值拷进去。闭包捕获的是这个新变量,每个 goroutine 各拿各的,互不干扰。

写法二:用参数传值

go 复制代码
for _, n := range nums {
	wg.Add(1)
	go func(n int) { // n 作为参数,调用时就完成了值拷贝
		defer wg.Done()
		fmt.Println(n)
	}(n) // 立刻把当前值传进去
}

调用 go func(n int){...}(n) 时,实参 n 的当前值被拷贝给形参,这个拷贝发生在循环迭代的当下,而不是 goroutine 执行的当下。所以每个 goroutine 拿到的是正确的快照值。

这两种写法本质一样:在迭代的当下把值固定下来,不让闭包去追那个会变的循环变量。

Go 1.22 改了什么

Go 1.22 开始,循环变量的作用域改成了每次迭代都是一个新变量。也就是说,上面那段最初的「有 bug」代码,在 Go 1.22 里直接就是对的:

go 复制代码
// go.mod 里 go 1.22+,这段现在打印 1 2 3(乱序)
for _, n := range nums {
	wg.Add(1)
	go func() {
		defer wg.Done()
		fmt.Println(n) // Go 1.22+ 正确,每次迭代 n 都是新的
	}()
}

这是 Go 团队少见的破坏性语义变更 ,靠 go.mod 里声明的版本号来控制:

  • go.modgo 1.22 或更高 → 新语义,每轮迭代新变量。
  • go.modgo 1.21 或更低 → 老语义,循环共用变量。

编译器按模块声明的版本决定用哪套语义,所以同一份源码,改一下 go.mod 的版本号,行为就变了

新的坑:版本混淆

语义变更解决了老 bug,却带来两个新的现实问题。

第一,老代码里的 n := n 现在是冗余的,但它无害,别急着删。因为你的库可能同时被 1.21 和 1.22 的项目引用,留着它两个版本都安全:

go 复制代码
for _, n := range nums {
	n := n // 1.22 下冗余但无害;1.21 下必需。跨版本兼容就留着
	go work(&n)
}

第二,依赖「循环结束后变量停在最后一个值」的代码会被悄悄改坏。 这种写法本来就少见、也不推荐,但确实存在:

go 复制代码
var last int
for _, n := range nums {
	last = n
	_ = n
}
// 用 last,没问题,last 是普通变量,不受影响

真正会变的是这种「循环外读循环变量」------但 Go 本来就不允许在循环外访问 n(作用域限制),所以实际影响面比想象小。真正要留意的是跨版本行为差异:CI 用 1.22、老服务器编译用 1.20,同一份代码可能跑出不同结果。

排查清单

遇到「goroutine/闭包读到的循环变量值不对」,按这个顺序查:

  1. go.mod 的版本 :go 1.21 及以下 → 老语义,必须手动拷贝;go 1.22+ → 新语义,可以不拷。
  2. 确认是不是闭包捕获 :goroutine、append(&n)defer func(){...n...}() 都是捕获变量地址的典型场景。
  3. n := n 一律安全:不确定版本时,加上它两个版本都对,代价只是一行。
  4. go vet :go vetloopclosure 检查能在编译期揪出「在 1.21 语义下会出错」的循环闭包,CI 里挂上它。
bash 复制代码
# 让 vet 帮你抓循环闭包问题
go vet ./...

小结

  • Go 1.22 之前 :for-range 的循环变量整个循环共用一个,闭包捕获的是变量本身,goroutine 延迟执行时读到的是最终值 → 全打印最后一个。
  • 修法:循环体内 n := n,或用 go func(n int){}(n) 传值------本质都是在迭代当下把值固定住
  • Go 1.22 之后 :每轮迭代都是新变量 ,老 bug 自动消失,但要警惕同一代码在不同 Go 版本行为不同
  • 跨版本兼容就保留 n := n(1.22 下冗余但无害),CI 挂 go vet 兜底。

一句话记忆:闭包捕获的是变量不是值;循环变量是不是「每轮一个」,取决于你的 go.mod 写的是不是 1.22。

相关推荐
SimonKing1 小时前
别再写 setter 了!MapStruct Plus vs MapStruct,谁才是 Bean 转换的真神?
java·后端·程序员
AI多Agent协作实战派1 小时前
AI多Agent协作系统实战(二十五):Spring Boot静态资源同步:为什么你改了代码但线上还是旧的?
后端
码栈研说1 小时前
Go 语言大白话入门 12 - JSON:工作里最常见的数据格式
后端·程序员
达达尼昂1 小时前
AI Native 工程实践:如何为 Claude 5 设计更有效的上下文
android·人工智能·后端
用户2930750976691 小时前
从零搭建 AI 日记助手:Milvus 向量数据库 + RAG 实战
后端
zguigo1 小时前
结合苍穹外卖对redis进行总结和教学
后端
玉宇夕落1 小时前
Milvus向量数据库和cs bs架构学习
后端
倒流时光三十年1 小时前
第一阶段 05 · Java 客户端查询类详解(Query / SearchCriteria / Response 与复杂拼接)
java·开发语言·python
Larcher1 小时前
别再搞混了!一文讲透 AI Workflow 和 Agent 的本质区别
javascript·人工智能·后端