5

一例误用unsafe包引起的内存问题

 1 year ago
source link: https://blog.gotocoding.com/archives/1781?amp%3Butm_medium=rss&%3Butm_campaign=%25e4%25b8%2580%25e4%25be%258b%25e8%25af%25af%25e7%2594%25a8unsafe%25e5%258c%2585%25e5%25bc%2595%25e8%25b5%25b7%25e7%259a%2584%25e5%2586%2585%25e5%25ad%2598%25e9%2597%25ae%25e9%25a2%2598
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

一例误用unsafe包引起的内存问题

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。

作者 重归混沌发布于 2022年11月26日2022年11月26日分类 Bug小记标签 Go

发表回复 取消回复

您的电子邮箱地址不会被公开。 必填项已用*标注

评论 *

显示名称 *

电子邮箱地址 *

网站地址

Math Captcha
fifty six − = 48


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK