歴史
Goのメモリアロケーターは、C++のマルチスレッドサーバー用に設計されたGoogleのスレッドキャッシュmallocであるTCMallocに由来します。ランタイムは、競合プログラムにおけるロック競合を排除するために特に設計されたマルチレベルキャッシュを実装しています。この設計は、小さなオブジェクトのファストパスにおいてスループットをメモリ効率よりも優先します。
問題
高度に同時実行されるサービスでは、すべての割り当てがグローバルヒープロックを取得する必要があると、ゴルーチンが直列化され、スループットが破壊されます。課題は、一般的なケースに対して安全性を維持しながら、同期なしでO(1)の割り当てレイテンシを提供することです。従来のmalloc実装は、複数のCPUが同じロックワードを競うときにキャッシュラインのバウンシングに苦しみます。
解決策
ランタイムは、67のサイズクラスごとにスパンを含むPごとのキャッシュ(mcache)を維持します。ゴルーチンが小さなオブジェクト(≤32KB)を割り当てると、境界ポインタをインクリメントするか、そのmcache内のスレッドローカルのフリリストからポップします。これには原子操作は必要ありません。重要な不変条件は、mcacheが常に1つのPによって独占的に所有され、割り当てはPの境界を越えないため、共有された可変状態を回避することです。
type PriceTick struct { Symbol uint32 Price float64 } func ProcessTick() { // mcacheからロックなしで16バイトを割り当てる tick := &PriceTick{} _ = tick }
ハイフリーケンシートレーディングプラットフォームは、500,000のマーケットデータイベントを秒間処理し、各イベントには価格正規化のために一時的な24バイトの構造体が必要でした。最初の実装では、これらのオブジェクトのためにグローバルsync.Poolを利用しましたが、負荷の下で重大な競合ポイントとなり、原子操作およびキャッシュの整合性トラフィックでCPU時間の35%を消費しました。
解決策A: 手動プールシャーディング
チームは、プールをゴルーチンIDハッシュで選択された256の内部サブプールに手動でシャーディングすることを検討しました。利点: キャッシュライン間で競合を分散します。欠点: 不均一な利用がアイドルシャードにおけるメモリの膨張を引き起こし、他にフリーオブジェクトが存在する間にローカルシャードが空になるときに複雑なスタベーション処理が必要です。
解決策B: ワーカーごとのアリーナ
彼らは、バンプポインタ割り当てを使用してワーカーゴルーチンごとに大きなメモリアリーナを事前に割り当てることを評価しました。利点: 競合ゼロで非常に速い割り当てパス。欠点: 手動メモリ管理が必要で、リセットポインタが扱いを誤るとメモリが漏洩するリスクがあり、非同期境界を越えたオブジェクトのライフタイム追跡が複雑になります。
解決策C: スタック割り当てとバッチ処理
選択されたアプローチは、ポインタの代わりに値構造体を使用するようにイベントプロセッサを再構築し、できる限りスタック上にデータを保持し、1000のイベントをバッチ処理して割り当てを緩和します。利点: 短命データに対してヒーププレッシャーを完全に排除し、同期プリミティブを必要としません。欠点: 以前はポインタセマンティクスを期待していたインターフェースの大規模なリファクタリングが求められ、ゴルーチンごとのスタック使用量が増加します。
結果
ソリューションCを実装することで、サービスはホットパスにおけるヒープ割り当てを99%排除しました。P99レイテンシは12ミリ秒から180マイクロ秒に低下し、ガベージコレクションサイクルは85%減少し、サービスはサブミリ秒のSLA要件を満たすことができました。
Goは、固定サイズのスパンからさまざまなサイズのオブジェクトを割り当てる際に、どうやってメモリの断片化を制限しますか?
Goは、特定の粒度(8バイト単位で512バイトまで、その後は大きな間隔)で67の異なるサイズクラスを採用しています。オブジェクトは、内部の断片化を約12.5%に制限するために、最も近いクラスサイズに切り上げられます。外部の断片化は、各mspanがちょうど1つのサイズクラスのオブジェクトを含むため、小さなオブジェクトが大きなメモリブロックをピン留めすることを防ぐため最小限に抑えられます。
ランタイムは、割り当ての際にユーザーが目に見えるメモリではなく、ヒープビットマップをクリアする理由は何ですか?
アロケーターは、オブジェクトヘッダーではなくheapArenaメタデータ構造にタイプ情報とポインタビットマップを保持します。メモリが割り当てられると、必要に応じてポインタスロットを示すビットマップのみがゼロクリアされます。データメモリは、ミュテータによって要求されたとき、または同時スウィープ中にゼロクリアされます。このアプローチは、作業を遅延させ、キャッシュの局所性を改善し、割り当て時に必要なメモリ帯域幅を減少させます。
ガベージコレクション中に、スパンがmcacheからmcentralに遷移する理由は何ですか?
GCのスウィープフェーズ中に、ランタイムはmcacheインスタンスで保持されているスパンを調べます。スパンに割り当てられたオブジェクトが含まれていない場合(すべてのスロットが解放された場合)、Pはそれをmcentralに戻し、保持せずにコレクションを行います。これにより、メモリの蓄積が防止され、プロセッサ間でのフリーメモリのバランスの取れた配分が保証されますが、中央ロックの再取得に伴うコストが発生します。