Go语言的指针真的很灵活,远非其他带GC的语言可以比拟的。
比如下面这段代码,并不会产生GC问题。
而在其他带GC的语言中,是不太可能写出类似这种代码的。
var b *int
{
a := make([]int, 3)
b = &a[1]
}
fmt.Println(*b)
在写Go代码时,常常会让人有种在写C语言的错觉,他的指针几乎实现了90%的功能,剩下没有实现的10%则是与GC有关。
而unsafe包则可以让我们做到剩下的10%, 但是内存安全性需要我们自己保证。
比如下面代码就是完全正确的,只要c还活着,a就不可能被回收。
var b unsafe.Pointer
{
a := make([]int, 3)
a[1] = 0x01020304
b = unsafe.Pointer(&a[1])
}
c := (*byte)(b)
fmt.Printf("%04x\n", *c)
在一些性能敏感的地方,比如在一场回合制战斗或者一个复杂对象的反序列化中,内存分配可能会吃掉相当一部分算力。
这些场景都有一个共同的特别,有一大批小对象同生共死。如果能够将这些小对象分配进行批量化,就可以显著提高性能。
比如下面代码,性能会有将近300%的差别。
package main
import (
"os"
"unsafe"
)
type Foo struct {
a uint64
}
var pool []uint64
//go:noinline
func Alloc(direct bool) *Foo {
if direct {
return &Foo{}
} else {
var f *Foo
size := unsafe.Sizeof(*f)
need := (size + 7) / 8
if len(pool) < int(need) {
pool = make([]uint64, 512)
}
p := unsafe.Pointer(&pool[0])
pool = pool[need:]
return (*Foo)(p)
}
}
func main() {
direct := len(os.Args) == 1
for i := 0; i < 64*1024*1024; i++ {
Alloc(direct)
}
}
/*
$ time ./a
./a 0.77s user 0.01s system 110% cpu 0.710 total
$ time ./a x
./a x 0.26s user 0.06s system 129% cpu 0.250 total
*/
不用怀疑,上面的代码一定是对的。
因为所有使用Alloc分配出来的指针一定是8字节对齐的,而所有的Foo指针也必将引用pool对象的内存,使他不被回收。
然而,有朝一日,代码被迭代成了下面的样子,GC就会开始紊乱了。
package main
import (
"os"
"unsafe"
)
type Bar struct {
b uint64
}
type Foo struct {
a uint64
b *Bar
}
var pool []uint64
//go:noinline
func Alloc(direct bool) *Foo {
if direct {
return &Foo{}
} else {
var f *Foo
size := unsafe.Sizeof(*f)
need := (size + 7) / 8
if len(pool) < int(need) {
pool = make([]uint64, 512)
}
p := unsafe.Pointer(&pool[0])
pool = pool[need:]
return (*Foo)(p)
}
}
func main() {
direct := len(os.Args) == 1
for i := 0; i < 64*1024*1024; i++ {
f := Alloc(direct)
f.b = &Bar{}
//do something with f
}
}
让我们来写段代码测试一下:
package main
import (
"fmt"
"runtime"
"time"
"unsafe"
)
type Bar struct {
b [64]int
}
type Foo struct {
a uint64
b *Bar
}
var pool []uint64
//go:noinline
func Alloc(direct bool) *Foo {
if direct {
return &Foo{}
} else {
var f *Foo
size := unsafe.Sizeof(*f)
need := (size + 7) / 8
if len(pool) < int(need) {
pool = make([]uint64, 512)
}
p := unsafe.Pointer(&pool[0])
pool = pool[need:]
return (*Foo)(p)
}
}
func fin(r *Bar) {
fmt.Printf("fin %p\n", r)
}
func main() {
f := Alloc(false)
f.b = &Bar{}
runtime.SetFinalizer(f.b, fin)
fmt.Printf("%p %d\n", f.b, unsafe.Sizeof(*f.b))
time.Sleep(time.Second)
runtime.GC()
time.Sleep(time.Second)
fmt.Printf("%p %d\n", f.b, f.b.b[0])
}
在第50行代码,我特意加上了fmt.Print来引用f对象,甚至引用了f.b对象。
但是我们可以从log看出,在48行GC时,f.b指向的内存已经被回收了。
之所以会出现这种情况,本质上和Go语言的GC机制有关系。
在上一篇文章中,我找到了一些资料来解释为什么Go语言的指针可以有这么大的自由度。
现在这篇文章同样能解释这次的Bug。
本质上在Go语言代码层面,所有的指针和unsafe.Pointer的功能并无两样,仅用于指明这个变量是指针,在进行GC Mark时,需要Mark这个变量所指向的内存块。
至于这块内存中指针变量的位置,是在new这块内存时,Go编译器会根据这块内存的类型来标记的。
我们上面的Alloc池,在分配内存时给GC的信息是,我们要分配一个512大小的uint64类型的slice。
编译器生成代码时就会标明,这块内存中没有指针成员,在GC Mark时不需要继续Mark子元素。
虽然我们将其中的某块内存使用unsafe包强转成Foo对象的指针,但这也仅能保证pool对象内存的安全,并不能保证Foo对象中指针变量指向内存的安全。
GC系统从Foo的指针最终是Mark的是pool对象,当然也就不能Mark到Foo.b所指向的内存。
在这段代码中,还有一个有意思的现象,即使第50行我们引用了f.b对象。但是依然不能阻止GC对他的回收。
但是我们在47行之后,如果插入一行b := f.b就可以阻止f.b对象被回收。
这是因为,虽然Go的最新版GC使用了混合写屏障,但是在一个GC循环中,每个goroutine的栈至少会被扫描一遍的。
GC不在运行中,代码44行的混合写屏障没能开启,所以f.b没能被mark。
GC运行时扫描栈对象时,又识别不出f.b变量是个指针,更不可能Mark内存块f.b。