フューチャー技術ブログ

Go 1.20リリース連載が始まります&メモリアリーナの紹介&落ち穂拾い

フューチャーのテックブログ恒例のGoの新バージョンリリース記念のブログが始まります。この執筆時点でrc3が出ています。かつてこんな順調なことがあったでしょうか?

Date Title Author
1/23(月) メモリアリーナの紹介&落ち穂拾い 澁川喜規
1/24(火) contextパッケージのWithCancelCauseとCause 真野隼記さん
1/25(水) Wrapping multiple errors 川口翔大さん
1/26(木) timeのアップデート 宮永崇史さん
1/27(金) HTTP ResponseController 辻大志郎さん
1/30(月) New ReverseProxy Rewrite hook を動かしながら理解する 棚井龍之介さん
1/31(火) vetのアップデート 今泉智義さん
2/1(水) go build に追加される cover オプション(利用例付き) 藤井亮佑さん

初回は、メモリアリーナの紹介ついでに、他の人が触れない残ったネタも紹介します。

メモリアリーナとは

メモリアリーナについては以下のプロポーザルで提案されたものです。

Goはガベージコレクタを備えた言語ですが、ガベージコレクタは実行時にコストが多少かかります。メモリをスキャンし、他から参照されていないかどうかを探索する必要があるからです。メモリアリーナとして、あらかじめGC対象外のメモリ領域を手動で確保することで、GCがオブジェクトを探索するコストなどが節約できて、15%ほどの性能向上があった、とプロポーザルにはあります。

しかし、メモリの解放を手動で行う必要があったり、本質的に「危険」な機能であるし、プロポーザルで提案されている使い方(protobufのデコードとか)以外はほとんどパフォーマンスに寄与しない可能性もあります。

ドキュメントがない?どこにあるの?

Goは開発版のリリースノートやライブラリリファレンスも公開してくれています。さっそく、arenaパッケージのドキュメントを見てみましょう!

https://pkg.go.dev/std@go1.20rc3

と思ったけどない?インストールすると、確かに$GOROOT/src/arenaフォルダは存在します。フォルダがあるならローカルのgodocで見られそうなのでgodocを入れて見てみます。

$ go install golang.org/x/tools/cmd/godoc@latest
$ godoc

しかし、これでもリファレンスは表示されず、パッケージドキュメントしか表示されません。

スクリーンショット_2023-01-18_0.08.44.png

この機能はオプトインで動くもので、ビルドの時に環境変数が必要だったことを思い出し、これを指定するとようやく見れました!機能はシンプルですね。 ちなみに、これを書く時にまったく違う同名のライブラリを見て、ふむふむと読んでいたのですが、本家の方は検索では出てこないのでみなさまもお気をつけください。

$ GOEXPERIMENT=arenas godoc
スクリーンショット_2023-01-18_0.13.05.png

使い方はシンプルですね。

  • まずは arena.NewArena()*Arenaを作成
  • 使い終わったら Free()メソッドを呼び出す
  • arena.MakeSlice[Type](arena)arena.New[Type](arena)といった関数を使ってアリーナ内部のメモリを利用
  • arena.Clone(obj)を使うと、アリーナが終了しても残るよう、ヒープに値を移動する(浅いコピー)

Goはメソッドのジェネリクスが使えないのですが、そのかわりに、1番目の引数に値を取るジェネリクスなヘルパー関数を用意するという、C言語でオブジェクト指向している時代のような設計をすることで代替するというテクニックが使われていますね。

ベンチマーク

小さいオブジェクトをたくさん確保するユースケースで性能差が出るということで、標準ライブラリのリンクドリストのcontainer/listをちょびっと改造してみました。Elementはポインタが3つとinterface{}を1つ持つ構造体です。interfaceにポインタを入れるとしたら40バイト(インタフェースはポインタ2つ保持するので)の値のメモリの確保にarenaを利用する、というユースケースになります。

既存のコードのメモリ確保部分をいじるだけであれば、そんなに難しくないですね。

+import (
+ "arena"
+)
+
// Element is an element of a linked list.
type Element struct {
// Next and previous pointers in the doubly-linked list of elements.
@@ -48,6 +52,7 @@
type List struct {
root Element // sentinel list element, only &root, root.prev, and root.next are used
len int // current list length excluding (this) sentinel element
+ a *arena.Arena
}

// Init initializes or clears list l.
@@ -59,8 +64,16 @@
}

// New returns an initialized list.
-func New() *List { return new(List).Init() }
+func NewWithArena(a *arena.Arena) *List {
+ r := arena.New[List](a)
+ r.a = a
+ return r.Init()
+}

// Len returns the number of elements of list l.
// The complexity is O(1).
func (l *List) Len() int { return l.len }
@@ -101,7 +114,9 @@

// insertValue is a convenience wrapper for insert(&Element{Value: v}, at).
func (l *List) insertValue(v any, at *Element) *Element {
- return l.insert(&Element{Value: v}, at)
+ e := arena.New[Element](l.a)
+ e.Value = v
+ return l.insert(e, at)
}

ベンチマークのコードは以下の通りです。通常の実装、arena利用、中に入れる要素もarenaを利用の3つでテストしています。一回に入れる要素数を要素数を1万、10万、100万と変えてみています。

package list2

import (
"arena"
"container/list"
"testing"
)

type V struct {
n int
}

func BenchmarkListWithoutArena(b *testing.B) {
for n := 0; n < b.N; n++ {
l := list.New()
for i := 0; i < 10000; i++ {
l.PushBack(&V{i})
}
}
}

func BenchmarkListWithArena(b *testing.B) {
for n := 0; n < b.N; n++ {
a := arena.NewArena()
l := NewWithArena(a)
for i := 0; i < 10000; i++ {
l.PushBack(&V{i}) // valueはarena使わず
}
a.Free()
}
}

func BenchmarkListWithArena2(b *testing.B) {
for n := 0; n < b.N; n++ {
a := arena.NewArena()
l := NewWithArena(a)
for i := 0; i < 10000; i++ {
v := arena.New[V](a) // valueもarena利用
v.n = i
l.PushBack(v)
}
a.Free()
}
}

結果は以下の通りです。1万要素程度だとほとんど差がつきません。10万ぐらいになるとだいぶ差が・・・という感じです。ちなみに、最初に書いたときはFree()を書き忘れてしまいました。そうしたら処理時間が3倍になってました。要注意です。

方式\要素数 10,000 100,000 1,000,000
標準のメモリ戦略 0.30mS 4.58mS 62.07mS
arena利用(Elementのみ) 0.29mS (-4.2%) 2.77mS (-39.5%) 28.00mS (-54.9%)
arena利用(valueも利用) 0.28mS (-8.4%) 2.65mS (-42.1%) 26.80mS (-56.8%)

どこで使えるのか?

パフォーマンスがあがる!素敵!じゃあ明日からガンガン使う!ということにはならなそうなのが今回の機能です。なんといっても、コンパイラのフラグをセットしないと使えません。後述のライフサイクルを考えると、APIの形がアリーナ利用とそうでない場合で変わる可能性があり、公開するライブラリだと、後方互換性を考えると、最低でも1.21がリリースされ、1.20がサポートされている最低バージョンになってから、となるかもしれません。そもそもExperimentalが外れてからその次、の方がいいかもしれません。もちろん、個別のアプリで使うなら自己責任ですぐにでも使えるとは思います。

ユースケースには何があるか?

そもそも、小さいメモリをたくさん使う、というユースケースがどこにあるのか、というところが問題です。一番考えられるのが何かしらの木構造の処理ですね。あとは常駐プロセスでたくさんのオブジェクトを扱うケースです。考えられるのはだいたいこんな感じでしょうか?

  • XMLのパース(Excelのパース)
  • 言語処理系の構文木
  • HTMLのサーバーサイドレンダリング(DOMツリー)
  • RDB自作勢(タプルなどの内部のデータ管理)

一番上が一番有望なユースケースな気がしています。というのも、前職で作ったExcelからのマスターデータ変換はプロファイルを取ると、ほとんどがメモリ確保の処理時間でした。Goのxmlパッケージは処理が遅いというissueも上がっていたりします。最近のGoでは試していないですが、attributeとかをパースのときにそれぞれメモリ確保して格納しているようなところが遅かったと記憶しています。

ライフサイクル管理とAPI設計の考察

もう1つあるのが、Arenaのライフサイクルの戦略をどうするか、です。たとえば、Excelのファイルのパースであれば1ファイルごとにArenaを作る方法もあります。ただし、同時処理数の最大が見えていて、最大メモリ量が見積もれるのであれば、複数のExcelファイルを処理するのに、1つのArenaを共有し、sync.Pool的な再利用の仕組みも作って載せる、というのが一番効率よくなりそうです。

ただし、Arena上のメモリの解放はArena一括で行う必要があります。徐々にメモリが少なくなったからといって、「じゃあGC実行して開けよう」みたいなことはできません。データベースのような仕組みを作るのであれば、自分で確保したメモリの量も見ながら、ときどきArenaにメモリを返す、みたいなメモリ管理の仕組みを自作する必要があるかもしれません。

僕が最初に間違って読んでいたGoogle製の同名のライブラリではcontext.ContextにArenaを登録したり取り出せるAPIがありましたが、これと同じようにcontext.Contextと同じライフサイクルで使う、というのも1つの手かと思います。そうすると、ある程度まとまった処理単位でArenaを共有する、といったことが可能となりますし、アリーナ利用とそうでない場合にAPIを変えずにできます(あまり良いことでもないかもですが)。プロポーザルの議論の中でもこのようなリクエスト単位での解放というのが紹介されていました。

ライブラリのAPIとしては、最低限、Arenaを外から渡せるように、という口の用意すれば、使う側で、これらの戦略を利用者が選べるので良さそうですね。あとは、レスポンスで返すオブジェクトをヒープにするか、Arenaの上に作るかはオプションで指定できる必要もありそうです。たとえば、Excelのパースであれば、XMLのメモリはArena上において、Excelとして処理するライブラリが使い終わったらXMLのメモリを解放してあげて、ブックの値はヒープにおいておくことでユーザーに返す、というのが可能です。最終的に返すブックがArenaだと、それを使う間はArenaの解放はできません。ただし、Excelから値を読み取って作ったドメインオブジェクトが必要なレスポンスであれば、ブックがArenaでも良い(ドメインオブジェクトを作ったら不要になる)となります。誰がどう使うかでどちらにあると良いかが変わってきてしまうので、汎用的なライブラリを作るなら明示的に指定できる必要がありそうです。

ビルド時に環境変数を指定するのですが、環境変数だと条件コンパイルに使えないので、別途ビルドタグでArenaなし版のみをビルドするように、というのも1.21が出るまでは必要そうですね。

Arenaから追い出されないように気を付ける/開放後はArenaを触らないようにする

Arenaを活用するには、そこでメモリを確保して、そこをきちんと使う必要がありますが、Goの場合はエスケープ処理が便利かつ強力なので、ヒープ側にメモリが確保されてGC対象になってしまう可能性があります。Arenaをせっかく使うのであれば、Arenaから追い出されないようにする必要があります。

  • スライスはarena.MakeSlice[Type](a)で毎回確保する。可変長として使おうとしてappend()をすると、伸長するために新しいメモリ領域を確保するためにヒープに逃げていってしまい、Arenaから外に出てしまうので、次のように伸長する必要がある場合は再度arena.MakeSlice[Type](a)で確保してコピーを自前でやる必要がある。ただし、伸長したときに、前のスライスのメモリがArenaを解放するまでは残り続けるため、やはり基本的に固定長のみで運用で、可変長で扱わない方が良さそう。
func main() {
mem := arena.NewArena()
s := arena.MakeSlice[int](mem, 10, 10)
s = Append(mem, s, 11)
log.Println(s)
}

func Append[T any](mem *arena.Arena, s []T, v T) []T {
l := len(s)
if cap(s) == l {
newS := arena.MakeSlice[T](mem, l+1, l*2)
copy(newS, s)
newS[l] = v
return newS
}
return append(s, v)
}

  • 文字列も、[]byteとしてArenaに置いておく必要がある(以下のコード参照)。
src := "source string"

mem := arena.NewArena()
defer mem.Free()

bs := arena.MakeSlice[byte](mem, len(src), len(src))
copy(bs, src)
str := unsafe.String(&bs[0], len(bs))
  • mapやchanはArenaを使う版がないので、必要であれば作る必要がある。ただし、chanはそこまで大量に作って・・・ということもなさそうなので、問題はない気がします。mapが必要であれば頑張って実装する必要がありますね。

文字列はUptraceのブログのブログから引用しました。

もう1つはアリーナが解放された後にアリーナの中のメモリを触らない、というのもあります。go run -asan main.goのようにアドレスサニタイザーを有効にして実行すると、このようなエラーは検知できます。アリーナ解放後も利用する必要がある値はarena.Clone()を使って、ヒープに逃してあげましょう。

アリーナのまとめ

マイクロベンチマークで性能が2倍以上になる、というのをやってみました。Uptraceのブログでも2分探索のコードを改造して使っていたので、この手の小さなメモリをたくさん使うケースに適用すると良さそうです。

ただし、APIデザインを考えると、汎用的な設計を作るのは結構大変そうだな、と思いました。

その他の小ネタ

コンパイラとかcgo周りとか細かくいろいろアップデートがあります。CGo依存でデフォルトで実装されていたmacOSが非依存になったりして、デフォルトのmac上のビルドとクロスビルドで違いがなくなりました(他のCGo依存ライブラリがない場合)。まあ、大きく実装が変わるとかに関わるものは今のところなさそう?CPUアーキテクチャのサブタイプごとに細かくかき分けたい(ARMの中の命令セットごととか)人向けにビルドタグとか追加されていますが、多くの人には関係ないかな?
あとは、標準ライブラリのビルド済みのパッケージが添付されなくなって、配布物が小さくなっています。まあクロスコンパイルをする場合などはローカルでビルドされてキャッシュされていたので、それと同じような感じの扱いになっただけで、初回ビルドがちょっと遅いかな?ぐらいのものです。二酸化炭素を減らさないと!という会社さんはローカルがキャッシュ済みのイメージを作って使うといいかも?

ライブラリ系

unsafe

文字列とバイト列のファイルコピーをしない変換、スライスの裏の配列の取得ができるようになります。github.com/valyala/fasthttpはなるべくstringを作らないことで高速なベンチマークを達成している(と思う)のですが、net/httpの標準ライブラリでも同じぐらいのパフォーマンスアップを期待しちゃいますね。

圧縮系のライブラリ

archive/tararchive/zipで現在のフォルダの外だったり、絶対パスが入るとErrInsecurePathを返すようになりました。ディレクトリトラバーサル攻撃対策ですね。GODEBUG=tarinsecurepath=0とかGODEBUG=zipinsecurepath=0を設定して実行すれば前と同じ動作にはなります。

暗号系ライブラリ

crypto/ecdhで楕円暗号のパッケージが追加になりました。RSAの終わりの始まり - 暗号移行再びにあるように、暗号強度を強くしよう、という流れがまた来そうなので、要注目パッケージです。
あとはcrypto/ecdsaとかcrypto/rsaとかめずらしく、性能が悪くなる改善ですが、処理速度が定数時間で終わるようになるということで、処理する時間で内容が推測できちゃう系の最近たまに話題になる系統のセキュリティ対策ですかね。

io.OffsetWriter

io.Readerにはio.SectionReaderという、オフセット+サイズ制限、io.LimitReaderというサイズ制限のReaderはありましたが、実はio.Writerとしてはオフセット系のはなかったんですね。書き込み上限のWriter(LimitWriter?)はなさそうなので、誰か提案すると良い気がします。

math/rand

地味に変更が多いです。1.20から、デフォルトの乱数の種が固定値でなくなりました。1.19までは間違ったプログラムの実行の仕方を防ぐために、常に同じ乱数が変えるようになっていて、開発者に適切な種の設定を即す挙動になっていました。実行時にGODEBUG=randautoseed=0をつけると、以前と同じ挙動になります。このグローバルな乱数ジェネレータの乱数の種を設定するグローバルなrand.Seed()は廃止になっています。種を固定した乱数が必要な場合は、乱数生成器を明示的に作って使いましょう。
あと、rand.Read()も廃止になっています。ランダムなバイト列取得というセキュリティ用途でよくあるユースケースで間違って使われるケースが多かったんでしょうか?crypto/randReadを使えとなっていますね。

regexp

正規表現でメモリを消費しすぎるパターンの場合にsyntax.ErrLargeが返るようになりました。Go 1.19のパッチリリースでセキュリティ対策されたのですが、そのときは新しいAPIを導入しないルールに従い、 syntax.ErrInternalErrorを一時的に返していたが、1.20からは上記のエラーが新規で作られたとのことです。バージョンアップのやり方として参考になりますね。

さいごに

明日(1/24)は真野さんの contextパッケージのWithCancelCauseとCauseです。