1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

C++ コアエンジンと Avalonia UI を別プロセスにする場合の構成を考えてみた

1
Last updated at Posted at 2026-05-25

はじめに

前回記事: C++アプリケーションのUIをAvaloniaにするのは現実的か?

本記事はその続編です。前回は、C++ コアエンジンと Avalonia UI を 同一プロセス内で狭い C-ABI 境界で直結する 構成を論じ、現代の .NET の進化があればこれは十分現実的だ、と結論付けました。

ただ、書き終えてから読み返すと、論点を一つ逃していました。実務では クラッシュ分離、ライセンス境界、ヘッドレス CI 運用、Avalonia 以外のクライアントとの共存 などの理由で、プロセス分離が要件になることが少なくありません。前回 4.4 節ではこれを「複雑度が増すので過剰」と片付けてしまい、「ではプロセス分離が要件として確定したとき、具体的にどう設計するのか」 という本題を回避していました。

とりわけ、私自身が念頭に置いている sukimasim(SystemVerilog シミュレータ)+ SukimaDebug(波形デバッガ GUI)のような EDA / 検証ツール領域 では、プロセス分離はむしろ業界標準です。Cadence の Xcelium + SimVision / Verisium Debug、Synopsys の VCS + Verdi、Siemens EDA(旧 Mentor)の Questa + Visualizer ── どれもシミュレーション実行エンジン、波形 DB、GUI デバッグ環境が明確に分離・連携する構成です。前回 4.4 節の「シンプルなデスクトップアプリでは過剰」という但し書きは、この種の検証ツールには当てはまらなかった、と訂正しておきます。

そこで本記事のテーマは:

C# Avalonia UI プロセスと C++ コアエンジンプロセスを別プロセスとして分離する場合、何をどう設計すべきか?

なお、前回構成を否定する意図はありません。動機が無いなら前回構成のままが正解 で、本記事はあくまで「分離せざるを得ないとき」の見取り図です。

この記事の要点

「同一プロセス + 狭い C-ABI」は最速・最シンプルですが、それが採れない理由があります。クラッシュ分離、ライセンス分離、GPL/商用ライセンス境界、ヘッドレスサーバ運用、複数 UI からの共有 ── どれか一つでも当てはまるなら、プロセス分離を検討する価値があります。

その場合、IPC 機構の選択肢は無数にあるように見えますが、「機能性 × 速度 × クロスプラットフォーム」の三軸を全部それなりに満たす単一技術は存在しません。最も実務的なのは:

  • 制御プレーン(コマンド、設定、エラー、メタデータ、進捗): UDS(Windows では Named Pipe / TCP loopback)+ gRPC + protobuf。スキーマ駆動、双方向ストリーミング、観測性、認証 ── 機能セットの厚みが効く層
  • データプレーン(波形、トレース、AST、大量バイナリ): 共有メモリ + リングバッファ。コピーコストを排除したい層。ただし .NETMemoryMappedFile.OpenExisting(name) は Windows 限定 API なので、Unix では実ファイルバックト mmap で接続するのが現実解

両者を同じプロセスペアの中で 役割で分離する のがハイブリッド構成の核です。Microsoft Learn の公式 gRPC IPC ガイダンスも UDS と Named Pipe を「同一マシン IPC で TCP より効率的」と明示しており、商用 EDA や HFT(高頻度取引)系のシステムでも同様の階層分けが定石として採られています。

ただし注意点もあります。プロセス分離は 設計の複雑度を確実に上げます。同一プロセス C-ABI で済むなら、原則そちらが正解です。本記事はあくまで「プロセス分離が要件として確定した」場合の現実解を示すものです。


1. なぜプロセスを分けるのか ── 動機の棚卸し

前回記事のアーキテクチャ(C# プロセスに C++ を .so/.dll/.dylib として dlopen する)は、シンプルさと性能の両方で他のどんな構成にも勝ちます。境界はインプロセス関数呼び出しと変わらず、protobuf シリアライゼーションも IPC スタックも介在しません。これを捨ててまでプロセスを分ける動機は何か、を最初に整理しておきます。

1.1 クラッシュ分離

C++ エンジンのバグで segfault しても UI プロセスは生き残る、という性質。デバッガ系・シミュレータ系のツールでは事実上必須に近い要件です。同一プロセスだと C++ 側の不正アクセスがそのまま C# プロセス全体をダウンさせ、ユーザーが直前まで編集していた状態を失います。商用 EDA ツールで GUI(SimVision / Verdi / Visualizer など)とシミュレーション本体(Xcelium / VCS / Questa)が分離されているのは、まさにこの理由です。

1.2 ライセンス境界

これは技術的というより法務的な動機です。

  • GPL/LGPL の論点を減らせる: C++ 側に GPL ライブラリを使う場合、同一プロセスでリンクするとマネージド側まで巻き込みかねないという懸念があります。プロセス境界を切ることはこの論点を減らす有力な設計上の手段ですが、FSF の GPL FAQ も指摘するとおり、通信機構の種類だけでなく、交換されるデータの粒度や両者の結合度、配布形態にも依存 します。特に、共有メモリで複雑な内部データ構造をやり取りする場合は、評価が変わり得ます。本記事は法的助言ではないので、商用配布では必ず法務レビューを通してください。GPL と LGPL は条項が異なる(LGPL は動的リンクや再リンク可能性などの論点が別途ある)ため、それぞれ個別に評価する必要があります
  • 商用ライブラリの再頒布制約: C++ コア側に再頒布禁止の商用ライブラリを抱える場合(EDA 系ツールや業界特化ライブラリで頻出)、UI 側は OSS として公開し、エンジン側だけクローズドにする、という分離が成立します

1.3 ヘッドレス運用との両立

エンジン側を ヘッドレスサーバとしても運用したい ニーズ。CI のリグレッションテストはエンジンだけ走らせたい、クラウドのワーカーノードに UI は要らない、という要件です。プロセス分離していれば、エンジン側をそのまま systemd ユニットや Docker コンテナとして起動でき、UI 側は開発時だけアタッチする、という運用が自然になります。

1.4 複数 UI / 複数言語クライアントからの共有

エンジンに対して、Avalonia UI 以外のクライアント(VS Code 拡張、CLI、Web UI、Python スクリプト)からも接続したい場合。プロセス分離 + gRPC は、.proto から各言語のスタブを自動生成できるので、この要件と相性が極めて良いです。同一プロセス C-ABI で C# 以外も呼ぶには、別言語ごとに FFI バインディングを書き直す必要があります。

1.5 動的なエンジン交換

エンジン側のバージョンを UI を再起動せずに差し替えたい ケース。LSP(Language Server Protocol)や Jupyter カーネルがプロセス分離している主因がこれです。.sodlopen した状態では、ファイル差し替え + 再起動が必要になります。

1.6 セキュリティ境界

エンジンを 限定権限の OS ユーザーで動かす、サンドボックスに閉じ込める、SELinux/AppArmor のプロファイルを別に適用する、といった隔離は、プロセス分離があって初めて成立します。

1.7 ── そして、これらに当てはまらないなら?

ここまでの動機のどれにも該当しないなら、前回記事の「同一プロセス C-ABI」のままが正解です。プロセス分離は 常に複雑度を上げます: シリアライゼーション、スキーマ管理、ライフサイクル管理、エラー伝播、観測性、デプロイ ── どれもタダではありません。

観点 同一プロセス C-ABI プロセス分離 IPC
関数呼び出しコスト 数ナノ秒(普通の関数呼び出し) 数µs〜数十µs(IPC + serialize)
デバッグ mixed-mode debugger で一気通貫 二つのデバッガを別々にアタッチ
デプロイ バイナリ 1 つ(+ .so) 二つのプロセスのライフサイクル管理
クラッシュ伝播 C++ のクラッシュが UI まで波及 UI は生存。エンジン再起動だけで復旧可能
スキーマ管理 C ヘッダ 1 枚 .proto + バージョニング戦略
観測性 デバッガ + ログ gRPC reflection、トレース、メトリクス
複数言語クライアント 言語ごとに FFI 必要 gRPC スタブ自動生成

「複雑度の上がり方が、得られるメリットに見合うか」を冷静に評価することが、プロセス分離設計の出発点です。


2. IPC 技術の地図 ── 何が選択肢にあるのか

プロセスを分ける、と決めた瞬間に、「では何で通信するのか」という選択肢が一気に広がります。Microsoft Learn の「Inter-process communication with gRPC」では、UDS と Named Pipe を中心に整理されていますが、世の中の選択肢全体を見渡すと以下のとおりです。

2.1 主要 IPC 技術の見取り図

分類 技術 同一マシンで使えるか クロスプラットフォーム 帯域・遅延の特性 スキーマ管理
ソケット系 TCP loopback TCP スタック分のオーバーヘッド 自前
UDS(Unix Domain Socket) Linux/macOS/Windows10+ TCP より速い 自前
Named Pipe Windows ◎ / Linux・macOS: POSIX FIFO や .NET NamedPipeStream は別物。ASP.NET Core/gRPC の ListenNamedPipe は Windows 前提 UDS と同程度 自前
RPC フレームワーク gRPC(over TCP/UDS/Pipe) HTTP/2 オーバーヘッド + serialize protobuf で IDL 駆動
Cap'n Proto RPC wire format = in-memory layout で encode/decode コストを構造的に排除 Cap'n Proto IDL
Apache Thrift gRPC 同等 IDL あり
Microsoft Bond / FlatBuffers ゼロコピー寄り IDL あり
共有メモリ系 file-backed mmap(Unix 推奨)/ Win32 named File Mapping(Windows)/ POSIX shm OS ごとに方式が異なる(6.2 / 7.3 詳述) 桁違いに高速(ゼロコピー) 自前
iceoryx Linux/Win/macOS サブµs オーダーの pub/sub IDL なし
LMAX Disruptor / Aeron HFT クラス 自前
ゼロコピー混在 Cap'n Proto on shm shm の速度 + IDL の安心感 Cap'n Proto

2.2 「機能 × 速度 × クロスプラットフォーム」の三軸

ここで重要なのは、これらの技術はレイヤーが違う ということです。

  • トランスポート層: TCP / UDS / Named Pipe / 共有メモリ
  • シリアライゼーション層: protobuf / Cap'n Proto / FlatBuffers / JSON / 自前バイナリ
  • RPC レイヤ: gRPC / Thrift / 自前

つまり「gRPC + protobuf + UDS」は、RPC × シリアライザ × トランスポート の組み合わせを一つ選んだ結果に過ぎません。組み合わせを変えれば挙動も変わります。

世の中の論争(「gRPC は遅い」「いや UDS なら十分」「共有メモリの方が速い」)の多くは、このレイヤーの違いを混同したまま行われています。本記事では 層ごとに評価して、層ごとに最適なものを選ぶ という姿勢で進めます。


3. なぜ UDS + gRPC + protobuf が制御プレーンの第一候補か

3.1 UDS(Unix Domain Socket)を選ぶ理由

Microsoft Learn の「Inter-process communication with gRPC and Unix domain sockets」(learn.microsoft.com/en-us/aspnet/core/grpc/interprocess-uds)は、UDS の利点を次のように整理しています。

「Unix domain sockets (UDS) is a widely supported IPC transport that's more efficient than TCP when the client and server are on the same machine.」

具体的な利点を整理すると:

  • オーバーヘッドが少ない: TCP/IP スタック(チェックサム計算、輻輳制御、ルーティング判定、ソケットバッファのコピー)をスキップする
  • ポートが不要: ポート番号の枯渇や衝突を考えなくてよい
  • OS のセキュリティモデルが使える: ファイルパーミッション(chmod/chown)でアクセス制御できる。localhost:50051 だと同じユーザーの他プロセスが繋ぎに行けてしまうが、UDS なら Unix の権限モデルで弾ける
  • ネットワークに露出しない: 誤って外部から到達されるリスクがゼロ

そして本記事の文脈で大きいのは:

  • Windows 10 / Server 2019 以降でネイティブサポート: Microsoft の公式ドキュメントが「Unix domain sockets (UDS) is the best choice for building cross-platform apps, and it's usable on Linux, macOS, and Windows 10/Windows Server 2019 or later」と明記しています。クロスプラットフォーム単一実装に最も近いトランスポートになりました
  • .NET 側のサポートが成熟: ASP.NET Core の Kestrel は ListenUnixSocket(socketPath) で UDS をリッスンでき、Grpc.Net.Clientunix:///path 形式のアドレスをカスタムコネクションファクトリ経由でサポート

3.2 Named Pipe ── Windows 中心ならこちら

.NET 8 以降、ASP.NET Core / gRPC は Named Pipe を組み込みサポート しました(Microsoft Learn「Inter-process communication with gRPC and Named pipes」:.NET 8 以降の Windows 環境が前提)。

観点 UDS Named Pipe
Windows サポート Win10/Server 2019+ で AF_UNIX が動く(.NET 経由) 全バージョンでネイティブ
Linux/macOS サポート ネイティブ POSIX FIFO は別物。ASP.NET Core / gRPC 文脈の Named Pipe は実質 Windows 専用
クロスプラットフォーム一発書き 最も近い Windows 偏重
Windows セキュリティ統合 ファイルパーミッション(SO_PEERCRED 等で補強可) PipeSecurity で ACL 細かく
Microsoft 推奨 クロスプラットフォーム時 Windows 専用時

注意: POSIX の FIFO(mkfifo)も日本語で「名前付きパイプ」と呼ばれますが、Windows Named Pipe や ASP.NET Core の ListenNamedPipe とは別物です。本記事中の「Named Pipe」は gRPC / ASP.NET Core 文脈 の Windows Named Pipe を指します。

つまり「クロスプラットフォーム第一」なら UDS、「Windows 専用・Windows 重視」なら Named Pipe、という分け方になります。前回記事の Avalonia アーキテクチャは Linux/macOS も視野に入れる前提なので、UDS を主軸に、必要なら Windows で Named Pipe または TCP loopback にフォールバック という戦略が素直です。

3.3 gRPC を選ぶ理由

「ローカル IPC なら自前プロトコルの方が速いのに、なぜ gRPC を選ぶのか?」── という問いは正当です。素の UDS + 自前バイナリプロトコルの方が、レイテンシだけ見れば確実に速い。それでも gRPC を選ぶ理由は 機能性とエコシステム にあります。

  • スキーマ駆動: .proto ファイルからクライアント・サーバ両側のスタブを自動生成。インターフェース変更時の事故(片側だけ更新して動かなくなる)が激減
  • 双方向ストリーミング: 進捗報告、ログ転送、イベント通知などの「サーバから能動的にプッシュ」を、HTTP/2 のストリームで自然に表現できる
  • エコシステム: gRPC reflection、grpcurl での手動デバッグ、OpenTelemetry での分散トレーシング、deadline/cancellation の伝播、認証(mTLS / トークン)── 商用品質に必要なものが揃っている
  • 多言語対応: C# / C++ / Python / Go / Rust / Java / Node.js などのスタブ生成が同一の .proto から行える。前述 1.4 の「複数 UI / 複数言語クライアント」要件と直結

3.4 protobuf を選ぶ理由

シリアライゼーションフォーマットの選択肢は実は多数ありますが、「IDL + 多言語 + 後方互換」 という三点セットの実績で protobuf が頭ひとつ抜けています。

  • タグベースのワイヤフォーマット: フィールド番号でエンコードするため、フィールドの追加・削除・並べ替えが後方互換を保ったまま行える
  • oneofmapAnygoogle.protobuf.Timestamp などの基本型 が揃っている
  • *.proto がそのままドキュメント: 別途インターフェース仕様書を書かなくても、.proto ファイルがそれを兼ねる

Cap'n Proto / FlatBuffers は wire format がそのままメモリレイアウトなので encode/decode のステップ自体が存在せず(Cap'n Proto 公式自身が "Cap'n Proto gets a perfect score because there is no encoding/decoding step" と説明)、CPU コストの観点では構造的に有利です。一方、protobuf は tag/length/value のエンコードが入る代わりに 圧縮率が高く、言語サポート・ツール成熟度・後方互換の枯れ具合で頭ひとつ抜けています制御プレーン用途では encode/decode コストは支配要因にならない(後述の通り、データプレーンは別レイヤに分離するため)ので、protobuf を選んで困る場面はあまりありません。

3.5 制御プレーンとしての三点セット

以上をまとめると、UDS + gRPC + protobuf という組み合わせは:

  • 同一マシン IPC として TCP より速く、十分にローカル最適化されている(UDS)
  • スキーマ駆動・多言語・観測性・ストリーミングを一式提供(gRPC)
  • 後方互換を保ったままインターフェースを進化させられる(protobuf)

という「機能性とローカル速度のバランスが最もよい既製品」になります。「単一技術で素の UDS + 自前プロトコル以上に速くて、しかも機能で勝てるもの」は存在しません(Cap'n Proto RPC が一番近い対抗馬ですが、エコシステムの差は依然大きい)。


4. 「でも、それで全部済むのか?」── データプレーンの問題

ここまでで制御プレーンは UDS + gRPC + protobuf でほぼ決まりです。問題はもう一方の軸 ── 大量データを高頻度で動かしたいケース です。

4.1 EDA / シミュレータ系で典型的に発生する負荷

具体例を挙げると:

  • 波形ダンプ: シミュレーション中、1 サイクルごとに数百〜数千の信号値が変化する。1 ns 刻みで 1 ms シミュレートすると 100 万サンプル。VCD / FSDB / FST のような波形形式は、こうした負荷を前提に作られている
  • AST / ネットリスト共有: 一度パースした 100 万ノード規模の AST を、UI と複数の解析プロセスで共有したい
  • カバレッジデータベース: ライン・トグル・FSM・SVA・関数カバレッジを集計したテーブル。サイズは数十 MB〜数 GB
  • ヒストグラム・統計情報の高頻度更新: シミュレーション中にリアルタイムで UI に反映したい

これらを 全部 gRPC で運ぼうとすると、HTTP/2 のフレーミング + protobuf エンコード + UDS のシステムコール が呼び出しごとに発生し、データ量が大きくなると無視できないオーバーヘッドになります。

4.2 gRPC over UDS の現実的なレイテンシ目安

ざっくりの相対比較(数値はワークロード依存で大きく変わるので、あくまでオーダー感):

方式 レイテンシ感
共有メモリ + lock-free リングバッファ サブµs
素の UDS + 自前プロトコル 数µs
gRPC over UDS 数十µs
gRPC over TCP(loopback) 数十〜100µs+
REST/JSON over TCP 数百µs 〜 ms

gRPC over UDS は「便利さと速さのバランス点」であって、純粋な速度勝負ではゼロコピー系に勝てません。1 サイクル単位のシミュレーションコールバック のような呼び出しを gRPC で運ぶと、数十µs × 数百万回 = 数秒〜数分のオーバーヘッドが積み上がり、致命的になります(この表のオーダー感は両側から実測で確認しています ── 「共有メモリ + lock-free リングバッファ: サブµs」は Appendix A.2.5 で p50 232 ns、「gRPC over UDS: 数十µs」は Appendix A.2.4 で p50 144 µs。階層差は約 620 倍)。

4.3 共有メモリの導入動機

ここで共有メモリの出番です。

  • コピーが発生しない: 同じ物理メモリページを両プロセスの仮想アドレス空間にマップするので、データの「転送」自体がない
  • 同期コストだけが残る: lock-free リングバッファや atomic を使えば、システムコールすら避けられる
  • 大量データに強い: GB クラスの共有でも、マッピングするだけならコストは一定

弱点は明確で、

  • 同期と整合性を全部自前で管理する必要がある: メモリバリア、可視性、torn read を避ける書き込み順序、producer/consumer の役割分担
  • スキーマが弱い: バイナリレイアウトを両側で一致させる、バージョン互換性を確保する、といったことは自前で
  • デバッグしづらい: gRPC のように grpcurl で覗ける、というわけにはいかない

つまり共有メモリは、機能性をスキーマも観測性も全部捨てる代わりに、生のメモリ帯域に到達する という極端な選択肢です。

4.4 結論: 役割分担しかない

ここまでの整理から導かれる結論は単純です:

「機能性のあるレイヤ」と「速度のレイヤ」を分けて、それぞれの最適解を当てる

これがハイブリッド構成の動機です。


5. ハイブリッド構成の全体像

5.1 アーキテクチャ図

前回記事の「狭い C-ABI 境界」を、IPC 構成に置き換えるとこうなります:

┌──────────────────────────────────────────────────────┐
│  C# プロセス (Avalonia UI)                            │
│   ├─ View / ViewModel (XAML, MVVM)                   │
│   ├─ Dispatcher.UIThread                             │
│   ├─ Engine RPC Client (gRPC, Grpc.Net.Client)       │
│   └─ Shared Memory Consumer                          │
│      (MemoryMappedFile.CreateFromFile on Unix /      │
│       OpenExisting on Windows ── 6.2 詳述)           │
│        ▲                  ▲                          │
│        │ Control plane    │ Data plane               │
│        │ (gRPC / UDS)     │ (shm + ringbuffer)       │
│ ───────┼──────────────────┼──────────────────────────│
│        ▼                  ▼                          │
│  C++ コアエンジンプロセス                              │
│   ├─ Engine RPC Server (grpc++, gRPC C-core)         │
│   ├─ Shared Memory Producer                          │
│      (file-backed mmap / Win32 named mapping /       │
│       POSIX shm ── 7.3 詳述)                          │
│   ├─ AST / ネットリスト / シミュレータ                  │
│   └─ Worker threads                                  │
└──────────────────────────────────────────────────────┘

5.2 制御プレーンが運ぶもの

.proto で定義し、gRPC over UDS で運ぶのに向いている情報:

  • セッション開始 / 終了 / ハートビート
  • コンパイル開始コマンド、解析オプション、include パス、+define
  • エラー・警告(Wpitfall 含む)の構造化レポート
  • 進捗・統計情報(サーバ側ストリーミングで継続プッシュ)
  • カバレッジサマリの取得
  • 共有メモリ領域のハンドオフ情報(方式パス / 名前サイズレイアウトバージョン)── 具体的な .proto は 8.1 を参照

頻度が低めで、構造が複雑で、後方互換性が必要なもの ── つまり 「スキーマで縛るのが正しい」もの がここに集まります。

5.3 データプレーンが運ぶもの

共有メモリ + リングバッファで運ぶのに向いている情報:

  • 波形サンプル(信号 ID + 時刻 + 値のレコードを高頻度に produce)
  • AST ノードの実体(一度作って何度も読まれる、巨大な構造)
  • シンボルテーブル(同上)
  • シミュレーション中のトレースイベント
  • カバレッジカウンタ(増分のみを共有メモリに書き、サマリは gRPC で取得)

高頻度・大容量で、「スキーマで縛るより、速度を取りたい」もの がここに集まります。

5.4 ハンドオフのシーケンス

両プレーンを連携させる典型的なシーケンス:

  1. 起動時: UI プロセスがエンジンプロセスを Process.Start で起動するか、systemd 等で先に常駐させておく(起動パターンの選択は 11 章で詳述)。エンジン側は UDS パスを bind() してリッスン開始
  2. 接続: .NET 側はクライアントの GrpcChannel に直接 unix:///... を渡さないGrpcChannel.ForAddress("http://localhost") を作り(これはダミーアドレス)、SocketsHttpHandler.ConnectCallback が実 transport を UnixDomainSocketEndPoint に差し替える(具体例は 6.1)。C++ grpc++ サーバ側のリッスン URI は unix:///tmp/myengine.sock(これは gRPC C-core の名前解決表記)。両者の文字列形式が違う点に注意
  3. ハンドシェイク: gRPC で OpenSession(SessionRequest) を呼ぶ。エンジンが共有メモリ領域を確保し、その識別情報(OS によって名前/パス/fd など方式が異なる ── 詳細は 6.2 / 7.3 / 8.1 参照)を SessionResponse で返す
  4. データプレーン確立: UI 側がその識別情報を使って共有メモリを自プロセスにもマップ
  5. ランタイム: エンジンが波形を共有メモリに書く。書いた旨の 「通知」だけ を gRPC のサーバストリーミング(stream WaveformChunkReady)で送る。UI は通知を受けて共有メモリから直接読む
  6. 終了: UI が CloseSession を gRPC で呼ぶ。両側が共有メモリをアンマップして解放

ポイントは、データそのものは gRPC のメッセージに乗せない ことです。gRPC はあくまで「メタデータと通知のチャネル」として機能します。


6. .NET 側の実装ポイント

6.1 gRPC over UDS のセットアップ

Microsoft Learn のサンプルに沿うと、サーバ側(C++ ではなく逆向きに「C# がサーバ」の例ですが構造は同じ):

var socketPath = Path.Combine(Path.GetTempPath(), "myengine.sock");

var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(serverOptions =>
{
    serverOptions.ListenUnixSocket(socketPath, listenOptions =>
    {
        listenOptions.Protocols = HttpProtocols.Http2;
    });
});

クライアント側(C# 側がクライアントの場合)は、Grpc.Net.Client で UDS 用のコネクションファクトリを与えます:

public sealed class UnixDomainSocketsConnectionFactory(EndPoint endPoint)
{
    public async ValueTask<Stream> ConnectAsync(
        SocketsHttpConnectionContext _,
        CancellationToken cancellationToken = default)
    {
        var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
        try
        {
            await socket.ConnectAsync(endPoint, cancellationToken).ConfigureAwait(false);
            return new NetworkStream(socket, ownsSocket: true);
        }
        catch
        {
            socket.Dispose();
            throw;
        }
    }
}

public static GrpcChannel CreateChannel()
{
    var socketPath = Path.Combine(Path.GetTempPath(), "myengine.sock");
    var udsEndPoint = new UnixDomainSocketEndPoint(socketPath);
    var connectionFactory = new UnixDomainSocketsConnectionFactory(udsEndPoint);

    var socketsHttpHandler = new SocketsHttpHandler
    {
        ConnectCallback = connectionFactory.ConnectAsync
    };

    return GrpcChannel.ForAddress("http://localhost", new GrpcChannelOptions
    {
        HttpHandler = socketsHttpHandler
    });
}

Microsoft の公式ドキュメントが注記している重要な制約:

「Some connectivity features of GrpcChannel, such as client side load balancing and channel status, can't be used together with Unix domain sockets.」

ローカル IPC で使う分には、これらの機能はまず要らないので実害はありません。

6.2 共有メモリ ── MemoryMappedFile(OS で実装方針が分かれる重要な落とし穴)

.NET 側は System.IO.MemoryMappedFiles を使いますが、ここは Qiita 記事を書くまでに私自身が一番ハマって調べ直した部分 です。MemoryMappedFile には Windows 限定の API が含まれており、Unix で素朴に同じコードを書くと例外で落ちます。

MemoryMappedFile.OpenExisting(name) は Unix では使えない

Microsoft Learn の公式 API リファレンスを見ると、MemoryMappedFile.OpenExisting(string mapName) 系のオーバーロードには [SupportedOSPlatform("windows")] 属性が明示されています。dotnet/runtime のテストにも次のような Unix 側の挙動確認テストが存在します。

[PlatformSpecific(TestPlatforms.AnyUnix)]  // Map names unsupported on Unix
public void MapNamesNotSupported_Unix(string mapName)
{
    Assert.Throws<PlatformNotSupportedException>(() => MemoryMappedFile.OpenExisting(mapName));
}

つまり、「C++ が shm_open("/myengine.waveform.v1") で作り、C# が MemoryMappedFile.OpenExisting("myengine.waveform.v1") で同じセグメントを開く」という素朴な構成は、Linux/macOS では成立しません。POSIX shm の名前空間と .NET の名前付き MMF の名前空間は接続されていません。

では Unix でどうつなぐか ── 4 つの選択肢

  1. 実ファイルバックト mmap(最も移植性が高い): エンジン側が XDG_RUNTIME_DIR(/run/user/<uid>/)配下や /tmp に一時ファイルを作り、mmap(MAP_SHARED, fd, ...) でマップする。C# 側は MemoryMappedFile.CreateFromFile(path, ...) で同じファイルを開く。ファイルパスを gRPC 経由で渡す
  2. /dev/shm/ 上の実ファイル: Linux 限定。tmpfs 上の実ファイルとして見えるので、上の (1) と同じ作法で扱える
  3. shm_open を C# 側から P/Invoke: 名前で繋ぎたい場合、libcshm_open を P/Invoke で呼んで fd を取り、SafeFileHandle に包んで FileStream を作り、MemoryMappedFile.CreateFromFile(FileStream, ...) に渡す。あるいは mmap 自体も P/Invoke で扱う(やや手間だが原理は単純)
  4. UDS の SCM_RIGHTS で fd 渡し: 最も Unix らしい設計だが、注意点が多い。SCM_RIGHTS は sendmsg/recvmsg の ancillary data として fd を渡す Linux/macOS 固有の仕組みで、protobuf メッセージに fd を直接乗せるわけではない。gRPC とは別に raw UDS チャネルを用意するか、sendmsg を P/Invoke / native helper で扱う必要がある。.NET の標準 Socket API では現時点でも気軽には扱えず(dotnet/runtime#932 は 2019 起票で 2026 年時点も open。メンテナも「現状は P/Invoke でネイティブ関数を直接呼ぶしかない」と回答している)、また Windows の AF_UNIX 実装は ancillary data 非対応(Microsoft Devblogs "AF_UNIX comes to Windows" で明記)なので、クロスプラットフォーム前提なら採用しない方が無難

クロスプラットフォーム単一実装に最も近いのは (1) 実ファイルバックト mmap です。本記事ではこの方針を推奨します。

Windows ではどうするか

Windows では MemoryMappedFile.CreateNew(name, size) / OpenExisting(name) がそのまま動きます。Win32 File Mapping のページファイルバックト named mapping にマップされます。

つまり、OS でハンドオフの「識別情報」を変える 必要があります。先述の 5.4 シーケンス、および 8.1 の .proto でこれを表現します。

C# 側のサンプルコード(ファイルバックト mmap、概念例)

using System.IO.MemoryMappedFiles;

// エンジン側がハンドオフしてきたパスでファイルを開く(Unix / Windows 両方で動作)
string mmapFilePath = handoffInfo.PathOrName;  // 例: /run/user/1000/sukimasim/waveform-<sid>.bin
using var mmf = MemoryMappedFile.CreateFromFile(
    mmapFilePath,
    FileMode.Open,
    mapName: null,            // Unix では必ず null。Windows でも null で OK
    capacity: 0,              // 0 = ファイルサイズに従う
    MemoryMappedFileAccess.ReadWrite);
using var accessor = mmf.CreateViewAccessor();

// レイアウト: 先頭にヘッダ(リングバッファの read/write index)、続いてサンプル領域
long writeIdx = accessor.ReadInt64(0);
long readIdx  = accessor.ReadInt64(8);

while (readIdx < writeIdx)
{
    // サンプル 1 個ぶん読み出し
    accessor.Read(HeaderSize + (readIdx % Capacity) * SampleSize, out WaveformSample sample);
    HandleSample(sample);
    readIdx++;
}

// read index を進めて producer に「ここまで読んだ」を通知
accessor.Write(8, readIdx);

⚠️ このコードは API 使用法の概念例です。 実際に lock-free SPSC リングバッファとして使う場合、ReadInt64 / WriteInt64 はそれ自体で acquire / release 相当のメモリ順序保証を提供しません。本気で実装するなら、MemoryMappedViewAccessor 経由ではなく、AcquirePointer でポインタを取得し、Volatile.Read / Volatile.WriteInterlocked を使うか、Unsafe.ReadUnaligned + 明示的なバリアで扱う必要があります。また、共有メモリ上のレコードは固定幅の整数型、明示的なアラインメント、レイアウトバージョンヘッダ、エンディアン規約を定め、ポインタを含めないことが前提です。これらの注意点は 6.3 と 7.4 で続けて扱います。

6.3 同期 ── これが一番事故るポイント

共有メモリ + リングバッファの典型実装パターン:

パターン 適性 難易度
SPSC(Single Producer Single Consumer) エンジン → UI の波形流しに最適 低(lock-free で書ける)
MPSC(Multi Producer Single Consumer) エンジン内の複数スレッドから UI へ集約
MPMC(Multi Producer Multi Consumer) 一般的な pub/sub

「複数の SPSC を並列に立てる」設計が、性能と実装難易度のバランスで最も無難です。MPMC が必要なら、std::folly::ProducerConsumerQueue 系のライブラリか、後述の cloudtoid/interprocess などの既製品を使うのが堅実です。

メモリバリアの選び方は、x86 では誤魔化しが効きますが ARM では即座にバグります。最低限:

  • producer 側は「データを書く → release fence → write index を更新」
  • consumer 側は「write index を読む → acquire fence → データを読む」

を厳守する必要があります。std::atomicmemory_order_release / memory_order_acquire または .NET の Volatile.Write / Volatile.Read を正しく使います。

なお SPSC リングバッファそのものの動作確認(1M レコードでの sequence_no 連続性 / torn read 検出)は Appendix A.2.1(hybrid-e2e)で、単体ベンチでの throughput / latency / cross-process 動作は Appendix A.2.5(spsc-ringbuffer)で実施済みです ── ring buffer 単体の p50 latency は 232 ns、throughput は 20 M rec/s / 765 MB/s。cache-line padding 影響・ARM64 上でのメモリオーダリング再検証・並列 SPSC の cache contention などの細部チューニングは未実施(Appendix A.4 参照)。

6.4 通知 ── 共有メモリと gRPC の連携

「データを書いた」ことを consumer に知らせる方法は、性能要件で選びます:

方式 レイテンシ 注意点
busy polling(consumer がリングバッファを spin で監視) サブµs CPU 食う。常時走る UI には不向き
eventfd / 名前付きセマフォ 数µs OS の同期プリミティブ。クロスプラットフォーム性に注意
gRPC ストリームでイベント送信 数十µs 観測性・キャンセル伝播が gRPC エコシステムに乗る

UI 用途では「gRPC のサーバストリーミングで chunk_ready イベントを流し、それを受けて共有メモリを読みに行く」が圧倒的に書きやすいです。最終フレームの µs を削りたい HFT 系でなければ、これで十分です(この 3 方式のレイテンシ比較は Appendix A.2.4 で実測しています ── 60 Hz UI なら gRPC で問題ないことが p50 = 144 µs という具体数値で確認できます)。

6.5 既製品ライブラリの選択肢

ゼロから書きたくない場合の選択肢:

  • cloudtoid/interprocess(C#): クロスプラットフォーム共有メモリキューを提供する .NET ライブラリ。Linux/macOS/Windows 対応。ただし「.NET プロセス同士」が前提 で、内部の同期プリミティブ・バイナリレイアウトをそのまま C++ 側から再現できるわけではない点に注意
  • Phylliida/MemoryMappedFileIPC(C#): Pub/sub インターフェース。ただし Windows 限定(Linux/macOS 用の cross-process wait handle 実装が無いため)
  • Microsoft/IPC: Windows 限定の C++ + .NET wrapper。Microsoft Bond との統合あり
  • std-microblock/cpp-ipc(libipc): C++ 側で使える高速 IPC ライブラリ。Linux/Windows 対応、lock-free / spin-lock、STL 以外の依存なし
  • Eclipse iceoryx / iceoryx2(C++ / Rust): Bosch の自動運転ミドルウェアチーム(リーダー Michael Pöhnl)が 2019 年に POSIX 移植を Eclipse 化した、真のゼロコピー pub/sub。サブµs クラスのレイテンシ。ROS 2 Foxy Fitzroy リリース で zero-copy LoanedMessage API として初統合され、AUTOSAR Adaptive の ara::com 実装としても採用されている
  • Cap'n Proto: 共有メモリ上のバイトレイアウトを Cap'n Proto で IDL 化する使い方が可能。「ゼロコピー + スキーマ」を両立したい場合の有力解

EDA / シミュレータ系で「数µs 単位の応答性が欲しい」「pub/sub モデルで複数 consumer に配信したい」要件なら iceoryx が現時点では最有力です。C++ producer と C# consumer をまたぐ場合、既製ライブラリは「両側が同じバイナリレイアウト・同じ同期プロトコルで実装されているか」を個別に確認する必要があります。多くの C# 専用ライブラリは内部実装が公開仕様になっていないため、ここを誤ると後で苦労します。素直に 「C++ で確保、C# は MemoryMappedFile.CreateFromFile でファイルを開く」+ 同期プロトコルは自前実装 という最低限の構成が、結局一番ハマりにくいかもしれません。


7. C++ 側の実装ポイント

7.1 grpc++ のセットアップ

C++ 側で gRPC over UDS を立てるのは、gRPC C++ 公式ドキュメントが言うとおり URI スキーム unix:///path を渡すだけです。

#include <grpcpp/grpcpp.h>

int main() {
    EngineServiceImpl service;

    grpc::ServerBuilder builder;
    builder.AddListeningPort("unix:///tmp/myengine.sock",
                             grpc::InsecureServerCredentials());
    builder.RegisterService(&service);

    auto server = builder.BuildAndStart();
    server->Wait();
}

grpc.github.io/grpc/cpp/md_doc_naming.html によれば、unix:pathunix:///absolute_pathunix-abstract:abstract_path(Linux 抽象名前空間)が gRPC C-core でサポートされています。ただし同ドキュメントには「Unix systems only」と明記 されているため、Windows 上の grpc++ サーバで unix:///... がそのまま使えるかは別途検証が必要です。Windows 対応を本気で考えるなら、トランスポートは以下のように分けるのが堅実です。

  • Linux / macOS: grpc++ の unix:///path(UDS)
  • Windows: TCP loopback(127.0.0.1:port)、または ASP.NET Core / .NET 8+ 側の Named Pipe(C++ サーバではなく C# サーバの場合)

.NET クライアントの Grpc.Net.Client は Windows 10 以降で UDS をサポートしていますが、それは C# クライアントの話 であって、Windows 上の C++ grpc++ サーバの unix:// 対応とは別問題です。両者を混同しないようにします。

7.2 ソケットファイルの権限管理

UDS はファイルシステム上にエンドポイントが現れるため、作成直後にパーミッションを絞る のが基本です。

// 660 にして同一グループのみアクセス可能にする例
::chmod("/tmp/myengine.sock", S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP);

/tmp/ は他ユーザーから見えるので、セキュリティ重視なら XDG_RUNTIME_DIR(typically /run/user/<uid>/)配下の 0700 ディレクトリに置く のが堅実です(XDG Base Directory Specification で「ユーザー専用、0700、AF_UNIX socket 対応必須」と規定済み)。Linux 抽象名前空間(@myengine)は「stale socket ファイルが残らない」利点はありますが、ファイル権限によるアクセス制御は効きません(gRPC C++ ドキュメントもこの点を明記)。本記事の 既定推奨は unix:(pathname)+ XDG_RUNTIME_DIR + chmod 0600 で、unix-abstract:Linux 限定の opt-in 選択肢 という位置付けにします。アクセス制御が必要な場合は、peer credential check(SO_PEERCRED / getpeereid)やアプリ層認証を併用します。

7.3 共有メモリの確保(C++ 側)

C++ 側は、OS によって実装方針を変えます。6.2 で議論したとおり、Unix では「実ファイルバックト mmap」が C# 側との接続性が最も高く なります。

Unix(Linux / macOS): 実ファイルバックト mmap

// XDG_RUNTIME_DIR 配下のファイルを mmap する例
std::string path = "/run/user/" + std::to_string(::getuid()) +
                   "/myengine/waveform-" + session_id + ".bin";
int fd = ::open(path.c_str(), O_CREAT | O_RDWR, 0600);
::ftruncate(fd, segment_size);
void* base = ::mmap(nullptr, segment_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);

// レイアウトを初期化(ヘッダ、リングバッファ領域)
auto* hdr = static_cast<RingBufferHeader*>(base);
new (hdr) RingBufferHeader{};

// gRPC のハンドオフ情報には「path」を渡す

このパスを gRPC で UI に渡し、UI 側は MemoryMappedFile.CreateFromFile(path, ...)(6.2 参照)で開きます。C++ の shm_open("/name") と C# の MemoryMappedFile.OpenExisting("name") の名前空間は接続されていない ため、純粋な POSIX shm 名で繋ぐ構成は避けます。POSIX shm をどうしても使いたい場合は、C# 側を shm_open の P/Invoke にするか、後述の SCM_RIGHTS で fd を渡します。

Windows: 名前付き File Mapping

HANDLE hMap = ::CreateFileMappingW(
    INVALID_HANDLE_VALUE, nullptr, PAGE_READWRITE,
    high32, low32, L"Local\\myengine.waveform.v1");
void* base = ::MapViewOfFile(hMap, FILE_MAP_ALL_ACCESS, 0, 0, 0);
// gRPC のハンドオフ情報には「name」を渡す

C# 側は MemoryMappedFile.OpenExisting("myengine.waveform.v1") で開けます。Windows 限定なら名前で繋ぐ素朴な構成が成立します。

まとめ ── ハンドオフ情報を OS 別に表現する必要がある

このため、ハンドオフのスキーマには「方式 + パス/名前」を両方表現できるフィールドを用意するのが現実的です(8.1 の .proto 例で具体化します)。

7.4 ライフサイクル

  • 誰がセグメントを作って、誰が消すか を明確にする。原則「エンジン側が作る、エンジン側が消す」が無難
  • Unix の実ファイルバックト方式の場合: エンジン側のクラッシュでファイルがゴミとして残らないよう、起動時に古い同名ファイルを unlink してから作り直す。XDG_RUNTIME_DIR 配下は再起動でクリアされるので比較的安全
  • Windows の名前付き mapping の場合: カーネルオブジェクトなので、最後のハンドルが閉じられれば自動消滅する。ただし両側が異常終了して同名で再起動した場合の挙動は要検証
  • UI 側がクラッシュしても、エンジン側が refcount を持っていれば検出できる(自前管理のヘッダフィールドに参加プロセス数を持つ、または UDS の peer 切断を以って検知する)

8. EDA / 検証ツール文脈での具体例

前回記事と同様、本記事の話を sukimasim / SukimaDebug 風のシミュレータ + GUI 構成に当てはめると、こうなります。

8.1 制御プレーン(gRPC over UDS で運ぶ)

.proto の骨子例:

syntax = "proto3";
package sukimasim.v1;

service EngineService {
  rpc OpenSession(SessionRequest) returns (SessionResponse);
  rpc Compile(CompileRequest) returns (stream Diagnostic);  // 警告/エラーをストリームで
  rpc Simulate(SimulateRequest) returns (stream ProgressEvent);
  rpc QueryCoverage(CoverageQuery) returns (CoverageSummary);
  rpc CloseSession(SessionId) returns (Ack);
}

message SessionResponse {
  string session_id = 1;
  // ハンドオフ: データプレーン用の共有メモリ情報
  WaveformChannelInfo waveform_channel = 2;
  AstChannelInfo ast_channel = 3;
}

message WaveformChannelInfo {
  // 6.2 / 7.3 で議論したとおり、OS によってハンドオフの方式が異なる
  enum Kind {
    FILE_BACKED_MMAP = 0;        // Unix(Linux/macOS)推奨。path_or_name にファイルパス
    WINDOWS_NAMED_MAPPING = 1;   // Windows 推奨。path_or_name に mapping name
    POSIX_SHM_NATIVE = 2;        // C# 側で shm_open を P/Invoke する場合
  }
  Kind kind = 1;
  string path_or_name = 2;       // 方式に応じてパス or 名前
  uint64 size = 3;
  uint32 layout_version = 4;
}

message Diagnostic {
  enum Severity { INFO=0; WARNING=1; ERROR=2; PITFALL=3; }
  Severity severity = 1;
  string file = 2;
  uint32 line = 3;
  uint32 column = 4;
  string code = 5;    // "Wpitfall:casex-with-x"
  string message = 6;
}

Diagnostic を gRPC のサーバストリーミングで返せば、--Wpitfall の警告がコンパイル中にリアルタイムに UI に流れます。

8.2 データプレーン(共有メモリで運ぶ)

  • 波形チャネル: 信号 ID + シミュレーション時刻 + 値、を SPSC リングバッファに書き込む。1 サイクルあたり数十〜数百サンプル × MHz 級の頻度
  • AST チャネル: パース完了時に一度だけ、AST ノード配列を共有メモリ領域にレイアウト。UI 側はゼロコピーで読み取り
  • カバレッジカウンタ: シミュレーション中、エンジン側が原子的にインクリメント。UI は定期的に共有メモリから読みに行く

8.3 ヘッドレス CI との両立

このアーキテクチャの大きな副産物として、UI 抜きでエンジンだけ走らせる ことが自然にできます。

# CI 側はエンジンだけ起動(GUI なし)
sukimasim-engine --headless --uds /tmp/sukimasim.sock

# 別のスクリプト(Python でも何でも)が gRPC で叩く
python3 -c "import grpc; ..."

.proto から Python スタブを protoc で生成すれば、CI スクリプトが Python だろうが Go だろうが何でも、同じインターフェースで叩けます。Avalonia UI はそのうちの一つのクライアントに過ぎない、という整理になります。

8.4 クラッシュ分離の実利

シミュレータ開発で頻発する「内部 assert で落ちる」「特定の RTL でメモリエラー」といったエンジン側のクラッシュが、UI 側のユーザー編集状態を巻き込まなくなります。UI 側は OnDisconnected イベントを受けて「エンジンが落ちました。再起動しますか?」というダイアログを出すだけで済む。前回記事の「同一プロセス C-ABI」だと、これは構造的に達成できません。


9. デメリットと注意点

率直に挙げます。

9.1 設計と実装の複雑度が確実に上がる

.proto の設計、バージョニング、共有メモリのレイアウト、リングバッファのメモリオーダリング、ライフサイクル管理(セグメント名の衝突、ゴミ掃除、refcount)、エラー伝播、graceful shutdown ── どれも前回記事の「狭い C-ABI」のときには考えなくてよかった話です。

「動くものを作る」だけなら同一プロセス C-ABI が圧倒的に早い。本記事のアーキテクチャを採るのは、1 章で挙げた動機のどれかがクリティカルである場合に限る べきです。

9.2 観測性は確かに上がるが、デバッグの難易度も上がる

gRPC reflection や grpcurl で制御プレーンを覗ける、というのは大きな利点です。しかし、共有メモリ部分は依然としてブラックボックス に近く、デバッガで覗いてもバイナリレイアウトを目で読むしかありません。

対策としては:

  • リングバッファのレコードに シーケンス番号とタイムスタンプを必ず含める
  • 重要イベントは gRPC 側にもサマリを流しておく(「データプレーンで何が起きたか」が制御プレーン側からも辿れる状態にする)
  • 共有メモリ領域をダンプする CLI ツールを早めに用意する

9.3 配布が複雑になる

「バイナリ 1 個 + .so 数個」だった前回記事の構成と比べ、本記事のアーキテクチャは:

  • UI バイナリ(.NET publish 物)
  • エンジンバイナリ(C++ で別途ビルド)
  • 起動スクリプト(エンジンを daemon として上げるか、UI が spawn するか)
  • UDS パス・共有メモリ名の規約(XDG_RUNTIME_DIR の扱い、Windows の名前空間)

を一式管理する必要があります。Linux なら systemd --user ユニット + デスクトップエントリ、Windows なら named pipe + サービスインストーラ、という具合に OS ごとの作法に手を出す ことになります。

9.4 gRPC のローカル限定機能制約

Microsoft Learn が明示しているとおり、gRPC クライアントサイドロードバランシングや channel status の一部機能は UDS と併用できません。ローカル IPC で使う分には実害はほぼありませんが、「将来この通信を分散化するかも」と思っているなら、その境界線は意識しておきます。

9.5 NativeAOT との両立 ── .NET 10 / 11 での具体的な状況

前回記事の主張(C# 側を NativeAOT で publish する)とは、本記事のアーキテクチャは .NET 10 LTS(2025-11 リリース)時点で実用段階 にあります。Microsoft Learn の互換性表で gRPC は「✔️ Fully supported」 に分類され、dotnet new grpc --aot テンプレートも公式提供されています。Kestrel + Grpc.AspNetCore over UDS + Google.Protobuf + 共有メモリ + P/Invoke + Avalonia UI(11.x / 12.0)を全体として PublishAot=true で publish することは技術的に可能です。

各レイヤの AOT 対応状況(.NET 10 / 11 共通)

レイヤ 状態 備考
Grpc.Net.Client ✔ 完全対応 .NET 8 から GA
Grpc.AspNetCore Server ✔ 完全対応 WebApplication.CreateSlimBuilder() + MapGrpcService<T>() の最小構成で警告なし
Google.Protobuf(binary) ✔ 完全対応 protocolbuffers/protobuf#11128 マージ以降。生成コード・Any.Pack/Unpack<T> は警告ゼロ
System.IO.MemoryMappedFiles ✔ 完全対応 BCL 内、警告も実行時例外もなし
Socket / UnixDomainSocketEndPoint ✔ 完全対応 OpenTelemetry の Kestrel セマンティック規約も UnixDomainSocketEndPoint を明示サポート
NamedPipe{Server,Client}Stream ✔ 完全対応 .NET 11 で PipeOptions.CurrentUserOnly 強化(Unix の socket file が 0600 に)
LibraryImport / UnmanagedCallersOnly ✔ 完全対応 NativeAOT のために設計された API。[DllImport] ではなく [LibraryImport] を必ず使う
Volatile / Interlocked / Unsafe ✔ 完全対応 AOT 前提で設計された API
Avalonia UI 11.x ⚠ 条件付き Compiled Bindings 必須、ReflectionBinding 不可、<BuiltInComInteropSupport>false</BuiltInComInteropSupport> 必須
Avalonia UI 12.0(2026-04 GA) ✔ 本格対応 デフォルト構成で AOT-safe、起動時間最大 4× 高速化(公式ベンチ)
OpenTelemetry .NET SDK 1.10+ ✔ 完全対応 Grafana Labs 公式ブログ:「OpenTelemetry instrumentation libraries were able to deliver native support for .NET 10... the same week as .NET 10's first stable release」

"警告ゼロ完走" を阻む 2 箇所(回避策あり)

実装上、AOT 警告を完全に消すために避けるべきライブラリ・機能は次の 2 つです。

  1. Grpc.AspNetCore.Server.Reflection(gRPC server reflection): Type.GetProperty(...) 呼び出しで IL2075 警告が必ず出る(grpc/grpc-dotnet#2605 が 2025-03 提起で本記事執筆時点も open)。本記事の用途(コアエンジン↔UI の双方向 RPC)では gRPC reflection は不要なので、AddGrpcReflection()含めなければ警告ゼロで publish できます

  2. Avalonia の動的 XAML ローダ / ReflectionBinding / サードパーティコントロール: すべての Bindingx:DataType を付ける Compiled Bindings に統一し、<TrimmerRootAssembly Include="Avalonia.Controls;Avalonia.Base;Avalonia.Markup.Xaml" /> を保険として追加。サードパーティは Ursa(NativeAOT 公式対応)など、AOT 対応を明示するライブラリを優先

.proto のスタイル

本記事のテーマと整合する規律として、AOT 化を見据えるなら:

  • oneof は積極的に使う(AOT フレンドリー)
  • google.protobuf.Any はジェネリック版 Pack<T> / Unpack<T>() に限定。TypeRegistry 経由の動的版を使うなら <TrimmerRootAssembly>[DynamicDependency] で型を保護
  • JsonFormatter / JsonParser は使わない(警告ゼロだが内部はリフレクション動作。binary フォーマット限定で運用)
  • grpc.reflection.v1alpha.ServerReflection は本番ビルドから除外
  • Google.Protobuf 3.31.0 は Linux/Docker で AOT が壊れる既知問題(protobuf#21824 open)。3.30.2 か 3.31.1 以降を使う

観測性(OpenTelemetry)の AOT 留意点

Appendix B で扱う AI 観測性の文脈で重要なポイント:

  • OpenTelemetry .NET 1.10+ は AOT 公式対応。trace / metrics / logs 3 シグナルすべて
  • Microsoft 自身が .NET 11 Preview 4 で dotnet CLI のテレメトリを Application Insights から OpenTelemetry に切替。理由は dotnet/core release notes 引用「The motivation is to make the CLI AOT-friendly: Microsoft.ApplicationInsights was not, and removing it unblocks the NativeAOT entry point work below.」 → Application Insights SDK は明確に AOT 非対応で移行が公式推奨
  • Automatic Instrumentation(プロファイラー API ベースの ZIP 配布)は NativeAOT で使えない。コード一体型(OpenTelemetry.Extensions.Hosting)に限定

"落としどころ" ── ハイブリッド戦略

すべてを AOT 化するのが現実的でない場合、本記事のハイブリッド構成と相性のよい段階的戦略:

プロセス 推奨ランタイム 理由
C++ コアエンジン(sidecar) (Native, AOT 関係なし) そもそもネイティブ
.NET 側 gRPC サーバ NativeAOT Kestrel + Grpc.AspNetCore が完全対応、起動時間・メモリで明確なメリット
Avalonia UI(メイン) NativeAOT(12.0)or 通常 JIT サードパーティ MVVM ライブラリ(ReactiveUI、CommunityToolkit.Mvvm の reflection-heavy 機能)を使うなら、UI のみ通常 JIT・サイドカー側のみ AOT 化が現実的

切替判断の目安:

  • 起動時間が 1.5 秒以上 → AOT で短縮価値あり
  • 単一実行ファイルサイズ 30 MB 超 → AOT + trimming で半分以下に圧縮可能
  • メモリ使用量(WSS)100 MB 超 → 削減見込み 20〜30%
  • AOT publish で IL3050 / IL2026 が 20 個以上残るなら、原因ライブラリを差し替えるか部分的 JIT に逃がす方が早い

.NET 11(2026-11 GA 予定)を待つべきか

ASP.NET Core の互換性マトリクスは .NET 10 と .NET 11 Preview 4 時点で同一(MVC / Blazor Server / Session / Spa / Other Authentication は依然 ❌)。本記事スタックに直接効くのは Runtime Async の NativeAOT 対応(性能要因)と OpenTelemetry の ASP.NET Core 標準統合(別パッケージ依存削減)のみ。.NET 11 まで待つ必要は基本的にありません

つまり「基本的に両立」よりも「.NET 10 LTS で実用段階。Avalonia 12.0(2026-04 GA)を使えば UI 込みで全部 AOT も妥当、Application Insights SDK と gRPC server reflection だけ避ければ警告ゼロ」が、2026 年 5 月時点の正確な温度感です。

実運用での規律としては、<IsAotCompatible>true</IsAotCompatible> をプロジェクトに最初から入れて PublishAot=true の CI を並走 させ、後から AOT 化しようとしてリフレクション依存を踏む事故を予防するのが最も実効的です(本記事の AOT 関連の主張は、Appendix A で 8 spike すべて警告ゼロ・実機動作を確認しています)。

9.6 「同一プロセス C-ABI からの移行」は意外と重い

前回記事の構成で動いているプロダクトを、本記事のアーキテクチャに 後から移行する のは、想像より大きな工事になります。境界に出ている C 関数すべてを .proto メッセージに置き換え、コールバックの伝達経路を gRPC ストリームに移し、ライフサイクルを再設計する必要があります。

逆に、最初から「いつか分けるかもしれない」 という想定で C-ABI 境界を設計しておくと、移行は劇的に楽になります。具体的には:

  • C-ABI の関数を「コマンドオブジェクト」のような形(enum command_kind + payload struct)に既に寄せておく
  • コールバックを「イベント struct」として渡す形にしておく
  • 文字列・配列の所有権が境界をまたぐパターンを最小化しておく

こうしておくと、その「コマンド」「イベント」がそのまま protobuf メッセージにマップされます。これは前回記事の「狭い C-ABI 境界」原則がそのまま生きる、ということでもあります。


10. 両モード併存というオプション ── 狭い C-ABI と分離アーキテクチャを両方持つ

ここまで本記事は「分離するなら UDS + gRPC + protobuf + 共有メモリのハイブリッド」という方向で議論してきました。一方、前回記事は「同一プロセス + 狭い C-ABI」を推していました。

実は、この二つは排他選択ではなく、両方実装して切り替える という選択肢があります。本章ではこのオプションについて整理します。長めですが、sukimasim のような長期育成プロジェクトでは戦略的な意味が大きい節になります。

10.1 業界事例 ── 両モードを持つツールは意外と多い

「単一の通信機構しか持たない」のは、むしろ小規模ツールの特徴です。商用品質の長寿ツール / OSS は、しばしば両モードを併存させています。

  • SQLite: 通常は libsqlite3 をプロセスにリンクする in-process DB だが、sqlite3-server 系のラッパで client-server 化もできる
  • LLVM / Clang: コンパイラとして libLLVM をインプロセスでリンクして使うことも、clangd(Language Server)としてプロセス分離で使うこともできる
  • Jupyter: Kernel は通常別プロセスだが、テスト用に同一プロセスで動かす InProcessKernel が用意されている
  • WebKit / Blink: Single-process mode と Multi-process mode を起動時フラグで切り替えられる。デバッグ時は single-process で全体を一気に追える
  • TensorFlow / PyTorch: in-process Python バインディングと、TF Serving / TorchServe のような分離プロセスサーバの両方
  • Verilator: 生成された C++ シミュレーションコードをユーザーの C++ プログラムに直接リンクすることも、separate executable として動かすこともできる

EDA に近い領域では、Vivado / Quartus の Tcl コンソール がこの構造に近く、同一プロセス内の Tcl エンジンを叩く API と、vivado -mode batch で外から叩く CLI が同じコマンドセットを共有しています。

10.2 設計のキーポイント ── 抽象インターフェースを一段挟む

「両方実装する」を成立させる肝は、抽象インターフェースを一段挟む ことです。C# 側の ViewModel 以上は具体的な通信機構を知らず、抽象だけを見ます。

// C# 側に抽象を切る
public interface IEngine : IAsyncDisposable
{
    Task<CompileResult> CompileAsync(CompileRequest req, CancellationToken ct);
    IAsyncEnumerable<Diagnostic> CompileStreamingAsync(CompileRequest req, CancellationToken ct);
    Task<SimulationHandle> StartSimulationAsync(SimulateRequest req, CancellationToken ct);
    IAsyncEnumerable<WaveformChunk> SubscribeWaveformAsync(SimulationHandle h, CancellationToken ct);
    // ...
}

// 実装は2つ
internal sealed class InProcessEngine : IEngine
{
    // LibraryImport で C-ABI を直接叩く
    // 波形は共有メモリではなく直接ポインタで受け取る
}

internal sealed class RemoteEngine : IEngine
{
    // gRPC スタブを呼ぶ
    // 波形は共有メモリ + gRPC 通知ストリーム
}

切り替えは設定 or 起動オプションで:

IEngine engine = config.Mode switch
{
    EngineMode.InProcess => new InProcessEngine(),
    EngineMode.Remote    => new RemoteEngine(unixSocketPath: config.SocketPath),
    _ => throw new InvalidOperationException()
};

// ViewModel 以降はこの engine だけを見る
var viewModel = new MainViewModel(engine);

ViewModel 以上はこの IEngine だけを見るので、下位の通信機構を知らずに済む。これが綺麗に決まれば、両モードは表面的には完全に同じ挙動になります。

10.3 共通コマンド / イベント表現 ── .proto を真実の源にする

両モードを並行運用する時に一番効くのは、コマンドとイベントの表現を 1 か所で定義する ことです。

  • .proto を Single Source of Truth とする: コマンド・イベント・データ構造の スキーマ.proto に集約
  • protoc で C++ と C# のスタブを生成する
  • ただし注意: .proto 生成物のメモリレイアウトは互換ではない

ここは初学者が誤解しやすいポイントなので明示しておきます。protobuf が保証するのは wire format 互換性(両端で同じバイト列にエンコード/デコードできる)であって、C++ 生成クラスと C# 生成クラスのメモリ上のオブジェクトレイアウト互換性ではありませんprotoc が C++ 側に出すのは C++ class、C# 側に出すのは managed class で、内部表現はそれぞれの言語ランタイムに依存します。memcpy 的に直接やり取りしようとすると未定義動作になります。

したがって、現実的な構成は次のいずれかです:

  • InProcess モード: 生成 C# class / C++ message と、C-ABI 用の POD struct の間に 変換アダプタ を書く。C-ABI を通るのは POD のみ。.proto は「スキーマの真実の源」「変換アダプタの自動生成元」として機能する
  • Remote モード: 同じ .proto を gRPC の wire format として使う。両端は普通に protoc 生成コードでシリアライズ/デシリアライズする
  • どうしてもゼロコピーで構造化データを運びたい場合: protobuf ではなく Cap'n Proto / FlatBuffers / 自前 POD レイアウト に寄せる。これらはメモリ上のバイナリレイアウトが IDL から決定論的に決まるため、両側で読み書き互換にできる

こうすると、スキーマ管理が一本化されて、両モード間で 「スキーマ」は共有、「メモリ上のオブジェクトレイアウト」は共有しない という整理になります。前回記事の 4.1 節で書いた 設計原則 1「境界は C-ABI に限定する」 は、両モード併存の文脈では次のように発展します:

両モード併存版の設計原則: スキーマは .proto に集約し、両モードで共有する。InProcess モードは C-ABI 用 POD struct への変換アダプタを介して呼び出す。Remote モードは同じ .proto を gRPC でシリアライズして運ぶ。両モードで共有するのは スキーマ であって、メモリレイアウト ではない

このアプローチを採れば、前回記事で書いた 「いつか分けるかもしれない前提で C-ABI を設計しておけば移行が劇的に楽」 という主張が、最終形態として完成します(両モードで結果が完全一致することの実証は Appendix A.2.2 を参照)。

10.4 モードの使い分け

両方ある場合、現実にどう使い分けるか:

場面 適切なモード 理由
開発・デバッグ時(本人) InProcess mixed-mode debugger で一気通貫に追える、ステップ実行が境界をまたぐ。クラッシュ時のスタックトレースも繋がっている
CI のリグレッション Remote(エンジン単体) UI なしでヘッドレス起動可能、並列ジョブも素直。ライセンスサーバなしの環境でも動く
エンドユーザー配布(GUI) Remote クラッシュ分離。エンジン側 segfault でユーザーの編集中状態を守る。商用 EDA ツール業界標準に準拠
組み込み・小規模ユース・ライブラリ提供 InProcess バイナリ 1 個、IPC 不要、軽量。Verilator のリンク利用と同じ位置付け
ベンチマーク・性能評価 両方 IPC オーバーヘッドの実測ができる。「Remote で X% 遅い」を定量化
ライセンス分離が要件 Remote プロセス境界で法務境界を引く
複数言語クライアントから叩く Remote gRPC スタブ自動生成で Python / Rust / Go / TypeScript からも呼べる
Avalonia UI 単独利用 Remote 推奨 業界標準アーキテクチャ、長期運用に有利
新人開発者の onboarding InProcess 起動が簡単、デバッグ環境が一つで済む、学習曲線が緩い

特に 開発時は InProcess、配布時は Remote という使い分けは強力です。デバッグの快適さ(Visual Studio で mixed-mode で一気にステップ実行)と本番の堅牢性(クラッシュ分離)を両取りできます。前回記事の 4.3 節で論じた「Windows / Linux / macOS で mixed-mode debugger の事情が違う」問題も、開発時 InProcess モードがあれば回避できるシーンが増えます。

10.5 デメリットと注意点

率直に書きます。両モード併存はタダではありません。

実装コストはほぼ 2 倍

API 表面を抽象化で隠せても、ライフサイクル管理・エラー処理・コールバック経路は別実装になります。コード量は単純に増えます。「IEngine の実装を 2 つ書く」という一文の重さは見た目以上です。

テストマトリクスが倍増

「InProcess では動くが Remote では動かない」「Remote では起きるが InProcess では起きない」というバグが必ず出ます。

  • InProcess で発見されにくいバグ: シリアライズ漏れフィールド、エンディアン依存、スレッド境界、deadline / cancellation の挙動差
  • Remote で発見されにくいバグ: 所有権の取り違え、寿命の暗黙仮定、同一プロセス前提のグローバル状態共有

両モード両方で同じテストを回す必要があります。CI のテストジョブを [InProcess, Remote] の matrix で 2 倍走らせる、というのが現実解です。

片方のモードが腐敗するリスク

「InProcess は開発時しか使われない」「Remote は CI でしか動かしていない」というアンバランスな運用になりがちで、長期的に 片方だけ進化して片方がメンテされなくなる という事象が起きやすい。CI で両モードを定期的に回すことで予防しますが、ある程度の規律が必要です。

商用 EDA ツールでも、たとえば「Tcl コンソールで叩いた時の挙動と GUI 操作した時の挙動が微妙に違う」「batch モード固有のバグ」といった不整合は実際によく聞く話で、両モード併存の宿命と言えます。

ABI / プロトコル互換性の二重管理

C ヘッダのバージョニングと .proto のバージョニングの両方を整合させる必要があります。対策としては:

  • .proto を真実の源として、C ヘッダを生成スクリプトで自動生成する: フィールド追加が .proto の編集だけで両モードに反映される
  • InProcess モードもバージョンタグを付ける: 古い C ABI バージョンの DLL を新しい UI が dlopen しないようにする

「どっちが正しい挙動か」問題

結果が微妙に違ったときの正解判定。基本方針は Remote 側を仕様とみなす のが堅実です(将来も生き残る方、ヘッドレス CI で検証される方なので)。これは明示しておかないと開発者間でブレるので、設計ドキュメントに書いておきます。

10.6 sukimasim / SukimaDebug 文脈での段階的ロードマップ

ここまでの議論を、現在の私(あるいは似た立場の個人 OSS 開発者)に当てはめて、段階的なロードマップを書いてみます。最初から両方並行で実装する のは避けるべきで、必要になった時点で機構を足していく方が現実的です。

フェーズ 1: 狭い C-ABI で同一プロセス(前回記事の構成)

  • 前回記事で論じた「狭い C-ABI 境界 + 同一プロセス」で実装する
  • ただし C-ABI を最初から「コマンド struct」「イベント struct」ベースで設計 する(関数の引数列で表現せず、メッセージ構造体を渡す形)
  • 例えば engine_compile(const char* source) ではなく、engine_send(const CompileCommand*) のような形にしておく
  • この時点では gRPC も protobuf も登場しない。しかし API の構造は最初から「メッセージのやり取り」になっている

ここまでで、前回記事の「いつか分けるかも前提で C-ABI を設計しておけば移行が楽」という主張を実装に反映させています。

フェーズ 2: .proto 切り出し(まだ単一プロセス)

  • フェーズ 1 で定義した「コマンド struct」「イベント struct」を .proto に切り出す
  • protoc で C++ struct / C# class を生成する
  • InProcess 実装は、.proto 生成の C# class を「フィールド単位で詰め替えるアダプタ」で C-ABI struct に変換する形に書き換える
  • この時点ではまだ単一プロセス、しかし API 表面は protobuf 風になっている
  • .proto が真実の源になり、スキーマ管理が一本化される

ここまでで、フェーズ 3 への移行コストが劇的に下がっている状態が完成します。

フェーズ 3: gRPC service 定義を追加して Remote モード実装

  • .protoservice EngineService { ... } の定義を足す
  • gRPC C++ サーバを sukimasim 本体プロセスに組み込む
  • Grpc.Net.Client を使った RemoteEngine : IEngine 実装を SukimaDebug 側に書く
  • データプレーン(波形ストリーム)用に共有メモリ + リングバッファを追加
  • 設定ファイル / 起動オプションで InProcess と Remote を切り替えられるようにする
  • CI で両モードのリグレッションテストを走らせるようにする

フェーズ 4(オプション): エコシステム拡張

  • .proto から Python / TypeScript スタブも生成して、CLI や VS Code 拡張から sukimasim を叩けるようにする
  • grpc-webConnect プロトコルを使えば、ブラウザからも叩ける(WASM 化された SukimaDebug とは別経路で)
  • ヘッドレスエンジンを Docker コンテナ化し、CI 専用の軽量配布版を作る

10.7 個人 OSS 開発者にとっての現実的バランス

ここまで書いてきましたが、全部を真面目にやるのは個人プロジェクトとしては相当重い のも事実です。仕事の傍ら開発している身として正直に書くと、フェーズ 1 + フェーズ 2 までで十分という割り切りも合理的です。

  • フェーズ 1 だけ: 同一プロセス C-ABI、ただし「メッセージ struct」スタイル。これだけでも、将来分離する選択肢を完全に閉ざさない設計になる
  • フェーズ 2 まで: .proto を真実の源にする規律を入れる。これによりスキーマ進化が綺麗になり、ドキュメントとしての価値も生まれる
  • フェーズ 3 以降: 実需(例:外部から叩きたいニーズが具体的に発生した、商用ユースで採用が見えた、CI 並列化が必要になった等)が出た時点で初めて検討する

フェーズ 3 を「やる前提」で設計しておくが、「実際にやる時期は柔軟」に保つ ── このバランスが、個人 OSS の現実解だと考えています。前回記事と本記事の論点全部を最初から実装しようとすると、設計マニアックさで本来やりたかった検証エンジンの中身の進化が止まってしまいます。

10.8 まとめ ── 両モード併存は「将来への投資」

「両方実装」は技術的に十分妥当で、特に 開発時のデバッグ快適性と本番の堅牢性を両取りしたい ケースでは、ほぼ唯一の解になります。EDA / 検証ツール領域では商用ツールが事実上これを採用しているので、検証ツールとして本気で育てるならこの方向に行く価値が高い。

ただし、最初から両方並行で実装するのは避ける べきです。コスト 2 倍を払う前に、まず片方をしっかり作り、抽象化レイヤとメッセージ struct 設計を介して 後から差し込めるように設計しておく のが現実的です。前回記事の「狭い境界、明示的な規約、片側所有権」の原則が、両モード併存の土台としてそのまま生きる場面でもあります。

本記事の主張を圧縮すると、最終的にはこういうことになります:

同一プロセス C-ABI と分離アーキテクチャは、敵対関係ではなく、同じ設計原則の表裏である。両者を意識的に両立させる設計を入り口から採れば、プロジェクトの成長に応じてどちらにも舵を切れる。前回記事と本記事は、その両側を別々に詳述しただけで、本来は地続きの話だった。


11. プロセス分離時の起動パターン ── 誰がいつ C++ を立ち上げるのか

ここまで本記事は「分離した場合の通信路をどう設計するか」を中心に書いてきましたが、実装に取り掛かるとすぐ次の疑問が出てきます ── 「GUI が立ち上がるとき、C++ エンジンプロセスは誰がいつ起動するのか?」。これは設計の見た目以上に運用性を左右するポイントなので、独立した章で扱います。

業界に定着している起動パターンは大きく 4 通りあり、選び方で運用・配布・デバッグの全部が変わります。

11.1 起動パターンの全体像

パターン 動作 代表例
A. Sidecar(親子プロセス + stdio handshake) GUI が起動時に C++ を子プロセスとして Process.Start で fork+exec し、stdout から READY 行を読んで接続。GUI 終了で子も死ぬ VS Code 拡張機能(clangd / pyright / OmniSharp 等の Language Server)、Chrome レンダラープロセス、Jupyter Kernel
B. Lazy spawn(オンデマンド起動) GUI 起動時は子プロセスを起こさず、ユーザーが「Run」を押した瞬間に spawn → 終わったら kill 単発タスク実行ツール(CI runner、エディタのビルドボタン)
C. Detached daemon(システムサービス) OS サービス(systemd / launchd / Windows Service)として常駐、GUI は単に接続するだけ Docker Desktop ↔ dockerd、Adobe Creative Cloud、PostgreSQL ↔ pgAdmin
D. Connect-or-spawn 既知 endpoint に接続試行 → 居なければ自分で spawn → 複数 GUI で共有 tmux、emacsclient、.NET MSBuild server

それぞれの設計トレードオフを整理しておきます。

  • A. Sidecar: 「1 GUI = 1 エンジン」の単純な親子関係。配布が最も楽。ユーザーが何もインストールしなくても動く。クラッシュ伝播の検知が即座
  • B. Lazy spawn: 起動の軽さが最大の利点。常駐コストゼロ。代わりに RPC ごとに spawn する場合は起動レイテンシが効く
  • C. Detached daemon: 複数 GUI クライアントから接続したい、ヘッドレス運用が主要要件、OS 起動時から常駐させたい、という要件があれば必須。配布は最も重い(インストーラと管理者権限が必要)
  • D. Connect-or-spawn: 「最初の GUI が起こし、後から来た GUI は既存サーバに繋ぐ」中間的なモデル。MSBuild の build server や tmux のように、起動コストを償却したい用途で効く

11.2 sukimasim + SukimaDebug の場合の推奨 ── A. Sidecar が筋がよい

EDA / 検証ツール文脈で本記事のテーマに照らすと、A. Sidecar パターン が筋がよいと考えます。理由を整理します。

  1. 1 GUI = 1 シミュレーション: SukimaDebug は同時に 1 sim しか動かさない設計にする想定。複数 GUI で server を共有するメリットは薄い(→ D 不要)
  2. インストール不要: ユーザーが systemd ユニットや launchd plist を触らずに済む。配布は実行ファイルとバイナリ一式の zip / msi / dmg だけで完結する(→ C 不要)
  3. クラッシュ時の挙動が明快: 子プロセスの死亡を親が即座に検知できる(Process.Exited イベント、waitpidSIGCHLD)。エンジンが落ちたら UI に「再接続しますか?」ダイアログを出す、という制御が素直に書ける
  4. デバッグしやすい: 1 つの親子関係に閉じている。stdout の補足だけで「起動失敗」「READY が来ない」「異常終了コード」が即わかる。systemd ログを読みに行く必要がない
  5. 既存実装からの自然な進化: 既に SukimaDebugProcess.Startsukimasim を起こすコードを持っているなら、それは sidecar 起動の薄い実装そのものです。--inspect=<sock> のようなフラグを追加して、エンジン側で gRPC server を立てるよう拡張すればよい

C(daemon)を採るほどの要件 ── 複数 GUI からの共有、OS 起動時からの常駐、ヘッドレスサーバ運用 ── は、検証ツール用途では発生しないか、発生してもオプション扱いです。CI でヘッドレス実行したい場合も、A の sidecar 構成のまま CLI フロントエンドから sukimasim を直接起こせば済みます(エンジンプロセスが、GUI の代わりに CLI を親に持つだけ)。

11.3 Sidecar の具体的な起動シーケンス

A パターンの典型的なシーケンスを擬似コードで書くとこうなります。

GUI 起動
  ↓
1. mktemp /tmp/sukimasim-<guid>.sock              (UDS パスを用意)
  ↓
2. Process.Start("sukimasim",
       "--inspect=" + sockPath,                   (エンジンに endpoint を渡す)
       "--ready-fd=1")                            (READY 通知用)
  ↓
3. 子の stdout を非同期で読む
  ↓ "READY ready=/tmp/sukimasim-<guid>.sock\n" を待機
  ↓
4. GrpcChannel.ForAddress("http://localhost") +
   SocketsHttpHandler.ConnectCallback で
   /tmp/sukimasim-<guid>.sock に接続(具体例は 6.1)
   C++ grpc++ サーバ側は unix:///tmp/sukimasim-<guid>.sock をリッスン
  ↓ unary / streaming 確立
  ↓
5. Healthcheck RPC で双方向確認
  ↓
GUI 通常動作
  ↓
GUI 終了(App.OnExit)
  ↓
6. CloseSession RPC または SIGTERM で graceful shutdown を要求
   (子に共有メモリ / UDS ファイル等の cleanup チャンスを与える)
  ↓ 一定時間(例: 3 秒)待つ
  ↓
7. まだ生存していれば child.Kill(entireProcessTree: true) で強制終了
  ↓
8. 親側でも UDS ファイル・一時 mmap ファイルを best-effort で削除

ポイントは 3 番のハンドシェイク6〜8 番のライフタイム管理 で、ここを雑に作ると本番でハマります。特に いきなり Kill を呼ぶと、子に cleanup の機会を与えられず、共有メモリのバックファイルや UDS ソケットファイルがゴミとして残り続けます。まず graceful shutdown を要求し、タイムアウトしたら強制終了、という二段階を踏むのが堅実です。以下、それぞれの作法を整理します。

11.4 ハンドシェイクの作法

エンジンが起動を完了して接続可能になったことを、UI 側が正確に知る必要があります。方式は次の三つに分かれます。

方式 評価
(a) 静的 endpoint(/tmp/sukimasim.sock 固定) ✗ 複数 GUI で衝突、stale socket(前回起動の死骸)問題が頻発
(b) Server が stdout に READY 行を print ◎ 標準パターン、race condition なし、ポート/パスを server に選ばせることもできる
(c) GUI が mktemp してパスを env / 引数で渡す ○ シンプル。ただし server 起動失敗の判定のために (b) と併用が推奨

(b) + (c) のハイブリッドが最も堅牢 です。具体的には:

  • GUI が /tmp/sukimasim-<guid>.sockmktemp し、--inspect=<path> で渡す
  • Engine は bind() 成功後、stdout に READY ready=<path>\n を 1 行 print
  • GUI は子の stdout を読み、
    • READY 受信 → gRPC 接続開始」
    • 「子が READY 前に exit → エラーダイアログ + 終了コード表示」
    • 「タイムアウト(例:5 秒)経過 → kill + エラー」
      を判定する

VS Code 拡張機能の Language Server プロトコルでも、Jupyter の connection file 方式でも、本質的にはこの「子が用意できたことを stdout 経由で親に知らせる」パターンになっています。

11.5 子プロセスのライフタイム ── 親が死んだら子も死なせる

Sidecar 構成で最大の落とし穴が 「親 GUI が SIGKILL されて子だけ残る」 という orphan 問題です。これを放置すると、6.2 / 7.3 で論じた共有メモリのバックファイルが XDG_RUNTIME_DIR 配下に散らかり続け、UDS ソケットファイルも残ります。さらに悪いことに、孤児になったエンジンプロセスがバックグラウンドで CPU を食い続ける、というのも実際にあります。

orphan 問題への対処は 「単一の魔法の API」ではなく、複数の仕組みを併用する のが堅実です。各 OS の主役級の手段は以下のとおりですが、これらは 「最後の砦」 であって、通常は 親側の graceful shutdown 要求 + 制御チャネル(UDS / pipe)の EOF 検出 + healthcheck + timeout 回収 を主系とし、最後の砦は SIGKILL / 強制プロセス殺害ケースだけで発動する設計にします。

OS 親死亡時に子を自動 kill する補助手段
Linux prctl(PR_SET_PDEATHSIG, SIGTERM) を子側 main() の冒頭で設定。ただし補助線として扱う ── これは setuid/setgid を伴う exec でクリアされる、fork 後の子では引き継がれない、設定タイミングと親死亡のレースが存在する、などの制約がある。設定直後に getppid() で親がまだ生存しているか確認するレースガードは必須。最終的な回収は waitpid / 制御チャネル EOF / healthcheck / timeout の併用に依存 する
Windows Job Object を主方式に据える。子プロセスを Job に assign し、JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE フラグを立てる。親プロセス終了で Job 内の全子プロセスが自動 kill される。注意: 既存 Job 内で起動されるケース(他のラッパーや IDE 経由の起動)や Windows ストア配布パッケージアプリでの挙動は実機確認が必要
macOS prctl 相当が無い。実装候補として kqueue で親プロセス(EVFILT_PROC + NOTE_EXIT)を監視して自前で exit する、あるいは GUI 側のファイナライザで明示的に Kill。Linux/Windows のような単一の定番 API ではない

Linux と Windows は標準 API で部分対応できますが、macOS だけは自前監視スレッドが必要です。クロスプラットフォーム対応の既製ライブラリ(Rust の process-killer や C++ の boost.process の一部)を使う手もありますが、製品依存性を増やす判断は別途必要です。

なお Linux パスは Appendix A.1 の spike sidecar-lifecycle で 3 シナリオ(T1: 正常終了 / T2: 親 graceful exit / T3: 親 SIGKILL)を実装検証済みで、T3 の PR_SET_PDEATHSIG + getppid() レースガード経路では child が 105 ms で exit することを確認しています。ただしこれは「親が SIGKILL された場合の最終的な砦」が機能した数値で、通常運用の主系は 11.3 step 6〜8 で示した 「graceful 要求 → 3 秒待ち → 必要なら Kill」 + Healthcheck(11.6)です。

11.6 ヘルスチェックと再接続

長時間動かす GUI では、エンジンプロセスが何らかの理由で固まったり、応答しなくなったりすることが現実に起きます。対策として、周期的なヘルスチェック RPC を入れます。

  • 5 秒ごとに Ping RPC を打つ
  • 3 連続失敗で「エンジン不調」と判定
  • UI 側で「再接続しますか?」ダイアログを出すか、自動で kill + 再起動

gRPC には built-in の healthcheck サービス(grpc.health.v1.Health)があり、これをそのまま使えます。.proto を 1 行 import するだけで導入できるので、自前実装する理由はありません。

11.7 業界事例 ── 起動パターンの実例集

具体例で見ると、本記事の主張がどう一般化されているかが見えます。

アプリ パターン ハンドシェイク 親子終了同期
VS Code + clangd A. Sidecar clangd の stdin/stdout 直接で JSON-RPC、別 endpoint 不要 VS Code 終了で process.kill
Chrome → レンダラー A. Sidecar(多重) mojo IPC、起動時に socket fd を継承 Job Object(Win)/ prctl(Linux)
Jupyter Notebook → kernel A. Sidecar kernel が connection_file.json(5 つの ZMQ ポート)を書き、Jupyter が読む 終了時に SIGTERM、3 秒後 SIGKILL
Docker Desktop ↔ dockerd C. Daemon UDS 既定パス、CLI / GUI は等しくクライアント。dockerd は VM 内(macOS: Apple Virtualization.framework、Windows: WSL2、Linux: QEMU/KVM) dockerd は常駐、GUI 終了しても残る
tmux D. Connect-or-spawn $TMUX_TMPDIR/tmux-<UID>/default(未設定なら /tmp/tmux-<UID>/default)、tmux attach で接続、無ければ自動 start 最後の client 切断後、server 残るか die-when-empty かは設定
.NET MSBuild server D. Connect-or-spawn Windows: Named Pipe / Unix: UDS、初回 build で起動、idle 15 分で self-exit timeout で自殺

VS Code + clangd / Chrome + レンダラー / Jupyter + kernel という、現代の代表的な「GUI + 重い C++ バックエンド」の組み合わせがどれも A. Sidecar に着地しているのは、偶然ではないと思います。「ユーザーが追加インストール無しで使える」「ライフタイムが GUI と一致する」「クラッシュ検知が明快」 という性質が、デスクトップアプリの配布形態と整合的だからです。

11.8 段階的な導入 ── 「分離前提」を将来オプションとして残す

10 章のフェーズ別ロードマップに、起動パターンの観点を加えると次のようになります。

フェーズ 1 + 2:同一プロセス C-ABI(分離なし)

起動パターン云々の話は発生しない。Process.StartJob Objectprctl も不要。前回記事の構成のまま。

フェーズ 2.5:Inspector(観測専用)チャネルだけ別チャネル

「メイン経路は in-process C-ABI、Inspector(波形ダンプや外部観測ツール接続)だけ gRPC サーバを同一プロセス内のスレッドとして立てる」という中間段階。プロセス分離はまだ発生していない ── gRPC サーバは sukimasim 本体プロセス内のスレッドであって、別プロセスではない。

この段階では、--inspect=<sock> フラグを足して、UDS パスを bind して READY を stdout に print する、という最小実装で済む。子プロセス管理は不要。

フェーズ 3:本格的な Sidecar 化

sukimasim を独立プロセスとして起動し、SukimaDebug が親として spawn + 接続 + lifetime 管理を行う構成。本章 11.3 以降の全フルセット(handshake、PR_SET_PDEATHSIG、Job Object、health check、orphan cleanup)が必要になる。

このフェーズに移ると、配布形態、デバッグ手順、エラー復旧 UI、CI のテストフィクスチャ ── これら全部に手を入れることになります。実装サイズ感としては、既存の Process.Start ベースの runner を進化させる形で、おそらく 150〜300 行程度の追加。難所はコード量ではなく、OS ごとの作法の違いをどこまで埋めるかの判断 の方です。

11.9 起動パターンの選択 ── 実務的な指針

最後にまとめると、検証ツール / EDA ツール領域で本記事の構成を採るときの指針:

  • デフォルトは A. Sidecar: 配布が楽、ユーザー設定不要、ライフタイムが GUI と一致、クラッシュ検知が明快
  • CI / ヘッドレス需要は別フロントエンド: GUI のかわりに CLI / Python スクリプトがエンジンの「親」になる。アーキテクチャは Sidecar のまま、親が GUI かどうかが違うだけ
  • C. Daemon に格上げするのは要件が確定してから: 「複数 GUI クライアント共有」「OS 起動時から常駐」が要件として現れたタイミング。先回りで作ると配布の重さだけが残る
  • B. Lazy spawn / D. Connect-or-spawn は EDA 文脈では出番が少ない: シミュレーションエンジンは「常駐するもの」というメンタルモデルが業界標準

そして、これらの起動パターンの選択は .proto のスキーマ設計や IPC 通信路の選定とは独立 です。10 章で論じた「両モード併存」の文脈で言えば、フェーズ 3 で初めて起動パターンを決めればよく、フェーズ 1 / 2 の時点では Process.Start の薄い実装(SukimasimRunner のような)を維持するだけで済みます。

起動パターンの選択は、IPC の選択とは別レイヤの問題
Sidecar / Daemon / Lazy spawn / Connect-or-spawn は、UDS や gRPC や共有メモリの上にどれでも乗る。
先に決めるのは IPC 路(本記事 3 章〜7 章)、起動形態は最後に決めればよい。


12. まとめ

前回記事で「同一プロセス + 狭い C-ABI 境界」が、現代の .NET と Avalonia で十分現実的だ、という結論を出しました。本記事はその逆側 ── プロセスを分離する場合の現実解 を整理しました。

分離が必要な動機(クラッシュ分離、ライセンス境界、ヘッドレス運用、複数クライアント対応、動的なエンジン交換、セキュリティ境界)のどれかが当てはまれば、IPC スタックの選定が必要になります。本記事の主張は次の三点に集約できます。

  1. 単一の IPC 技術で「機能 × 速度 × クロスプラットフォーム」をすべて取ろうとしてはいけない。レイヤごとに最適なものを選ぶ
  2. 制御プレーンは UDS + gRPC + protobuf が、機能性とローカル速度のバランスで最良の既製品。Microsoft Learn の公式ガイダンスも UDS と Named Pipe を「同一マシン IPC で TCP より効率的」と明示している
  3. データプレーンは共有メモリ + リングバッファ で、コピーコストを排除する。ただし .NETMemoryMappedFile.OpenExisting(name) は Windows 限定 API なので、共有メモリのハンドオフ方法は OS ごとに変える ── Unix では実ファイルバックト mmap のパス、Windows では named mapping の名前、を gRPC のメッセージに乗せて渡す

そして、両者の ハンドオフ(共有メモリの方式・パス/名前・サイズ・レイアウトバージョンを gRPC のメッセージで渡す) が、二つのプレーンを束ねる接着剤になります。

このハイブリッド構成は、最初から完璧に組む必要はない という性質が現場でとても効きます。最初は制御プレーンも含めて全部 gRPC over UDS で書いておき、プロファイルを取ってボトルネックになった経路だけ共有メモリに移していけば、段階的に最適化できます。商用 EDA ツールや HFT 系のシステムが結果的にこの構造に収束しているのは、偶然ではないと思います。

一方で、本記事の構成は 同一プロセス C-ABI と比べて確実に複雑度が高い という事実は、何度でも強調する価値があります。1 章で挙げた動機のどれにも当てはまらないなら、前回記事の「狭い C-ABI 境界 + 同一プロセス」のままが正解です。本記事は「分けざるを得なくなったときに、何を選び、何を避けるか」の見取り図を提供することが目的でした。

そして最後に、前回記事から一貫している原則を改めて書いておきます: 境界は狭く、規約は明示的に、所有権は片側に。C-ABI でも protobuf でも共有メモリでも、この三原則は変わりません。境界の物理的な実装が変わっても、設計の規律は同じです。

そしてこの三原則が綺麗に効くと、10 章で論じたように 同一プロセス C-ABI と分離アーキテクチャの両方を併存させる 道が開けます。前回記事と本記事は別々のアーキテクチャを扱っているように見えて、実は同じ設計原則の表と裏でしかありません。プロジェクトの成長に応じてどちらにも舵を切れる ── あるいは両方を持つ ── 設計を、入り口の段階から仕込んでおけるかどうか。それが個人 OSS から長期育成プロジェクトへの分かれ目になると思います。

最後に、本記事の主要主張(§3 / §5 / §6 / §10 / §11)は、執筆と並行して 8 個の独立した spike(検証用ミニ実装) で 2026-05-23 時点に実装可能性と動作を確認しました ── 1M レコード(40 MB)のデータプレーン処理が gRPC を 214 通知しか通過しないこと、両モードで結果が完全一致すること、sidecar の SIGTERM → graceful exit が 44 ms で完了すること、SPSC リングバッファ単体が p50 232 ns / 20 M rec/s で動くこと、NativeAOT publish の全 spike で警告ゼロ。詳細は Appendix A にまとめています。


Appendix A. 実装検証(Spike)結果

本付録は本記事の主張を実装で裏付けるためのデータです。 §3 / §5 / §6 / §10 / §11 で論じた IPC 設計の各要素を、独立した spike(検証用ミニ実装)として作り、想定された性質を満たすことを実測で確認しました。

検証環境

  • 日付: 2026-05-23(本付録の数値はこの時点)
  • ハードウェア: AMD Ryzen 9 7950X / 96 GB RAM
  • OS: WSL2 Ubuntu 24.04.4 LTS(bare-metal Linux ではない点に注意)
  • ランタイム: .NET 10.0.107 + NativeAOT publish
  • 主要 NuGet バージョン: Grpc.Net.Client 2.71.0、Grpc.AspNetCore 2.71.0、Grpc.HealthCheck 2.71.0、Google.Protobuf 3.30.2(3.31.0 は AOT で壊れる既知問題、9.5 参照)
  • Spike 配置: spikes/ 配下に 8 独立ディレクトリ、各 ./run.sh 1 発で再実行可能

エコシステムは更新が速いため、数値・互換性は 2026-05 時点 のものです。.NET 11 GA / Avalonia 12.x 系のマイナーリリース / Grpc.AspNetCore.Server.Reflection の AOT 対応進捗(grpc/grpc-dotnet#2605)等によって、本付録の前提が変わる可能性があります。

A.1 全体表 ── 記事章 ↔ spike ↔ 結果

記事章 テーマ Spike 主結果
§3, §6.2 UDS + gRPC + protobuf + 共有メモリ個別 AOT 検証 grpc-uds-aot 1000 サンプル drain 859 ms / 0 warnings / 7.4 MB ELF
§11.5 sidecar 子プロセスのライフタイム sidecar-lifecycle T1/T2/T3 全 PASS、親 SIGKILL 後 child 105 ms で exit
§11.6 / §7.2 / §3.4 標準 grpc.health.v1.Health + UDS 0600 + oneof AOT grpc-health-aot Health=Serving / 5 variant oneof / 0 warnings / 7.5 MB
§5 / §6 / §8 ハイブリッド構成エンドツーエンド hybrid-e2e 1M レコード(40 MB)を 214 通知のみ で完走
§10 全体 両モード併存(IEngine / InProcess / Remote) dual-mode 両モードで fold_hash=0xA37CC0A27270E7AD 完全一致
§11.3 全 8 step sidecar フルセット(handshake〜graceful) sidecar-full 全 8 step PASS、SIGTERM → 44 ms graceful exit
§6.4 通知機構レイテンシ実測 notify-latency busy 74 ns / eventfd 24.8 µs / gRPC 144 µs(p50)
§6.3 / §7.4 / §9.2 SPSC リングバッファ単体検証 spsc-ringbuffer T1–T6 全 PASS、単体 p50 232 ns / 20 M rec/s / 765 MB/s

A.2 ハイライト ── 本記事の主要主張の実装裏付け

A.2.1 「データプレーンは gRPC に乗らない」が定量実証された(hybrid-e2e)

§4.2 で「gRPC over UDS は数十µs。1M 回呼ぶと数分」と論じ、§5 で「制御プレーンと データプレーンを分ける」と結論付けたハイブリッド構成を、E2E で実装しました。

項目 結果
1M サンプル(40 MB)のデータプレーン 909 ms で完走
データプレーン経路 共有メモリ(file-backed mmap)+ SPSC リングバッファ
gRPC stream に乗ったもの 214 個の小さい ChunkReady 通知のみ
sequence_no 連続性(全件 drop / dup 検出) OK
Value hash 一致(torn read 検出) OK
layout_version(proto + mmap 二重検証) OK

40 MB のデータが gRPC を 1 度も通過していないことが定量的に確認できました。これは記事 §4.2 の論証「gRPC で全部運ぼうとすると致命的、レイヤ分離で構造的に回避」の最も直接的な裏付けです。

A.2.2 「両モードで結果が完全一致」が達成された(dual-mode)

§10.3 で論じた「スキーマは共有・メモリレイアウトは共有しない、それでも結果は同一」が成立することを fold_hash で実証しました。

モード 実装 1 万サンプル fold_hash 所要時間
InProcess Channel<T> でローカル simulate 0xA37CC0A27270E7AD 1 ms
Remote gRPC over UDS + mmap + SPSC consumer 0xA37CC0A27270E7AD 119 ms

Runner/Program.csRunWorkload は engine 中立で、IEngine 抽象越しに両モードを呼び出します。具体的 engine 実装名は switch の 2 case にしか出てきません。§10.2 の「抽象インターフェースを一段挟む」が綺麗に機能している ことの実装上の証拠です。Remote モードの 119× オーバーヘッドは想定通り(IPC + serialize + mmap マッピングコスト)。

A.2.3 sidecar 全 8 ステップが動いた(sidecar-full)

§11.3 の擬似コード 8 ステップを、Parent(Avalonia 役、NativeAOT)↔ Child(sukimasim 役、self-contained single file)で実装し、全 step 通過を確認しました。

Step 内容 結果
1 mktemp UDS パス確保 OK
2 Process.Start("Child", "--inspect=<sock>") OK
3 stdout の READY socket=... 待機(10 s timeout) 即時受信
4 gRPC over UDS 接続 OK
5 grpc.health.v1.Health.Check → SERVING OK
6 OpenSession + Simulate stream + drain(§5.4 step 5 経路) 1000 サンプル OK
7 CloseSession(graceful)+ mmap unlink OK
8 SIGTERM → 3 秒 graceful → 必要なら Kill 44 ms で graceful exit(3 秒タイムアウトの 1.5% で完了)

§11.7 業界事例表で言及した VS Code + clangd / Chrome + renderer / Jupyter + kernel が辿った設計を、.NET 10 + NativeAOT で再現可能であることが示されました。

A.2.4 通知機構レイテンシは記事の表通りのオーダー(notify-latency)

§6.4 の表「busy polling: サブµs / eventfd: 数µs / gRPC stream: 数十µs」を WSL2 で実測しました。

busy-poll    n=5000  p50=   74 ns  p90=  220 ns  p99=  3.6 µs  max=  15.4 µs
eventfd      n=5000  p50= 24.8 µs  p90= 33.6 µs  p99= 44.9 µs  max=  68.6 µs
grpc-uds     n=5000  p50=144.4 µs  p90=175.3 µs  p99=630.3 µs  max=  12.29 ms
方式 記事の予測 実測 p50 評価
busy polling サブµs 74 ns 完全一致
eventfd 数µs 24.8 µs 記事より一桁大きい(WSL2 の syscall overhead。bare-metal なら 1-3 µs 想定)
gRPC stream over UDS 数十µs 144.4 µs 記事より一桁大きい(Kestrel + HTTP/2 + protobuf serialize)

絶対値は WSL2 のオーバーヘッドで全体的に重めですが、比率は記事の予測通り:

  • busy → eventfd: 335× 遅い(syscall ペナルティ)
  • eventfd → gRPC: 5.8× 遅い(HTTP/2 + protobuf)
  • busy → gRPC: 1950× 遅い

§6.4 の結論「UI 用途では gRPC のサーバストリーミングで chunk_ready イベントを流すのが圧倒的に書きやすい。HFT 系でなければ十分」は、p50 144 µs = 1 ms 以下という具体数値で裏付けられました。60 Hz UI なら gRPC で全く問題ない ことが確認できます。

A.2.5 SPSC リングバッファ単体が「サブµs」階層に乗ることを確認(spsc-ringbuffer)

§4.2 表の「共有メモリ + lock-free リングバッファ: サブµs」を、ring buffer を gRPC や mmap ハンドオフから分離して単体で実測しました。hybrid-e2e(A.2.1)では E2E のコンテキストでしか確認できていなかった指標を、単体ベンチで分離。

テスト 内容 結果
T1 basic empty / full / wraparound × 6 サイクル PASS
T2 concurrent 1M sequence_no + value hash + sim_time 全件検証 PASS、30.6 M rec/s
T3 throughput 10M 連続書き込み・読み出し PASS、20.0 M rec/s = 765 MB/s
T4 latency ring 単体(IPC なし)の per-record コスト p50 232 ns / p90 2.8 µs / p99 6.4 µs
T5 cross-process 1M file-backed mmap 経由で別プロセスから消費 PASS、parent 33 ms / child 31 ms
T6 error paths 5 経路の例外発火確認 5/5 PASS
AOT publish 単体 ELF サイズ(gRPC 依存なし) 0 warnings / 2.3 MB ELF

最大の論証 ── 階層差の単体実証: リングバッファ単体の overhead は p50 232 ns = サブµs 階層に確実に乗っています。A.2.4 で実測した gRPC stream over UDS の 144 µs と比較すると、ring buffer は gRPC stream の約 620 倍速い。§4.2 の「サブµs / 数十µs」階層差は、ring buffer と gRPC それぞれの単体実測で両方向から立証されました。これこそが §5 の「制御プレーンと データプレーンを分ける」設計が機能する物理的根拠です。

スループット観点: 20 M rec/s × 40 byte/レコード = 765 MB/s は、sukimasim の典型波形 throughput(数 M event/sec オーダー)に対して オーバースペック。CPU bound にならない余裕を持っていることが確認できました。

cross-process mmap での再現(T5): gRPC 通知レイヤを除いた純粋な ring buffer 共有として 1M レコードを parent 33 ms / child 31 ms で完走。共有メモリでの SPSC 通信が、ネットワークプロトコルのオーバーヘッドなしに動作することを単体で実証しました。

A.3 NativeAOT publish 警告ゼロ確認リスト

§9.5 で論じた「.NET 10 LTS で実用段階、Application Insights SDK と gRPC server reflection だけ避ければ警告ゼロ」を、以下すべてで実証しました。

  • Grpc.Net.Client + SocketsHttpHandler.ConnectCallback(UDS 接続)
  • MemoryMappedFile.CreateFromFile(file-backed mmap)
  • Volatile.Read / Volatile.Write 経由の SPSC リングバッファ
  • Grpc.HealthCheck クライアントスタブ
  • protobuf oneof シリアライズ(5 variant)
  • LibraryImport(prctl, eventfd, kill)
  • Process.Start / Process.Exited(sidecar 起動・ライフタイム)

Spike grpc-uds-aot / grpc-health-aot / hybrid-e2e / dual-mode / sidecar-full / spsc-ringbuffer すべてで AOT publish 警告ゼロ、ELF サイズ 2.3〜7.7 MB(依存ライブラリ最少の spsc-ringbuffer は 2.3 MB、gRPC スタックを含むものが 7.4〜7.7 MB)。

なお実装トリビアとして、Grpc.HealthCheck 2.71.0 は Google.Protobuf >= 3.30.2 を要求します。9.5 で言及した「Google.Protobuf 3.31.0 は AOT で壊れる、3.30.2 か 3.31.1+ を使う」と整合しています。

A.4 検証していない範囲

実装可能性が示されたのは Linux(WSL2)のみで、本記事で論じた全ての OS / シナリオを網羅したわけではありません。

  • Windows / macOS: 全 spike は WSL2 Linux のみで検証。§6.2 / §7.3 の Windows WINDOWS_NAMED_MAPPING パスは spec / proto enum までしか実装していない
  • macOS の kqueue + NOTE_EXIT(§11.5): spec のみ
  • Windows Job Object(§11.5): spec のみ
  • bare-metal Linux 上での再計測: notify-latency および spsc-ringbuffer は WSL2 数値。bare-metal なら eventfd / gRPC が一桁低い、リングバッファ latency も改善する可能性
  • 長時間 stress / soak テスト: 全 spike は数秒〜数十秒のシナリオ。§9.1 で論じた「ライフサイクル管理・ゴミ掃除・refcount」の異常パス全網羅はしていない
  • producer crash 時の consumer 側挙動: §7.4 で議論した refcount / UDS peer 切断検出は未実装
  • InProcess モードを C-ABI 経由で実装: dual-mode の InProc は Channel<T> で simulate。実際の sukimasim 統合では LibraryImport + 共有メモリポインタ直接受け取りになる
  • リングバッファの細部チューニング: spsc-ringbuffer(spike #8)で単体 throughput / latency / cross-process は確認したが、以下は未実施 ── cache-line false sharing 影響の定量化([StructLayout(LayoutKind.Explicit)] + 64 byte padding の有無比較)、ARM64 上でのメモリオーダリング再検証(Apple Silicon / AWS Graviton)、複数 SPSC を並列に立てた場合の cache contention、back-pressure セマンティクス選択(drop / block / skip)による挙動差

A.5 まとめ ── 本記事の主張は実装可能

記事の主要論点 ── §3(UDS + gRPC + protobuf)/ §5(ハイブリッド構成)/ §6(.NET 側実装)/ §10(両モード併存)/ §11(sidecar 起動パターン)── は、2026-05-23 時点の .NET 10.0.107 + NativeAOT で実装可能、警告ゼロで動作、想定された性質を満たす ことを 8 spike で確認しました。

特に重要な定量データ:

  • 1M レコード / 40 MB のデータプレーン処理が gRPC を 214 通知しか通過しない(§4 〜 §5 の主張の構造的裏付け)
  • 両モードで完全に同じハッシュが得られる(§10.3 の主張の実装裏付け)
  • sidecar の SIGTERM → graceful exit が 44 ms(§11.3 step 8 の運用可能性)
  • 通知レイテンシの比率が記事の予測と一致(§6.4 表の妥当性)
  • SPSC リングバッファ単体は p50 232 ns / 20 M rec/s / 765 MB/s(§4.2 「サブµs / 数十µs」階層差の単体実証、ring と gRPC で約 620 倍の速度差)

これらは本記事の理論的主張を、実装可能性 + 実測値という形で補強します。sukimasim + SukimaDebug の段階的統合(Mode A in-process C-ABI → Mode B Inspector 拡張 → Mode C sidecar)のどの段階でも、本記事のアーキテクチャを採用可能 ── これが 8 spike から得られた最大の収穫です。


Appendix B. AI 時代のデバッグと観測性

本付録は本論を読み終えた読者向けの補足です。 1〜11 章で C++ コアエンジンと Avalonia UI を分離する場合の IPC 設計・実装・起動パターンを一通り扱い、12 章で総括しました。本付録は主題から少し広がって、「分離アーキテクチャは同一プロセス C-ABI よりデバッグが難しい、と本記事は繰り返し書いてきたが、Claude Code や ChatGPT のような AI デバッグ支援が当たり前になった現代、それでも分離は不利なのか?」 という問いに答えます。

AI 支援(単一エージェント、複数エージェント)が分離アーキテクチャのデバッグをどこまで救えるか、を率直に整理します。結論を先に書くと、「補助としては強力、ただし同一プロセスの white-box デバッグには情報量で届かない」 です。

B.1 単一 AI エージェントが効く場面

まず、単一の AI エージェント(Claude Code / ChatGPT 等)が、分離アーキテクチャのデバッグで明確に効く作業を整理します。

  • ログの横断検索と時系列再構成: UI 側 / エンジン側 / OS の syslog を全部投げて「session_id=abc123 の前後で何が起きた?」を時系列で再構成する。grep 力と仮説生成力の合わせ技 ── AI の得意領域
  • gRPC スキーマ進化バグの特定: .proto の変更履歴と、両プロセスのバイナリビルド時刻を突き合わせて「片側だけ古いスタブで動いている」を見抜く
  • 共有メモリのレイアウト不整合の検出: C++ 側の struct 定義と C# 側の Marshal レイアウトを横並びで比較し、アラインメントやパディングのズレを指摘してもらう
  • OS 依存の落とし穴の事前警告: 本記事で議論したような「MemoryMappedFile.OpenExisting は Windows 限定」「PR_SET_PDEATHSIG には getppid() レースガードが要る」といった既知の罠を、コードレビューの段階で指摘してもらう
  • 再現スクリプトの自動生成: 「この症状を再現する最小ケースを pytest で書いて」と頼める。検証エンジニアにとってはこれが大きい

これらは「人間が知識と注意力で頑張れば見つかるが、見落としやすい」種類の作業で、AI が 見落とし率を下げる 効果が確実にあります。

B.2 それでも残る本質的な難しさ

一方、AI 支援を入れても 本質的に難しいまま残る 作業もあります。

  • 時系列・並行性が絡むバグ: 「UI が Send したのと、エンジンが Recv したのと、共有メモリへの書き込みが、どの順番で起きたか」── タイムスタンプ精度、クロックずれ、シリアル化の問題が複雑に絡みます。AI に「ログを読んで」と頼んでも、ログに記録されていない順序関係は復元不可能
  • メモリオーダリングのバグ: x86 では動くが ARM で落ちる類。再現性が極めて低いので、AI が「このコードは怪しい」と指摘できても、実機で踏むまで判定できない
  • mixed-mode debugger 不在問題は解決しない: 別プロセスだと、UI 側のステップ実行がエンジン側の関数呼び出しに「入っていけない」── これは AI でカバーできる範囲を超えています。コールスタックが境界で切れる以上、推論で補うしかなく、本質的な情報量が減っている
  • observer effect: AI にログを足してもらうと、それ自体がタイミングを変えてバグが再現しなくなる(printf デバッグの宿命)。これは AI ではどうにもなりません
  • 共有メモリのバイナリダンプ解析: 「壊れている状態のリングバッファを覗いて、いつ・誰が・どう壊したか」を推論する作業。AI はバイナリの目視解析は得意ではありません

B.3 複数エージェント配置 ── UVM の verification environment に近い見立て

もう一歩進んだ提案として、「各プロセスごとに AI エージェントを配置し、全体を統括する Orchestrator エージェントを置く」 というアーキテクチャがあります。

                ┌──────────────────────┐
                │ Orchestrator Agent   │ ← 全体統括・仮説生成・反証
                │ (タイムライン再構成 │
                │  、仮説立案、判定)  │
                └──────────────────────┘
                   ▲                ▲
                   │                │
     ┌─────────────┘                └─────────────┐
     │                                            │
┌────▼────────────┐                      ┌────────▼────────┐
│ UI Agent        │   gRPC / 共有メモリ   │ Engine Agent    │
│ (C# / Avalonia │ ◄──────境界──────►   │ (C++ / sukimasim│
│  プロセスに担当)│                      │  プロセスに担当)│
└─────────────────┘                      └─────────────────┘

この配置は、構造上は マイクロサービスの観測アーキテクチャ(各サービスに OpenTelemetry collector、それを束ねる Jaeger / Tempo)に近く、検証エンジニア視点で見ると UVM の verification environment にも近い見立てができます。

検証の世界 AI デバッグの世界
uvm_agent(sequencer + driver + monitor) 各プロセスに張り付く専門エージェント
uvm_scoreboard(全体集約・判定) Orchestrator Agent
インターフェース signal(vif) gRPC ストリーム + 共有メモリのメタログ
white-box / black-box verification white-box / black-box debugging

UVM が「各 interface に agent を置き、scoreboard が全体を集約する」のは、まさに本提案と近い構造です。検証エンジニアにとっては馴染みのある発想で、自然に受け入れやすいアーキテクチャだと思います。

ただし一点だけ違いを明示しておくと、UVM monitor は実際の signal / transaction を決定論的に観測する仕組みであるのに対し、AI エージェントは観測済みのログやダンプを要約・推論する存在 です。「観測器」と「観測済みデータの解釈器」という役割の違いがあり、両者を完全に同一視するわけではありません ── あくまで「構造上は似ている」という見立てです。

B.4 複数エージェントが効くポイント

単一エージェントに対する複数エージェント配置の優位点を整理します。

1. ログの局所性 ── コンテキストウィンドウを浪費しない

単一エージェントに「UI とエンジン両方のログ」を全部投げると、コンテキストウィンドウが一瞬で埋まります。複数エージェントなら、各エージェントが自分の担当ログだけを深く読み、Orchestrator にはサマリだけ渡せる

  • UI Agent: gRPC クライアント側のログ、Avalonia の Dispatcher.UIThread 例外、Process.Exited イベントだけ見る
  • Engine Agent: sukimasim 側の stderr、/proc/<pid>/maps、共有メモリのダンプを見る
  • Orchestrator: 両者の タイムスタンプ整合済みサマリ を受け取り、ストーリーを組み立てる

検証エンジニアの世界で言えば、各シミュレーションノードに分散カバレッジ収集を置いて、マネージャに統合させる構造に似ています。

2. ドメイン知識の特化

それぞれのエージェントに 特化したシステムプロンプトと skills を載せられる。

  • UI Agent: Avalonia / MVVM / Compiled Bindings / NativeAOT 警告の専門家
  • Engine Agent: SystemVerilog 仕様 / UVM / sv-tests / メモリオーダリング の専門家
  • Orchestrator: IPC プロトコル / 分散システム / タイムライン解析 の専門家

これは「専門家 3 人で会議する」のと近い構造で、単一の汎用エージェントよりも各領域で深く掘れます。

3. 並列化

UI 側の調査と Engine 側の調査を 同時に走らせる ことができる。「UI のフリーズ時刻と、エンジンの GC ポーズ時刻を突き合わせたい」みたいな対称的な作業で、シリアルに調べるより速い。

4. 仮説の対立構造を制度化できる

意外にこれが大きい論点です。UI Agent と Engine Agent に、それぞれ「相手側のせいで自分が苦しんでいる」という仮説で調査させる、という配置が成立します。

  • UI Agent: 「エンジンが late respond したから UI がブロックした」を立証しに行く
  • Engine Agent: 「UI が cancellation token を不適切に投げたからエンジンが中断した」を立証しに行く
  • Orchestrator: 両方の証拠を見て判定

これは人間のチームでも「フロントエンドとバックエンドが互いに相手のせいにする」現象がしばしば起きますが、それを 健全な対立構造として制度化する 設計です。デバッグの分業として効率的で、UVM の scoreboard が「DUT の出力」と「reference model の出力」を突き合わせるのと同じ構造になります。

B.5 それでも越えられない観測モデル上の限界

楽観的になり過ぎないように、率直に書きます。複数エージェントを配置しても、観測情報そのものが無い場所には届きません。

1. プロセス境界を越える時系列推論は、観測情報がそもそも無い

UI Agent と Engine Agent が、自プロセス内で観測されなかったイベントの順序は再構成できません。たとえば「UI がメッセージを送ったのと、エンジンがそれを受信したのと、共有メモリへの書き込みが、ナノ秒オーダーでどう並んだか」── これはエージェントを増やしても 物理的に観測情報が無いものは無い

対策としては、両プロセスに高精度タイムスタンプ + シーケンス番号を埋め込む規律 を最初から入れること。これは AI でなく設計の問題で、各エージェントに「ログを書くときは必ず monotonic clock のナノ秒タイムスタンプとシーケンス番号を含めるよう、コードを直して」と頼める段階で初めて活きます(次節 B.6 で具体化)。

2. エージェント間の通信コストは無視できない

3 つのエージェントが互いに状況を共有するためのオーバーヘッドは、思ったより重いです。

  • Orchestrator が UI Agent に「最後の 1 分のログを要約して送って」
  • Orchestrator が Engine Agent にも同じ依頼
  • 両者の応答を Orchestrator が突き合わせる
  • 矛盾があれば再質問

これだけで実時間で数分かかります。人間が直接両方のログを横並びにして 10 秒で見つかるバグもあるので、「シンプルなバグには複数エージェントは過剰」 という現実があります。マイクロサービス化したら開発者体験が悪化した、というパターンに似ています。

3. Orchestrator の判断ミスは下流の調査を全部無駄にする

Orchestrator が単独で「両側の話を聞いて判定する」役なので、Orchestrator が判断ミスすると下流の調査が全部無駄になる。検証エンジニアの世界で言えば、verification plan を書くチーフ verification engineer が誤った仮説でテストを設計すると、リグレッションが一晩走った後で気付く、というやつと同じ構造の事故が起きえます。

これを避けるには Orchestrator に 「自分の仮説に反する証拠を能動的に探せ」 という規律(反証主義)を入れる必要があります。これはエージェントのシステムプロンプトレベルの設計で、自然には起きません。

4. 共有メモリのバイナリ解析は依然として弱い

複数エージェントを置いても、共有メモリの中身を「目で読んで意味を理解する」作業は強くなりません。Engine Agent に「共有メモリをダンプして」と頼んでも、得られるのはバイト列のテキスト表現で、これを構造体定義と照らし合わせて壊れ方を見抜くのは、本質的に難しいタスクのまま。

ここを強化するなら、専用の解析ツールを書いて Engine Agent に使わせる(共有メモリダンプを構造化 JSON にする CLI を作る)方が効きます。これは「エージェントを増やす」より「ツールを与える」方向の最適化です。

5. mixed-mode debugger の欠如は埋まらない

これは構造的な限界です。エージェントを何個置いても、ステップ実行がプロセス境界を越えられないという事実は変わらない。AI で「推論によって境界を越える」のは、実機の情報を超えた精度では絶対にできません。

デバッグ手段 観測可能性 EDA 領域での対応物
同一プロセス C-ABI + mixed-mode debugger white-box(内部信号すべて可視) シミュレータ内部の波形を Verdi / Verisium で見る
分離 + 単一 AI エージェント black-box(IPC メッセージのみ可視、推論で補う) プロトコルアナライザのログ解析
分離 + 複数 AI エージェント + Orchestrator black-box の集約(各プロセスの内部ログを集約、推論で再構成) UVM 環境で各 interface に monitor、scoreboard で集約

「複数エージェントが white-box に追いつくか」と問えば、答えは 「追いつかない」。観測モデル上、ログ・トレース・ダンプに残っていない事実は、AI でも推論以上には扱えないからです。

B.6 設計に AI 観測性を組み込む ── 白箱化に近づける数少ない手段

B.5 の悲観を少しでも緩和する方法はあります。それは 「AI に渡しやすいデータを最初から用意する」 という設計レベルの規律です。これは AI を増やすより効果があります。

IPC メッセージのメタデータ規約

すべての .proto メッセージに、メタデータヘッダを義務化する。

message MessageHeader {
  // 順序を決定するフィールド
  uint64 monotonic_ns = 1;       // CLOCK_MONOTONIC のナノ秒値
  uint64 sequence_no = 2;        // プロセス内の単調増加

  // 識別と再起動の区別
  string session_id = 3;
  uint32 process_id = 4;         // OS の pid(再起動を見分けるため)

  // 相関(causal correlation)── 順序ではなく「同じ処理系列」を束ねるための ID
  string trace_id = 5;           // 分散トレース ID(OpenTelemetry 互換)
  string span_id = 6;            // この操作のスパン
  string parent_span_id = 7;     // 因果上の親スパン
  uint64 message_id = 8;         // send/recv ペアを対応付ける ID
}

message Diagnostic {
  MessageHeader header = 1;
  // ... 本来のフィールド
}

ここで重要なのは 「順序を決めるフィールド」と「相関を取るフィールド」は別物 ということです。trace_id は同じ処理系列のイベントを束ねる相関 ID であって、それ単体で順序を決めるものではありません。プロセス内順序は sequence_no、プロセス間の因果関係は message_id の send/recv ペアと span_id / parent_span_id で復元する ── これが OpenTelemetry 系のトレースが採っている標準パターンです。OpenTelemetry の messaging semantic conventions も、transport 側のトレースだけでは producer / consumer の相関に不十分で、別の context propagation(やアプリ層の sequence number)が必要だと整理しています。本記事では monotonic_ns + sequence_no をアプリ層 sequence の代表として配置しています。

これらが揃っていれば、両プロセスのログを Orchestrator がマージするときに、AI に渡せる情報量が桁違いに増えます。逆に、これが無いと AI は「ログに残っていない順序」を推測するしかなく、どんなに優秀なエージェントでも黒箱推論の上限を超えられません。

共有メモリのレコードフォーマット規約

リングバッファのレコードに、(monotonic_ns, sequence_no, producer_id) を必ず含める。

struct WaveformRecord {
    uint64_t monotonic_ns;
    uint64_t sequence_no;
    uint32_t producer_id;
    uint32_t signal_id;
    int64_t  sim_time;
    uint64_t value;
};
static_assert(sizeof(WaveformRecord) == 40);
static_assert(alignof(WaveformRecord) == 8);

ヘッダ部に レイアウトバージョン構造体サイズ を入れておけば、AI(または専用 CLI)が「これは v3 レイアウトだから 40 バイト単位で読めばよい」と判定できます。

構造化ログの徹底

各プロセスは printf ではなく structured log(JSON Lines、tracing クレート、Serilog、OpenTelemetry など)で出力する。AI が grep ではなく jq でクエリできる状態にしておく。

共有メモリ解析 CLI を最初から用意する

$ sukimasim-shmdump --path=/run/user/1000/sukimasim/waveform-abc123.bin --format=json
{"record_idx":0,"monotonic_ns":1234567890,"signal_id":42,"sim_time":100,"value":1}
{"record_idx":1,"monotonic_ns":1234567950,"signal_id":42,"sim_time":110,"value":0}
...

Engine Agent がこの CLI を Bash ツール経由で叩けば、バイナリの目視解析を完全にスキップして、構造化データとして扱えます。これは検証ツールの世界で言えば VCD/FSDB から JSON エクスポート機能を提供しておく のと同じ発想で、観測性を白箱に近づける最も実効性のある手段です。

B.7 結論 ── AI は分離の意思決定軸ではなく、分離後の生活を楽にする補助

ここまでの議論を踏まえた結論:

AI 支援(単一でも複数でも)は、分離アーキテクチャの運用を楽にしてくれる強力な補助だが、「分離するか否か」の意思決定軸ではない

具体的に言うと:

  • AI があるから別プロセスにしよう ── これは要件分析の飛ばし。私なら採らない判断
  • 要件として分離が必要、AI があるから初期実装と運用が楽になる ── これは正しい順序
  • 複数エージェントを使えば、同一プロセス C-ABI と同等のデバッグ体験になる ── 観測モデル上、ログに残っていない事実は復元できないので成立しない
  • 複数エージェントを使うなら、IPC メッセージと共有メモリに最初から AI 観測性を埋め込んでおく ── これは本当に効く

そして個人 OSS 開発者として正直に書くと、AI の最大の貢献はデバッグより実装フェーズ だと思います。本記事を書く過程で、私自身が MemoryMappedFile.OpenExisting の Unix 非対応を AI レビューで指摘されて修正できたのが象徴的です。書く前・コードレビューの段階で罠に気付ける のが、AI が最も価値を発揮する場面です。「AI でデバッグできるから分離する」ではなく、「AI があるから、分離するときの初期実装の事故率が下がる」が、現時点での正確な温度感だと思います。

本記事の主張を本付録で更新する必要はありません: 要件があるなら分離する。AI はその後の運用と初期実装の事故率を下げてくれる優秀な補助だが、判断軸を変えるものではない。ただし、もし分離前提で組むなら、設計の段階で AI 観測性を埋め込んでおく(IPC メタデータ規約、構造化ログ、共有メモリダンプ CLI)── これは将来の自分とエージェント群への投資になります。


参考リンク

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?