Goランタイム:4年間の歩み

Go runtime: 4 years later by Michael Knyszek

2018年に公開したGo GCに関する前回のブログ記事以来、Go GC、そしてより広くGoランタイム全体が着実に改善を続けてきました。実際のGoプログラムや、Goユーザーが直面する現実の課題に後押しされて、いくつもの大きなプロジェクトに取り組んできました。ここでその主なハイライトをまとめて紹介します。

新機能

  • メモリの再利用のためのGCを意識したツールである sync.Pool は、以前よりもレイテンシへの影響が小さくなりメモリをはるかに効果的に再利用できるようになりました。(Go 1.13)

  • Goランタイムは、不要になったメモリを以前よりもずっと積極的にオペレーティングシステムに返すようになり、余分なメモリ消費とメモリ不足エラーの発生確率を減らしました。これによりアイドル時のメモリ消費量が最大20%削減されます。(Go 1.13、Go 1.14)

  • Goランタイムは多くの場合においてゴルーチンをより速やかにプリエンプトできるようになり、ストップ・ザ・ワールドのレイテンシを最大90%削減しました。Gophercon 2020での講演をご覧ください。 (Go 1.14)

  • Goランタイムは、特にCPUコア数の多いマシンにおいて、以前よりも効率的にタイマーを管理するようになりました。(Go 1.14)

  • defer 文で遅延実行される関数呼び出しは、ほとんどの場合、通常の関数呼び出しと同程度の低コストで済むようになりました。Gophercon 2020での講演をご覧ください。 (Go 1.14)

  • メモリアロケータのスローパスは、CPUコア数に対してより良くスケールするようになり、特に高度に並列なプログラムにおいてスループットを最大10%向上させ、テールレイテンシを最大30%削減しました。(Go 1.14、Go 1.15)

  • Goのメモリ統計情報は、より詳細で柔軟、かつ効率的なAPIである runtime/metrics パッケージを通じて取得できるようになりました。これによりランタイム統計情報を取得する際のレイテンシが2桁(ミリ秒からマイクロ秒に)改善されました。(Go 1.16)

  • Goのスケジューラは、新しい仕事を見つけるためにスピンする時間を最大30%削減しました。(Go 1.17)

  • Goのコードは、amd64、arm64、ppc64においてレジスタベースの呼び出し規約に従うようになり、CPU効率を最大15%改善しました。(Go 1.17、Go 1.18)

  • Go GCの内部的な計算処理とスケジューリングが再設計され、効率性と堅牢性に関する長年の課題の数々が解決されました。これにより、ゴルーチンのスタックがメモリ使用量の大部分を占めるアプリケーションにおいて、アプリケーションのテールレイテンシが大幅に(最大66%)削減されました。(Go 1.18)

  • Go GCは、アプリケーションがアイドル状態のときに自身のCPU使用量を制限するようになりました。これにより、非常にアイドルな状態のアプリケーションにおいて、GCサイクル中のCPU使用率が75%低下し、ジョブのリソース割り当てを調整する仕組み(job shaper)を混乱させかねないCPUスパイクを削減します。(Go 1.19)

これらの変更のほとんどはユーザーからは見えないものです。ユーザーが使い慣れて愛用しているGoのコードは、Goをアップグレードするだけでより良く動作するようになります。

新しいノブ

Go 1.19には、長らく要望されていた機能が搭載されました。使うには少し追加の作業が必要ですが、大きな可能性を秘めています。それがGoランタイムのソフトメモリ制限です。

長年、Go GCの調整パラメータは GOGC の1つだけでした。GOGC を使うことで、ユーザーはGo GCによるCPUオーバーヘッドとメモリオーバーヘッドのトレードオフを調整できます。長年にわたり、この「ノブ」は幅広いユースケースをカバーし、Goコミュニティにうまく機能してきました。

Goランタイムチームは、正当な理由があって、Goランタイムに新しいノブを追加することに消極的でした。新しいノブが1つ増えるたびに、テストとメンテナンスをし続けなければならない設定空間に新たな 次元 が1つ加わることになり、それはもしかすると永久に続くかもしれないからです。ノブの増加はまた、Go開発者に対してそれらを理解し効果的に使いこなすという負担を課すことになり、ノブが増えれば増えるほどそれは難しくなります。それゆえ、Goランタイムはこれまで常に、最小限の設定で妥当に振る舞うことを目指してきました。

では、なぜメモリ制限というノブを追加したのでしょうか。

メモリはCPU時間ほど代替可能なものではありません。CPU時間であれば、少し待てば未来に必ずもっと多くの時間が手に入ります。しかしメモリの場合、手元にある量には限りがあります。

メモリ制限は2つの問題を解決します。

1つ目は、アプリケーションのピーク時のメモリ使用量が予測できない場合、GOGC だけではメモリ不足に対する保護がほとんど得られないという点です。GOGC だけでは、Goランタイムは自分がどれだけのメモリを利用できるのかをまったく把握していません。メモリ制限を設定することで、ランタイムはメモリオーバーヘッドを減らすためにいつより頑張って働く必要があるかを把握できるようになり、一時的で回復可能な負荷の急増に対して頑健になります。

2つ目は、メモリ制限を使わずにメモリ不足エラーを回避しようとすると、GOGC をピーク時のメモリ使用量に合わせて調整せざるを得なくなるという点です。その結果、アプリケーションがピーク時のメモリ使用量に達しておらず、まだ十分にメモリが余っている場合でも、低いメモリオーバーヘッドを維持するためにGCのCPUオーバーヘッドが高くなってしまいます。これは、プログラムが特定の隔離されたメモリ予約枠を持つ箱(コンテナ)に配置される、私たちのコンテナ化された世界において特に関係が深い話です。せっかくその枠があるのですから、活用しない手はありません。負荷の急増から保護してくれるメモリ制限があれば、CPUオーバーヘッドの観点から GOGC をはるかに積極的に調整できるようになります。

このメモリ制限は、導入しやすく、かつ頑健であるように設計されています。たとえば、これはGoヒープだけでなく、アプリケーションのうちGoが占めるメモリフットプリント全体に対する制限であるため、ユーザーはGoランタイムのオーバーヘッド分を考慮する必要がありません。また、ランタイムはメモリ制限に応じてメモリの回収(スカベンジング)ポリシーも調整するため、メモリ逼迫時にはより積極的にOSへメモリを返却するようになります。

しかし、メモリ制限は強力なツールである一方、いくらか注意して使う必要があります。1つ大きな注意点は、プログラムがGCスラッシングという状態に陥る可能性が生まれることです。GCスラッシングとは、プログラムがGCの実行に時間をかけすぎてしまい、意味のある処理を進める時間が十分に取れなくなる状態を指します。たとえば、実際にプログラムが必要とするメモリ量に対してメモリ制限が低く設定されすぎていると、Goプログラムがスラッシングを起こす可能性があります。GCスラッシングは、以前は GOGC を明示的にメモリ使用量重視で大きく調整していない限り、起こりにくいものでした。私たちはスラッシングよりもメモリ不足を起こす方を選ぶことにしたため、その緩和策として、たとえメモリ制限を超えることになったとしても、ランタイムはGCの実行時間を全CPU時間の50%までに制限するようにしています。

ここまでの内容は考慮すべき点が多いため、この作業の一環として、GCのコストとその操作方法を理解する助けとなるインタラクティブな可視化ツールを備えた真新しいGCガイドを公開しました。

まとめ

メモリ制限をぜひ試してみてください。本番環境で使ってみてください。GCガイドを読んでみてください。

私たちは常にGoを改善するためのフィードバックを求めていますが、うまく機能している場合にもぜひ教えてください。フィードバックを送ってください

By Michael Knyszek