LoginSignup
26
6

More than 3 years have passed since last update.

GCに思いを馳せてunsafeなコードを書く

Last updated at Posted at 2019-12-07

これはGo Advent Calendar 8日目の記事です.

こんにちはtaxioです.今はヒーヒー言いながら修論書いてます.
今回はunsafe.Pointerを使ってunsafeなことをしてみます.

unsafe.Pointerとは

GoDocより,

Pointer represents a pointer to an arbitrary type. There are four special operations available for type Pointer that are not available for other types:
- A pointer value of any type can be converted to a Pointer.
- A Pointer can be converted to a pointer value of any type.
- A uintptr can be converted to a Pointer.
- A Pointer can be converted to a uintptr.

Goでは変数のアドレスは&を付けることで取得でき,ポインタ変数は型に*を付けることで宣言できます.
unsafe.Pointerは任意の型のポインタ変数として振る舞い,相互に変換が可能です.また,プリミティブ型の1つであるuintptrとの相互変換も可能で,これを使ってポインタ演算が可能になります.
如何にもunsafeな匂いがプンプンしてきますね.

しかし,GoDocに書いてある通りに気をつけて書けば,チューニングに非常に役に立つパッケージです1
今回はそんなunsafeパッケージを使ってパフォーマンスチューニングする......のではなく,実際にunsafeなコードを書いてみようと思います.

How?

GoDocを読み進めていけばネタバレが書いてあるのですが,今回は一切読まずに仮説を建てて検証していきます2
というのも,先日Go Conference 2019 AutumnにてGoのGCについて調べて発表したのですが,これを調べている時に,「そういえばunsafe.Pointerというものがあったな.これをゴニョゴニョしていい感じに参照を切れば解放済みのオブジェクトを操作するunsafeなコードが書けるのでは?」という疑問が出てきました.この記事はそれがモチベーションとなって書かれています.

GoのGCアルゴリズムについては登壇資料Goのソースコードを御覧ください.

GoはPrecise GC(正確GC)であるため3,ポインタを判定するためのセマンティクスが存在するはずです.
なのでunsafe.Pointerが保持するアドレスをintに変換すれば,その変数に入っているのは最早ただの値であるため,このセマンティクスからは外れるはず...です4
その状態で元々のオブジェクトやポインタを保持する他の変数の参照をRootから排除しGCを実行,int型に入っている値としてのアドレスunsafe.Pointerに変換し直して元々のオブジェクトに更に変換し直した場合どうなるのでしょうか?

ちゃんとpanicしてくれるでしょうか?それとも変に動作し続けるのでしょうか?そもそもそんなことが可能なのでしょうか?
やってみた!!

unsafeなコードを書いてみる

雑に書いてみた.

type X struct {
    A int
    B string
}

func newX() *X {
    x := &X{A: 123, B: "abc"}
    fmt.Printf("before: %v\n", x)
    return x
}

func newXAsIntPtr() int {
    x := newX()
    xp := unsafe.Pointer(x)
    xup := uintptr(xp)
    return int(xup)
}

func main() {
    xip := newXAsIntPtr()
    runtime.GC()
    xp := unsafe.Pointer(uintptr(xip))
    x := (*X)(xp)
    fmt.Printf("after: %v\n", x)
}

結果

❯ go run main.go
before: &{123 abc}
after: &{123 abc}

なんでや.

考察

雑に書きすぎました.何が起きたか考えてみます.

そもそもGCは並行実行されている.x := (*X)(xp)のAllocationが間に合ってしまっている?
-> それはない.Mark setupフェーズ中はSTW状態で,Root Rescanも行われないのでruntime.GC()が呼ばれた時点でRootからの参照ツリーに存在しないオブジェクトは死んでいるはずである.

スタック領域に確保されているのでは?
-> ありそう.確認してみよう.

❯ go build -gcflags="-m" main.go 
# command-line-arguments
./main.go:21:12: inlining call to fmt.Printf
./main.go:26:6: can inline newXAsIntPtr
./main.go:42:21: inlining call to newXAsIntPtr
./main.go:46:12: inlining call to fmt.Printf
./main.go:20:21: &X literal escapes to heap
./main.go:21:13: x escapes to heap
./main.go:21:12: newX []interface {} literal does not escape
./main.go:21:12: io.Writer(os.Stdout) escapes to heap
./main.go:46:13: x escapes to heap
./main.go:46:12: main []interface {} literal does not escape
./main.go:46:12: io.Writer(os.Stdout) escapes to heap
<autogenerated>:1: (*File).close .this does not escape

ちゃんとヒープ領域に確保されている.ん?待て,インライン展開されてるぞ.もしかしてこれか?

❯ go build -gcflags="-m -l" main.go
# command-line-arguments
./main.go:20:21: &X literal escapes to heap
./main.go:21:12: newX ... argument does not escape
./main.go:21:13: x escapes to heap
./main.go:46:12: main ... argument does not escape
./main.go:46:13: x escapes to heap

❯ ./main 
before: &{123 abc}
after: &{123 abc}

残念.

そもそもオブジェクトはGCで解放されているのか?
-> Finalizer書いてみよう.

func finalizer(x *X) {
    fmt.Printf("finalize for -> %v\n", x)
    return
}

func newX() *X {
    x := &X{A: 123, B: "abc"}
    fmt.Printf("before: %v\n", x)
    runtime.SetFinalizer(x, finalizer)
    return x
}
❯ ./main                           
before: &{123 abc}
finalize for -> &{123 abc}
after: &{123 abc}

されている!!

段々分かってきました.
GoはTCMallocベースなAllocation機構を使っているので,32KB以下のサイズのオブジェクトに関しては,そのサイズごとに事前にページが作られています56.つまり解放時に前後のオブジェクトを見て連結させたりする必要がなく,フィールドに対して何かしらの初期化もされていないのです(多分).
ということは,GC後,*Xに再変換する前に同じサイズのオブジェクトを大量に作ってしまえばどこかしらで上書きが発生するはず!

再挑戦

*Xをsliceに大量にappendしてみる.

func main() {
    xip := newXAsIntPtr()
    runtime.GC()

    var xs []*X
    for i := 0; i < 100000000; i++ {
        xs = append(xs, &X{A: i, B: "dummy"})
    }

    xp := unsafe.Pointer(uintptr(xip))
    x := (*X)(xp)
    fmt.Printf("after: %v\n", x)
}

結果

❯ ./main                           
before: &{123 abc}
finalize for -> &{123 abc}
after: &{61947 dummy}

ほら!!上書きされてる!!!!unsafeな挙動だ!!!!

GoDocを読んでみる

具体的にunsafe.Pointerのunsafeな使い方をしてみたところで,GoDocをちゃんと読んでみます.

A uintptr is an integer, not a reference. Converting a Pointer to a uintptr creates an integer value with no pointer semantics. Even if a uintptr holds the address of some object, the garbage collector will not update that uintptr's value if the object moves, nor will that uintptr keep the object from being reclaimed.

なるほど,わざわざintに変換しなくても,uintptrに変換した時点でポインタのセマンティクスから外れるわけですね.これは気をつけないと踏み抜きそう.

他にも6つのunsafe.Pointer使用パターンとその注意が書かれていました.基本的には上記のuintptrの扱いに関連するものです.

Running "go vet" can help find uses of Pointer that do not conform to these patterns, but silence from "go vet" is not a guarantee that the code is valid.

ちなみに今回のはgo vetでちゃんとWarning出してくれます.

❯ go vet main.go 
# command-line-arguments
./main.go:42:8: possible misuse of unsafe.Pointer

まとめ

  • unsafe.Pointerからuintptrに変換した時点で参照のセマンティクスから外れるので,間違ってGCに回収されないように注意する必要がある
  • 見かけ上動いていても,期待しているオブジェクトを指していない可能性がある
  • go vetを無視しない.しかし完全に信用もしない.

意図せずunsafeなコードを書いてしまい,来年のやらかしアドカレに載らないように気をつけましょう.

最終的なコード
package main

import (
    "fmt"
    "runtime"
    "unsafe"
)

type X struct {
    A int
    B string
}

func finalizer(x *X) {
    fmt.Printf("finalize for -> %v\n", x)
    return
}

func newX() *X {
    x := &X{A: 123, B: "abc"}
    fmt.Printf("before: %v\n", x)
    runtime.SetFinalizer(x, finalizer)
    return x
}

func newXAsIntPtr() int {
    x := newX()
    xp := unsafe.Pointer(x)
    xup := uintptr(xp)
    return int(xup)
}

func main() {
    xip := newXAsIntPtr()
    runtime.GC()

    var xs []*X
    for i := 0; i < 100000000; i++ {
        xs = append(xs, &X{A: i, B: "dummy"})
    }

    xp := unsafe.Pointer(uintptr(xip))
    x := (*X)(xp)
    fmt.Printf("after: %v\n", x)
}

Go Playground

最後に

完全に理解しているわけではないので,間違っている場合はコメントお願いします.
また,まだまだ検証できそうな派生問題がいくつもあるので,そのうちGoのソースコードとか追いながら試してみたいです7

ちなみに修論とGCは1㍉も関係ありません.


  1. 経験が無いのでUse Caseは思いつきませんが 

  2. 実際はちゃんと読まずに実験して,後から「書いてあるやんけー」となった感じです 

  3. https://github.com/golang/go/blob/9341fe073e/src/runtime/mgc.go#L7 

  4. ネタバレ: uintptrに変換した時点で外れます.後からGoDoc読んで知りました. 

  5. https://ascii.jp/elem/000/001/496/1496211/ 

  6. https://github.com/golang/go/blob/9341fe073e/src/runtime/malloc.go#L10-L15 

  7. 修論終わったら 

26
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
26
6