Go 闭包:你以为保存了快照,其实创建了共享状态
你好,我是 Bobby。
Go 里的闭包很顺手。函数里返回一个函数,或者把匿名函数交给 defer、go、http.HandlerFunc,代码都很短。
但闭包有一个容易误判的行为:它引用的是外层变量,创建闭包时不会自动把变量值复制一份保存下来。
麻烦通常出在这里:我们以为自己保存了一个快照,实际创建了一个会继续读取外部变量的函数。
这篇文章主要参考 Go 语言规范里的 Function literals、Go 官方博客 Fixing For Loops in Go 1.22,以及 go vet 的 loopclosure 分析器文档。
闭包读的是同一个变量
先看一个最小例子:
1 | |
输出是:
1 | |
buildURL 没有在创建时保存 "https://dev.example.com"。它捕获的是变量 endpoint。后面 endpoint 被重新赋值,闭包再执行时读到的就是新值。
Go 规范里对 function literal 的描述也正是这个意思:函数 literal 可以引用外层函数里的变量,这些变量会在外层函数和闭包之间共享,并且只要还能被访问就会继续存活。
如果你想让 buildURL 固定使用创建时的 endpoint,就要自己做快照:
1 | |
闭包捕获的仍然是变量,只是这次捕获的是一个后面不会再写的局部变量。
共享变量有时正是你要的效果
闭包读同一个变量,不一定是问题。计数器就是最典型的例子:
1 | |
这里每次调用 next 都能让 n 增加,因为所有调用访问的是同一个 n。如果闭包在创建时把 n 复制走,这个计数器反而没法工作。
判断闭包代码时,重点不是”有没有捕获变量”,而是”这个捕获是不是有意为之”。
如果闭包只是连续读写同一个局部状态,比如计数器,这通常很清楚。麻烦出在它被保存下来,之后又被多个 goroutine 调用。
一旦走到并发场景,就不能再把这个变量当普通局部变量看。谁会读、谁会写、靠什么同步,都要写清楚。
配置重载会放大这个问题
很多线上服务都有配置重载。闭包和配置指针放在一起时,很容易让语义变得含糊。
比如我们给后台任务生成一个 runner:
1 | |
这段代码里,runner 捕获了 cfg。如果后面配置重载把 cfg.Timeout 改了,已经创建好的 runner 会读到新 timeout。
这可能正是你想要的效果:配置一变,所有后续任务都使用新配置。那就应该把 cfg 当共享状态处理,至少要保证配置更新和读取之间没有数据竞争。常见做法是用 atomic.Value 保存整份不可变配置,或者用锁保护读写。
如果 runner 应该固定使用创建时的 timeout,就别捕获整个 cfg:
1 | |
一个读 live config,一个读 snapshot config。两种写法都成立,但含义完全不同。
并发框架会让局部变量变成共享状态
闭包里最隐蔽的并发问题,经常出现在 handler、callback、hook 这类代码里。源码里不一定看得到 go,但框架可能会并发调用你传进去的函数。
下面这个中间件想统计请求数:
1 | |
count 看起来是一个普通局部变量,但返回的 handler 闭包会长期持有它。net/http 会并发处理请求,多个请求进入同一个闭包后,就会同时读写同一个 count。
问题出在所有权太隐蔽:count 看起来只是局部变量,实际已经变成跨请求共享的状态。count 的生命周期已经从”函数调用期间”变成了”这个 handler 存活期间”,并且它会被多个 goroutine 访问。
如果确实要统计请求数,可以把共享关系写出来:
1 | |
这里 count 仍然被闭包捕获,但它的并发语义明确了。读代码的人能看出它是跨请求共享的状态,而不是某个普通局部变量。
如果某个值只应该在单次请求内变化,就在闭包内部声明它,让每次调用都有自己的副本。
方法值也会保存 receiver
闭包问题不只出现在 func() { ... } 这种显眼的匿名函数里。方法值也要小心。
看这个例子:
1 | |
key := s.Key 看起来只是保存了一个方法,但这个方法值带着 receiver s。后面 s.Prefix 改了,key("42") 再执行时也会读到新 prefix。
如果你想保存的是当时的 prefix,就要复制:
1 | |
方法值的麻烦在于,它不像匿名函数那样把捕获写在 {} 里面。赋值语句很短,receiver 却被悄悄带走了。代码评审时看到 s.Method 被保存到字段、切片、map 或回调里,最好继续追一下这个方法读了 receiver 的哪些字段。
Go 1.22 只修了循环变量这一类问题
很多人熟悉的闭包坑来自循环变量:
1 | |
Go 1.22 之前,这类代码容易让多个闭包共享同一个循环变量。Go 1.22 改了 for loop 变量的语义,每轮迭代都有自己的变量。这个改动解决了 Go 里最常见的一类闭包误用。
但它只覆盖循环变量。下面这些情况仍然要自己判断:
- 闭包捕获了指针,比如
*Config、*Store - 闭包捕获了会被后续修改的局部变量
- 方法值保存了 receiver
- 闭包被保存到长生命周期结构体里,后续由框架或其他 goroutine 调用
go vet 的 loopclosure 分析器也主要针对 loop variable 这类问题。它能抓一些典型错误,但不能理解所有“闭包后面会读到错误时间点的值”的场景。
go test -race 可以发现并发读写,但也不是完整答案。如果没有并发,只是闭包在稍后读到了新的配置值,race detector 不会报错。因为那可能是合法的串行读写,只是业务语义错了。
写闭包时先确定语义
需要创建时的值,就在创建闭包前复制。字符串、整数、布尔值、小结构体,通常都适合直接复制。
需要调用时的状态,就把它写成共享状态。用锁、atomic、channel 或不可变配置加原子替换,把读写路径说清楚。
需要保存 method value,就按保存闭包处理。s.Method 会带着 s,receiver 里的字段后面怎么变,方法值就可能读到什么。
写这类代码时,我希望你带走的不是某个技巧,而是一个判断顺序:闭包和方法值捕获会变的对象时,先确认它需要的是快照,还是共享状态。
需要快照,就在创建闭包前复制值;需要共享状态,就用锁、atomic、channel 或不可变配置加原子替换,把读写路径说清楚。
很多闭包 bug 难查,是因为共享状态被包装成了一个看起来很普通的局部函数。
如果这篇文章对你有帮助,欢迎点赞、在看、分享。
我是 Bobby,我们下次见。