PAYATHON 2026

SerialPort.Read(...) 不遵守 ReadTimeout 并长时间阻塞

支付老李

结论

ReadTimeout = 50 不能保证 SerialPort.Read(...) 一定在 50ms 后中断。它依赖 Windows 的串口驱动和超时机制。如果驱动程序、虚拟 COM 端口或底层 I/O 请求迟迟没有完成,托管代码就无法立即夺回控制权。

Application.DoEvents() 会改变现象,说明问题可能不只是“串口里没有数据”,还可能涉及以下情况:

  • 支付终端组件或串口驱动依赖 UI 线程的 Windows 消息循环。
  • UI 线程持有通信代码需要的锁、上下文或回调资源。
  • 多个线程同时对同一个 SerialPort 执行读取、清空、关闭或事件处理,造成阻塞或竞态。
  • 虚拟串口驱动没有正确处理超时或重叠 I/O。

更可靠的做法是用 DiscardInBuffer() 清空驱动的接收缓冲区,并把所有串口操作集中到一个专用通信线程。UI 线程不要同步等待通信线程,也不要依靠 Application.DoEvents() 维持通信。

为什么 50ms 会变成几分钟

SerialPort.ReadTimeout 会转换成底层串口超时设置,但实际读取仍要经过:

SerialPort
    → SerialStream
        → Windows 串口 API
            → 串口或 USB 转串口驱动
                → 设备

只有底层调用返回后,.NET 才能抛出 TimeoutException。如果 I/O 请求卡在驱动中,或者驱动一直不报告超时,托管层设置的 50ms 就无法强行终止读取。

这种情况更容易出现在旧版 Windows、早期 USB 多串口适配器和虚拟 COM 驱动中。直接连接时偶尔也会发生,所以不能把问题完全归咎于 EdgePort,但驱动实现仍是需要重点检查的因素。

另外,ReadTimeout 只限制单次底层读取,不代表整个 ClearReadBuffer() 最多执行 50ms。如果设备持续发送数据,循环中的每次读取都可能成功,整个循环也就可能一直运行。

Application.DoEvents() 为什么会影响通信

正常情况下,SerialPort.Read(...) 不需要 UI 线程主动调用 Application.DoEvents()。如果恢复消息泵后通信随之恢复,通常说明通信链路中还有依赖消息泵的代码。

常见来源有:

  • 支付终端厂商的 SDK、ActiveX 或 COM 组件通过隐藏窗口接收通知。
  • 某个回调通过 Control.Invoke、SynchronizationContext.Send 等方式同步切回 UI 线程。
  • 工作线程等待 UI 线程处理事件,而 UI 线程又在等待工作线程设置 WaitHandle。
  • DataReceived 事件处理程序、主线程和工作线程同时访问同一个端口,并持有不同的锁。
  • 关闭、重新打开或清空串口时,另一个线程仍在执行阻塞读取。

一种典型的死锁过程是:

UI 线程等待 WaitHandle
        ↓
通信线程需要 UI 线程执行回调
        ↓
UI 线程没有处理消息
        ↓
双方持续等待

Application.DoEvents() 会暂时运行消息泵,因此可能打破这类等待。但它不是安全的修复方法。当前调用还没有结束时,它就允许其他窗口消息和事件重入,可能造成重复支付、重复关闭端口或意外修改状态。

因此,这个现象可以用来判断是否存在 UI 消息泵依赖或线程互锁,但不能作为长期设计的一部分。

正确清空串口输入缓冲区

如果只是要丢弃尚未处理的输入,可以直接调用:

public void ClearReadBuffer()
{
    if (_serialPort.IsOpen)
    {
        _serialPort.DiscardInBuffer();
    }
}

DiscardInBuffer() 会请求驱动丢弃接收缓冲区中的数据,不必故意发起一次等待超时的读取,也不会因为设备持续发送而陷入无限读取循环。

不过,DiscardInBuffer() 同样需要经过驱动。如果驱动已经失去响应,这个调用理论上也可能阻塞,所以它必须遵守与其他串口操作相同的线程和并发规则。

如果业务上必须读取并检查已有数据,可以只读取当时已经报告为可用的字节,而不是固定要求读取 512 bytes:

public byte[] DrainAvailableData()
{
    if (!_serialPort.IsOpen)
        return Array.Empty<byte>();

    using var output = new MemoryStream();

    while (true)
    {
        int available = _serialPort.BytesToRead;
        if (available <= 0)
            break;

        var buffer = new byte[Math.Min(available, 512)];
        int read = _serialPort.Read(buffer, 0, buffer.Length);

        if (read <= 0)
            break;

        output.Write(buffer, 0, read);
    }

    return output.ToArray();
}

这段代码只读取已经报告为可用的数据,但仍不能与另一个读取线程并发执行。如果设备持续发送,循环可能始终有数据可读,因此生产代码还要设置总量或总时长限制。

推荐的通信结构

同一个串口应由一个专用通信线程独占。发送、读取、清空和关闭请求都通过队列交给该线程处理。不要让 UI 线程、线程池任务和 DataReceived 处理程序分别操作端口。

UI 层可以使用 async 等待结果,让消息循环继续正常运行:

private async void PayButton_Click(object sender, EventArgs e)
{
    PayButton.Enabled = false;

    try
    {
        PaymentResult result =
            await _terminal.MakePaymentAsync(_amount, CancellationToken.None);

        ShowResult(result);
    }
    catch (TimeoutException)
    {
        ShowError("支付终端响应超时");
    }
    finally
    {
        PayButton.Enabled = true;
    }
}

不要在 UI 线程中这样等待:

// 不推荐:阻塞 UI 线程及其消息循环
completionEvent.WaitOne();

如果旧版运行环境无法使用 async/await,通信线程完成后也应通过 BeginInvoke 异步通知 UI,不要让 UI 线程同步等待:

ThreadPool.QueueUserWorkItem(_ =>
{
    try
    {
        PaymentResult result = _terminal.MakePayment();

        BeginInvoke((Action)(() =>
        {
            ShowResult(result);
        }));
    }
    catch (Exception ex)
    {
        BeginInvoke((Action)(() =>
        {
            ShowError(ex.Message);
        }));
    }
});

这里的重点是 BeginInvoke。工作线程只负责投递通知,不等待 UI 线程立即执行。

必须增加整体超时

串口超时和业务超时是两层不同的保护:

  • ReadTimeout:在驱动正常工作的情况下,单次读取等待多长时间。
  • 业务超时:一次支付交易最多可以持续多长时间。

业务超时不能只靠反复设置 ReadTimeout。通信线程应记录绝对截止时间,并在解析响应时持续检查:

public byte[] ReadResponse(TimeSpan operationTimeout)
{
    var deadline = DateTime.UtcNow + operationTimeout;
    using var response = new MemoryStream();

    while (DateTime.UtcNow < deadline)
    {
        try
        {
            int value = _serialPort.ReadByte();
            response.WriteByte((byte)value);

            if (IsCompleteResponse(response))
                return response.ToArray();
        }
        catch (TimeoutException)
        {
            // 本次读取没有数据,继续检查整个操作的截止时间。
        }
    }

    throw new TimeoutException("等待支付终端响应超时。");
}

这可以限制正常情况下的交易时长。但如果驱动让某一次底层读取永久阻塞,业务线程仍无法仅凭托管计时器中断调用。这时需要关闭端口、取消底层 I/O,必要时结束并重建独立的通信进程。不要用 Thread.Abort 处理串口驱动阻塞。

排查重点

应重点检查以下问题:

  1. 确保任何时刻都只有一个线程访问 SerialPort,包括 Read、Write、DiscardInBuffer、Close 和 DataReceived。
  2. 检查所有回调,看其中是否存在 Control.Invoke、SynchronizationContext.Send、同步 COM 调用或其他等待 UI 线程的代码。
  3. 不要同时使用持续阻塞读取和 DataReceived 事件读取同一个端口。
  4. 更新 EdgePort、USB 转串口设备和主板串口驱动。旧驱动的超时行为可能不符合预期。
  5. 核对 Handshake、DtrEnable、RtsEnable 等设置。它们通常影响写入,但错误的流控配置也可能让整个通信流程停滞。
  6. 记录每次串口操作的线程 ID、开始时间、结束时间、端口状态和 BytesToRead,以判断究竟是底层读取阻塞,还是线程之间相互等待。
  7. 如果必须支持不稳定的旧驱动,可以考虑把串口通信放进独立进程。驱动调用无法返回时,可以结束并重启该进程,避免支付应用的 UI 一起挂起。

ReadTimeout 失效只是表面现象。Application.DoEvents() 能解除冻结,进一步说明系统中可能存在 UI 消息泵依赖或线程互锁。先用 DiscardInBuffer() 代替超时读取来清空缓冲区,再把端口访问串行化,并让 UI 异步等待,才能从结构上避免这类长时间挂起。

备注:内容仅供参考。