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。

相关推荐
gis开发之家5 分钟前
日志怎么打才专业?Spring Boot 4 日志体系与 Logback 配置(生产级实战)
java·spring boot·后端·logback
平常心的技术小牛9 分钟前
Qt-快速上手-QLabel
开发语言·qt
卷无止境10 分钟前
FastAPI CLI 你需要认识这把命令行利器
后端·python·fastapi
XR12345678812 分钟前
工厂办公楼无线网络:多楼层覆盖与访客体验怎么选?
开发语言·php
JL1513 分钟前
Java并发编程面试全攻略-从synchronized到AQS底层原理
java·开发语言·面试·并发编程
云泽80814 分钟前
Python 开发环境搭建全指南:从 Python 安装到 PyCharm 配置详解
开发语言·python·pycharm
不会代码的小猴21 分钟前
2. 了解Qt
开发语言·c++·笔记·qt·算法
ZJU_统一阿萨姆23 分钟前
【推理优化进阶】Hopper_Blackwell 微架构:从指令、流水线到真实性能上限
开发语言·人工智能·语言模型·架构·开源
AINative软件工程25 分钟前
LLM 请求合并工程实践:用 Single-Flight 把并发重复调用从 N 次砍成 1 次
后端·llm·ai编程
程序员爱钓鱼29 分钟前
Go 编程实战:数组 Array——固定长度的数据集合
后端·面试·go