arenaの端から端まで歩いてみる
先日 Go1.20 で追加される機能について紹介する記事を公開した際に、実験的にサポート される arena package が特に話題に上がっていました。arena package は GC の影響を受けずにメモリを確保できて CPU リソースの節約に繋がり、パフォーマンス向上が期待できる新機能として注目されています。
この記事では、その arena package を実際にどのように使うか、実際にどれだけパフォーマンスが上がるのか、仕組みはどうなっているのか、使う上でどのような点に注意する必要があるかについてお話しします。
arena package とは
arena package はユーザー自身がメモリ領域を確保・管理・解放する手段を提供するパッケージです。確保したメモリを解放するタイミングを制御できたり、連続したメモリ領域を確保することで空間的局所性を活かすことができるので、CPU やメモリなどのリソースを戦略立てて利用できるようになります。
なお、C 言語で動的にメモリを確保する関数 malloc
に似ていますが、少なくとも確保したメモリを集中管理・集中解放する点で違います。
arena package の使い方
arena を使う上で起点となるのは、メモリ領域を確保し管理するインスタンスの生成です(このインスタンスを arena.Arena
とします)。arena.Arena
は、関数 arena.NewArena()
によって生成します。
a := arena.NewArena()
arena.Arena
を使ってメモリを確保するには、関数 arena.New
を使用します。arena.New
は引数に arena.Arena
を指定し、type argument にメモリを確保したい値の型 T
を指定します。確保した値にアクセスするには、arena.New
の戻り値を使用します。なお戻り値の型は *T
なので、戻り値を代入した変数を上書きしたい場合は、通常の pointer 型の変数と同様に *
オペレータで dereference します。
例えば、int 型の値を確保したい場合は、次のように書くことができます。
i := arena.New[int](a)
fmt.Println(*i)
// => 0
*i = 1
fmt.Println(*i)
// => 1
また、slice としてまとめてメモリを確保したい場合は、関数 arena.MakeSlice
を使用します。この関数の第1引数には arena.Arena
、第2引数には slice の len、第3引数には slice の cap を指定し、type argument には slice の要素の型を指定します。
s := arena.MakeSlice[int](a, 10, 10)
fmt.Println(s)
// => [0 0 0 0 0 0 0 0 0 0]
s[1] = 1
fmt.Println(s)
// => [0 1 0 0 0 0 0 0 0 0]
arena.Arena
で確保したメモリを解放するには、arena.Arena
のメソッド Free
を使います。なお、確保したメモリを個別に解放する手段は提供されておらず、全て一気に解放するしかありません。
a.Free()
次に arena package を使用したコードをビルドする方法について説明します。
arena package は実験的にサポートされていることもあり、build tag によって通常のビルドに影響を与えないようになっています。
そこで、arena package を使用したコードをビルドする際は、arena package を認識させるために、環境変数 GOEXPERIMENT=arenas
を指定してビルドを行います。
$ GOEXPERIMENT=arenas go build main.go
パフォーマンスの比較
公式ではベンチマークのコードがなかったため、構造体 S
のポインタを100,...,500MiB分 slice で確保した変数に対して GC している時間のみを計測するマイクロベンチを、arena.Arena
を使用しなかった場合(WithoutArena)と使用した場合(WithArena)とで実装・比較しました。
package main
import (
"arena"
"fmt"
"runtime"
"testing"
)
type S struct {
byte
}
func BenchmarkLargeSliceWithoutArena(b *testing.B) {
for i := 1; i <= 5; i++ {
b.Run(fmt.Sprintf("%d00MiB", i), func(b *testing.B) {
// 2^17 * i * 100 * 8 bytes = i * 100 MiB
l := 1 << 17 * i * 100
for j := 0; j < b.N; j++ {
b.StopTimer()
func() {
s := make([]*S, l, l)
b.StartTimer()
runtime.GC()
b.StopTimer()
b.ReportAllocs()
runtime.KeepAlive(s)
}()
runtime.GC()
}
})
}
}
func BenchmarkLargeSliceWithArena(b *testing.B) {
for i := 1; i <= 5; i++ {
b.Run(fmt.Sprintf("%d00MiB", i), func(b *testing.B) {
// 2^17 * i * 100 * 8 bytes = i * 100 MiB
l := 1 << 17 * i * 100
for j := 0; j < b.N; j++ {
b.StopTimer()
func() {
a := arena.NewArena()
s := arena.MakeSlice[*S](a, l, l)
b.StartTimer()
runtime.GC()
b.StopTimer()
b.ReportAllocs()
runtime.KeepAlive(s)
a.Free()
}()
runtime.GC()
}
})
}
}
上記のコードを環境変数 GOEXPERIMENT=arenas GOMAXPROCS=1
をつけて実行した結果、次の通りになりました。縦軸が確保したメモリサイズ(MiB)、横軸は GC に要した時間(ms)です。
マイクロベンチマークだからかもしれませんが、arena.Arena
を使用しなかった場合とした場合とでそこまで実行時間に差はありませんでした。実際的なコードで試してみると結果が変わってくるかもしれないので、ぜひご自身のコードで試してみてください!
ちなみにGoogle 社内の様々なアプリケーションで試したところ、15%ほどCPUとメモリ使用量が削減できたという報告もありました。
ここまで arena package の主要な関数やメソッドについて説明し、パフォーマンスがどう変化するかについて見てきました。
以降では arena package の内部実装に足を踏み入れていきます。arena package の実用的な部分にのみ興味のある方はここまでの理解で大丈夫だと思うので、それ以上に興味のある方は、ぜひ続きもご覧ください。
arena package の関数やメソッドの実体
arena package の関数やメソッド内部では、runtime_arena_xxx
という arena package で宣言されている unexported function が呼ばれているのですが、function body 自体は runtime package で実装され、//go:linkname
によってリンクされています。
具体的には次の対応関係になっています。
arena package | runtime package |
---|---|
NewArena | arena_newArena |
Arena.Free | runtime_arena_arena_Free |
New | arena_arena_New |
MakeSlice | arena_arena_Slice |
Clone | arena_heapify |
arena.Arena の実体(メモリ確保の方法)
arena.Arena
の struct の定義自体は
type Arena struct {
a unsafe.Pointer
}
と、unsafe.Pointer
に隠蔽されて至ってシンプルなのですが、arena package もとい runtime package を読み進めていくと、実は arena.Arena
(のフィールド a
)の正体は runtime.userArena
であり、以下のように定義されています(読みやすさのためコメントは省略しています)。
type userArena struct {
fullList *mspan
active *mspan
refs []unsafe.Pointer
defunct atomic.Bool
}
arena.Arena
で確保されるメモリ領域の最小単位はチャンクと呼ばれ、1つのチャンクは Go の runtime package で定義されている構造体 mspan
で表現されます。
メモリを確保する先はフィールド active
です。
この active
に新しく確保するメモリを追加した際にチャンクの容量が一杯になった場合、新しいチャンクが自動的に生成されます。容量が一杯になった active
は容量が一杯になっているチャンクのリスト fullList
の先頭に追加され、新しく生成されたチャンクが active
になります。
なお runtime package 内で使用可能な変数 userArenaState
が再利用可能なチャンクを保持しているので、新しくチャンクを生成しようとする際に再利用可能なチャンクがあれば、生成する代わりに userArenaState
のチャンクを割り当てます。
フィールド refs
は、arena.Arena
が管理する全てのチャンクを表す slice で、active
と fullList
のリスト全てのチャンクの先頭へのポインタが含まれています。
フィールド defunct
は arena.Arena
で確保したメモリを解放した後に再度解放しようとしたときに解放せずに panic させるための変数です。
Free したときの内部処理
arena.Arena
で確保したメモリを解放する処理では、まず実体である runtime.userArenaState
のフィールドに対して次の処理を行います。
-
defunct
をtrue
にして二重にメモリ解放しないようにする。 -
fullList
の先頭から末尾までのチャンクを順番に解放する[1]。 -
active
にチャンクがある場合は、そのチャンクをuserArenaState
のフィールドreuse
に格納し、再利用できるようにする。 -
active
とrefs
をnil
にする。
以上の処理後、arena.Arena
の a
も nil
に設定し、arena.Arena
によってメモリを確保できなくします。
Free せずにプログラムを終了するとどうなるの?
arena.Arena
を生成する関数 arena.NewArena
を arena.NewArena -> runtime.arena_newArena -> runtime.newUserArena
と読み進めていくと、関数 runtime.SetFinalizer
が呼ばれていることに気づくと思います。この関数は第1引数に渡された変数が GC される際に第2引数に渡した関数によって何か処理をしたい時に使用する関数で、runtime.newUserArena
においては第2引数の関数内で arena.Arena
のメソッド free
を呼んでいます。この free
は arena.Arena
のメソッド Free
内部でも呼ばれているメソッドで、確保するメモリを解放するメソッドでした。
つまり、arena.Arena
がどこからも参照されなくなり GC される際に自動的にメモリが解放されます。
とはいえ、defer
などで忘れずに Free
を実行してメモリ解放のタイミングを制御するのが賢明かと思います。
使わなくなった部分のメモリを解放するには
arena.Arena
には確保したメモリを部分的に解放する機能はなく、arena.Arena
の Free
で確保したメモリを全て一気に解放するしかありません。
GC によって解放されない仕組み
本記事ではメモリアロケーションや GC の細かい説明は省略しますが、簡単に説明すると、Go における GC は、「グローバル領域(Data領域/BSS領域)」「スタック」「mheap arena」といったルートから到達可能な mspan を全てマークし、マークされなかった mspan をスイープします。arena.Arena
によってメモリ確保されるチャンクは mheap arena 配下で管理 されます。ちなみに mheap arena は arena package とは別物で、Go の heap の文脈における arena package は区別のため user arena と呼ばれています。
このスイープ処理を行う際に、mspan が user arena のチャンクである場合、mspan が持つフィールド isUserArenaChunk
が true
になっていて、スイープされる前に 早期リターンするため、GC によって解放されません。
なお Go GC をソースコードレベルで詳しく追った記事がテンセントの方によって書かれているので、興味のある方は翻訳サイトを駆使して読んでみてください。バージョンがちょっと古めですが、概要は把握できるかなと思います。
arena.Arena によって確保されるメモリの最低サイズ
arena.Arena
によって確保されるメモリ領域の最小単位はチャンクでしたが、このサイズはいくつでしょうか?その答えは、チャンクを新しく生成する関数 runtime.newUserArenaChunk
にあります。
runtime.newUserArenaChunk
内はチャンクを生成するだけでなく、デバッグ情報を埋め込んだり、スケジューラを制御したり若干込み入っていますが、新しく容量を割り当てているのは、ヒープからチャンク用の容量を確保している mheap_.allocUserArenaChunk
を呼んでいる箇所になります。
さらにこの関数を読み進めていくと、v, size := h.sysAlloc(userArenaChunkBytes, hintList, false)
という statement があり、userArenaChunkBytes
がチャンクのサイズであると分かります(実際には最低 userArenaChunkBytes
分、容量を割り当てるので userArenaChunkBytes
より大きくなる可能性はある)。
userArenaChunkBytes
は定数で、以下の通り定義されています。
const (
userArenaChunkBytesMax = 8 << 20
logHeapArenaBytes = (6+20)*(_64bit*(1-goos.IsWindows)*(1-goarch.IsWasm)*(1-goos.IsIos*goarch.IsArm64)) + (2+20)*(_64bit*goos.IsWindows) + (2+20)*(1-_64bit) + (2+20)*goarch.IsWasm + (2+20)*goos.IsIos*goarch.IsArm64
heapArenaBytes int = 1 << logHeapArenaBytes
userArenaChunkBytes = uintptr(int64(userArenaChunkBytesMax-heapArenaBytes)&(int64(userArenaChunkBytesMax-heapArenaBytes)>>63) + heapArenaBytes) // min(userArenaChunkBytesMax, heapArenaBytes)
)
ビット演算が複雑ですが丁寧に読み込むと、darwin/amd64
や linux/amd64
環境では、userArenaChunkBytesMax = 8 << 20
、heapArenaBytes = 1 << 26
となるので、これらの小さい方を取り、userArenaChunkBytes = 8 << 20
つまり 8MiB となります。
注意点
少量のメモリを確保するのには向いていない
上述の通り、arena.Arena
によって確保されるメモリの最低サイズ 8MiB と結構でかいので、少量のメモリを確保する場合には向いていません。
goroutine safe ではない
runtime package の構造体 userArena
のメソッド定義を読むと、
This operation is not safe to call concurrently with other operations on the same arena.
とあるように、arena.Arena
への操作は goroutien safe ではありません。
メモリを2回以上解放しようとすると panic が起きる
arena.Arena
で確保したメモリを解放した後に再度解放しようとすると panic することはすでに述べましたが、同期的に解放する場合と非同期的に解放する場合で panic する理由が異なってきます。
同期的に解放する場合、関数 Free
でメモリを解放する際に arena.Arena
のフィールド a
を nil
にする のですが、2回目に Free
もといメソッド userArena.free
を呼ぶと、すでに解放済みかどうかをチェックする条件文 if a.defunct.Load()
にて、nil
のフィールド defunct
を参照しようとして panic: runtime error: invalid memory address or nil pointer dereference
となります。
一方、非同期的に解放する場合、userArena.free
を実行して defunct
が true
になった直後からメソッドが終了するまでの間に別 goroutine で userArena.free
が呼ばれた時に、defunct
がすでに true
になっているので、 panic("arena double free")
が明示的に呼ばれます。ただし、1回目の userArena.free
で defunct
が true
になる前に2回目の userArena.free
が実行されると defunct
は false
のままなので、panic にならずそのまま実行されてしまう点に注意が必要です。
Free をした後に書き換えてはいけない
簡単な実験をして、Free 後に arena.Arena
によってメモリを確保した変数を書き換えてはいけないことを示します。
まずは arena.Arena
を使用して string 型の値をメモリに確保し "hello"
を書き込みます。
a := arena.NewArena()
s := arena.New[string](a)
*s = "hello"
fmt.Println(*s)
その直後に Free
を実行し、次は "world"
を書き込むとどうなるでしょうか?
a.Free()
*s = "world"
fmt.Println(*s)
手元の darwin/amd64
の環境では問題なく書き込むことができ、fmt.Println
によって "world"
を出力することができました。
linux/amd64
の環境でも同じく問題なく "world"
を書き込むことができたので、問題がありそうだと思いつつも正当な操作のように思えます。
実際に問題がないのか、Go1.18 で導入された Address Sanitizer の機能を使って確かめてみます。
Address Sanitizer はメモリに対する不正な操作を実行時に検知する機能で、Go では現時点では linux/amd64
か linux/arm64
環境でのみ使用可能です。
Address Sanitizer を使用するのは簡単で、フラグ -asan
をつけてビルドするだけです。
さっそくこのフラグをつけてビルドし実行したところ、見事 ERROR: AddressSanitizer: use-after-poison
という、メモリ解放して poison 化されたメモリ領域を使用している旨のエラーが検出できました。
この書き込みによりどのような不具合が起きるかは今のところ把握していませんが、意図せず panic が起きたり、メモリリークしたり、別の変数の値を上書きしたりする可能性が考えられるので、arena を使用する場合は Linux 環境で Address Sanitizer を使用して一度動作させてみると良いかもしれません。
Free をした後に書き換えたい場合は?
Free
後も arena.Arena
でメモリを確保した変数を使用したい場合は、メソッド arena.Clone
を使い、arena.Arena
が確保するメモリ領域外でも確保した変数の値を扱えるように別変数にクローンします。
t := arena.Clone(s)
a.Free()
*t = "world" // これはOK
なお、Clone
は shallow copy しか行わないことに注意が必要です。
-
現在の arena の実装では、解放したメモリ領域に対して runtime package の関数
sysFault
を呼び、ランタイム中に再び使用できないようにしている。なお、ランタイムが認識する使用中のメモリ容量は解放したものとみなしている。
https://github.com/golang/go/blob/go1.20rc1/src/runtime/arena.go#L779-L782 ↩︎
Discussion