SerialPort.Write()がブロックして書き込み停止する原因と対処法【WinUI 3・C#・MQTT・COMポート】

WinUI 3 で複数のシリアル機器へ同時送信していると、SerialPort.Write() が戻らずアプリ全体がフリーズする――。この現象は「コードの書き方」「フロー制御」「ドライバ/機器の特性」が重なると高確率で起きます。この記事では、原因の見極め方と堅牢な送信設計を、表・コード・チェックリストで具体的に整理します。すぐ試せる実装テンプレートも用意しました。

目次

ケース概要(前提と症状)

  • 構成:WinUI 3 アプリ。2 つのクラスが別々の COM ポート(例:COM1 / COM2)へ、同じ MQTT トピックから受け取った JSON を送信。
  • 症状:MQTT の受信処理までは正常。SerialPort.Write() を呼ぶと無限に復帰しない。IsOpen は true。WriteTimeout を設定すると例外で返るがデータは出ていない。

このとき、UI スレッドの占有/フロー制御の待ち/ドライバのブロック/不適切なロックのいずれか(複合含む)が主因であることがほとんどです。

SerialPort.Write() がブロックする仕組みを理解する

SerialPort は内部で OS の同期 I/O を呼び、以下の条件でブロックし続けます。

  • 送信バッファが満杯:相手機器(またはアダプタ)の取り込みが遅く、空きができるまで待機。
  • フロー制御が停止状態:RTS/CTS・XON/XOFF により「送るな」の合図が立っている。
  • 同一スレッド上のキュー詰まり:UI スレッドや 1 本のワーカーに複数ポートの処理を詰め込み、別処理の待機で巻き添えに。
  • ドライバの実装差やバグ:特定の USB-シリアル変換でのみ発生。タイミングやパケットサイズで再現性が変わる。

加えて SerialPort のインスタンスメンバーはスレッドセーフではありません。同一インスタンスを複数スレッドから同時に叩くと待ちや競合が起きます。必ず「ポートごとにスレッド(または非同期キュー)を分離」して「1 本の直列パイプ」に流すことが基本戦略です。

想定原因と解決策(要点まとめ)

想定原因解決策ポイント
スレッド競合・UI スレッドの占有
複数ポートでも同一スレッドに集約し、遅い書き込みが他をブロック
各 COM を専用タスク/専用スレッドに分離。UI スレッドで Write() しない。
BaseStream.WriteAsync() を優先。
.NET 4.5+ なら BaseStream は真の非同期。
独立パイプ化で巻き添えを防止。
フロー制御や受信側の遅延
RTS/CTS・XON/XOFF が「停止」状態、または機器が遅い
不要なら Handshake=None。必要な場合は機器設定と一致させる。
ボーレート/データ長/ストップビット/バッファを見直し。
CtsHolding 等でライン状態を監視。低速機器にはチャンク分割+間隔制御。
共有リソース/誤ったロック
静的ロックや共通インスタンスで競合
各ポートを完全独立。ログ/ファイル I/O のロックも分離。「安全のつもりのグローバルlock」が全送信を直列化している例が多い。
USB-シリアル変換のドライバ特性別アダプタで再現確認。
送信間隔やパケット長を調整。
可能なら WinRT SerialDevice を検証。
一部チップは一定条件でブロックが長い。再接続戦略を用意。

アンチパターン早見表

アンチパターンリスク回避方法
UI スレッドで Write()描画停止・入力不能UI はイベント発火のみ。送信はバックグラウンドへ。
同一 SerialPort を複数タスクから同時書き内部競合・ブロックポート単位のキュー/セマフォで直列化。
タイムアウト無限(デフォルト)障害時に永遠に戻らないRead/WriteTimeout にしきい値を設定し例外で復帰。
巨大フレームを一括送信バッファ飽和・待ち時間増大チャンク分割(例:256〜1,024B)+インタバル。
文字列送信に依存エンコード変換で遅延原則 byte[] を送る。WriteLine() 乱用を避ける。
不要なハンドシェイクが有効常時「停止」状態で待機相手機器と一致させる。不要なら None。

最小修正で効く:ポートごとに非同期タスクを分離

まずは「UI/共通スレッドで Write() しない」ことから。BaseStream.WriteAsync() をポートごとのタスクで呼びます。

// ポートごとに専用タスクへオフロード
await Task.Run(() => port1.BaseStream.WriteAsync(buf1, 0, buf1.Length));
await Task.Run(() => port2.BaseStream.WriteAsync(buf2, 0, buf2.Length));
// ※ これだけでも UI フリーズは避けられる。さらに堅牢化するなら下の「送信キュー方式」へ。

堅牢設計:ポート別キューで直列化する「SerialPortManager」

各ポートに送信キューを持たせ、「1 本の直列パイプで順番に送る」方式が最も安定します。ここでは Channel<T> を使った実装例を示します(BlockingCollection 版は後述)。

using System;
using System.Buffers;
using System.IO.Ports;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;

public sealed class SerialPortManager : IAsyncDisposable
{
private readonly SerialPort _port;
private readonly Channel _queue;
private readonly CancellationTokenSource _cts = new();
private readonly Task _writerTask;
private readonly SemaphoreSlim _portLock = new(1, 1);


public string PortName => _port.PortName;

public SerialPortManager(
    string portName,
    int baudRate = 115200,
    Parity parity = Parity.None,
    int dataBits = 8,
    StopBits stopBits = StopBits.One,
    Handshake handshake = Handshake.None,
    int writeTimeoutMs = 3000,
    int readTimeoutMs = 3000,
    int? writeBufferSize = null,
    int? readBufferSize = null,
    int queueCapacity = 1024)
{
    _port = new SerialPort(portName, baudRate, parity, dataBits, stopBits)
    {
        Handshake = handshake,
        WriteTimeout = writeTimeoutMs,
        ReadTimeout = readTimeoutMs,
        DtrEnable = true,   // 機器仕様に合わせて調整
        RtsEnable = handshake == Handshake.None ? false : true
    };
    if (readBufferSize.HasValue)  _port.ReadBufferSize = readBufferSize.Value;
    if (writeBufferSize.HasValue) _port.WriteBufferSize = writeBufferSize.Value;

    _queue = Channel.CreateBounded<byte[]>(new BoundedChannelOptions(queueCapacity)
    {
        SingleReader = true,
        SingleWriter = false,
        FullMode = BoundedChannelFullMode.Wait  // 速い投入に対して自然に背圧
    });

    _port.Open();
    _writerTask = Task.Run(WriterLoopAsync);
}

// 非同期に送信要求を投入(直列化される)
public ValueTask EnqueueAsync(byte[] payload, CancellationToken ct = default)
    => _queue.Writer.WriteAsync(payload, ct);

// 直ちに送信したい特殊ケース用(キューを介さず直列化)
public async Task WriteAsync(ReadOnlyMemory<byte> payload, CancellationToken ct = default)
{
    await _portLock.WaitAsync(ct).ConfigureAwait(false);
    try
    {
        await _port.BaseStream.WriteAsync(payload, ct).ConfigureAwait(false);
    }
    finally
    {
        _portLock.Release();
    }
}

private async Task WriterLoopAsync()
{
    var ct = _cts.Token;
    try
    {
        while (await _queue.Reader.WaitToReadAsync(ct).ConfigureAwait(false))
        {
            while (_queue.Reader.TryRead(out var data))
            {
                await SendWithChunkAsync(data, ct).ConfigureAwait(false);
            }
        }
    }
    catch (OperationCanceledException) { /* 正常終了 */ }
    catch (Exception ex)
    {
        // ログ:再接続戦略を入れてもよい
        Console.Error.WriteLine($"[{PortName}] writer loop error: {ex}");
    }
}

// チャンク分割で機器の取り込み速度に追従させる
private async Task SendWithChunkAsync(byte[] data, CancellationToken ct)
{
    const int Chunk = 512; // 機器に合わせて 256~1024 を試験
    int offset = 0;

    await _portLock.WaitAsync(ct).ConfigureAwait(false);
    try
    {
        while (offset < data.Length)
        {
            // フロー制御が止まっていないか観察(Handshake 無効時は参照のみ)
            if (_port.Handshake != Handshake.None && !_port.CtsHolding)
            {
                // 相手機器が再開するまで待機(タイムアウトは OS が判定)
                await Task.Delay(2, ct).ConfigureAwait(false);
                continue;
            }

            int size = Math.Min(Chunk, data.Length - offset);
            await _port.BaseStream
                       .WriteAsync(new ReadOnlyMemory<byte>(data, offset, size), ct)
                       .ConfigureAwait(false);
            offset += size;
        }
    }
    finally
    {
        _portLock.Release();
    }
}

public async ValueTask DisposeAsync()
{
    _cts.Cancel();
    _queue.Writer.TryComplete();
    try { await _writerTask.ConfigureAwait(false); } catch { /* ignore */ }
    if (_port.IsOpen) _port.Close();
    _port.Dispose();
    _cts.Dispose();
    _portLock.Dispose();
}


} 

使い方は簡単です。ポートごとにインスタンスを作り、MQTT の受信スレッドから EnqueueAsync するだけ。インスタンスは完全独立なので、COM1 の詰まりが COM2 に波及しません。

await using var com1 = new SerialPortManager(&quot;COM1&quot;, handshake: Handshake.None);
await using var com2 = new SerialPortManager(&quot;COM2&quot;, handshake: Handshake.None);

// MQTT 受信イベントから(擬似コード)
void OnMqttMessage(string topic, byte[] payload, string target)
{
    _ = target switch
    {
        &quot;COM1&quot; =&gt; com1.EnqueueAsync(payload),
        &quot;COM2&quot; =&gt; com2.EnqueueAsync(payload),
        _       =&gt; ValueTask.CompletedTask
    };
}

BlockingCollection 版(よりシンプルな実装)

BlockingCollection<T> を使う版です。Channel を使えない環境や既存コードに組み込みたい場合に有効です。

using System;
using System.Collections.Concurrent;
using System.IO.Ports;
using System.Threading;
using System.Threading.Tasks;

public sealed class SerialPortManagerBC : IDisposable
{
private readonly SerialPort _port;
private readonly BlockingCollection _queue = new(1024);
private readonly CancellationTokenSource _cts = new();
private readonly Task _worker;
private readonly object _gate = new(); // SerialPort はスレッドセーフではない


public SerialPortManagerBC(string portName, int baudRate = 115200)
{
    _port = new SerialPort(portName, baudRate)
    {
        Handshake = Handshake.None,
        WriteTimeout = 3000,
        ReadTimeout = 3000
    };
    _port.Open();
    _worker = Task.Run(WorkerLoop);
}

public void Enqueue(byte[] bytes) => _queue.Add(bytes);

private void WorkerLoop()
{
    try
    {
        foreach (var bytes in _queue.GetConsumingEnumerable(_cts.Token))
        {
            int offset = 0;
            while (offset < bytes.Length)
            {
                int len = Math.Min(512, bytes.Length - offset);
                lock (_gate)
                {
                    _port.Write(bytes, offset, len);
                }
                offset += len;
            }
        }
    }
    catch (OperationCanceledException) { }
}

public void Dispose()
{
    _cts.Cancel();
    _queue.CompleteAdding();
    try { _worker.Wait(); } catch { }
    if (_port.IsOpen) _port.Close();
    _port.Dispose();
    _cts.Dispose();
}


} 

WinUI 3 なら WinRT SerialDevice も選択肢

WinUI 3(Windows App SDK)でも、WinRT API Windows.Devices.SerialCommunication.SerialDevice を利用できます。こちらは非同期 API が第一級で、ドライバとの相性で安定するケースがあります。

using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Windows.Devices.Enumeration;
using Windows.Devices.SerialCommunication;
using Windows.Storage.Streams;

public static class WinRtSerialSample
{
public static async Task SendAsync(string portName, byte[] data, CancellationToken ct)
{
string aqs = SerialDevice.GetDeviceSelector(portName); // 例: "COM3"
var infos = await DeviceInformation.FindAllAsync(aqs);
if (infos.Count == 0) throw new InvalidOperationException($"{portName} not found");


    using var serial = await SerialDevice.FromIdAsync(infos[0].Id);
    serial.BaudRate = 115200;
    serial.Parity = SerialParity.None;
    serial.StopBits = SerialStopBitCount.One;
    serial.DataBits = 8;
    serial.Handshake = SerialHandshake.None;
    serial.WriteTimeout = TimeSpan.FromMilliseconds(3000);

    using var writer = new DataWriter(serial.OutputStream);
    int offset = 0;
    const int Chunk = 512;
    while (offset < data.Length)
    {
        int len = Math.Min(Chunk, data.Length - offset);
        writer.WriteBytes(data.AsSpan(offset, len).ToArray());
        await writer.StoreAsync().AsTask(ct);
        offset += len;
        // 必要に応じて Task.Delay で間隔を空ける
    }
    await writer.FlushAsync().AsTask(ct);
}


} 

移行は必須ではありませんが、SerialPort で詰まる局面の切り札になります。

フロー制御とライン状態の点検(現場で役立つスニペット)

「ブロックしているとき、実際に CTS が落ちているのか?」をコードから確認します。

static string LineStatus(SerialPort sp)
    => $"CTS={sp.CtsHolding}, DSR={sp.DsrHolding}, CD={sp.CDHolding}";

async Task LogWriteAsync(SerialPort sp, byte[] data, CancellationToken ct)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
await sp.BaseStream.WriteAsync(data, 0, data.Length, ct);
Console.WriteLine($"[{sp.PortName}] {data.Length}B sent in {sw.ElapsedMilliseconds} ms");
}
catch (TimeoutException)
{
Console.WriteLine($"[{sp.PortName}] TIMEOUT {LineStatus(sp)} after {sw.ElapsedMilliseconds} ms");
throw;
}
} 

タイムスタンプとライン状態を併記すると、原因の切り分けが一気に進みます。

MQTT からの背圧対策 ― 取り込み速度を超える入力をどう扱うか

MQTT 側が高速で連投する場合、シリアル送信キューは必ず膨らみます。無制限に溜めると OutOfMemory や遅延の爆発を招きます。以下のいずれかを必ず入れます。

  • バウンデッドキュー:容量超過時は「待つ/古いものを落とす/最新に置換する」を明示。
  • 集約(Coalesce):同一機器への上書き系コマンドは、最新 1 件に畳み込む。
  • レート制限:1 台あたり毎秒 N フレームに制限。
// 例:最新優先で古いコマンドを捨てる(擬似コード)
var opts = new BoundedChannelOptions(128)
{
    SingleReader = true, SingleWriter = false,
    FullMode = BoundedChannelFullMode.DropOldest
};
var queue = Channel.CreateBounded&lt;Command&gt;(opts);

構成の見直しチェックリスト

  • 各 COM ポートは専用の送信パイプ(スレッド/タスク + キュー)を持つ。
  • SerialPort のインスタンスを共有しない。共有するならセマフォで直列化。
  • UI スレッドでは送信しない。イベントハンドラからはキュー投入だけにする。
  • Handshake を機器の設定と揃える。不要なら None。
  • Read/WriteTimeout を設定。例:1,000〜5,000ms。
  • 送信は 256〜1,024B 程度にチャンク分割し、機器に合わせて小休止。
  • 例外(TimeoutException/IOException)時はログにライン状態とバイト数を記録。
  • ポートが存在しない場合は SerialPort.GetPortNames() で検知しリトライ。
  • ドライバ/アダプタを変えて再現性を比較。ケーブルや電源の品質も疑う。

再接続(自己回復)パターン

タイムアウトや I/O 例外を検知したら、ポートを一旦閉じて数百 ms 後に再接続します。バックグラウンドで行えば UI には影響しません。

private async Task&lt;bool&gt; TryReopenAsync(SerialPort sp, int retry = 3)
{
    for (int i = 0; i &lt; retry; i++)
    {
        try
        {
            if (sp.IsOpen) sp.Close();
            await Task.Delay(300);
            sp.Open();
            return true;
        }
        catch
        {
            await Task.Delay(500);
        }
    }
    return false;
}

デバッグの進め方(最短ルート)

  1. ハンドシェイクを一旦 None にして再現確認。ブロックが消えるならフロー制御の不一致。
  2. UI スレッドで書いていないか確認。イベントで Task.Run へ渡す。
  3. ポートを分離。COM1/COM2 を別インスタンス+別キューに。
  4. チャンク分割の導入。256B から開始し遅延と成功率を測定。
  5. アダプタ/ケーブルを変更。ドライバ差を切り分ける。

よくある質問(FAQ)

Q. WriteTimeout を入れたら例外で戻るが、結局送れない。
A. フロー制御/相手機器の取り込み不足が原因です。まず設定を合わせ、チャンク送信+インタバルで取り込み速度に合わせます。論理エラー(ポートを閉じたままなど)もログで切り分けます。

Q. SerialPort を複数タスクから同時に使っても大丈夫?
A. 非推奨です。インスタンスメンバーはスレッドセーフではないため、必ずポート単位で直列化してください。

Q. WriteBufferSize を増やせば改善する?
A. 多少の効果はありますが、OS/ドライバ次第で無視されることもあります。根本はチャンク分割と送信パイプの設計です。

Q. MQTT の処理スレッドは関係ある?
A. あります。ライブラリのコールバックスレッドで重い処理や同期待機を行うと、他の処理が滞ります。キュー投入で速やかに戻る構成にしましょう。

サンプル:JSON 受信からシリアル送信まで(端から端)

「MQTT → ルーティング → シリアル送信」を最短で繋ぐサンプルです。JSON からポート名とペイロード(Base64)を取り出し、該当ポートのキューに投入します。

public record SerialCommand(string Port, byte[] Payload);

// どこかの初期化で:
await using var com1 = new SerialPortManager("COM1");
await using var com2 = new SerialPortManager("COM2");

// MQTT コールバック(擬似)
void OnMessage(string json)
{
var cmd = System.Text.Json.JsonSerializer.Deserialize(json);
if (cmd == null) return;


_ = cmd.Port switch
{
    "COM1" => com1.EnqueueAsync(cmd.Payload),
    "COM2" => com2.EnqueueAsync(cmd.Payload),
    _       => ValueTask.CompletedTask
};


} 

トラブル発生時の観測ポイント

  • 送信開始/終了時刻、バイト数、経過時間、ライン状態(CTS/DSR/CD)。
  • キューの深さ(水位)。深さが増えていれば背圧不足。
  • タイムアウト発生回数と連続回数。連続する場合は再接続戦略を発動。
  • 機器側ログ(可能なら)。取り込み処理の忙しさやエラーを照合。

以下のように簡易計測用の構造体を仕込むと、可視化に役立ちます。

struct TxMetrics
{
    public string Port;
    public int Bytes;
    public long ElapsedMs;
    public bool Timeout;
    public string Lines;
}

ハードウェア側のチェック

  • ボーレート/データ長/パリティ/ストップビットが一致しているか。
  • フロー制御が必要か。必要なら RTS/CTS か XON/XOFF か。
  • ケーブル長やノイズ、電源品質。電圧降下やグラウンド不一致で取り込みが不安定に。
  • USB ハブ越しで不安定なら直結で再検証。

まとめ ― ハングを根こそぎ潰す設計原則

  • 送信はポートごとに独立・直列・非同期に。
  • フロー制御と機器設定を厳密に一致させる。不要なら無効化。
  • チャンク分割とレート制御で機器の取り込み速度に合わせる。
  • タイムアウトと例外処理で「永遠に待つ」を無くし、必要なら自動再接続。
  • ログで遅延とライン状態を可視化し、背圧・ドライバ・ハードを切り分ける。

これらを守れば、SerialPort.Write() のハングは「起きてもすぐ戻る」「そもそも起きにくい」状態にできます。まずは最小修正の非同期化、その次に送信キュー方式へ――段階的に堅牢化していきましょう。

この記事を書いた人

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

コメント

コメントする

目次