HQQ: キャッシュライン共有メモリを介したGoからのCライブラリの隔離

gosuda.org/hqq は、Go および C11 向けの zero-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 サービスです。その後、成熟した C ライブラリが必要になります。組み込みリレーショナルストレージのための SQLite、辞書圧縮のための Zstandard(libzstd)、あるいはハードウェアアクセラレーションを利用するメディアまたは暗号ライブラリなどです。
cgo を有効にして C ライブラリを Go プロセスにリンクすると、目前のバインディングの問題は解決しますが、次の 3 つの構造的な弱点が生じます。
- ツールチェーンとクロスコンパイルの結合:
CGO_ENABLED=1では、対象となる OS とアーキテクチャごとに C コンパイラ、ターゲットの libc ヘッダ、およびプラットフォームのリンカが必要になります。darwin/arm64のワークステーションからlinux/arm64やwindows/arm64の成果物をビルドする場合、単一のgo buildコマンドがクロスコンパイラと sysroot の組み合わせの行列へと変わります。 - ランタイムのスケジューリングオーバーヘッド:
cgoの各呼び出しは Go ランタイムの境界を越え、拡張可能な goroutine スタックからシステムスタックへ切り替わり、OS スレッド(M)を固定します。細粒度の呼び出しでは、遷移コスト(呼び出し 1 回あたり約 50~150 ns)とスケジューラの競合が急速に蓄積します。 - 障害ドメインの崩壊: C コードは Go ランタイムと同一の仮想アドレス空間で実行されます。C 関数内での範囲外書き込み、解放後使用、スタックオーバーフロー、または
abort()は、1 つでも発生すると 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 によるインプロセスリンク | No | なし(共有ヒープ) | 0 | 0 から 1 以上 | 上流 C と完全一致 |
| ソケット / パイプによるサブプロセス RPC | Yes | プロセス境界 | 2 から 4 | 複数(フレーミング + I/O) | 上流 C と完全一致 |
| 機械的な C から Go への翻訳 | Yes | インプロセスの Go | 0 | 複雑な呼び出しで多い | エミュレートされた C ランタイム |
| HQQ 共有メモリ IPC | Yes | プロセス境界 | 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 とその C11 対応部分である c/ は、POSIX mmap(Linux および macOS)および Win32 CreateFileMappingW(Windows)を用いて、2 つの共有メモリトランスポートモードを実装しています。すべての共有セグメントは、リングインデックスが読み取られる前に、プロトコルバージョン、バッファの形状、およびエンドポイントの PID を固定する、検証済みの HQQ_STL3 スーパーブロックで始まります。
すべてのホットな制御ワードおよびスロットヘッダは 64 バイトのキャッシュライン境界に整列されているため、生産者と消費者のコアが偽共有によって同一のキャッシュラインを競合することはありません。
1StandardLink スロットレイアウト(64 バイトのキャッシュライン整列)
2バイトオフセット: 0x00 0x08 0x10 0x40
3 +-------------------+-------------------+-----------------------------+
4ヘッダライン: | seq (u64 atomic) | len (u32) | flags | reserved (zero-padded) |
5 +-------------------+-------------------+-----------------------------+
6ペイロード領域: | payload bytes [0 .. BufferSize - 1] (directly encoded / decoded) |
7 +---------------------------------------------------------------------+
| フィールド | オフセット | サイズ | 意味 |
|---|---|---|---|
seq | 0x00 | 8 B | 単調増加するシーケンスカウンタ。奇数値は書き込み進行中を示し、偶数値は store-release によってコミット済みのフレームを公開する。 |
len | 0x08 | 4 B | スロットに書き込まれたペイロードのバイト長(0 <= len <= BufferSize)。 |
flags | 0x0C | 4 B | フレーム制御ビット(継続、エラー状態、またはシャットダウン通知)。 |
payload | 0x40 | BufferSize | 中間コピーなしで、その場で読み書きされる連続したペイロード領域。 |
呼び出し元は、並行性の構造に応じて 2 つのトランスポート層から選択します。
StandardLink(1:1 SPSC セッション): シーケンスロック(SeqLock)によって保護された専用のリクエストおよびレスポンススロットを介して、クライアントとサーバーのセッションを組にします。高速経路では、書き込み側がマップされたスロットに直接シリアライズし、atomic store-release によりコミットします。読み取り側はユーザー空間で短時間スピンした後、OS のウェイクアッププリミティブ(Linux ではfutex、macOS では__ulock_wait、Windows ではWaitOnAddress)にフォールバックします。単一フレームの往復は0 allocs/opで約 200 ns で完了します。Advanced(MPMC ディスパッチリング): ロックフリーの多生産者多消費者投入リング、共有スロットプール、および索引付き完了テーブルを用いて、N個の C ワーカースレッドに対してM個の並行 Go 呼び出し元を多重化します。呼び出し元はスロットを確保し、スロットインデックスをリングに公開し、ワーカースレッドがリクエストを順不同にデキューして完了させる間、自身の完了エントリで待機します。
両方の層は、ピアの生存状態を継続的に追跡します。各エンドポイントは Heartbeat() を介して atomic な単調増加タイムスタンプを更新し、待機中には PeerAlive(timeout) を確認します。ワーカープロセスが呼び出しの途中でクラッシュした場合、ピアは古くなったハートビートまたは停止した PID を即座に検出し、ブロックされた待機を ErrPeerGone によって中断します。また、共有メモリファイルを残すことなく、アトミックなセッションの Takeover をサポートします。
2. ゼロアロケーション RPC と世代検査付きハンドル(gosuda.org/hqq/bindgen)
数十の関数を持つ C ライブラリに対して共有メモリのフレーミングを手作業で記述することは、煩雑で誤りを生じやすい作業です。hqq-bindgen は、Interface Definition Language(.idl)ファイルを読み取る(または hqq-bindgen init を通じて C の .h ヘッダから直接ブートストラップする)スキーマコンパイラであり、zero-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 つの不変条件を満たすセッションごとのハンドルテーブルを維持します。
- 世代検証: すべての検索は、スロットが占有されていること、格納された世代がハンドルの上位 32 ビットと一致すること、およびそのハンドル型タグがパラメータ型(
DbとStmt)と一致することを検証します。閉じられたハンドルを再利用すると、C ライブラリを呼び出す前にHQQRPC_E_BAD_HANDLEで即座に失敗します。 - 階層的所有権(
@owned_by):Stmtは@owned_by(Db)で注釈されているため、Dbハンドルを閉じると、そのDbが所有する生存中のすべての子Stmtハンドルを自動的にたどり、割り当ての逆順でそれらのデストラクタを実行します(sqlite3_close_v2の前にsqlite3_finalize)。 - セッション終了時の一掃: 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() を実行すると、カーネルが実行する前に別のプロセスがファイルを上書きまたは差し替えることのできる、TOCTOU(Time-of-Check to Time-of-Use)の時間窓が残ります。gosuda.org/hqq/secureexec は、この時間窓を塞ぎます。
- Linux では、
secureexecはバイナリを開き、memfd_create(MFD_CLOEXEC | MFD_ALLOW_SEALING)によって作成された匿名メモリファイルディスクリプタにその内容をストリーミングしながら SHA-256 ハッシュを計算し、ハッシュを固定された 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 によるクリーンアップハンドラを実行できない場合であっても、ワーカーが親より長生きして孤立プロセスとなることは決してあってはなりません。
gosuda.org/hqq/launch は、--parent-stdin フラグを付けてワーカーの標準入力に渡される匿名 OS パイプを通じて、ライフタイムの結合を強制します。Go の親プロセスはパイプの書き込み側を保持し、そこへは決して書き込みを行いません。C ワーカーは専用のライフライン用スレッド上で読み取り側を監視します。Go プロセスが正常終了、パニック、またはカーネルによる強制終了(SIGKILL または OOM killer)された場合、カーネルは直ちに書き込み側のディスクリプタを閉じます。C ワーカーはマイクロ秒以内にパイプ上で EOF を受け取り、実行中の呼び出しを中断し、セッションのハンドルデストラクタを実行し、共有メモリファイルの関連付けを解除し、正常に終了します。
クイックスタート: 生成されたサービスの呼び出し
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) および低レベルの共有メモリリングプリミティブ
3├── advanced.go / slotpool.go # Advanced (MPMC) ディスパッチャ、スロットプール、および完了テーブル
4├── shm_*.go / wait_*.go # OS 固有の mmap/CreateFileMapping および futex/ulock/WaitOnAddress
5├── c/ # C11 トランスポートライブラリ (hqq.c) および RPC サーバーランタイム (hqq_rpc.c)
6├── bindgen/ # hqq-bindgen IDL パーサ、検証器(V1~V41)、および Go/C11 コードジェネレータ
7│ └── rt/ # 生成されたサービス向けのゼロアロケーション Go クライアントランタイム
8├── secureexec/ # SHA-256 / Ed25519 マニフェスト検証および memfd 封印付き実行
9├── launch/ # --parent-stdin ライフライン管理を備えたワーカープロセススーパーバイザ
10├── cmd/hqq-manifest/ # リリースバイナリマニフェストを生成および署名するための CLI ツール
11└── spec/ # 実行可能なワイヤ仕様および golden テストベクタ(BINDGEN.md)
HQQ が、Go エコシステムで最も広く使用されている 2 つの cgo ライブラリに対する、本番環境向けのドロップイン代替をどのように実現しているかを確認するには、このスタック上に構築された関連パッケージをご覧ください。
gosuda.org/sqlite3:github.com/mattn/go-sqlite3と互換性のあるドロップインdatabase/sqlドライバであり、パイプライン化されたバッチプリフェッチを伴って、共有メモリ上でネイティブ C の SQLite3.54.1を実行します。gosuda.org/zstd:github.com/DataDog/zstdのドロップイン代替であり、ゼロアロケーションのコンテキストとバルクバッチ処理を備えた隔離された共有メモリワーカー内で、ネイティブ C のlibzstd 1.5.7を実行します。- GitHub リポジトリ: https://github.com/gosuda/HQQ