C#とC++のWM_COPYDATA通信で衝突を防ぐ:SendMessageTimeoutとMarshal.StringToHGlobalAnsiの安全な直列化・ハートビート設計

WM_COPYDATAでC#からC++へ定期的にIsRunning()(60秒ごとのハートビート)を送りつつ、RunInside()/RunOutside()のような別リクエストも投げると「送受信がぶつかるのでは?」と不安になります。ポイントは、SendMessageが同期であることを正しく理解し、送信経路を直列化して“空いている時だけハートビートを送る”設計にすることです。

目次

まず結論:「ぶつかる」のは複数スレッドから同時に送っている時だけ

SendMessage + WM_COPYDATAは同期呼び出しです。C#側でSendMessageを呼ぶと、C++側(受信側)がWM_COPYDATAを処理して戻るまで、C#側はブロックします。つまり、同じスレッドから送っている限り、送信は勝手に直列になります。

状況IsRunningと他リクエストは重なる?やるべきこと
UIスレッドだけで送信(WinForms Timer + ボタン)基本的に重ならないハートビートは「処理中ならスキップ」でOK
ThreadPoolタイマー/Taskなど複数スレッドから送信重なり得る(衝突の本丸)送信部をロック/セマフォで直列化
複数クライアントが同一C++受信側へ送信受信側キューに溜まる(同時実行は通常しない)受信側は短時間で戻る/ワーカーへ委譲

質問の「他リクエストの応答待ち中はIsRunningを送らない」は、要するに“送信経路に1本のゲートをかけ、ハートビートは空いている時だけ通す”だけで達成できます。

WM_COPYDATAの同期性を正しく押さえる(ここがズレると全部ズレる)

WM_COPYDATAのデータは、受信側が処理している間だけ有効、というイメージで扱うのが安全です。C#側がMarshal.StringToHGlobalAnsiなどで確保したアンマネージメモリは、SendMessageが戻るまで変更も解放もしてはいけません。これは「衝突」を避ける上でも重要で、複数スレッドが同じバッファや同じ静的領域を共有すると簡単に壊れます。

  • 送信は同期:SendMessageが戻るまで送信元はブロック
  • データ寿命:SendMessageが戻るまでポインタを保持(変更・解放禁止)
  • 衝突の原因:複数スレッドが同時にSendMessageしたり、同じ送信用バッファを共有して書き換えること

逆に言うと、「同じスレッドで送っているのにぶつかる」は通常起きません。起きるとしたら、受信側が処理中に別のメッセージを再入させる設計(受信側が同スレッドで別送信をしてしまう等)や、送信側で別スレッドが同じ送信関数を叩いているケースです。

設計の最短ルート:「送信ゲート」を1つ作り、ハートビートは空いているときだけ

実装の要点はシンプルです。

  • RunInside/RunOutsideなどの“本命リクエスト”は必ずゲートを取って送る
  • IsRunning(ハートビート)はゲートが取れたら送る/取れなければスキップ
  • SendMessageは相手が固まると戻らない可能性があるので、SendMessageTimeoutで上限を設ける

これだけで「応答待ちの間にハートビートを送ってしまう」「複数スレッドの同時送信で送信データが壊れる」問題がほぼ消えます。

排他制御はどれを選ぶ?(lock / SemaphoreSlim / Interlocked / キュー)

目的は“同時送信をさせない”です。つまり、送信の手前に直列化ポイントを置けばよいだけです。代表的な選択肢を比較します。

方式向いている状況長所注意点
lock / Monitor同期メソッド中心、構成が単純簡単・速い・読みやすいasyncと混ぜると扱いにくい
SemaphoreSlim(1,1)async/await併用、ワーカー化しやすいWaitAsyncが使えるRelease漏れに注意
Interlockedフラグ「同時実行禁止」だけ欲しい軽量・実装が短い待機/順序制御が不要なケース向け
キュー(Channel等)+専用送信ワーカー順序保証・拡張性・優先度制御が必要保守が楽、設計がブレにくい初期実装は少し長くなる

質問の要件(他リクエスト中はIsRunningを送らない)だけなら、lock + TryEnterかSemaphoreSlimのWait(0)が最短です。

送信関数を“壊れにくく”作る:SendMessageTimeout + WM_COPYDATA + Marshal.StringToHGlobalAnsi

まず、送信の土台をしっかりさせます。ポイントは次の3つです。

  • SendMessageではなくSendMessageTimeoutを使い、上限時間を設ける
  • HGlobal確保→送信→finallyで必ずFreeにする(例外でも漏らさない)
  • cbDataは「バイト数」。ANSI文字列なら終端の\0まで含める(運用を統一)

以下はC#側の最小構成例です(文字列をANSIで送る想定)。

using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;

internal static class CopyDataSender
{
    private const int WM_COPYDATA = 0x004A;

    [StructLayout(LayoutKind.Sequential)]
    private struct COPYDATASTRUCT
    {
        public IntPtr dwData;   // コマンド種別など(自由に使う)
        public int cbData;      // lpDataのバイト数
        public IntPtr lpData;   // データポインタ
    }

    [Flags]
    private enum SendMessageTimeoutFlags : uint
    {
        SMTO_NORMAL = 0x0000,
        SMTO_BLOCK = 0x0001,
        SMTO_ABORTIFHUNG = 0x0002,
        SMTO_NOTIMEOUTIFNOTHUNG = 0x0008
    }

    [DllImport("user32.dll", SetLastError = true)]
    private static extern IntPtr SendMessageTimeout(
        IntPtr hWnd,
        int Msg,
        IntPtr wParam,
        ref COPYDATASTRUCT lParam,
        SendMessageTimeoutFlags fuFlags,
        uint uTimeout,
        out IntPtr lpdwResult);

    public static int SendAnsiString(
        IntPtr targetHwnd,
        int commandId,
        string text,
        uint timeoutMs = 3000)
    {
        // ANSIバイト数(終端\0込み)を自前でも求められるが、
        // StringToHGlobalAnsiは終端\0付きで確保する前提で運用を統一する。
        IntPtr p = IntPtr.Zero;
        try
        {
            p = Marshal.StringToHGlobalAnsi(text);

            // cbDataは「バイト数」:Encoding.Defaultで算出するのが分かりやすい
            // StringToHGlobalAnsiが使うANSIコードページと厳密に一致させたい場合は、
            // 実運用のコードページを固定する(後述)。
            int bytes = Encoding.Default.GetByteCount(text) + 1; // +1 = 終端\0

            var cds = new COPYDATASTRUCT
            {
                dwData = new IntPtr(commandId),
                cbData = bytes,
                lpData = p
            };

            IntPtr result;
            var ret = SendMessageTimeout(
                targetHwnd,
                WM_COPYDATA,
                IntPtr.Zero,
                ref cds,
                SendMessageTimeoutFlags.SMTO_BLOCK | SendMessageTimeoutFlags.SMTO_ABORTIFHUNG,
                timeoutMs,
                out result);

            if (ret == IntPtr.Zero)
                throw new Win32Exception(Marshal.GetLastWin32Error(), "SendMessageTimeout failed.");

            return result.ToInt32(); // 受信側の戻り値(LRESULT)を使って成否を返す運用例
        }
        finally
        {
            if (p != IntPtr.Zero)
                Marshal.FreeHGlobal(p);
        }
    }
}

「Encoding.Defaultで計算して大丈夫?」が気になる場合は、アプリの仕様としてコードページを固定するのが安全です。例えば“必ずASCIIだけ送る”“Shift_JISで送る(Encoding.GetEncoding(932))”“そもそもANSIをやめてUTF-16(StringToHGlobalUni)にする”など、取り決めを明文化すると事故が減ります。

ANSI運用でハマりやすい点(Marshal.StringToHGlobalAnsi)

  • 文字化け:実行環境の既定コードページ依存になりやすい(日本語Windows前提ならまだしも、将来が不安)
  • cbData不一致:送信側と受信側の「終端\0を含める/含めない」がズレると、受信側で読み取りが崩れる
  • \0を含む:バイナリを混ぜるなら文字列ではなくbyte[]で運ぶ方が安全

運用が許すなら、WM_COPYDATAのペイロードは「先頭4バイト=コマンド」「以降=長さ付きUTF-8」などの自前プロトコルに寄せると、言語・環境が変わっても壊れにくくなります。

ハートビートを“空いている時だけ”送る:一番簡単なパターン(lock + TryEnter)

今回の要件は「他リクエストの応答待ち中はIsRunningを送らない」です。これを最短で実現するのが、送信を囲うゲート(ロック)と、ハートビート側のTryEnterです。

private readonly object _gate = new();

public void RunInside()
{
    lock (_gate)
    {
        // ここでSendMessageTimeout(WM_COPYDATA)を送る
        CopyDataSender.SendAnsiString(_targetHwnd, commandId: 10, text: "RunInside");
    }
}

public void RunOutside()
{
    lock (_gate)
    {
        CopyDataSender.SendAnsiString(_targetHwnd, commandId: 11, text: "RunOutside");
    }
}

// 60秒ごとに呼ばれる想定
public void HeartbeatTick()
{
    if (!System.Threading.Monitor.TryEnter(_gate))
        return; // 他の要求中 → 今回は見送り

    try
    {
        CopyDataSender.SendAnsiString(_targetHwnd, commandId: 1, text: "IsRunning");
    }
    finally
    {
        System.Threading.Monitor.Exit(_gate);
    }
}

これで、RunInside/RunOutsideが進行中(=SendMessageTimeoutが戻っていない間)はハートビートが送られません。しかも「溜めて後でまとめて送る」必要も通常ありません。ハートビートは“最新の生存確認”が重要で、古い心拍を遅れて送っても価値が薄いからです。

「スキップ」ではなく「後ろ倒し」にしたい場合

たとえば「必ず60秒に1回は送りたい」「長い処理が続いたら処理直後に1回送りたい」という場合は、次のように“最後に送った時刻”で調整できます。

private readonly object _gate = new();
private long _lastHeartbeatTicks;

public void HeartbeatTick()
{
// 既に十分新しければ送らない(過剰送信を防ぐ)
var now = DateTime.UtcNow.Ticks;
var elapsedSec = TimeSpan.FromTicks(now - System.Threading.Volatile.Read(ref _lastHeartbeatTicks)).TotalSeconds;
if (elapsedSec < 60) return;


if (!System.Threading.Monitor.TryEnter(_gate)) return;

try
{
    CopyDataSender.SendAnsiString(_targetHwnd, commandId: 1, text: "IsRunning");
    System.Threading.Volatile.Write(ref _lastHeartbeatTicks, DateTime.UtcNow.Ticks);
}
finally
{
    System.Threading.Monitor.Exit(_gate);
}


}

“忙しい時は送らないが、落ち着いたらなるべく早く1回送る”という挙動になり、運用の体感が良くなります。

asyncが混じるならSemaphoreSlimが扱いやすい

UIイベントは同期、バックグラウンドはasync、といった構成では、SemaphoreSlim(1,1)に寄せると後々楽です。ハートビートはWait(0)で即時判定し、取れなければスキップします。

private readonly System.Threading.SemaphoreSlim _gate = new(1, 1);

public async System.Threading.Tasks.Task RunOutsideAsync()
{
    await _gate.WaitAsync().ConfigureAwait(false);
    try
    {
        CopyDataSender.SendAnsiString(_targetHwnd, commandId: 11, text: "RunOutside");
    }
    finally
    {
        _gate.Release();
    }
}

public void HeartbeatTick()
{
    if (!_gate.Wait(0))
        return; // 実行中 → スキップ

    try
    {
        CopyDataSender.SendAnsiString(_targetHwnd, commandId: 1, text: "IsRunning");
    }
    finally
    {
        _gate.Release();
    }
}

“RunInside/RunOutsideだけは必ず実行、ハートビートは空いている時だけ”という優先度が自然に表現できます。

Interlockedフラグは「待たない」設計に強い

「排他したいが、待ってまで送る必要はない(送れなければ捨てる)」が明確なら、Interlockedフラグも有効です。ハートビートはもちろん捨ててよい、という思想に合います。

private int _inFlight = 0;

private void SendIfIdle(Action send)
{
    if (System.Threading.Interlocked.CompareExchange(ref _inFlight, 1, 0) != 0)
        return; // 実行中なら送らない

    try { send(); }
    finally
    {
        System.Threading.Volatile.Write(ref _inFlight, 0);
    }
}

public void RunInside()
    => SendIfIdle(() => CopyDataSender.SendAnsiString(_targetHwnd, 10, "RunInside"));

public void HeartbeatTick()
    => SendIfIdle(() => CopyDataSender.SendAnsiString(_targetHwnd, 1, "IsRunning"));

ただしこの方式は“本命リクエストも捨てられ得る”形になりやすいので、RunInside/RunOutsideは確実に送る必要があるなら、lock/セマフォで「待つ」設計の方が扱いやすいです。

タイマーの種類で事故率が変わる:WinForms TimerとThreading系Timerの違い

「単一スレッドなら重ならない」という話は、タイマーの種類で前提が変わります。ここはトラブルが多いので、仕様として押さえておくと強いです。

タイマー呼び出しスレッド重なりやすさおすすめ対策
WinForms TimerUIスレッド低い(UIが詰まると遅延)基本はOK、ただしUIフリーズに注意
System.Timers.TimerThreadPoolが多い高い(イベントが並行で来る可能性)lock/セマフォで送信直列化
System.Threading.TimerThreadPool高い(処理時間次第で重なり得る)lock/セマフォで送信直列化

さらに現実的な落とし穴として、RunInside/RunOutsideが2秒ブロックするなら、UIスレッドからSendMessageTimeoutを呼ぶと操作感が悪くなります。UIフリーズを避けたいなら、送信をUIスレッドに置かず、バックグラウンドに寄せて(ただし排他)、結果だけUIに反映するのが定石です。

タイムアウトと回復戦略:固まった相手に引きずられない

WM_COPYDATAは同期です。受信側が固まると送信側も固まります。使うべきはSendMessageTimeoutで、2〜3秒程度の上限を設けるのが扱いやすいことが多いです(他リクエストが2秒で返る想定なら、少し余裕を持たせます)。

  • 通常時:SMTO_BLOCK | SMTO_ABORTIFHUNG + timeoutMs=3000 など
  • タイムアウト時:相手を「未応答」と扱い、状態を落とす(UI表示・ログ・再接続)
  • 再試行:短い間隔で連打せず、バックオフ(例:3秒→10秒→30秒)

ハートビートの運用でおすすめなのは、次のルールです。

  • 送れたら「生存」
  • 送れない(ゲートが取れない)なら「忙しい」なので何もしない
  • SendMessageTimeoutで失敗/タイムアウトしたら「不調/停止」扱いに寄せる

“忙しい”と“死んでいる”を分けるだけでも、監視の信頼性が上がります。

C++受信側の作法:WndProc内で重い処理をしないとさらに強い

WM_COPYDATAの受信は、受信側のウィンドウプロシージャで行うのが一般的です。ここで2秒かかる処理をそのまま実行すると、受信側のUIも詰まりがちです。可能なら「データをコピーしてすぐ戻り、実処理はワーカーへ」という設計が安定します。

最小例として、受信側で文字列をコピーしてコマンドを分け、戻り値で成否を返すパターンを示します(実際にはスレッド安全なキューへ積むのがおすすめです)。

// 例:C++側(概念スケッチ)
#include <windows.h>
#include <string>

static const UINT WM_COPYDATA_MSG = WM_COPYDATA;

LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
    if (msg == WM_COPYDATA_MSG)
    {
        const COPYDATASTRUCT* cds = reinterpret_cast<const COPYDATASTRUCT*>(lParam);
        if (!cds || !cds->lpData || cds->cbData <= 0)
            return 0;

        const int commandId = static_cast<int>(reinterpret_cast<INT_PTR>(cds->dwData));

        // ANSI文字列前提:終端\0を期待する運用なら、cbDataもそれに合わせる
        const char* p = reinterpret_cast<const char*>(cds->lpData);

        // 受信側は、ポインタを保持せず“ここでコピーして終わり”が安全
        std::string payload(p, strnlen_s(p, static_cast<rsize_t>(cds->cbData)));

        // ここで長い処理を直にやると送信側がずっと待つ
        // 推奨:payloadとcommandIdをキューへ積み、ワーカーで処理して結果を別経路で返す
        // ここでは簡略化して、受理したら1を返す
        return 1;
    }

    return DefWindowProc(hwnd, msg, wParam, lParam);
}

「でも今回は2秒の応答が欲しい」という場合、WM_COPYDATAだけで完結させるなら、受信側で処理を終えて戻り値で返す形になります。これは動きますが、受信側(特にUIスレッド)の応答性が落ちやすいので、将来的には“受理→非同期実行→別の応答(PostMessage/名前付きパイプ/ソケットなど)”に移行できるようにしておくと保守が楽です。

デッドロックを避ける:双方向SendMessageは要注意

今回の流れが「C#→C++へSendMessage」だけなら単純ですが、設計が進むと「C++側が処理中にC#側へSendMessageで問い合わせる」などの双方向同期が混ざりやすくなります。これをやると、双方が相手の応答待ちで止まる循環待ち(デッドロック)が起きます。

  • 原則:WM_COPYDATA(同期)で双方向のやり取りを作らない
  • 必要なら:片方をPostMessage(非同期)にする、または応答を別チャネル(名前付きパイプ等)に分ける
  • どうしても同期で返すなら:受信側はWndProcで重い処理をせず、再入を起こさない

「ぶつからない」だけでなく「止まらない」設計を意識すると、現場の事故率が一段下がります。

名前付きMutexは必要?(結論:同一プロセス内の直列化なら不要なことが多い)

今回の主戦場は「C#側でハートビートと他リクエストが同時に送信される」問題です。これは同一プロセス内の話なので、まずはlock/セマフォで十分です。

名前付きMutexが生きるのは、例えば次のような状況です。

  • 同一PCで「複数のC#クライアント」が同じC++受信プロセスへ送るが、運用上“同時送信を避けたい”
  • 別プロセス同士で「ある資源(ログ、ファイル、単一実行など)」を横断して守りたい

ただし、IPCとしてのWM_COPYDATAの「メッセージング自体」をOSミューテックスで縛るのは、設計として重くなりがちです。まずは送信元プロセス内の直列化で要件を満たし、必要が出た時点で“プロトコル層”での制御(受信側で順序・同時実行を管理する)へ寄せるのが無理がありません。

「送受信がぶつかる」を根こそぎ潰すチェックリスト

チェック項目症状対策
SendMessageを複数スレッドから呼んでいるたまに文字列が壊れる/別コマンドが混ざる送信部をlock/セマフォで直列化
Marshal確保領域をSendMessage前に解放している受信側でゴミ文字/クラッシュfinallyでSendMessage後にFreeHGlobal
cbDataの運用が統一されていない末尾が欠ける/余計な文字が入る終端\0含む/含まないを仕様化して一致させる
UIスレッドで2秒ブロックしている画面が固まるバックグラウンド送信+UI更新はInvoke
SendMessageが戻らないアプリ全体が固まるSendMessageTimeoutでタイムアウト+回復フロー

発展:順序保証や拡張性が欲しいなら「送信キュー + 専用ワーカー」

要件が増えると、送信箇所が散らばって「どこでロックしているか」が分かりにくくなります。そうなる前に、送信を1カ所へ寄せるのが強いです。考え方は次の通りです。

  • 全リクエストをキューへ投入
  • 送信ワーカーが1本で順次送る(WM_COPYDATAの同期ブロックもここで吸収)
  • ハートビートは「キューが空いていて、最後の送信から60秒以上」など条件付きで投入

この形にすると「ハートビートを低優先にしたい」「あるコマンドだけはキャンセルしたい」「送信ログを一元化したい」などが簡単に実現できます。結果として、運用・調査・保守が一気に楽になります。

重要なのは、いずれの方式でもゴールは1つです。

  • 送信経路を1本化(直列化)する
  • ハートビートは“空いている時だけ送る”
  • タイムアウトで固まりを切り離す

まとめ:実装で迷ったら「ゲート+タイムアウト」から始める

  • WM_COPYDATA(SendMessage)は同期なので、同一スレッド運用なら基本的に重ならない
  • 衝突の実態は「複数スレッド同時送信」か「送信用バッファの共有」による破壊
  • 対策は、送信部をlock/セマフォで直列化し、ハートビートはゲートが取れた時だけ送る
  • SendMessageTimeoutを使って受信側のハングに巻き込まれない
  • Marshal.StringToHGlobalAnsiのメモリはSendMessageが戻るまで保持し、戻ったら必ずFree

この方針で組むと、要件の「他リクエストの応答待ち中はIsRunning()を送らない」が自然に満たせて、しかも将来の拡張(キュー化、優先度、別チャネル応答)にも繋げやすくなります。

この記事を書いた人

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

コメント

コメントする

目次