go 建议:运行时:为gcWriteBarrier添加指针检查代码,以帮助调试僵尸指针,

olmpazwi  于 6个月前  发布在  Go
关注(0)|答案(8)|浏览(67)

背景

僵尸指针问题非常难以调试。现在GoGC只会在扫描对象时报告这个问题,尽管Go GC在这里会引发恐慌,但信息不足以进行调试(reportZombies将打印此范围,但不会告诉开发者这个指针是如何收集到gcw的,开发者需要自己找出这个内存地址的来源)。
目前有两种可能导致指针指向gcw的方法:greyobjectgcWriteBarrier(以及它的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(&current)), 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文件更新后,UserInfoFromARPCUserInfoFromBRPC可能会不同,而且有很高的可能性是新添加的字段将是指针类型,因为新字段通常是可选的。

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是一个经常被调用的函数,使用宏将避免更改常规代码。只有当开发者在他们的程序中发现了一个僵尸指针并且它是可重现的,他们才能使用-+和这个标志重新编译运行时并启用这些跟踪代码,然后等待恐慌找到错误所在的地方。

缺点

  1. 用C宏控制编译后的代码在Go中并不常见
    但可能是最少的性能开销?
    PS:我们是否应该修改reportZombies以使其在设置了invalidptr=0时不引发恐慌?
t40tm48m

t40tm48m1#

另一个解决这个问题的方法是等待 #50860 被接受和实施。然后我们可以让人们开始使用通用的 (*Pointer[T]).Store,它不会像当前的 StorePointer 那样产生困惑。

ee7vknir

ee7vknir2#

Go语言没有宏,所以我不太清楚你具体建议的是什么。
gcWriteBarrier 运行非常频繁,所以我同意我们不应该添加一个运行时检查来检查指针是否有效。
也许我们可以用构建标签来做这个,但我觉得很少有人会在实践中使用这样的功能。
CC @golang/runtime

vdzxcuhz

vdzxcuhz3#

第一个例子似乎是 vet 应该捕获的。很容易忘记 API 实际上是 func StorePointer(dst **T, t *T),或者拼写错误的难以理解的 (*unsafe.Pointer)(...) 表达式。我基本上每次使用 API 时都会犯这个错误。一个 vet 检查可以捕获问题的根本原因。
我认为 vet 可能能够捕获第二种情况。如果 UserInfoFromARPC 从 Go 的堆中分配(如 C.mallocsyscall.Mmap),则会产生误报,但可以通过使用 //go:notinheap 来解决这个问题。但是我不知道。
如果 vet 能够捕获这些情况,那么就不需要运行时检查了。

fykwrbwg

fykwrbwg4#

FWIW,在过去的调试过程中,我尝试将写屏障缓冲区大小设置为1,这样它总是会走慢速路径,至少有一些检查,例如在findObject中,这很有帮助。也许我们可以创建一个GODEBUG模式,将缓冲区大小设置为1。

wecizke3

wecizke35#

我认为,您实际上可以通过使用GODEBUG=cgocheck=2获得缓冲区大小为1的效果。尽管这并不直观。

ajsxfq5m

ajsxfq5m6#

一个验证器检查看起来合理:它可以去除unsafe.Pointer()转换并获得原始类型,并检查两个参数的类型是否满足以下条件:

atomic.StorePointer(T1, T2)

有效情况包括:

  • T1 = *T2
  • T1 = *T3, T2与T3兼容。

检查可以很容易地在类型上进行传递,例如当T2和T3是具有命名类型的字段的结构体时。因此,检查器可能会缩小它将报告的模式,例如仅报告当T1 = T2的情况,这涵盖了以下示例:

atomic.StorePointer((*unsafe.Pointer)(unsafe.Pointer(s1)), unsafe.Pointer(&f1))

以及

atomic.StorePointer((*unsafe.Pointer)(unsafe.Pointer(&current)), unsafe.Pointer(&rc))

然而,这些模式在实际代码中的频率是一个问题。

ecfdbz9o

ecfdbz9o7#

第一个例子似乎是 vet 应该捕获的问题。忘记 API 实际上是 func StorePointer(dst **T, t *T) 或拼写错误的难以使用的 (*unsafe.Pointer)(...) 表达式很容易。我每次使用 API 时基本上都会犯这个错误。一个 vet 检查可以捕获问题的根本原因。
我认为 vet 可能可以捕获第二种情况。如果 UserInfoFromARPC 是分配在 Go 堆上的(如 C.mallocsyscall.Mmap ),则会误报,但可以通过使用 //go:notinheap 解决这个问题。但我不知道。
如果 vet 可以捕获这些,那么就不需要运行时检查了。
我明白这一点,vet 将是一种更不侵入的方式。实际上,我正在尝试在 golangci-lint 下开发一个插件,以便如果有人遇到这个问题,他们可以使用它进行调试。(我认为这些代码不太常见,我应该在 go vet 而不是 golangci-lint 中添加它吗?
然而,我真正关心的问题是,我给出的两个示例只是来自我的个人知识,我不知道还有其他什么情况可能会触发这个问题。
我认为你今天实际上可以通过使用 GODEBUG=cgocheck=2 获得缓冲区大小为 1 的效果。尽管这并不直观。
它们实际上有点不同,我认为你是说当 cgocheck=2cgoCheckWriteBarrier 被调用时。cgoCheckWriteBarrier 似乎不会检查它,dstsrc 都是 Go 指针,因为这个检查取决于这个指针是否小于 span limit,而它们很可能“在 span”内。(这些僵尸指针比 span 小,但它们比 freeindex 大,它们的 allocBits 没有设置。它们可能在 span 和僵尸之间。)
Go 没有宏,所以我不清楚你具体建议什么。
gcWriteBarrier 运行得非常频繁,所以我同意我们不应该为检查指针是否有效添加运行时检查。
也许我们可以用构建标签来实现这一点,但我有种感觉,很少有人会在实践中需要这样的功能。
CC @golang/runtime
我确实发现 go 很少使用宏,然而 gcWriteBarrier 是汇编的,我想我可能可以引入一个类似于 GOAMD64_v2/3/4 的宏,并由标志控制?(我没有阅读相关代码,这只是我的猜测😂

eoigrqb6

eoigrqb68#

另外一件事,我认为可能应该指出的是 invalidptr 不能完全禁用僵尸指针检查。只有当 findObject 收集到这个指针时,invalidptr 才会受到影响。如果这个指针被 gcWriteBarrier 标记,那么在 reportZombies 中只会发生恐慌,而这并不受 invalidptr 的控制。
我不太确定,但是让 reportZombiesinvalidptr 控制是否可以?

相关问题