🎤

arenaの端から端まで歩いてみる

2023/01/12に公開

先日 Go1.20 で追加される機能について紹介する記事を公開した際に、実験的にサポート される arena package が特に話題に上がっていました。arena package は GC の影響を受けずにメモリを確保できて CPU リソースの節約に繋がり、パフォーマンス向上が期待できる新機能として注目されています。

https://zenn.dev/koya_iwamura/articles/bb9b590b57d825

この記事では、その 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とメモリ使用量が削減できたという報告もありました。
https://github.com/golang/go/issues/51317#issue-1147100583


ここまで 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 で、activefullList のリスト全てのチャンクの先頭へのポインタが含まれています。

フィールド defunctarena.Arena で確保したメモリを解放した後に再度解放しようとしたときに解放せずに panic させるための変数です。

Free したときの内部処理

arena.Arena で確保したメモリを解放する処理では、まず実体である runtime.userArenaState のフィールドに対して次の処理を行います。

  1. defuncttrue にして二重にメモリ解放しないようにする。
  2. fullList の先頭から末尾までのチャンクを順番に解放する[1]
  3. active にチャンクがある場合は、そのチャンクを userArenaState のフィールド reuse に格納し、再利用できるようにする。
  4. activerefsnil にする。

以上の処理後、arena.Arenaanil に設定し、arena.Arena によってメモリを確保できなくします。

Free せずにプログラムを終了するとどうなるの?

arena.Arena を生成する関数 arena.NewArenaarena.NewArena -> runtime.arena_newArena -> runtime.newUserArena と読み進めていくと、関数 runtime.SetFinalizer が呼ばれていることに気づくと思います。この関数は第1引数に渡された変数が GC される際に第2引数に渡した関数によって何か処理をしたい時に使用する関数で、runtime.newUserArena においては第2引数の関数内で arena.Arena のメソッド free を呼んでいます。この freearena.Arena のメソッド Free 内部でも呼ばれているメソッドで、確保するメモリを解放するメソッドでした。

つまり、arena.Arena がどこからも参照されなくなり GC される際に自動的にメモリが解放されます。

とはいえ、defer などで忘れずに Free を実行してメモリ解放のタイミングを制御するのが賢明かと思います。

使わなくなった部分のメモリを解放するには

arena.Arena には確保したメモリを部分的に解放する機能はなく、arena.ArenaFree で確保したメモリを全て一気に解放するしかありません。

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 が持つフィールド isUserArenaChunktrue になっていて、スイープされる前に 早期リターンするため、GC によって解放されません。

なお Go GC をソースコードレベルで詳しく追った記事がテンセントの方によって書かれているので、興味のある方は翻訳サイトを駆使して読んでみてください。バージョンがちょっと古めですが、概要は把握できるかなと思います。
https://zhuanlan.zhihu.com/p/359582221

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/amd64linux/amd64 環境では、userArenaChunkBytesMax = 8 << 20heapArenaBytes = 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 のフィールド anil にする のですが、2回目に Free もといメソッド userArena.free を呼ぶと、すでに解放済みかどうかをチェックする条件文 if a.defunct.Load() にて、nil のフィールド defunct を参照しようとして panic: runtime error: invalid memory address or nil pointer dereference となります。

一方、非同期的に解放する場合、userArena.free を実行して defuncttrue になった直後からメソッドが終了するまでの間に別 goroutine で userArena.free が呼ばれた時に、defunct がすでに true になっているので、 panic("arena double free") が明示的に呼ばれます。ただし、1回目の userArena.freedefuncttrue になる前に2回目の userArena.free が実行されると defunctfalse のままなので、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/amd64linux/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 しか行わないことに注意が必要です。

脚注
  1. 現在の arena の実装では、解放したメモリ領域に対して runtime package の関数 sysFault を呼び、ランタイム中に再び使用できないようにしている。なお、ランタイムが認識する使用中のメモリ容量は解放したものとみなしている。
    https://github.com/golang/go/blob/go1.20rc1/src/runtime/arena.go#L779-L782 ↩︎

Discussion