GoSuda

HQQ: キャッシュライン共有メモリを用いたGoからのC言語ライブラ리의隔離

By Lemon Mint
views ...

gosuda.org/hqq は、GoおよびC11向けのゼロcgoプロセス間通信ランタイムおよびコード生成ツールチェーンです。ネイティブのCライブラリを独立したOSのワーカープロセス内に分離し、メモリマップトでキャッシュラインにアライメントされた共有メモリリングを介してGoの呼び出し元に接続します。本プロジェクトは、2つのトランスポート層(専用の単一プロデューサー単一コンシューマーセッション向けの StandardLink と、マルチワーカーのMPMCディスパッチ向けの Advanced)、hqq-bindgen IDLコンパイラ、および改ざん検証済みのプロセススーパーバイザー(secureexec および launch)を組み合わせています。

1go get gosuda.org/hqq

C言語とアドレス空間を共有することの代償

すべてのGoシステムエンジニアは、やがて同じ分岐点に行き当たります。あなたは、CGO_ENABLED=0 で数秒のうちにコンパイルされ、単一の静的バイナリとして配布され、sysrootなしでラップトップからLinuxまたはWindowsターゲットへクロスコンパイル可能であり、メモリ安全なガベージコレクション付きヒープ内で実行されるGoサービスを構築しています。そのとき、埋め込みリレーショナルストレージのためのSQLite、辞書圧縮のためのZstandard(libzstd)、あるいはハードウェアアクセラレーションによるメディアや暗号ライブラリといった、成熟したCライブラリが必要になります。

そのCライブラリをGoプロセスにリンクするために cgo を有効にすることは、即座のバインディング問題を解決する一方で、3つの構造的負債をもたらします。

  1. ツールチェーンとクロスコンパイルの結合: CGO_ENABLED=1 では、ターゲットとなるすべてのOSおよびアーキテクチャに対して、Cコンパイラ、ターゲットのlibcヘッダー、およびプラットフォームリンカーが必要になります。darwin/arm64 のワークステーションから linux/arm64 や windows/arm64 の成果物をビルドすると、単一の go build コマンドが、クロスコンパイラとsysrootのマトリックスへと変貌します。
  2. ランタイムスケジューリングのオーバーヘッド: すべての cgo 呼び出しはGoランタイムの境界を越え、拡張可能なゴルーチンスタックからシステムスタックへと切り替わり、OSスレッド(M)をピン留めします。きめ細かい呼び出しの場合、遷移コスト(1回の呼び出しあたり約50〜150 ns)とスケジューラーの競合が急速に蓄積します。
  3. 障害ドメインの崩壊: CコードはGoランタイムと同じ仮想アドレス空間内で実行されます。C関数内における単一の境界外書き込み、解放済みメモリの使用(use-after-free)、スタックオーバーフロー、あるいは abort() は、Goのヒープメタデータを破損させるか、サーバープロセス全体を即座に強制終了させ、recover() をバイパスして進行中のすべてのHTTPまたはRPCリクエストを巻き添えにします。

チームが cgo を回避しようと試みる際、一般的な代替手段は別の妥協を強いることになります。Cコードベースの純粋なGoによる機械的翻訳は CGO_ENABLED=0 を維持しますが、多くの場合、上流のCのリリースに遅れをとり、エミュレートされた仮想メモリ層を搬送し、負荷の下で大量に割り当てを行います。ループバックTCPやUnixドメインソケットを介してCライブラリをヘルパープロセスに移行すると障害分離は回復しますが、すべての呼び出しにおいて、2回のカーネルコンテキストスイッチ、ソケットバッファのコピー、およびワイヤシリアライゼーションのコストが発生します。

私たちは率直な疑問を投げかけた上でHQQを構築しました。もし、Cライブラリが独自の仮想アドレス空間を持つ独立したOSプロセスに存在し、一方でGoとCが、ファストパスにおいてカーネルに入る事なく、共有CPUキャッシュラインを介して直接フレームを交換できるとしたらどうなるでしょうか?

統合モデルCGO_ENABLED=0障害分離ファストパスシステムコール呼び出しごとのヒープ割り当て上流C11忠実度
cgo インプロセスリンクいいえなし(共有ヒープ)00 から 1+正確な上流C
ソケット / パイプ サブプロセスRPCはいプロセス境界2 から 4複数(フレーミング + I/O)정확な上流C
機械的なCからGoへの翻訳はいインプロセスGo0複雑な呼び出しで高負荷エミュレートされたCランタイム
HQQ 共有メモリIPCはいプロセス境界0(適応型スピン)0(StandardLink)正確な上流C

アーキテクチャの概要:プロセス境界を跨ぐ4つの層

HQQはプロセス分離を4つの構成可能な層に分割し、共有メモリリングの仕組み、型付きRPCコード生成、バイナリ完全性検証、および子プロセスの監視がそれぞれ独立した状態を維持できるようにしています。

 1+-------------------------------------------------------------------+
 2| Go Application Process (CGO_ENABLED=0)                            |
 3|                                                                   |
 4|  +-----------------------+     +-------------------------------+  |
 5|  | Generated Go Client   |     | Process Supervisor            |  |
 6|  | (gosuda.org/hqq/      |     | (gosuda.org/hqq/launch +      |  |
 7|  |  bindgen/rt)          |     |  gosuda.org/hqq/secureexec)   |  |
 8|  +-----------+-----------+     +---------------+---------------+  |
 9|              |                                 |                  |
10|  +-----------v-----------+                     | Anonymous Pipe   |
11|  | Transport Tier        |                     | Lifeline         |
12|  | (StandardLink /       |                     | (--parent-stdin) |
13|  |  Advanced MPMC)       |                     |                  |
14+--------------+---------------------------------+------------------+
15               |                                 |
16===============|========= Shared Memory =========|===================
17               |     (mmap / CreateFileMapping)  |
18+--------------v---------------------------------v------------------+
19| C11 Worker Process (Isolated Address Space)                       |
20|                                                                   |
21|  +-----------------------+     +-------------------------------+  |
22|  | Generated C11 Server  |---->| Generation-Checked Handle     |  |
23|  | & Batch Shims         |     | Table (hqq_rpc.c)             |  |
24|  +-----------+-----------+     +---------------+---------------+  |
25|              |                                 |                  |
26|              +----------------+----------------+                  |
27|                               |                                   |
28|                     +---------v---------+                         |
29|                     | Native C Library  |                         |
30|                     | (SQLite, Zstd...) |                         |
31|                     +-------------------+                         |
32+-------------------------------------------------------------------+

1. 共有メルトランスポート(gosuda.org/hqq および c/)

ベースパッケージの gosuda.org/hqq および c/ 内のC11対応物は、POSIXの mmap(LinuxおよびmacOS)とWin32の CreateFileMappingW(Windows)を介して、2つの共有メルトランスポートモードを実装しています。すべての共有セグメントは、リングインデックスが読み取られる前に、プロトコルバージョン、バッファジオメトリ、およびエンドポイントのPIDをピン留めする検証済みの HQQ_STL3 スーパブロックで始まります。

すべてのホットコントロールワードおよびスロットヘッダーは64バイトのキャッシュライン境界にアライメントされているため、プロューサーコアとコンシューマーコアが偽の共有(false sharing)によって同じキャッシュライン上で競合することは決してありません。

1StandardLink Slot Layout (64-Byte Cache-Line Aligned)
2Byte Offset:  0x00                0x08                0x10                          0x40
3              +-------------------+-------------------+-----------------------------+
4Header Line:  | seq (u64 atomic)  | len (u32) | flags | reserved (zero-padded)      |
5              +-------------------+-------------------+-----------------------------+
6Payload Area: | payload bytes [0 .. BufferSize - 1] (directly encoded / decoded)    |
7              +---------------------------------------------------------------------+
フィールドオフセットサイズセマンティクス
seq0x008 B単調増加するシーケンスカウンター。奇数値は書き込み中を示し、偶数値はストアリリースを介してコミットされたフレームを公開します。
len0x084 Bスロットに書き込まれたペイロードのバイト長(0 <= len <= BufferSize)。
flags0x0C4 Bフレーム制御ビット(継続、エラー状態、またはシャットダウン通知)。
payload0x40BufferSize中間のコピーなしでインプレースで読み書きされる連続したペイロード領域。

呼び出し元は、並行性の構造に応じて2つのトランスポート層から選択します。

  • StandardLink(1:1 SPSCセッション): シーケンスロック(SeqLock)によって保護された専用のリクエストスロットおよびレスポンススロットを介して、クライアントとサーバーのセッションをペアにします。ファストパスにおいて、書き込み側はマップされたスロットに直接シリアライズし、アトミックなストアリリースでコミットします。読み取り側は、OSのウェイクプリミティブ(Linuxでは futex、macOSでは __ulock_wait、Windowsでは WaitOnAddress)にフォールバックする前に、ユーザー空間で短時間スピンします。単一フレームのラウンドトリップは 0 allocs/op で約200 nsで完了します。
  • Advanced(MPMCディスパッチリング): ロックフリーのマルチプロューサーマルチコンシューマーのサブミッションリング、共有スロットプール、およびインデックス付き完了テーブルを使用して、M 個の並行するGoの呼び出し元を N 個のCワーカースレッドに多重化します。呼び出し元はスロットを予約し、スロットインデックスをリングに公開し、ワーカースレッドがリクエストをデキューして順序なしで完了するのを待つ間、自身の完了エントリで待機します。

両方の層はピアの生存状態を継続的に追跡します。各エンドポイントは Heartbeat() を介してアトミックな単調タイムスタンプを更新し、待機中に PeerAlive(timeout) を検査します。ワーカープロセスが呼び出しの途中でクラッシュした場合、ピアは古くなったハートビートまたは無効なPIDを即座に検出し、ブロックされた待機を ErrPeerGone でアボートし、 wedged(楔留めされた)共有メモリファイルを背後に残すことなく、アトミックなセッションの Takeover(テイクオーバー)をサポートします。

2. ゼロ割り当てRPCおよび世代確認済みハンドル(gosuda.org/hqq/bindgen)

数十の関数を持つCライブラリのために共有メモリのフレーミングを手動で記述することは、単調でありエラーの原因になりやすい作業です。hqq-bindgen は、インターフェース定義言語(.idl)ファイルを読み取る(または hqq-bindgen init を介してCの .h ヘッダーから直接ブートストラップする)スキーマコンパイラであり、ゼロcgoのGoクライアントパッケージをC11サーバーのスケルトンとともに生成します。

あらゆるCバインディングにおいて最も危険な誤りは、言語の境界を越えて生のCポインタ(sqlite3*、sqlite3_stmt*、ZSTD_CCtx*)を露出させることです。呼び出し元がポインタを解放した後に渡したり、別のセッションに属するポインタを渡したり、整数アドレスを破損させたりした場合、Cライブラリは無効なメモリを参照解除します。

hqq-bindgen はワイヤ上での生のポインタを禁止します。その代わりに、IDLはステートフルなCリソースを型付きの handle 宣言としてモデル化します。

1service sqlite3 {
2    handle Db;
3    handle Stmt @owned_by(Db);
4
5    fn open_v2(path: string, flags: i32) -> result<Db, i32>;
6    fn prepare_one(db: Db, sql: string) -> result<Stmt, i32>;
7    fn finalize(stmt: @consume Stmt) -> result<void, i32>;
8    fn close(db: @consume Db) -> result<void, i32>;
9}

ワイヤ上で、すべてのハンドルは32ビットのテーブルインデックスと32ビットの非ゼロの世代カウンターをパッキングした64ビット整数です:(uint64_t)generation << 32 | index。Cランタイム(c/src/hqq_rpc.c)は、3つの不変条件を持つセッションごとのハンドルテーブルを維持します。

  1. 世代の検証: すべてのルックアップは、スロットが占有されていること、格納されている世代がハンドルの上位32ビットと一致すること、およびハンドルの型タグがパラメータの型(Db 対 Stmt)と一致することを検証します。閉じられたハンドルを再利用すると、Cライブラリへと呼び出す前に、HQQRPC_E_BAD_HANDLE を伴って即座に失敗します。
  2. 階層的所有権(@owned_by): Stmt は @owned_by(Db) と注釈されているため、Db ハンドルを閉じると、その Db が所有するすべてのライブな子 Stmt ハンドルが自動的に走査され、割り当て順序とは逆の順序でそのデストラクタが実行されます(sqlite3_close_v2 の前に sqlite3_finalize)。
  3. セッション終了のスイープ: Goクライアントがハンドルを閉じずに切断またはクラッシュした場合、Cサーバーのセッションデストラクタはセッションハンドルテーブル全体を逆順でスイープし、クライアントの再起動を跨いだCの割り当てリークがゼロであることを保証します。

セッションが最初のRPC呼び出しを処理する前に、クライアントとサーバーは、正規化されたIDL(HQQ-BINDGEN-IDL-V1\n プレフィックス)に対して計算された32バイトのSHA-256インターフェースダイジェストを交換します。GoクライアントとCワーカーが互換性のないIDLスキーマからコンパイルされていた場合、ハンドシェイクはブートストラップ時に接続を拒否します。

3. 改ざん防止バイナリ実行(gosuda.org/hqq/secureexec)

Cコードをワーカーバイナリに移行すると、サプライチェーンに関する疑問が生じます。ディスク上のワーカーバイナリが検証と実行の間において変更または置換されていないことを、Goプロセスはどのように知るのでしょうか。

ファイルパスに対して crypto/sha256 を呼び出し、その後に os/exec.Command(path).Start() を続けると、カーネルがそれを実行する前に別のプロセスがファイルを上書きまたはスワップできるTime-of-Check to Time-of-Use(TOCTOU)のウィンドウが残ります。gosuda.org/hqq/secureexec はこのウィンドウを閉じます。

  • Linuxでは、secureexec はバイナリを開き、SHA-256ハッシュを計算しながら memfd_create(MFD_CLOEXEC | MFD_ALLOW_SEALING) を介して作成された匿名メモリファイル記述子へとその内容をストリーミングし、ピン留めされた32バイトのダイジェストまたはEd25519署名済みリリースレイアウト(cmd/hqq-manifest)に対してハッシュを検証し、システム上のどのプロセスもページを改変できないように fcntl(F_ADD_SEALS, F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE) を適用し、シールドされた記述子から子プロセスを生成します。
  • macOSおよびWindowsでは、secureexec は検証済みバイナリをSHA-256ダイジェストをキーとするパーミッションロックされたキャッシュディレクトリに実体化し、起動前にファイルサイズとダイジェストの完全性を検証します。

4. 親ライフラインプロセス監視(gosuda.org/hqq/launch)

GoアプリケーションがバックグラウンドのCワーカープロセスを生成する場合、たとえGoの親プロセスが SIGKILL(kill -9)で強制終了され defer クリーンアップハンドラーを実行できなくなった場合であっても、ワーカーが孤立プロセス(orphan process)として親より長生きしてはなりません。

gosuda.org/hqq/launch は、--parent-stdin フラグとともにワーカーの標準入力に渡される匿名のOSパイプを通じて、ライフタイムの結合を強制します。Goの親プロセスはパイプの書き込み側を保持し、そこには決して書き込みを行いません。Cワーカーは専用のライフラインスレッドで読み取り側を監視します。Goプロセスが正常終了するか、パニックを起こすか、あるいはOSカーネル(SIGKILL またはOOMキラー)によって強制終了された場合、カーネルは書き込み側の記述子を即座に閉じます。Cワーカーは数マイクロ秒以内にパイプ上で EOF を受信し、アクティブな呼び出しを中断し、セッションハンドルのデストラクタを実行し、共有メモリのファイルをunlinkし、クリーンに終了します。

クイックスタート:生成されたサービスの呼び出し

hqq-bindgen と launch を使用すると、隔離されたCワーカーをGoから呼び出すことは、CGO_ENABLED=0 を維持しながら通常の型付きGoクライアントのように見えるようになります。

 1package main
 2
 3import (
 4	"context"
 5	"fmt"
 6	"log"
 7	"time"
 8
 9	"gosuda.org/hqq/bindgen/rt"
10	"gosuda.org/hqq/launch"
11	"gosuda.org/hqq/secureexec"
12)
13
14func main() {
15	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
16	defer cancel()
17
18	hash, err := secureexec.ParseHash("7649b1b45dacd934cde8149f35f4fcc3937f6486fbac23ce976466719b3752bb")
19	if err != nil {
20		log.Fatal(err)
21	}
22
23	listener, err := launch.NewListenerName("demo")
24	if err != nil {
25		log.Fatal(err)
26	}
27
28	srv, err := launch.Start(ctx, launch.Config{
29		Binary:   "/path/to/verified-worker",
30		SHA256:   hash,
31		Listener: listener,
32		Args:     []string{listener},
33	})
34	if err != nil {
35		log.Fatal(err)
36	}
37	defer srv.Close()
38
39	fmt.Printf("worker running on shared-memory listener %s (opts=%+v)\n", listener, rt.SessionOptions{})
40}

リポジトリ構造

gosuda.org/hqq 内のすべてのサブシステムは、明確な境界を持つフォーカスされたパッケージに整理されています。

 1gosuda.org/hqq
 2├── standard.go / ring.go      # StandardLink (SPSC) and low-level shared-memory ring primitives
 3├── advanced.go / slotpool.go  # Advanced (MPMC) dispatcher, slot pool, and completion table
 4├── shm_*.go / wait_*.go       # OS-specific mmap/CreateFileMapping and futex/ulock/WaitOnAddress
 5├── c/                         # C11 transport library (hqq.c) and RPC server runtime (hqq_rpc.c)
 6├── bindgen/                   # hqq-bindgen IDL parser, verifier (V1-V41), and Go/C11 code generator
 7│   └── rt/                    # Zero-allocation Go client runtime for generated services
 8├── secureexec/                # SHA-256 / Ed25519 manifest verification and memfd-sealed execution
 9├── launch/                    # Worker process supervisor with --parent-stdin lifeline management
10├── cmd/hqq-manifest/          # CLI tool for generating and signing release binary manifests
11└── spec/                      # Executable wire specifications and golden test vectors (BINDGEN.md)

Goエコシステムで最も広く使用されている2つの cgo ライブラリのドロップイン本番代替として、HQQがどのように機能しているかを確認するには、このスタック上に構築されたコンパニオンパッケージをご覧ください。

  • gosuda.org/sqlite3: パイプライン化されたバッチプリフェッチを備えた共有メモリ上でネイティブCのSQLite 3.54.1 を実行する、github.com/mattn/go-sqlite3 と互換性のあるドロップイン database/sql ドライバー。
  • gosuda.org/zstd: ゼロ割り当てコンテキストと一括バッチ処理を備えた独立した共有メモリワーカー内でネイティブCの libzstd 1.5.7 を実行する、github.com/DataDog/zstd のドロップイン代替。
  • GitHubリポジトリ: https://github.com/gosuda/HQQ