Go 1.19リリース連載の6本目です。
Go 1.19では、いくつかメモリ周りの更新がありました。1つはガベージコレクタ周りのお話と、あとはメモリモデルの更新です。 ライブラリではsync/atomic.Int64など、いくつか型が追加されました。
ガベージコレクタ
ガベージコレクタの詳細と調整の仕方についてのドキュメント
が追加されました。このドキュメントはスライダーで動作の変化がみられるインタラクティブなドキュメントになっているので、ぜひご覧ください。
「GoはJavaと違って、GCの調整ポイントがほとんどなく、最初からトップスピード(オプションの選択の中で相対的に)だよ」みたいに説明されることもありましたが、そういうわけにも行かなくなったというか、ある程度知っておく必要はあるかもしれません。とはいえ、デフォルトでも十分うまくやってくれますし、そもそも即座に終了するユーティリティでは頑張る必要もないので、長期間動作するサーバー系とくにリクエストごとのメモリ消費が大きいものを使っている人は必読ですが、そうでない人はそこまで追いかける必要はないかもしれません。
なお、これはGoの言語仕様ではなく、Go製の純正Go処理系に特化した話(ただしgccgoもかなり似ているという注釈つき)とのことです。また、処理系依存の話なので将来はまた変わってくる可能性があります。
Goのメモリ戦略が最初に紹介されています。
- スタックメモリはGCを使わずにまとめて解放という戦略で扱われている
- ヒープはGCが管理する。ヒープにおくかスタックにおくかはプログラムのロジックを見てきまる
- GoのGCはマークアンドスイープ方式を利用
環境変数: GOGC (go 1.18以前よりあった)
ここで設定した値(100で10MB、200で20MB)だけヒープメモリが使用されると、GCが実行されるようになります。数値を小さくすればするほど、GCが走りやすくなるため、無駄なメモリは減ります。一方でGCのマーク処理は重いため、CPUを消費します。このパラメータは「CPUコストとメモリ使用量の間のトレードオフ」を調整するものであると説明されています。
環境変数: GOMEMLIMIT (go 1.19で追加)
GOGCはCPUとメモリのトレードオフの設定としては有効ですが、実際のメモリ使用量を考慮したものではありません。実際に必要なライブヒープ(GCで削除されない)が急に増えた場合に、GOGCだけで調整しようとするとCPU消費量が無駄に多くなり過ぎてしまいます。そこで提案されたのがこの環境変数での設定です。このGCガイドには書かれてないですが、”100MB”とか、”100MiB”といった内容を設定するようです。
なお、これはソフトリミットであって、GCが稼働後にこのメモリ量以下になることが保証されてません。GC対象じゃないライブヒープが増えてしまうといくらGCでも解放できません。ガベージじゃないですし。5-10%ほど余裕をみて設定しましょう、と書かれています。また、データ量に比例してメモリを消費するようなプログラムの場合、データ量側を制限せずにメモリ制限だけしてしまうとパフォーマンス低下が発生するとあります。
このドキュメントでは、Goの仮想メモリの使い方の解説、ストップザワールドのGCは重い(CPUの25%を持っていくし、マークとスイープを同時に行うのでレイテンシーが大幅に伸びる)という話や、プロファイリングの指標の紹介などがあります。
メモリモデルの更新
The Go Memory Modelが8年ぶりに更新されています。そのベースとなっているのがRuss Coxが投稿した3つのドキュメントです。
なお、2014年版も、2022年版も同じ前書きとなっています。
アドバイス
同時にアクセスする複数のgoroutineによってデータを変更するようなプログラムでは、そのようなアクセスを直列化しなければなりません。
アクセスを直列化してデータを保護するには、チャネル操作またはsync、sync/atomicパッケージ内の要素などを使います。
プログラムの動作を理解するためにこのドキュメントの残りの部分を読まなければならない場合、あなたは頭が良すぎます。
賢くならないでください。
ようするにGo開発者が行うべきことは、チャネル、sync/atomicパッケージなどを適切に使うこと、と言い切っています。これは2014年版も2022年版も変わりません。CPU特有の癖などを駆使したトリッキーなロックフリーな順序制御!!!みたいなことをするとトラブルの元になるのでやめとけよ、ということですね。このドキュメント自体はGoでアプリを書く人向けというよりも、主にランタイムやコンパイラを実装する人向けのデザインドキュメントのようなものかと思います。
The Go Memory Modelの更新内容
Go公式ドキュメントの方でどのような変更があったのかを目diffで差分を調べました。
- Happens Beforeが[https://go.dev/ref/mem#model](Memory Model)にリライト
- 他の言語のメモリモデルの論文等と同じ語彙を使って厳密に書き換えたように見える
- Implementation Restrictions for Programs Containing Data Racesが追加
- ThreadSanitizer (go build -race) の紹介や、単一の命令で一度に読み書きできない操作が分断されて行われた時(スライスの3つの構成要素とか)の問題について言及
- Synchronization
- Locksのセクションで、1.18で追加されたTryLockの記述が追加
- Atomic Values, Finalizers, Additional Mechanismsが追加
- Incorrect compilationが追加
- コンパイラ実装者向け
Synchronizationのセクションは追加説明やここ 8年間のライブラリの追加を反映した小粒な変更となっています。大きく説明が変わったのが最初のモデル説明(ただし内容は大きく変わっていないように見える)と、コンパイラ実装者向けの説明の追加ですね。
メモリモデルの概要
Russ Coxの3つの記事を読んでみました。1周では理解できなかったので、何周か読んでみた結果のダイジェストです。
メモリモデルとは
- シングルスレッドでは不要だったが、マルチプロセスが出てきたので必要になった。
- 大雑把にメモリモデルというのは、「メモリの読み書きの順序」の決定性の問題
- コンパイラが最適化目的で命令を入れ替えることもある
- RISCのCPU向けコンパイラの方が積極的に介入するというのを聴いたことがある(渋川補足)
- CPUが命令を解釈して制約を崩さない中でリアルタイムに順序を勝手に書き換えることもある(渋川補足: アウトオブオーダー)
- コンパイラが最適化目的で命令を入れ替えることもある
- メモリオーダーにはランクがある。逐次一貫性(Sequential Consistency)→弱い一貫性→ゆるい一貫性とここでは3段階あるある。弱いほど並び替えが自由に行われる。
- 保証が弱ければ弱いほど、パフォーマンスのための組み替えの自由度が上がるのでパフォーマンスは上がる
- ハードウェアとプログラミング言語の両方がメモリをモデルを持つ
- ハードウェアメモリモデルは、ハードウェアとその上で動くソフトウェアの契約
- 言語側のメモリモデルは、コンパイラとプログラマーの契約
- 言語側のメモリモデルの現在の標準は極めて保守的な(Java 1.5、C++11、Go 1.19など)は逐次一貫性だが、 これでは性能が上がらないため、ハードウェアはより柔軟性が高い(ゆるい)メモリモデルを持つのが普通とのこと
- 保証が必要以上に強いと、それに甘えたソフトウェア実装になってしまい、将来速度のためにゆるいメモリモデルのCPUがリリースされると動かないということになりかねない。
ハードウェアのお話
以下の図はHardware Memory Modelsからの引用になります。
- 逐次一貫性では常に共有メモリと同期を取る
- Intel(x86-TSO)の方がそれよりもゆるいモデルとなっている。
- ARMはさらに弱いモデル。スレッドがメモリのコピーを持って、必要に応じて他のスレッドにコピーを伝搬させる実装とのこと。
- メモリバリア(あるいはメモリフェンスとも呼ばれる)を使うことで、前の処理が終わることを明示的に示せる。
あとは、Plan 9をPentium Proに移植しようとしたときの苦労話がたくさん書かれています。ここが一番語りたかったことではないか?
プログラミング言語のメモリモデル
- 主にDRF-SCが利用される
- SC for DRFとも呼ばれる
- データレースがない状態ではデータレースがないプログラムは逐次一貫した方法で実行されることが保証されて、結果が想像しやすい。
- Goのメモリモデル
- DRF-SC。他の言語と同じ。
- データのレースコンディションがある状況での実装がちょっと違う。
- C++は自由
- Javaはそのコンディションの状況もより厳密に定義されていてデバッグが楽
- Goは保守的なrace conditionチェックと、実行時はノーガード(パニック)
1行にまとめると、メモリモデルの強度はパフォーマンスとのトレードオフがあり、弱くするほど最適化の余地があるが、コードと実際の動きの違いが出てきて動作の予測はしにくくなる。最新のGoをはじめとした各種プログラミング言語は保守的で予測しやすいものを選んだが、その下のハードウェアは柔軟なモデルを採用している、という感じですかね。
まとめ
GCの方は調整の余地が多少出てきたため、大規模なアプリケーションではこのあたりも意識する必要が出てきました。といっても、調整の環境変数はまだ2つなので、まだまだ十分にシンプルかと思います。
メモリモデルの説明は、読めば読むほど普段のプログラミングの話からはちょっと遠い、レイヤーがかなり低い話でした。普段の開発でどのような影響があるかというとあまりないかと思います。syncパッケージの提供する各種同期プリミティブ、チャネルを使った同期、sync/atomicパッケージなどを普段から正しく使っていれば特に問題はありません。
The Go Memory Modelのドキュメントを今回初めてじっくり読んでみましたが、Synchronizationのセクションが、メモリに限らず、「○○と△△は、必ず前者の完了後にもう片方が呼ばれる」といったタイミングについての言語仕様集ともなっていますので、一度軽く目を通しておくとよさそうです。読書会のネタに最適。
余談ですが、Apple SiliconのmacはRosetta2でインテルバイナリをARMでエミュレーションして実行しますが、Appleはこのためにインテル方式の強い制約を実装したようです。同じARMといっても、MicrosoftのSurfaceのインテルエミュレーションと比べてAppleの方がパフォーマンスが圧倒的に高いのは、元々のコアの性能差もあるがこういうところにあるようです。