转载 | 防止在 Go 中意外复制结构

Apr 28, 2025

默认情况下,Go 语言在传递值时会进行复制。但有时候这种复制并不理想。例如,如果你不小心复制了一个互斥锁(mutex),并且多个 goroutine 分别使用复制出来的锁实例,那么它们之间就不能正确同步。在这种情况下,传递指向锁的指针可以避免复制,从而按预期工作。

举个例子:按值传递一个 sync.WaitGroup 会以微妙的方式导致问题发生:

func f(wg sync.WaitGroup) {
    // ... do something with the waitgroup
}

func main() {
    var wg sync.WaitGroup
    f(wg) // oops! wg is getting copied here!
}

sync.WaitGroup 让你等待多个 goroutine 完成一些工作。在其内部,它是一个带有 AddDoneWait 等方法的结构体,用于同步并发运行的 goroutine

这段代码虽然可以正常编译,但会导致错误的行为,因为我们在 f 函数中复制了锁,而不是引用它。

幸运的是,go vet 工具能够检测到这个问题。如果你对这段代码运行 go vet,你会收到类似如下的警告:

f passes lock by value: sync.WaitGroup contains sync.noCopy
call of f copies lock value: sync.WaitGroup contains sync.noCopy

这意味着我们错误地按值传递了 wg,而实际上应该传递它的引用。下面是修正方法:

func f(wg *sync.WaitGroup) { // pass by reference
    // ... do something with the waitgroup
}

func main() {
    var wg sync.WaitGroup
    f(&wg) // pass a pointer to wg
}

由于这种错误的复制不会导致编译时错误,如果你跳过了 go vet,可能永远也无法发现它。这也是你应该始终使用 go vet 进行代码检查的另一个原因。

我很好奇 Go 工具链是如何强制执行这一点的。线索就在 vet 的警告中:

call of f copies lock value: sync.WaitGroup contains sync.noCopy

所以,sync.WaitGroup 里面的 sync.noCopy 结构体在你按值传递时会做一些操作,以提醒 go vet

查看 sync.WaitGroup 的实现,你会看到:

type WaitGroup struct {
    noCopy noCopy

    state atomic.Uint64
    sema  uint32
}

然后我追踪了 noCopysync/cond.go 中的定义。

// noCopy may be added to structs which must not be copied
// after the first use.

// Note that it must not be embedded, due to the Lock and Unlock methods.
type noCopy struct{}

// Lock is a no-op used by -copylocks checker from `go vet`.
func (*noCopy) Lock()   {}
func (*noCopy) Unlock() {}

只要在 noCopy 上定义空操作的 LockUnlock 方法就足够了。这实现了 Locker 接口。然后,如果你把这个结构体放到另一个结构体里,go vet 会标记那些试图复制外层结构体的情况。

另外,注意注释:不要将 noCopy 嵌入(embed)进去,而是要显式地包含它。嵌入会将 LockUnlock 方法暴露到外层结构体上,而这通常不是你想要的。

Go 工具链通过 -copylocks 检查器来强制执行这一点。它是 go vet 的一部分。你可以用 go vet -copylocks ./... 专门调用它。它会查找任何嵌套了带有 LockUnlock 方法结构体的值复制情况。不管那些方法实际做什么,只要有就足够了。

运行 vet 时,它会遍历抽象语法树(AST),并对赋值、函数调用、返回值、结构字面量、range 循环、channel 发送等几乎所有值可能被复制的地方应用这个检查器。如果发现你复制了带有 noCopy 的结构体,它就会警告你。你可以在这里看到检查实现。

有趣的是,如果你定义的 noCopy 不是一个结构体,但实现了 Locker 接口,vet 会忽略它。我在 Go 1.24 上测试过这一点。

type noCopy int     // this is valid but vet doesn't get triggered
func (*noCopy) Lock()   {}
func (*noCopy) Unlock() {}

这不会触发 vet 警告。只有当 noCopy 是结构体时才有效。原因是 vet 在检查何时触发警告时采用了一个捷径。它目前明确地只查找满足 Locker 接口的结构体类型,即使其他类型也实现了该接口,vet 也会忽略。

你在 sync 包的其他部分也会看到这种做法。sync.Mutex 就用了同样的技巧:

type Mutex struct {
    _ noCopy

    mu isync.Mutex
}

sync.Once 相同:

type Once struct {
    done   uint32
    m      Mutex
    noCopy noCopy
}

下面是一个完整的示例,展示如何滥用 -copylocks 来防止复制我们自己的结构体:

type Svc struct{ _ noCopy }

type noCopy struct{}

func (*noCopy) Lock()   {}
func (*noCopy) Unlock() {}

// Use this
func main() {
    var svc Svc
    _ = svc // go vet will complain about this copy op
}

运行 go vet 会得到以下结果:

assignment copies lock value to s: play.Svc contains play.noCopy
call of fmt.Println copies lock value: play.Svc contains play.noCopy

有人在 Reddit 上问我,究竟是什么触发了 go vetcopylock 检查器——是结构体字面量的名字 noCopy,还是它实现了 Locker 接口?

名字 noCopy 并没有特别的意义。你可以随意命名它。只要它实现了 Locker 接口,如果包含它的结构体被复制,go vet 就会发出警告。详见这个 Go Playground 代码片段


https://inasa.dev/posts/rss.xml