C#とC++でUIなしフルデュプレックス通信を実装する方法|WM_COPYDATA不要で名前付きパイプを使いこなす

Windowsサービスやバックグラウンドの常駐プロセス同士で、C#とC++をUIなしのまま安全にフルデュプレックス通信させたい――そんなときに真っ先に思いつくのがWM_COPYDATAですが、ウィンドウハンドルやメッセージポンプ前提の仕組みは、コンソール/サービス型アプリには相性がよくありません。この記事では、WM_COPYDATAを捨てて「名前付きパイプ(Named Pipe)」でC# ⇆ C++の双方向通信を実現する具体的な実装手順と設計のコツを、サンプルコード付きで詳しく解説します。

目次

UIなしでC# ⇆ C++をフルデュプレックス接続したいときの課題

まず、今回の要件を整理しておきます。よくあるシナリオとしては次のようなものがあります。

  • バックグラウンドで動いているC++ネイティブサービスと、C#製の常駐ツールを双方向に接続したい
  • どちらもウィンドウを持たない(コンソールアプリ/Windowsサービスなど)
  • WM_COPYDATAなどのWindowsメッセージ方式は使いたくない(メッセージポンプや隠しウィンドウが邪魔)
  • 接続開始・切断・異常終了からの自動再接続まで含めてシンプルに書きたい

ところが素直にWindowsメッセージでやろうとすると、途端に面倒になります。

  • CWndはMFC専用のC++クラスなので、C#から継承することはできません
  • WM_COPYDATAは「HWND」とメッセージポンプがあることが大前提です
  • サービスやコンソールアプリで無理やりメッセージループを用意すると、設計が歪みがちです
  • Marshalクラスはあくまでメモリ変換(マーシャリング)用であり、IPCそのものを簡単にしてくれるわけではありません

このような背景から、UIなしプロセス間のC# ⇆ C++通信では、Windowsメッセージをきっぱり諦めて別のIPC手段を選ぶ方が結果的にシンプルになります。

結論:WM_COPYDATAではなく「名前付きパイプ」が最短ルート

今回のような要件に最もフィットするのが、Windows標準のIPC機構である名前付きパイプ(Named Pipe)です。特にローカルマシン内だけで完結する通信なら、ほぼこれ一択と言っていいほど実践的です。

名前付きパイプを採用するメリットを整理すると、次のようになります。

  • ウィンドウ/メッセージポンプが不要:完全にUIなしでOK
  • フルデュプレックス(同時送受信)対応:パイプ1本で双方向通信
  • C++ / C#どちらも標準APIで利用可能
    • C++:Win32 API(CreateNamedPipe, ConnectNamedPipe, ReadFile, WriteFile…)
    • C#:System.IO.Pipes名前空間(NamedPipeServerStream, NamedPipeClientStream)
  • 例外やエラーを「切断」の合図として扱えるので、ループで再接続をとても書きやすい
  • ローカル限定ならファイアウォール設定も不要、書き味はほとんどファイルI/Oと同じ

他の方式との違いをざっくり比較すると、以下のようなイメージです。

方式UI不要フルデュプレックス実装容易さ再接続のしやすさ主な用途
名前付きパイプ◎◎○◎ローカルIPC全般
WM_COPYDATA×(UI必須)△(片方向寄り)△△簡易なUI間連携
TCP(localhost)◎◎△(プロトコル設計が必要)○マシン跨ぎ/将来拡張
メモリマップトファイル+イベント◎◯(実装次第)△(同期制御が難しい)△高速・低レイテンシ用途
標準入出力◎△(親子1対1向け)◎×(再接続が困難)バッチ処理・子プロセス制御

この比較の通り、UIなし・ローカルIPC・双方向・再接続前提という条件が揃うと、名前付きパイプが最もバランスのいい選択になります。

名前付きパイプの基本設計:まずはここだけ押さえる

実装に入る前に、名前付きパイプで最低限決めておくべきことを整理します。

項目内容この記事での例
パイプ名サーバー/クライアントで同じ名前にする必要あり\\.\pipe\CSharpCppPipe(C++側)、
CSharpCppPipe(C#側)
役割どちらがサーバー/クライアントを担当するかC++ = サーバー、C# = クライアント
モードメッセージ単位 or バイトストリームメッセージモード(PIPE_TYPE_MESSAGE)
同期/非同期同期I/Oか非同期I/Oかまずは同期でシンプルに、後で非同期(フルデュプレックス化)
再接続戦略切断時のリトライ間隔やバックオフの方法サーバーは無限ループ、クライアントはバックオフつきリトライ

この記事では以下の方針で進めます。

  • C++がパイプサーバーとして待ち受け
  • C#がパイプクライアントとして接続&再接続
  • まずは同期I/Oで単一クライアント対応からスタート
  • その後、フルデュプレックス化(同時送受信)や非同期I/Oの考え方を解説

C++側:UI不要のNamed Pipeサーバー最小実装

まずはC++でコンソールアプリとして動作する名前付きパイプサーバーの最小サンプルです。ここでは、クライアントから文字列メッセージを受け取り、ACK:を付けて送り返すだけの単純なエコーサーバーを実装します。

// PipeServer.cpp(x64 などターゲットは C# 側と合わせる)
#define UNICODE
#define _WIN32_WINNT 0x0600
#include <windows.h>
#include <string>
#include <iostream>

const wchar_t* kPipeName = L"\\\\.\\pipe\\CSharpCppPipe";

static bool ReadMessage(HANDLE hPipe, std::string& out) {
    out.clear();
    char buf[4096];
    DWORD cbRead = 0;
    while (true) {
        BOOL ok = ReadFile(hPipe, buf, sizeof(buf), &cbRead, nullptr);
        if (!ok) {
            DWORD err = GetLastError();
            if (err == ERROR_MORE_DATA) {      // メッセージがバッファを超えた
                out.append(buf, buf + cbRead); // 読めた分を蓄積
                continue;                      // 残りを続けて読む
            }
            return false;                      // 切断 or エラー
        }
        out.append(buf, buf + cbRead);         // 最終チャンク
        break;
    }
    return true;
}

int wmain() {
    for (;;) {
        HANDLE hPipe = CreateNamedPipeW(
            kPipeName,
            PIPE_ACCESS_DUPLEX,  // 双方向
            PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT,
            PIPE_UNLIMITED_INSTANCES,
            4096, 4096, 0, nullptr);

        if (hPipe == INVALID_HANDLE_VALUE) {
            std::wcerr << L"CreateNamedPipe 失敗: " << GetLastError() << std::endl;
            return 1;
        }

        BOOL connected = ConnectNamedPipe(hPipe, nullptr) ?
            TRUE : (GetLastError() == ERROR_PIPE_CONNECTED);
        if (!connected) {
            CloseHandle(hPipe);
            continue;
        }

        std::cout << "client connected\n";

        while (true) {
            std::string msg;
            if (!ReadMessage(hPipe, msg)) break;     // 切断

            std::cout << "recv: " << msg << "\n";
            std::string reply = "ACK:" + msg;

            DWORD written = 0;
            if (!WriteFile(hPipe, reply.data(),
                           static_cast<DWORD>(reply.size()),
                           &written, nullptr)) {
                break; // 切断
            }
        }

        FlushFileBuffers(hPipe);
        DisconnectNamedPipe(hPipe);
        CloseHandle(hPipe);
        std::cout << "client disconnected. waiting...\n";
    }
}

C++サーバー側コードのポイント解説

  • kPipeName
    • \\.\pipe\CSharpCppPipe という名前でパイプを公開します
    • C#側と完全一致させる必要があるので、文字列の綴りミスに注意しましょう
  • CreateNamedPipeW
    • PIPE_ACCESS_DUPLEX:双方向通信のためのフラグです
    • PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE:メッセージ単位で送受信するモード
    • PIPE_WAIT:ブロッキングモード(簡単な実装に向いている)
    • バッファサイズは 4096 バイト程度なら多くのケースで十分ですが、用途に応じて調整可能です
  • ConnectNamedPipe
    • クライアントの接続を待ち受けるブロッキング呼び出しです
    • ERROR_PIPE_CONNECTEDの場合は、既にクライアントが接続しているので成功扱いにします
  • ReadMessage関数
    • PIPE_TYPE_MESSAGEの場合、メッセージがバッファサイズを超えるとERROR_MORE_DATAになります
    • そのため、エラーコードを見て「残りのデータも読む」ループが必要です
    • これを1か所に隠蔽しておくと、メインロジックが非常にすっきりします
  • 再接続ループ
    • for (;;)の無限ループで、クライアント切断後に新しいクライアントを待ち続ける設計になっています
    • 切断時にはFlushFileBuffers → DisconnectNamedPipe → CloseHandleの順で後片付けをしています

ここまでで、ウィンドウもメッセージポンプも一切使わずに、C++側で名前付きパイプサーバーを起動できるようになりました。

C#側:Named Pipeクライアントの最小実装(再接続付き)

次に、上記C++サーバーへ接続するC#クライアントの例を示します。こちらは切断時の自動再接続も視野に入れた構成になっています。

// Program.cs
using System;
using System.IO;
using System.IO.Pipes;
using System.Text;
using System.Threading;

class Program {
    const string PipeName = "CSharpCppPipe";

    static NamedPipeClientStream ConnectWithRetry(int attempts = 12, int firstDelayMs = 250) {
        for (int i = 1; i <= attempts; i++) {
            try {
                var pipe = new NamedPipeClientStream(
                    ".",
                    PipeName,
                    PipeDirection.InOut,
                    PipeOptions.Asynchronous);

                pipe.Connect(1000); // 1秒タイムアウト
                pipe.ReadMode = PipeTransmissionMode.Message;
                return pipe;
            }
            catch (TimeoutException) {
                // サーバー未起動など。リトライ。
            }
            catch (IOException) {
                // パイプがビジー、または接続できない場合もここに入る
            }

            Thread.Sleep(firstDelayMs * i); // リトライごとに待ち時間を伸ばす(バックオフ)
        }

        throw new IOException("パイプに接続できませんでした。");
    }

    static string ReadMessage(NamedPipeClientStream pipe) {
        var buf = new byte[4096];
        using var ms = new MemoryStream();
        do {
            int read = pipe.Read(buf, 0, buf.Length);
            if (read == 0) {
                throw new IOException("パイプが切断されました。");
            }
            ms.Write(buf, 0, read);
        } while (!pipe.IsMessageComplete);

        return Encoding.UTF8.GetString(ms.ToArray());
    }

    static void WriteMessage(NamedPipeClientStream pipe, string text) {
        var bytes = Encoding.UTF8.GetBytes(text);
        pipe.Write(bytes, 0, bytes.Length);
        pipe.Flush();
    }

    static void Main() {
        while (true) {
            try {
                using var pipe = ConnectWithRetry();
                Console.WriteLine("connected.");

                // 受信用スレッド(同時受信)
                var receiver = new Thread(() => {
                    try {
                        while (true) {
                            var msg = ReadMessage(pipe);
                            Console.WriteLine("server: " + msg);
                        }
                    }
                    catch {
                        // 切断時はここに来る。Main側のwhileに戻って再接続。
                    }
                }) {
                    IsBackground = true
                };
                receiver.Start();

                // 送信ループ(コンソール入力をそのまま送信)
                string? line;
                while ((line = Console.ReadLine()) != null) {
                    WriteMessage(pipe, line);
                }

                break; // 入力が終わったら終了
            }
            catch (Exception ex) {
                Console.WriteLine("接続エラー: " + ex.Message);
                Thread.Sleep(1000); // 少し待って再接続
            }
        }
    }
}

C#クライアント側コードのポイント解説

  • ConnectWithRetry
    • 最大試行回数と初回待ち時間を受け取り、バックオフ付きでリトライします
    • NamedPipeClientStreamのConnect(int timeout)で、接続タイムアウトも制御しています
    • サーバー未起動中やビジー状態でも、一定時間おきに接続を試みることができます
  • ReadMessageとIsMessageComplete
    • メッセージモードでは、C++側と同様にデータが複数チャンクに分かれて届く場合があります
    • pipe.IsMessageCompleteがtrueになるまでループしてメッセージ全体を読み切るのがポイントです
    • read == 0になったら切断として扱い、例外を投げています
  • 受信用スレッドと送信ループ
    • 受信処理を別スレッドに切り出すことで、コンソールからの入力(送信)と同時並行に受信できます
    • この構成により、すでにフルデュプレックス(同時送受信)が実現できています
  • 再接続ループ
    • Mainの外側にwhile(true)を置き、例外発生時に先頭からやり直す構造になっています
    • サーバー側が落ちても、復帰後に自動的に再接続できるようになります

これで何が実現できたか:接続開始・終了・自動再接続

ここまでのC++サーバー+C#クライアントで、次の要件がすでに満たされています。

要件実装箇所説明
接続開始C++:ConnectNamedPipe
C#:pipe.Connect()
サーバーが待ち受け、クライアントが接続要求を出す標準的なNamed Pipeパターン
接続終了C++:FlushFileBuffers, DisconnectNamedPipe, CloseHandle
C#:Dispose()/usingブロック
双方とも「読み書きが失敗したら切断」とみなす設計で、例外をトリガーに確実に後片付けが行われる
自動再接続C++:for (;;)ループ
C#:while(true)+ConnectWithRetry
切断後もループの先頭に戻ることで、再接続を半自動的に行っている

このように、エラーや例外を「切断イベント」として扱うことで、接続ライフサイクルを非常にシンプルに記述できるのが名前付きパイプの大きな利点です。

より「フルデュプレックス」らしくする設計パターン

上記サンプルもすでに同時送受信が可能ですが、より本格的にフルデュプレックス通信を行うには、次の改善ポイントを検討するとよいでしょう。

1. C++側も非同期I/O(OVERLAPPED)にする

現在のC++サーバーはブロッキングI/Oなので、1つのパイプに対して1スレッドが占有されます。接続数が増えてくると、スレッド数が増える=オーバーヘッドが増える問題が出てきます。

ここを改善するために、FILE_FLAG_OVERLAPPEDフラグを付けて非同期I/Oに切り替えるパターンがあります。

HANDLE hPipe = CreateNamedPipeW(
    kPipeName,
    PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED,
    PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT,
    PIPE_UNLIMITED_INSTANCES,
    4096, 4096, 0, nullptr);

この場合、ReadFile/WriteFile呼び出しもOVERLAPPED構造体を伴う非同期版に変わるため、スレッド設計や完了通知(イベント/IOCP)をどう扱うかを考える必要があります。その分複雑になりますが、多数クライアント・高頻度通信の用途では非常に有効です。

2. 明示的な送信キューを挟む

フルデュプレックス通信では、「受信スレッド」と「送信ロジック」が同じソケット/パイプを触るため、排他制御が必要になります。シンプルに済ませるなら、送信時にlock(C#)やstd::mutex(C++)で保護すれば十分なケースも多いですが、以下のような構成もよく使われます。

  • メインスレッドからは「送信キュー」にメッセージを積む
  • 送信専用のスレッドがキューから取り出し、パイプに書き込む
  • 受信専用のスレッドは読み込みだけに集中する

このように役割を明確に分離することで、同時送受信時のデッドロックやレースコンディションを避けやすくなります。

プロトコル設計:テキストかバイナリか、UTF-8か

名前付きパイプ自体は「バイト列を送受信するだけ」の仕組みなので、その上でどのようなプロトコルを流すかは自由です。ただし、C#とC++の両方から扱いやすいように、次の指針をおすすめします。

文字列ベースの簡易プロトコル

  • 用途がログや簡単なコマンド程度なら、UTF-8文字列+改行区切りでも十分
  • メッセージモードを使っている場合は、1メッセージ=1文字列として扱えるので、改行すら不要なこともあります
  • C++側ではstd::string、C#側ではstringでそのまま扱えるのが利点です

バイナリプロトコル+長さヘッダ

  • パフォーマンスや柔軟性を求める場合は、「4バイトの長さヘッダ+本体」のような構成にしてもよいでしょう
  • メッセージモードでも、長さヘッダを入れておくとデバッグがしやすくなります
  • 例えば最初の4バイトをリトルエンディアンの長さとして、その後にバイナリ構造体やJSONを流すなど

文字コードの統一

  • 文字コードはUTF-8に統一するのがおすすめです
  • C#ではEncoding.UTF8、C++ではstd::stringとして扱っておけば、日英混在でも問題なく扱えます
  • UTF-16(wchar_t / string)を使う手もありますが、C#側の扱いが少し冗長になりがちです

サービス・コンソール・GUIの混在環境での注意点

UIなしプロセス間通信を行う場合、C++側がWindowsサービス、C#側がユーザーコンテキストの常駐アプリ、という構成もよくあります。このときに気を付けるべきポイントをまとめておきます。

  • セキュリティ(DACL)の設定
    • サービスがLocalSystemなど高権限で動作し、ユーザーアプリから接続する場合、パイプのアクセス制御リスト(DACL)を適切に設定する必要があります
    • デフォルトのDACLのままだと、期待するユーザーが接続できない/逆に誰でも接続できてしまうなどの問題が起きます
    • C++ではSECURITY_ATTRIBUTESとInitializeSecurityDescriptor+SetSecurityDescriptorDaclあたりを使って、アクセスを明示的に定義します
  • bitness(x86/x64)の一致
    • パイプそのものは32bit/64bit混在でも動作しますが、両プロセスの構成を揃えておくとデバッグが圧倒的に楽になります
    • 特にC++側をx64でビルドしているのに、C#側がAnyCPUのままで場合によってはx86になる、といったミスマッチに注意しましょう
  • サービス起動順と待機ロジック
    • サービス側が先に起動してパイプを待ち受け、クライアントが後から接続する設計が一般的です
    • クライアント側は必ずリトライ付きの接続処理にしておき、サービスの起動遅延にも耐えられるようにしておきましょう

WM_COPYDATAやその他のIPC方式と比べたときの立ち位置

最後に、「名前付きパイプを選ぶべきか?」を判断しやすいように、よくある代替案との比較をもう少し深掘りしてみます。

方式特徴UIなしのC# ⇆ C++双方向通信との相性
名前付きパイプWindows標準IPC。フルデュプレックス、メッセージ/バイトストリーム対応、多重接続可。◎ 最適。UI不要。サービス/コンソール/GUIどの組み合わせでも自然に使える。
WM_COPYDATA(Windowsメッセージ)ウィンドウハンドル間でメモリブロックをコピーして渡す簡易IPC。× 不向き。隠しウィンドウやメッセージループが必要。サービスとは相性が悪い。
TCP(localhost)ネットワーク経由の汎用通信。プロトコルを自由に設計可能。○ あり。将来リモート対応する予定があるなら有力候補。ただしプロトコル設計と実装コストが増える。
メモリマップトファイル+イベント共有メモリを使った高速なIPC。両プロセスでメモリを共有する。△ 特殊用途。非常に高速だが、同期制御が難しくバグるとデバッグが大変。
標準入出力(親子プロセス)親プロセスが子プロセスのstdin/stdoutに書き込み・読み取りを行う。△ 限定的。プロセス生成時にしか接続できず、再接続や多重接続に向かない。

UIなしのC#とC++を対等な2プロセスとして扱いたいなら、やはり名前付きパイプが一番自然な落としどころになります。

よくあるハマりポイントと対策

実際に実装してみると、次のようなところでハマりがちです。事前に意識しておくと、デバッグ時間を大幅に削減できます。

メッセージが途中で切れてしまう

症状: 長めの文字列を送信すると、C++側/C#側でメッセージが分割されて届き、途中で切れたように見える。

原因: パイプのバッファサイズを超えて一度に送ろうとしたため、複数チャンクに分割されて届いている。

対策:

  • PIPE_TYPE_MESSAGEとPIPE_READMODE_MESSAGEを使い、メッセージモードで扱う
  • C++側ではERROR_MORE_DATAを見ながらループして読み切る
  • C#側ではIsMessageCompleteがtrueになるまでループする(この記事のReadMessageのように)

サービスから接続できない/接続エラーになる

症状: 管理者権限のサービスと、通常ユーザーのアプリの間で接続しようとすると、Access is deniedなどのエラーが発生する。

原因: 名前付きパイプのセキュリティ設定(DACL)が合っていない。

対策:

  • C++側でパイプを作成するときに、アクセス権を明示的に設定する
  • 特定のユーザー/グループにのみ接続を許可したい場合は、SIDを指定してDACLに追加する
  • 開発初期は「誰でも接続可」のゆるいDACLで動作確認し、後から締めていく、という段階的なアプローチも有効

プロセス終了時にブロックして落ちない

症状: パイプのReadやWriteでブロックしている状態のまま、プロセスが終了できない。

原因: ブロッキングI/O中に「終了したい」イベントが発生しても、待ち状態から抜けられない。

対策:

  • 終了時には、相手側から明示的に切断してもらう
  • もしくは、非同期I/O(FILE_FLAG_OVERLAPPED)+キャンセル用イベントを用意しておく
  • 「終了コマンド」をプロトコルに定義し、受信したらクリーンにループを抜ける、などを徹底する

WM_COPYDATAに執着しない:CWndやMarshalへの誤解

最後に、元々の疑問であった「CWndを継承すれば何とかなる? Marshalの使い方は誤り?」という点を整理しておきます。

  • CWndはMFCのネイティブC++クラスであり、C#から継承することはできません
    • C#でウィンドウハンドルを扱うなら、System.Windows.FormsやWPFのクラスを使うことになります
    • しかし今回の要件は「UIなし」なので、そもそもウィンドウを持つ設計自体がミスマッチです
  • WM_COPYDATAはウィンドウメッセージです
    • 「HWND」があり、「GetMessage/DispatchMessage」でメッセージポンプを回していることが前提です
    • サービスやコンソールアプリで無理にこれを導入すると、隠しウィンドウや専用スレッドを増やすことになり、保守性が落ちます
  • MarshalクラスはIPCそのものを簡単にするものではありません
    • 役割はあくまでネイティブとマネージの間のメモリ変換(マーシャリング)です
    • どんなIPC手段を採用するか(Named Pipe, TCP, メモリマップトファイル…)とは別問題です

つまり、「C# ⇆ C++のIPCを簡単にしたい」という観点で見ると、WM_COPYDATAやCWnd、Marshalはどれも本質的な解決策ではない、というのが結論になります。代わりに、名前付きパイプという「目的に直結した仕組み」を使う方が、設計も実装もシンプルで堅牢です。

まとめ:名前付きパイプでUIなしC# ⇆ C++通信をシンプルに

この記事では、ウィンドウを持たないC#とC++のプロセス間でフルデュプレックス通信を行う方法として、名前付きパイプを使う実装を詳しく紹介しました。

  • UIなしのプロセス間通信にWM_COPYDATAは不向き(ウィンドウ・メッセージポンプ前提)
  • 名前付きパイプなら、ウィンドウ不要・フルデュプレックス・標準APIのみで実装可能
  • C++側にパイプサーバー、C#側にパイプクライアントを置き、例外=切断の合図として再接続ループを構成すると堅牢
  • メッセージモード(PIPE_TYPE_MESSAGE)+ERROR_MORE_DATA/IsMessageCompleteで、メッセージ境界を安全に扱える
  • UTF-8文字列ベースのシンプルなプロトコルから始め、必要に応じてバイナリや非同期I/Oに拡張できる

まずはこの記事のサンプルコードをそのまま試してみて、コンソールからメッセージを送ってC++側のログが動くことを確認してみてください。そのうえで、あなたのプロジェクト固有のプロトコルやエラー処理、ログ出力などを少しずつ足していけば、UIに一切依存しない、実戦投入可能なC# ⇆ C++通信基盤が完成します。

WM_COPYDATAありきの設計から一歩離れて、名前付きパイプという素直な仕組みをベースにした方が、結果的にシンプルで壊れにくいアーキテクチャに落ち着きます。ぜひあなたの環境でも、今回のアプローチを試してみてください。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次