Go 1.6 以前は、開発者は Go と C の間でポインタを自由に渡すことができましたが、これによりガーベジコレクターがヒープオブジェクトを移動させる際に C コードが参照を保持していると、断続的なクラッシュが発生しました。これらのメモリ安全違反を防ぐために、Go 1.6 では、呼び出しが戻るときに C が Go ポインタを保持することを禁止する厳格なポインタ渡しルールが導入されました。ランタイムは、プログラム実行中にこれらの制約を強制するために cgocheck と呼ばれる検証システムを実装しています。
C コードは Go ランタイムのメモリ管理の外で動作するため、C で確保されたメモリは正確なガーベジコレクターからは見えません。もし C がグローバル変数やヒープ割当てに Go オブジェクトのポインタを保存し、そのオブジェクトが後に GC によって移動される(将来の移動GC実装において)か、または Go からアクセスできなくなると、そのポインタを参照することは use-after-free エラーやデータ破損を引き起こします。これを検出するには、ガーベジコレクション中に C メモリをスキャンする必要があり、これは計算コストが高く、デフォルトでは本番環境では実行不可能です。
ランタイムは、GODEBUG=cgocheck 環境変数を提供し、これには 3 つのモードがあります。モード 1(デフォルト)は、C 関数に渡される引数に他の Go ポインタへの Go ポインタが含まれていないかをチェックします。モード 2 は、GC 中に C スタックとヒープメモリの高コストの保守スキャンを有効にして、C 空間に保持されている Go ポインタを検出し、見つかった場合にはパニックを引き起こします。モード 0 はすべてのチェックを無効にします。モード 2 はデフォルトで無効になっており、これはすべての GC サイクル中に C メモリを潜在的なポインタルートとして扱うことにより、パフォーマンスオーバーヘッド(最大 50% の遅延)を課すためです。
C ライブラリ (librdkafka) をラッピングする高スループットメッセージキューアダプタを構築している際に、非同期バッチ送信のためにメッセージペイロードを Go から C へバイトスライスとして渡す必要がありました。C ライブラリはこれらのポインタを内部リンクリストにキューイングし、後のネットワーク送信のためにバックグラウンドスレッドで処理することによって、初期呼び出しの戻り後に C が Go ポインタを保持できないという CGO ルールに違反していました。負荷テスト中、Go GC が基になる配列データを回収する際に C がまだ参照を保持しているため、断続的なセグメンテーションフォールトが発生しました。
解決策 1 - C ヒープへのコピー: メッセージペイロードをキューイングする前に C.malloc を用いて C で確保されたメモリにコピーし、配信コールバックで解放することを検討しました。
利点: 完全に安全、Go ポインタの保持がない、任意の Go バージョンで動作。 欠点: 2 重メモリ割当て(Go から C への)、大きなメッセージ(1MB+)に対する memcpy の CPU オーバーヘッド、ネットワークタイムアウト中に C コールバックがバッファを解放しないリスクによるメモリリーク。
解決策 2 - cgo.Handle の使用: Go バイトスライスを cgo.Handle(整数トークン)に保存し、整数だけを C に渡し、データを取得するためのコールバックを必要とするという評価をしました。
利点: ペイロードのゼロコピー、型安全な参照管理、および C に長期保存するための Go 1.17+ の慣用パターン。 欠点: C コードにコールバックメカニズムを実装する必要がある、データ取得のための余分な CGO 境界を越えることによる遅延が増加し、C が完了を信号しない場合にハンドルテーブルが無制限に成長する。
解決策 3 - ランタイムピンニング(Go 1.21+): runtime.Pinner を使用して、C が参照を保持している間に GC がバイトスライスを移動または収集しないようにする案を探りました。
利点: C ヒープ割当てなしでの真のゼロコピー、直接的なメモリ共有、最小の API オーバーヘッド。 欠点: Go 1.21+ が必要、手動ライフサイクル管理(すべてのエラーパスで Unpin を呼び出さない場合のメモリリークのリスク)、ピン止めされたメモリのデバッグは難しい(プロファイル内に残っているヒープオブジェクトとして表示される)。
我々は cgo.Handle(解決策 2)を選択しました。なぜならアダプタアーキテクチャはすでに配信確認コールバックを必要としていたからです。このアプローチは、100MB/s のスループット要件に対してデータコピーを排除し、Go バージョン全体での安全性を維持しました。我々は、リークを防ぐために成功およびエラーコールバックの両方でハンドル削除を明示的に追加しました。
システムは 10ms 未満の安定した 99.9 パーセンタイルのレイテンシを達成し、運用中に 500k メッセージ/秒以上を処理しました。GODEBUG=cgocheck=2 を有効にして、ポインタ違反がないことを検証するために 1 週間のストレステストを通過しました。メモリプロファイルは、すべてのコードパスでの適切なクリーンアップによりハンドルの蓄積からゼロリークを確認しました。
なぜデフォルトの cgocheck=1 モードは、呼び出しの戻り後に C のグローバル変数に保存された Go ポインタを検出できないのですか?
デフォルトモードは、ポインタ・トゥ・ポインタ違反のために CGO 境界を越える即時引数と戻り値のみを検証します。これは、保持されている Go ポインタのために C メモリ(グローバル変数、ヒープ、スタック)をスキャンすることはありません。GODEBUG=cgocheck=2 のみが、ガーベジコレクション中に C メモリの保守スキャンを有効にして、そのような保持を検出します。この高コストのチェックはデフォルトで無効になっており、すべての C メモリを潜在的な GC ルートとして扱う必要があるため、コレクションサイクル中の停止時間と CPU 使用量が大幅に増加します。
cgo.Handle は、C コードが整数トークンを保持している間に、ガーベジコレクターが参照される Go 値を回収しないようにどのように防ぎますか?
cgo.Handle は、整数をキーとして使用して、内部ランタイムマップ (runtime/cgo パッケージ内) に Go 値を保存します。マップは値への参照を維持しているため、ガーベジコレクターはルートスキャン中にそれを到達可能とマークし、メモリを回収しません。 C に渡される整数トークンにはポインタメタデータが含まれないため、C は Go のメモリ管理に干渉することなく、無期限にそれを保存することができます。C がコールバックを呼び出すか、Go が明示的にハンドルを削除すると、マップエントリは削除され、参照が失われ、通常の収集が可能になります。
関数呼び出し中に CGO ポインタ渡し違反を示す特定のパニックとは何であり、検出感度を変更するランタイムフラグは何ですか?
ランタイムは、cgocheck=1 が引数として渡される C 内の Go メモリへのポインタを検出したときに runtime error: cgo argument has Go pointer to Go pointer を出力します。C メモリに保存されたポインタを含むより広い検出を行うには、GODEBUG=cgocheck=2 を有効にする必要があり、これにより GC スキャン中に runtime: cgo result contains Go pointer などの致命的エラーが発生する可能性があります。これらのパニックは、C コードがポインタを保持または受信していることで契約に違反し、ガーベジコレクション中に無効になる可能性がある Go 管理メモリへのポインタです。