五分鐘搞懂 Golang noCopy 策略
本文介紹了 Golang 中的 noCopy 策略,解釋了如何防止包含鎖的結(jié)構(gòu)體被錯誤拷貝,以及如何使用 go vet 工具檢測潛在的拷貝問題。
1. Sync.noCopy
在學(xué)習(xí) Go 的 WaitGroup 代碼時,我注意到了 noCopy,并看到一個熟悉的注釋:"首次使用后不得復(fù)制"。
// A WaitGroup must not be copied after first use.
//
// In the terminology of the Go memory model, a call to Done
// “synchronizes before” the return of any Wait call that it unblocks.
type WaitGroup struct {
noCopy noCopy
state atomic.Uint64 // high 32 bits are counter, low 32 bits are waiter count.
sema uint32
}
搜索后發(fā)現(xiàn),"首次使用后不得復(fù)制" 經(jīng)常和 noCopy 一起出現(xiàn)。
// 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() {}
通過查看 Go 1.23 中 `noCopy` 的定義發(fā)現(xiàn):
- noCopy 類型是一個空結(jié)構(gòu)體。
- noCopy 類型實現(xiàn)了兩種方法:Lock 和 Unlock,這兩種方法都是非操作方法。
- 注釋強(qiáng)調(diào),Lock 和 Unlock 由 go vet 檢查器使用。
noCopy 類型沒有實際的功能特性,只有通過思索和實驗才能理解其具體用途,以及為什么 "首次使用后不得復(fù)制"?
2. Go Vet 和 "鎖值錯誤傳遞"
當(dāng)我們輸入以下命令:
go tool vet help copylocks
輸出:
copylocks: check for locks erroneously passed by value
Inadvertently copying a value containing a lock, such as sync.Mutex or
sync.WaitGroup, may cause both copies to malfunction. Generally such
values should be referred to through a pointer.
Go Vet 告訴我們在使用包含鎖(如 sync.Mutex 或 sync.WaitGroup)的值并通過值傳遞時,可能會導(dǎo)致意想不到的問題。例如:
package main
import (
"fmt"
"sync"
)
type T struct {
lock sync.Mutex
}
func (t T) Lock() {
t.lock.Lock()
}
func (t T) Unlock() {
t.lock.Unlock()
}
func main() {
var t T
t.Lock()
fmt.Println("test")
t.Unlock()
fmt.Println("finished")
}
運(yùn)行這段代碼,將輸出錯誤信息:
// output
test
fatal error: sync: unlock of unlocked mutex
goroutine 1 [running]:
sync.fatal({0x4b2c9b?, 0x4a14a0?})
/usr/local/go-faketime/src/runtime/panic.go:1031 +0x18
// ? go vet .
# noCopy
./main.go:12:9: Lock passes lock by value: noCopy.T contains sync.Mutex
./main.go:15:9: Unlock passes lock by value: noCopy.T contains sync.Mutex
Copy
錯誤原因是 Lock 和 Unlock 方法使用了值接收器 t,在調(diào)用方法時會創(chuàng)建 T 的副本,這意味著 Unlock 中的鎖實例與 Lock 中的鎖實例不匹配。
為了解決這個問題,可以將接收器改為指針類型:
func (t *T) Lock() {
t.lock.Lock()
}
func (t *T) Unlock() {
t.lock.Unlock()
}
同樣,在使用 Cond、WaitGroup 和其他包含鎖的類型時,需要確保它們在首次使用后不會被復(fù)制。例如:
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, wg sync.WaitGroup) {
defer wg.Done()
fmt.Printf("Worker %d starting\n", id)
time.Sleep(time.Second)
fmt.Printf("Worker %d done\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go worker(i, wg)
}
wg.Wait()
fmt.Println("All workers done!")
}
運(yùn)行這段代碼,也會輸出錯誤信息:
/////
Worker 3 starting
Worker 1 starting
Worker 2 starting
Worker 1 done
Worker 3 done
Worker 2 done
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [semacquire]:
sync.runtime_Semacquire(0xc000108040?)
// ? go vet .
# noCopy
./main.go:9:24: worker passes lock by value: sync.WaitGroup contains sync.noCopy
./main.go:21:16: call of worker copies lock value: sync.WaitGroup contains sync.noCopy
要解決這個問題,可以使用相同的 wg 實例,大家可以自己試一下。有關(guān) copylocks的更多信息可以查看 golang 官網(wǎng)。
3. 嘗試 go vet 檢測
go vet 的 noCopy 機(jī)制是一種防止結(jié)構(gòu)體被拷貝的方法,尤其是那些包含同步原語(如 sync.Mutex 和 sync.WaitGroup)的結(jié)構(gòu),目的是防止意外的鎖拷貝,但這種防止并不是強(qiáng)制性的,是否拷貝需要由開發(fā)者檢測。例如:
package main
import "fmt"
type noCopy struct{}
func (*noCopy) Lock() {}
func (*noCopy) Unlock() {}
type noCopyData struct {
Val int32
noCopy
}
func main() {
c1 := noCopyData{Val: 10}
c2 := c1
c2.Val = 20
fmt.Println(c1, c2)
}
上面的示例沒有任何實際用途,程序可以正常運(yùn)行,但 go vet 會提示 "passes lock by value" 警告。這是一個嘗試 go vet 檢測機(jī)制的小練習(xí)。
不過,如果需要編寫與同步原語(如 sync.Mutex 和 sync.WaitGroup)相關(guān)的代碼,noCopy 機(jī)制可能就會有用。
4. 其他 noCopy 策略
據(jù)我們了解,go vet 可以檢測到未被嚴(yán)格禁止的潛在拷貝問題。有沒有嚴(yán)格禁止拷貝的策略?是的,有。讓我們看看 strings.Builder 的源代碼:
// A Builder is used to efficiently build a string using [Builder.Write] methods.
// It minimizes memory copying. The zero value is ready to use.
// Do not copy a non-zero Builder.
type Builder struct {
addr *Builder // of receiver, to detect copies by value
// External users should never get direct access to this buffer,
// since the slice at some point will be converted to a string using unsafe,
// also data between len(buf) and cap(buf) might be uninitialized.
buf []byte
}
func (b *Builder) copyCheck() {
if b.addr == nil {
// This hack works around a failing of Go's escape analysis
// that was causing b to escape and be heap allocated.
// See issue 23382.
// TODO: once issue 7921 is fixed, this should be reverted to
// just "b.addr = b".
b.addr = (*Builder)(abi.NoEscape(unsafe.Pointer(b)))
} else if b.addr != b {
panic("strings: illegal use of non-zero Builder copied by value")
}
}
// Write appends the contents of p to b's buffer.
// Write always returns len(p), nil.
func (b *Builder) Write(p []byte) (int, error) {
b.copyCheck()
b.buf = append(b.buf, p...)
return len(p), nil
}
關(guān)鍵點是:
b.addr = (*Builder)(abi.NoEscape(unsafe.Pointer(b)))
這行代碼的作用如下:
- unsafe.Pointer(b):將 b 轉(zhuǎn)換為 unsafe.Pointer,以便與 abi.NoEscape 一起使用。
- abi.NoEscape(unsafe.Pointer(b)):告訴編譯器 b 不會轉(zhuǎn)義,即可以繼續(xù)在棧而不是堆上分配。
- (*Builder)(...): 將 abi.NoEscape 返回值轉(zhuǎn)換回 *Builder 類型,以便正常使用。
最后,b.addr 被設(shè)置為 b 本身的地址,這樣可以防止 Builder 被復(fù)制(在下面的邏輯中檢查 b.addr != b)。
使用有拷貝行為的 strings.Builder 會導(dǎo)致 panic:
func main() {
var a strings.Builder
a.Write([]byte("a"))
b := a
b.Write([]byte("b"))
}
// output
panic: strings: illegal use of non-zero Builder copied by value
goroutine 1 [running]:
strings.(*Builder).copyCheck(...)
5. 總結(jié)
- 同步原語(如 sync.Mutex 和 sync.WaitGroup)不應(yīng)被拷貝,因為一旦被拷貝,其內(nèi)部狀態(tài)就會重復(fù),從而導(dǎo)致并發(fā)問題。
- 雖然 Go 本身并沒有提供嚴(yán)格防止拷貝的機(jī)制,但 noCopy 結(jié)構(gòu)提供了一種非嚴(yán)格的機(jī)制,用于 go vet 工具的識別和拷貝檢測。
- Go 中的某些源代碼會在運(yùn)行時執(zhí)行 noCopy 檢查并返回 panic,例如 strings.Builder 和 sync.Cond。