Go 闭包:你以为保存了快照,其实创建了共享状态

你好,我是 Bobby。

Go 里的闭包很顺手。函数里返回一个函数,或者把匿名函数交给 defergohttp.HandlerFunc,代码都很短。

但闭包有一个容易误判的行为:它引用的是外层变量,创建闭包时不会自动把变量值复制一份保存下来。

麻烦通常出在这里:我们以为自己保存了一个快照,实际创建了一个会继续读取外部变量的函数。

这篇文章主要参考 Go 语言规范里的 Function literals、Go 官方博客 Fixing For Loops in Go 1.22,以及 go vetloopclosure 分析器文档。

闭包读的是同一个变量

先看一个最小例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import "fmt"

func main() {
endpoint := "https://dev.example.com"

buildURL := func(path string) string {
return endpoint + path
}

fmt.Println(buildURL("/users"))

endpoint = "https://prod.example.com"

fmt.Println(buildURL("/users"))
}

输出是:

1
2
https://dev.example.com/users
https://prod.example.com/users

buildURL 没有在创建时保存 "https://dev.example.com"。它捕获的是变量 endpoint。后面 endpoint 被重新赋值,闭包再执行时读到的就是新值。

Go 规范里对 function literal 的描述也正是这个意思:函数 literal 可以引用外层函数里的变量,这些变量会在外层函数和闭包之间共享,并且只要还能被访问就会继续存活。

如果你想让 buildURL 固定使用创建时的 endpoint,就要自己做快照:

1
2
3
4
5
6
7
8
9
10
endpoint := "https://dev.example.com"
snapshot := endpoint

buildURL := func(path string) string {
return snapshot + path
}

endpoint = "https://prod.example.com"

fmt.Println(buildURL("/users")) // https://dev.example.com/users

闭包捕获的仍然是变量,只是这次捕获的是一个后面不会再写的局部变量。

共享变量有时正是你要的效果

闭包读同一个变量,不一定是问题。计数器就是最典型的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
func counter() func() int {
n := 0
return func() int {
n++
return n
}
}

next := counter()

fmt.Println(next()) // 1
fmt.Println(next()) // 2
fmt.Println(next()) // 3

这里每次调用 next 都能让 n 增加,因为所有调用访问的是同一个 n。如果闭包在创建时把 n 复制走,这个计数器反而没法工作。

判断闭包代码时,重点不是”有没有捕获变量”,而是”这个捕获是不是有意为之”。

如果闭包只是连续读写同一个局部状态,比如计数器,这通常很清楚。麻烦出在它被保存下来,之后又被多个 goroutine 调用。

一旦走到并发场景,就不能再把这个变量当普通局部变量看。谁会读、谁会写、靠什么同步,都要写清楚。

配置重载会放大这个问题

很多线上服务都有配置重载。闭包和配置指针放在一起时,很容易让语义变得含糊。

比如我们给后台任务生成一个 runner:

1
2
3
4
5
6
7
8
9
10
11
12
13
type Config struct {
Timeout time.Duration
}

type Job struct {
ID string
}

func newRunner(cfg *Config) func(Job) error {
return func(job Job) error {
return runWithTimeout(job, cfg.Timeout)
}
}

这段代码里,runner 捕获了 cfg。如果后面配置重载把 cfg.Timeout 改了,已经创建好的 runner 会读到新 timeout。

这可能正是你想要的效果:配置一变,所有后续任务都使用新配置。那就应该把 cfg 当共享状态处理,至少要保证配置更新和读取之间没有数据竞争。常见做法是用 atomic.Value 保存整份不可变配置,或者用锁保护读写。

如果 runner 应该固定使用创建时的 timeout,就别捕获整个 cfg

1
2
3
4
5
6
7
func newRunner(cfg *Config) func(Job) error {
timeout := cfg.Timeout

return func(job Job) error {
return runWithTimeout(job, timeout)
}
}

一个读 live config,一个读 snapshot config。两种写法都成立,但含义完全不同。

并发框架会让局部变量变成共享状态

闭包里最隐蔽的并发问题,经常出现在 handler、callback、hook 这类代码里。源码里不一定看得到 go,但框架可能会并发调用你传进去的函数。

下面这个中间件想统计请求数:

1
2
3
4
5
6
7
8
9
func countRequests(next http.Handler) http.Handler {
count := 0

return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
count++
w.Header().Set("X-Request-Count", strconv.Itoa(count))
next.ServeHTTP(w, r)
})
}

count 看起来是一个普通局部变量,但返回的 handler 闭包会长期持有它。net/http 会并发处理请求,多个请求进入同一个闭包后,就会同时读写同一个 count

问题出在所有权太隐蔽:count 看起来只是局部变量,实际已经变成跨请求共享的状态。count 的生命周期已经从”函数调用期间”变成了”这个 handler 存活期间”,并且它会被多个 goroutine 访问。

如果确实要统计请求数,可以把共享关系写出来:

1
2
3
4
5
6
7
8
9
func countRequests(next http.Handler) http.Handler {
var count atomic.Int64

return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := count.Add(1)
w.Header().Set("X-Request-Count", strconv.FormatInt(n, 10))
next.ServeHTTP(w, r)
})
}

这里 count 仍然被闭包捕获,但它的并发语义明确了。读代码的人能看出它是跨请求共享的状态,而不是某个普通局部变量。

如果某个值只应该在单次请求内变化,就在闭包内部声明它,让每次调用都有自己的副本。

方法值也会保存 receiver

闭包问题不只出现在 func() { ... } 这种显眼的匿名函数里。方法值也要小心。

看这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type Store struct {
Prefix string
}

func (s *Store) Key(id string) string {
return s.Prefix + id
}

func main() {
s := &Store{Prefix: "v1:"}

key := s.Key

fmt.Println(key("42")) // v1:42

s.Prefix = "v2:"

fmt.Println(key("42")) // v2:42
}

key := s.Key 看起来只是保存了一个方法,但这个方法值带着 receiver s。后面 s.Prefix 改了,key("42") 再执行时也会读到新 prefix。

如果你想保存的是当时的 prefix,就要复制:

1
2
3
4
5
prefix := s.Prefix

key := func(id string) string {
return prefix + id
}

方法值的麻烦在于,它不像匿名函数那样把捕获写在 {} 里面。赋值语句很短,receiver 却被悄悄带走了。代码评审时看到 s.Method 被保存到字段、切片、map 或回调里,最好继续追一下这个方法读了 receiver 的哪些字段。

Go 1.22 只修了循环变量这一类问题

很多人熟悉的闭包坑来自循环变量:

1
2
3
4
5
for _, v := range values {
go func() {
fmt.Println(v)
}()
}

Go 1.22 之前,这类代码容易让多个闭包共享同一个循环变量。Go 1.22 改了 for loop 变量的语义,每轮迭代都有自己的变量。这个改动解决了 Go 里最常见的一类闭包误用。

但它只覆盖循环变量。下面这些情况仍然要自己判断:

  • 闭包捕获了指针,比如 *Config*Store
  • 闭包捕获了会被后续修改的局部变量
  • 方法值保存了 receiver
  • 闭包被保存到长生命周期结构体里,后续由框架或其他 goroutine 调用

go vetloopclosure 分析器也主要针对 loop variable 这类问题。它能抓一些典型错误,但不能理解所有“闭包后面会读到错误时间点的值”的场景。

go test -race 可以发现并发读写,但也不是完整答案。如果没有并发,只是闭包在稍后读到了新的配置值,race detector 不会报错。因为那可能是合法的串行读写,只是业务语义错了。

写闭包时先确定语义

需要创建时的值,就在创建闭包前复制。字符串、整数、布尔值、小结构体,通常都适合直接复制。

需要调用时的状态,就把它写成共享状态。用锁、atomic、channel 或不可变配置加原子替换,把读写路径说清楚。

需要保存 method value,就按保存闭包处理。s.Method 会带着 s,receiver 里的字段后面怎么变,方法值就可能读到什么。

写这类代码时,我希望你带走的不是某个技巧,而是一个判断顺序:闭包和方法值捕获会变的对象时,先确认它需要的是快照,还是共享状态。

需要快照,就在创建闭包前复制值;需要共享状态,就用锁、atomic、channel 或不可变配置加原子替换,把读写路径说清楚。

很多闭包 bug 难查,是因为共享状态被包装成了一个看起来很普通的局部函数。


如果这篇文章对你有帮助,欢迎点赞、在看、分享。

我是 Bobby,我们下次见。


Go 闭包:你以为保存了快照,其实创建了共享状态
https://blog.zhangliangliang.cc/post/go-closure-variable.html
作者
Bobby Zhang
发布于
2026年5月14日
许可协议