Goのコンパイラは、1.18バージョンで導入されたジェネリックをコンパイルする際に、GCshape ステンシリングという手法を採用しています。歴史的に、言語はフルモノモルフィゼーション(各型インスタンスのために別々の機械コードを生成すること)によってジェネリックを実装し、バイナリの膨張を引き起こすか、ボクシング(ランタイムオーバーヘッドと割り当てのコストを伴う型の消去)を使用してきました。Goが直面した問題は、バイナリサイズが重要な高性能システムプログラミングをサポートしつつ、実行速度を完全に犠牲にしないことでした。
解決策は、サイズとポインタビットマップ(型内のポインタパターン)によって定義されるGCシェイプで具体的な型をグループ化することです。コンパイラは、同じGCシェイプを共有するすべての型のために単一の関数インスタンス化を生成し、型メタデータを含むランタイム辞書を暗黙的パラメータとして渡します。
// *int と *string は同じインスタンス化を共有します。 // それは同一のGCシェイプ(単一のポインタ)を持っているからです。 func Identity[T any](x T) T { return x } func main() { Identity((*int)(nil)) // インスタンス化 #1 を使用 Identity((*string)(nil)) // インスタンス化 #1 を使用(同じシェイプ) Identity(42) // インスタンス化 #2 を使用(スカラ、ポインタなし) }
私たちのチームは、ジェネリックミドルウェアハンドラー Handler[T Event] を使用して、高スループットのイベント処理パイプラインを構築していました。低レイテンシとコンテナ化されたデプロイメントのための合理的なバイナリサイズを維持しながら、50の異なるイベントタイプを処理する必要がありました。
最初のアプローチは、ランタイム型スイッチに依存した型アサーションを利用したinterface{}を使用しました。これは柔軟性を提供し、古いGoバージョンで機能しましたが、実質的な割り当てオーバーヘッドを引き起こしました—インターフェースにラップされた各イベントはヒープ割り当てを必要とし、コンパイル時の型安全性を排除し、型の不一致が発生した場合に本番環境でパニックを引き起こしました。
2番目のアプローチは、第三者のツールを使用したgo generateによるコンパイル時コード生成で、HandlerClickEvent、HandlerPurchaseEventなどを生成しました。これによりランタイムオーバーヘッドなしで最適なパフォーマンスが得られましたが、50のイベントタイプに対応する際にバイナリサイズが40MB増加し、ジェネレーターテンプレートの更新時にメンテナンスの悪夢を引き起こしました。
私たちは3番目のアプローチを選びました:GCシェイプに細心の注意を払ったネイティブGoジェネリックです。イベントタイプを構造体へのポインタ(均一なGCシェイプ)にすることで、コンパイラがインスタンス化を再利用できるようにしました。メソッドディスパッチのための辞書ルックアップのわずかなオーバーヘッドを受け入れることで、バイナリサイズはわずか2MBの増加にとどまりました。その結果、interface{}に比べて15%のレイテンシ削減を達成し、フルコード生成と比較して管理可能なバイナリフットプリントを実現しました。
ランタイム辞書は、共有ジェネリックインスタンスに型特有の情報をどのように提供しますか?
辞書は、型記述子(_type)、メソッドテーブル(itab)、およびGCメタデータへのポインタを含む構造体です。コンパイラが func Print[T any](x T) のようなジェネリック関数のコードを生成する際、辞書を暗黙的な最初の引数として渡します。メソッド x.String() を呼び出すために、生成されたコードは辞書内のメソッドポインタを検索し、直接呼び出しをコンパイルするのではなく、同じ機械コードで T=bytes.Buffer と T=strings.Builder を扱うことを可能にします。
どのように異なるポインタ型がジェネリックインスタンスを共有できる一方で、その要素型は別々のものを必要とするかもしれないのか?
Goは型をGCshapeで分類し、これはガーベジコレクターおよびアロケーターに関連するメモリ配置のみを考慮します。*int と *string の両者は、ポインタを含む単一の機械語を持ち、同じシェイプクラスに配置されます。対照的に、intはポインタを含まず、特定のサイズに揃えられ、stringはポインタと長さを含む2ワードの構造体です。メモリ配置が異なるため、適切なガーベジコレクションとメモリアドレス指定を処理するために、別々の生成コードパスが必要になります。
ジェネリック制約における値レシーバーとポインタレシーバーの使用のパフォーマンス上の影響は何ですか?
ジェネリック関数が型パラメーター T のメソッドを呼び出す場合、コンパイラは任意の可能な T に対して機能するコードを生成しなければなりません。制約が値レシーバー func (T) Method() を必要とするが、具体的な型が大きい場合、コンパイラは辞書を渡し、インライン化を妨げる間接呼び出しを行わざるを得なくなることがあります。ポインタレシーバー func (*T) Method() を使用することで、GCシェイプを共有するポインタ型がより頻繁に出現するため、コンパイラは特定のインスタンス化コンテキストでコンパイル時に具体的な型が知られている場合に呼び出しをより簡単にデバーチャル化できます。