新しもの好きプログラマの耳より情報ブログ

仕事でもあるプログラミングについて役に立ちそうな情報を発信していこうというブログです。役に立たなそうな情報はfacebookで。

.NET 8のプロセス間通信には、gRPCのパイプ通信が優秀なようです

概要

Windowsアプリを作成していると、PC内のプロセス同士で通信をしたい場合があります。その実装方法として、.NET 8で公式サポートされたgRPCのパイプ通信が優秀なようです。

それについて次のスライドで、イベントで登壇させてもらいました。

www.docswell.com

その内容を、記事にまとめました。具体的なコードなどは、スライドよりも詳しい情報になっていると思います。

従来のgRPCのTCP通信との比較や移行について、次の流れで紹介します。 (以下、プロセス間通信をIPCと略記します)

  1. 話の前提:IPCを使いたいケースと、gRPCでのIPC
  2. 標準のTCP通信をパイプ通信に移行
  3. そのメリットと実測データ

結論

説明が長くなるので結論だけ先に書いておくと、こんな感じになります。

  • 速度メリットとしては、ストリームを作らずに接続・切断を繰り返すケースではかなり速い。速度以外のメリットもある。デメリットは無さそう。
  • 既存のgRPC(TCP通信)からの移行も容易
  • gRPC自体の使いやすさは変わらずにメリットを得られるので、.NET 8でIPCをするのなら第一選択肢だと思う
  • 実装は割と簡単だが、用途によってはパイプのACL設定をWin32 APIで変更する必要がある点に注意

話の前提

IPCを使いたいケース

Windowsアプリでは、プロセス1つで機能を完結できず、他のプロセスと通信したいケースがあります。主に、動作するセッションが異なる場合が多いと思います。

例えば次の図のように、バックグラウンドで動くWindowsサービスと、GUIを持つユーザーセッションアプリで通信したい場合などです。

.NET8でgRPCのパイプ通信を評価してみる&ついでに既存プロジェクトを.NET8へ移行_図.png

gRPCでのIPC

この用途で、gRPCを使うことができます。gRPCとは、専用の構文で通信内容を定義しておくことで、クロスプラットフォームの通信ができる方式です。かなり有名だと思うので、ここでは詳しくは触れません。

.NET8でgRPCのパイプ通信を評価してみる&ついでに既存プロジェクトを.NET8へ移行_図-1.png

そうした方式ですが、同一プラットフォーム内でIPCする今回の用途でも、十分にメリットがあります。通信内容と通信方式が分離されていて、かつ通信方式部のコード生成も手厚いことから、他のIPC方式よりもかなり手軽に使えます。速度としても、localhostへのTCP通信となるので、十分に速いです。

標準のTCP通信をパイプ通信に移行

パイプ通信にできる

TCPでも十分に速いと書きましたが、とはいえWindows内のIPCであればTCP通信よりもパイプ通信の方が速いはずです。.NET 8で、パイプ通信が公式サポートされました。gRPCの特徴から、通信内容の定義はそのままにして、通信方式だけをパイプ通信へ変えることが出来ます。

.NET8でgRPCのパイプ通信を評価してみる&ついでに既存プロジェクトを.NET8へ移行_図-2.png

パイプ通信にするメリット

TCP通信と比べてのメリットは、このようなものがあります。

  1. 他ソフトとのポート番号の衝突を気にしなくていい
    • パイプは文字列なので衝突する可能性は低い
  2. ファイルと同様に、WindowsACLでの権限管理ができる
  3. TCPでも十分に速い」とは言ったが、より高速な用途ではパイプが優れるはず

デメリットは標準のTCPよりも実装に少し手間が増えるくらいで、他は特に見当たりません。

そのメリットと実測データ

パイプのほうが速いはずだと言ってきましたが、具体的にどういうケースでどれくらい速いのか。実測してみました。

接続・通信・切断を繰り返すケース

100ms間隔で「接続・送信・受信・切断」を繰り返し、1回ごとの所要時間の中央値を計測しました。

.NET8でgRPCのパイプ通信を評価してみる&ついでに既存プロジェクトを.NET8へ移行_図-3.png

結果は・・・

  • TCP:約2000ms/1回
  • パイプ:約1ms /1回

おそらくTCPの接続のハンドシェイク等の時間かと思いますが、かなり大きな差が付きました。数秒オーダーよりも頻繁に通信を繰り返す場合、パイプ通信が有効と言えます。

ストリーム通信を確立し、その上で通信を繰り返すケース

ストリーム通信を確立した上で、その上で10ms間隔での通信を繰り返し、次の内容を計測しました。

  1. ループ1回ごとの所要時間の中央値
  2. 送信元の通信データ作成から、送信先の通信データ取得までの所要時間の中央値

.NET8でgRPCのパイプ通信を評価してみる&ついでに既存プロジェクトを.NET8へ移行_図-4.png

結果は・・・

  1. ループ1回の所要時間
    • TCP:約15.65ms
    • パイプ:約15.55ms
  2. 送信元の通信データ作成から、送信先の通信データ取得までの所要時間
    • TCP:約0.25ms
    • パイプ:約0.15ms

通信繰り返しの所要時間はほぼ同じで、通信内容そのものの到達時間にはわずかに差がありました。と言っても、通信全体の所要時間からすると誤差に近いです。

よほど通信遅延のタイミングにシビアな用途でなければ、差は無いと言って良いと思います。

具体的な実装方法やポイントの紹介

gRPC(TCP)でのプロセス間通信を行うコードがすでにある前提で、これをパイプ通信に変更するところが対象です。

とにかくコード全体を見たい人へ

この記事で説明しているコードは、次の場所のソリューションの中の、ポイントとなる部分です。全体を見たい人はそちらをどうぞ。 https://github.com/suusanex/sample_winservice_pipe_duplex_wcf_and_grpc/tree/master/gRPCWinServiceSample

クライアント側

サーバーへ接続する部分は、TCPならば1行で書けますが、パイプ通信の場合は少し複雑になります。

TCPの場合はこのように接続できます。

var channel = GrpcChannel.ForAddress("http://localhost:50100/Connect1");

パイプ通信の場合は、次のように独自のConnectCallbackを作成する必要があります。

var connectionFactory = new NamedPipesConnectionFactory("gRPCWinServiceSamplePipeName");
var socketsHttpHandler = new SocketsHttpHandler
{
    ConnectCallback = connectionFactory.ConnectAsync
};
m_Channel = GrpcChannel.ForAddress("http://localhost/Connect1", new GrpcChannelOptions
{
    HttpHandler = socketsHttpHandler
});

ConnectCallbackを実装している「NamedPipesConnectionFactory」は組み込みのクラスではなく、独自に実装する必要があります。次のようになります。ここで、パイプの細かい設定を変更することができます。

public class NamedPipesConnectionFactory(string m_PipeName)
{
    public async ValueTask<Stream> ConnectAsync(SocketsHttpConnectionContext _,
        CancellationToken cancellationToken = default)
    {
        var clientStream = new NamedPipeClientStream(
            serverName: ".",
            pipeName: m_PipeName,
            direction: PipeDirection.InOut,
            options: PipeOptions.WriteThrough | PipeOptions.Asynchronous,
            impersonationLevel: TokenImpersonationLevel.Anonymous);

        try
        {
            await clientStream.ConnectAsync(cancellationToken).ConfigureAwait(false);
            return clientStream;
        }
        catch
        {
            await clientStream.DisposeAsync();
            throw;
        }
    }
}

実装する量は少々多いですが、つまりはGrpcChannel.ForAddressで接続をしている部分のコードだけ置き換えれば、TCP通信をパイプ通信に変えることが出来ます。

GitHubのコードで言うと、このあたりこのクラスになります。

クライアント側についてはおおむね、次のMS Learnの記載通りであり、特に注意点はありません。 gRPC と名前付きパイプを使ったプロセス間通信 | Microsoft Learn

サーバー側

サーバー側は、GenericHostを使っていることを前提に説明します。GenericHostについてはこの記事では触れませんが、DI・ロガー・各種サービスホストなどをまとめて取り扱える.NETの仕組みで、Windowsサービスもこれを使って作っていくのがお勧めです。

その前提だと非常に簡単で、TCPだと次のようにするところを

.ConfigureWebHostDefaults(webBuilder =>
{
    webBuilder.ConfigureKestrel(options =>
    {
        options.Listen(IPAddress.Loopback, 50100, listenOptions =>
        {
            listenOptions.Protocols = HttpProtocols.Http2;
        });
    });

次のようにListenNamedPipeへ置き換えるだけです。

.ConfigureWebHostDefaults(webBuilder =>
{
    webBuilder.ConfigureKestrel(options =>
    {
        options.ListenNamedPipe("gRPCWinServiceSamplePipeName", listenOptions =>
        {
            listenOptions.Protocols = HttpProtocols.Http2;
        });
    });

置き換えずに両方を記載すると、TCPとパイプの両方を待ち受けることができます。サーバー側は、かなり簡単に実装可能だと思います。

GitHubのコードで言うと、このあたりになります。

ただし、サーバー側には注意点があります。

注意点:パイプのACL

Windowsサービス(LocalSystem権限)からパイプを作ると、デフォルトではユーザー権限の書き込み不可になります。

それはそれで良いのですが、もしユーザーセッションからユーザー権限で操作したい場合は、権限を追加する必要があります。gRPC側からそれを設定できるインターフェースが有るといいのですが、見つかりませんでした。

Win32 APIを使って、パイプを開いてACLを設定すれば、実現できます。C#でも書けると思いますが、C++/CLIで書きました。記事の中に貼るにはWin32 APIを使ったコードは長すぎるので、ポイントを説明した後にGitHubのリンクを貼ります。

  1. パイプを開く
  2. 設定したいユーザーグループのSIDを文字列で指定
  3. パイプのセキュリティ設定を取得して、SIDに対する許可を設定

以上のような処理をします。最初の「パイプを開く」処理は、GenericHost側でパイプが作成されるまで成功しません。別スレッドで成功するまでリトライするなどの工夫が必要になります。

GitHubのコードは、このあたりになります。

ここまでが、TCPのgRPC通信コードを、パイプ通信に変更する方法です。

.NET8より前の既存プロジェクトからの移行は?

その前に、.NET 8へ移行しなければ、gRPCのパイプ通信は使用できません。幸い、既存プロジェクトを.NET 8に移行するのは、割と簡単です。そこも少し説明します。

WPF・クラスライブラリなど(C#)

プロジェクトの「ターゲットフレームワーク」で「.NET 8.0」を選択するだけで、たいていは動きます。もちろんファイルの<TargetFramework>を直接書き換えてもOKです。GitHubのコードではこの部分です。

C++/CLI

プロジェクトの「.NETターゲットフレームワーク」で「.NET 8.0」を選択するだけ、と書きたいところですが・・・選択肢にありません。

GUIではコンボボックスになっていますが手入力可能なので、「net8.0」と書けばOKです。プロジェクトファイルの直接書き換えも可能で、GitHubのコードではこの部分です。

まとめ

PC内でのプロセス間通信について、gRPCのパイプ通信はTCP通信よりも優れた選択肢のようです。速度面では悪くとも同等、ストリームを使わずに接続・通信・切断を繰り返すようなケースであれば大きく優れます。それ以外の運用上のメリットもあります。また、すでにTCP通信で実装している場合にも、通信内容の実装に影響することなくパイプ通信への置き換えができます。

また、記事にしてみるとそこそこ長い気がしますが、ソースコードの視点ではさほど大きな変更をすることなく、gRPCをTCPからパイプ通信に変更できます。

以上より、新規実装・既存の置き換えのどちらでも、gRPCのパイプ通信は第一選択肢になると思います。これ自体が .NET 8への移行の動機にもなるかもしれません。