背景
僵尸指针问题非常难以调试。现在GoGC只会在扫描对象时报告这个问题,尽管Go GC在这里会引发恐慌,但信息不足以进行调试(reportZombies将打印此范围,但不会告诉开发者这个指针是如何收集到gcw的,开发者需要自己找出这个内存地址的来源)。
目前有两种可能导致指针指向gcw的方法:greyobject
和gcWriteBarrier
(以及它的cx、dx...派生)。greyobject
已经有了debug.gccheckmark
标志来验证这个指针是否正确,然而gcWriteBarrier
没有相应的方法来进行验证。
例如,一个错误的atomic.StorePointer
使用,如果开发者不关注这个值的正确性,就不会有任何错误报告。(特别是当这个字段是一个ID或其他没有实际意义的int64字段时)code snippet
package main
import (
"fmt"
"sync/atomic"
"unsafe"
)
type struct1 struct {
Field int64
}
type struct1ptr *struct1
var s1 struct1ptr = &struct1{
1,
}
func main() {
f1 := struct1{
Field: 0,
}
atomic.StorePointer((*unsafe.Pointer)(unsafe.Pointer(s1)), unsafe.Pointer(&f1))
fmt.Println(s1.Field)
}
此外,如果这个函数在高负载下工作,这个错误的赋值可能会导致僵尸指针,这很难调试。因为这个恐慌只会告诉开发者哪个mspan
包含僵尸指针,但没有关于这个指针在哪里分配的信息。
package main
import (
"fmt"
"runtime"
"sync"
"sync/atomic"
"time"
"unsafe"
)
var current time.Time
func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(2)
go func() {
defer wg.Done()
rc := time.Now()
atomic.StorePointer((*unsafe.Pointer)(unsafe.Pointer(¤t)), unsafe.Pointer(&rc))
}()
go func() {
defer wg.Done()
fmt.Printf("%v\n", current.Unix())
}()
runtime.GC()
}
wg.Wait()
fmt.Println("===over===")
}
另一个情况是不安全的类型转换。相同的UserInfo
可能会导致开发者使用unsafe.Pointer来消除新的分配性能损失并避免为Map字段编写过长的代码。但是在IDL文件更新后,UserInfoFromARPC
和UserInfoFromBRPC
可能会不同,而且有很高的可能性是新添加的字段将是指针类型,因为新字段通常是可选的。
package main
import (
"fmt"
"runtime"
"sync"
"unsafe"
)
type UserInfoFromARPC struct {
ID int64
Age int64
}
type UserInfoFromBRPC struct {
ID int64
Age int64
Extra *string // this field was introduced after UnsafeQueryB is finished, and generated by some idl generator
}
func Query() *UserInfoFromARPC {
return &UserInfoFromARPC{
ID: 100,
Age: 100,
}
}
func UnsafeQueryB() *UserInfoFromBRPC {
return (*UserInfoFromBRPC)(unsafe.Pointer(Query()))
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
fmt.Println(UnsafeQueryB())
wg.Done()
}()
runtime.GC()
}
wg.Wait()
}
建议
概要
在gcWriteBarrier中添加一个由宏控制的代码段,gcWriteBarrier将尝试在存储之前检查指针验证。如果指针非法,则在这里引发恐慌。通常情况下,恐慌堆栈将包含发生错误“分配”的函数的帧。开发者可以使用此功能更容易地找到他们的错误。
为什么选择修改gcWriteBarrier
僵尸指针只会发生在错误的指针被转储到堆中时。如果一个指针没有转储到堆中,它至少不会引起僵尸指针。
为什么选择使用宏而不是标志
因为gcWriteBarrier
是一个经常被调用的函数,使用宏将避免更改常规代码。只有当开发者在他们的程序中发现了一个僵尸指针并且它是可重现的,他们才能使用-+
和这个标志重新编译运行时并启用这些跟踪代码,然后等待恐慌找到错误所在的地方。
缺点
- 用C宏控制编译后的代码在Go中并不常见
但可能是最少的性能开销?
PS:我们是否应该修改reportZombies
以使其在设置了invalidptr=0
时不引发恐慌?
8条答案
按热度按时间t40tm48m1#
另一个解决这个问题的方法是等待 #50860 被接受和实施。然后我们可以让人们开始使用通用的
(*Pointer[T]).Store
,它不会像当前的StorePointer
那样产生困惑。ee7vknir2#
Go语言没有宏,所以我不太清楚你具体建议的是什么。
gcWriteBarrier
运行非常频繁,所以我同意我们不应该添加一个运行时检查来检查指针是否有效。也许我们可以用构建标签来做这个,但我觉得很少有人会在实践中使用这样的功能。
CC @golang/runtime
vdzxcuhz3#
第一个例子似乎是
vet
应该捕获的。很容易忘记 API 实际上是func StorePointer(dst **T, t *T)
,或者拼写错误的难以理解的(*unsafe.Pointer)(...)
表达式。我基本上每次使用 API 时都会犯这个错误。一个vet
检查可以捕获问题的根本原因。我认为
vet
可能能够捕获第二种情况。如果UserInfoFromARPC
从 Go 的堆中分配(如C.malloc
或syscall.Mmap
),则会产生误报,但可以通过使用//go:notinheap
来解决这个问题。但是我不知道。如果
vet
能够捕获这些情况,那么就不需要运行时检查了。fykwrbwg4#
FWIW,在过去的调试过程中,我尝试将写屏障缓冲区大小设置为1,这样它总是会走慢速路径,至少有一些检查,例如在findObject中,这很有帮助。也许我们可以创建一个GODEBUG模式,将缓冲区大小设置为1。
wecizke35#
我认为,您实际上可以通过使用
GODEBUG=cgocheck=2
获得缓冲区大小为1的效果。尽管这并不直观。ajsxfq5m6#
一个验证器检查看起来合理:它可以去除
unsafe.Pointer()
转换并获得原始类型,并检查两个参数的类型是否满足以下条件:有效情况包括:
检查可以很容易地在类型上进行传递,例如当T2和T3是具有命名类型的字段的结构体时。因此,检查器可能会缩小它将报告的模式,例如仅报告当T1 = T2的情况,这涵盖了以下示例:
以及
然而,这些模式在实际代码中的频率是一个问题。
ecfdbz9o7#
第一个例子似乎是
vet
应该捕获的问题。忘记 API 实际上是func StorePointer(dst **T, t *T)
或拼写错误的难以使用的(*unsafe.Pointer)(...)
表达式很容易。我每次使用 API 时基本上都会犯这个错误。一个vet
检查可以捕获问题的根本原因。我认为
vet
可能可以捕获第二种情况。如果UserInfoFromARPC
是分配在 Go 堆上的(如C.malloc
或syscall.Mmap
),则会误报,但可以通过使用//go:notinheap
解决这个问题。但我不知道。如果
vet
可以捕获这些,那么就不需要运行时检查了。我明白这一点,
vet
将是一种更不侵入的方式。实际上,我正在尝试在golangci-lint
下开发一个插件,以便如果有人遇到这个问题,他们可以使用它进行调试。(我认为这些代码不太常见,我应该在go vet
而不是golangci-lint
中添加它吗?然而,我真正关心的问题是,我给出的两个示例只是来自我的个人知识,我不知道还有其他什么情况可能会触发这个问题。
我认为你今天实际上可以通过使用
GODEBUG=cgocheck=2
获得缓冲区大小为 1 的效果。尽管这并不直观。它们实际上有点不同,我认为你是说当
cgocheck=2
和cgoCheckWriteBarrier
被调用时。cgoCheckWriteBarrier
似乎不会检查它,dst
和src
都是 Go 指针,因为这个检查取决于这个指针是否小于 span limit,而它们很可能“在 span”内。(这些僵尸指针比 span 小,但它们比freeindex
大,它们的 allocBits 没有设置。它们可能在 span 和僵尸之间。)Go 没有宏,所以我不清楚你具体建议什么。
gcWriteBarrier
运行得非常频繁,所以我同意我们不应该为检查指针是否有效添加运行时检查。也许我们可以用构建标签来实现这一点,但我有种感觉,很少有人会在实践中需要这样的功能。
CC @golang/runtime
我确实发现 go 很少使用宏,然而
gcWriteBarrier
是汇编的,我想我可能可以引入一个类似于GOAMD64_v2/3/4
的宏,并由标志控制?(我没有阅读相关代码,这只是我的猜测😂eoigrqb68#
另外一件事,我认为可能应该指出的是
invalidptr
不能完全禁用僵尸指针检查。只有当findObject
收集到这个指针时,invalidptr
才会受到影响。如果这个指针被gcWriteBarrier
标记,那么在reportZombies
中只会发生恐慌,而这并不受invalidptr
的控制。我不太确定,但是让
reportZombies
受invalidptr
控制是否可以?