工业通讯中的断线重连:一个优雅的 .NET 实现方案

在工业自动化领域,PLC 通讯断线是家常便饭。网络抖动、设备重启、网线被踢掉……如何在 .NET 中优雅地实现断线重连?本文分享一个经过生产验证的设计方案。


背景

在项目中,我们需要同时与多种 PLC 通讯协议打交道:Modbus TCP/RTU、OPC UA、S7、CIP (EtherNet/IP)、Fins UDP 等。无论底层协议如何,它们都有一个共同痛点:连接不可靠

一个合格的断线重连方案需要解决几个核心问题:

  1. 如何检测断线? —— 不能轮询心跳,浪费资源
  2. 何时重连? —— 立即?定时?还是按需?
  3. 重连期间请求怎么处理? —— 排队?丢弃?报错?
  4. 如何保证线程安全? —— 重连和业务读写不能冲突

下面逐一展开。


整体架构:两层分离

我们把方案拆成两层,各司其职:

┌─────────────────────────────────────────┐
│         CommunicationWorker             │
│   ┌─────────────────────────────────┐   │
│   │  Channel<IWorkRequest>          │   │
│   │  ┌─────┬─────┬─────┬─────┐      │   │
│   │  │ Req │ Req │ Req │ Req │ ...  │   │
│   │  └──┬──┴─────┴─────┴──┬──┘      │   │
│   │     │                 │         │   │
│   │     ▼                 ▼         │   │
│   │  ┌─────────────────────────┐    │   │
│   │  │    WorkerLoop (串行)     │    │   │
│   │  │  ① EnsureConnectedAsync │    │   │
│   │  │  ② RunAsync(request)    │    │   │
│   │  │  ③ if 连接异常 → Mark    │    │   │
│   │  └─────────────────────────┘    │   │
│   └─────────────────────────────────┘   │
│                    │                    │
│                    ▼                    │
│   ┌─────────────────────────────────┐   │
│   │       ReconnectSuper            │   │
│   │  _isConnected (volatile bool)   │   │
│   │  ConnectAsync()      (abstract) │   │
│   │  DisconnectCoreAsync()(abstract)│   │
│   │  EnsureConnectedAsync()         │   │
│   └─────────────────────────────────┘   │
└─────────────────────────────────────────┘
  • ReconnectSuper:管理连接生命周期,定义重连算法骨架
  • CommunicationWorker:消息队列 + 串行调度,负责线程安全和故障检测

第一层:ReconnectSuper —— 连接状态机

这是所有通讯 Worker 的基类,只有 ~80 行代码,却是整个重连逻辑的核心:

public abstract class ReconnectSuper : IAsyncDisposable
{
    // volatile 保证多线程可见性,避免加锁
    private volatile bool _isConnected;
    private bool _disposed;

    // 子类实现具体连接逻辑(TCP/串口/OPC UA...)
    protected abstract Task ConnectAsync(CancellationToken cancellationToken);
    protected abstract Task DisconnectCoreAsync(CancellationToken cancellationToken);
}

核心方法:EnsureConnectedAsync

protected async Task EnsureConnectedAsync(CancellationToken cancellationToken)
{
    ObjectDisposedException.ThrowIf(_disposed, this);

    // 快速路径:已连接就直接返回
    if (_isConnected)
        return;

    // ① 先断开(清理残留的半开连接)
    try { await DisconnectCoreAsync(cancellationToken); }
    catch { /* 断开失败不阻塞重连 */ }

    // ② 重新连接
    await ConnectAsync(cancellationToken);

    // ③ 标记已连接
    _isConnected = true;
}

这个方法的三个关键决策:

决策 做法 原因
连接检查 volatile bool 无锁读 高频调用,加锁是性能浪费
重连前先断开 调用 DisconnectCoreAsync 并吞掉异常 上一个连接可能处于半开状态(TCP 半关闭),直接新建可能导致资源泄漏
连接成功后标记 _isConnected = true 单 worker 串行执行,不需要原子 CAS

MarkDisconnected:由外部触发

protected void MarkDisconnected()
{
    if (!_isConnected) return;
    _isConnected = false;
}

这个方法不主动断开连接,只是把标志位设为 false。实际断开 + 重连延迟到下一次 EnsureConnectedAsync 调用——这就是"惰性重连"的关键。


第二层:CommunicationWorker —— 串行工作队列

CommunicationWorker 继承自 ReconnectSuper,在上面加了一层 生产者-消费者队列

public abstract class CommunicationWorker : ReconnectSuper
{
    private readonly Channel<IWorkRequest> _channel =
        Channel.CreateBounded<IWorkRequest>(new BoundedChannelOptions(1000)
        {
            FullMode = BoundedChannelFullMode.Wait,  // 队列满时阻塞生产者
            SingleReader = true,                      // 单消费者
            SingleWriter = false,                     // 多生产者
        });
}

选择 System.Threading.Channels 而不是 BlockingCollection 或 TPL Dataflow,原因:

  • 零分配:值类型支持好,减少 GC 压力
  • 异步原生ReadAllAsync 配合 await foreach 天然支持异步消费
  • 背压控制BoundedChannelFullMode.Wait 在队列满时自动阻塞生产者

Worker 循环:断线检测 + 惰性重连

private async Task WorkerLoopAsync(CancellationToken token)
{
    await foreach (var request in _channel.Reader.ReadAllAsync(token))
    {
        Exception? exception = await request.RunAsync(token);

        // 关键:如果返回的连接类异常,标记断开
        if (exception is not null && IsConnectionFailure(exception))
        {
            MarkDisconnected();  // 只是置标志位,不断开
        }
    }
    // 循环退出后,清空剩余请求
    while (_channel.Reader.TryRead(out var request))
        request.TrySetCanceled(token);
}

数据流如下:

Read/Write 请求入队
       │
       ▼
  WorkerLoop 取出请求
       │
       ▼
  RunAsync 内部调用 EnsureConnectedAsync()
       │
       ├── 已连接 → 直接执行操作
       │
       └── 未连接 → DisconnectCoreAsync() → ConnectAsync() → 执行操作
                            │
                            ▼
                      操作成功 → 返回结果
                      操作失败 → 返回异常
                            │
                            ▼
                      WorkerLoop 检查异常类型
                            │
                ┌───────────┴───────────┐
                │                       │
          连接类异常              非连接异常
                │                       │
        MarkDisconnected()        透传给调用方
                │
                ▼
         下一个请求进来
         EnsureConnectedAsync() 检测到未连接
                │
                ▼
            自动重连

异常识别:IsConnectionFailure

什么样的异常算"连接断开"?这里用了递归检查:

private bool IsConnectionFailure(Exception exception)
{
    return exception is TimeoutException       // 读写超时
        or IOException                         // IO 异常(网络断开)
        or SocketException                     // Socket 层错误
        or EndOfStreamException                // 流意外结束
        or TaskCanceledException               // 操作被取消(超时取消)
        or ObjectDisposedException             // 连接对象已释放
        || exception.InnerException is not null
           && IsConnectionFailure(exception.InnerException); // 递归检查内部异常
}

用 C# 的 or 模式匹配一口气列出所有"不可恢复"的异常类型,递归检查 InnerException,防止聚合异常漏网。

每个请求都内嵌重连

ReadAsync 为例:

public async Task<byte[]> ReadAsync(BaseValueConfig valueConfig,
                                     CancellationToken cancellationToken)
{
    Start();  // 懒启动 worker

    var request = new ReadWorkRequest<byte[]>
    {
        CancellationToken = cancellationToken,
        Action = async ct =>
        {
            await EnsureConnectedAsync(ct);        // ← 这里触发重连
            return await ReadCoreAsync(valueConfig, ct);  // 实际协议读
        }
    };

    await _channel.Writer.WriteAsync(request, cancellationToken);
    return await request.Completion.Task;
}

每个请求的 Action 里,第一步就是 EnsureConnectedAsync。所以断线后的处理是:

  1. 请求 A 执行时发现断线 → MarkDisconnected()
  2. 请求 B 从 Channel 中出队
  3. 请求 B 的 Action 第一行:EnsureConnectedAsync() → 检测到 _isConnected == false → 断开旧连接 → 建立新连接 → 执行操作

没有专门的"重连线程"、没有定时器、没有心跳包。重连是按需的、惰性的。

请求抽象:IWorkRequest

private interface IWorkRequest
{
    Task<Exception?> RunAsync(CancellationToken workerToken);
    void TrySetCanceled(CancellationToken cancellationToken);
}

两个实现:ReadWorkRequest<T>WriteWorkRequest,封装了:

  • Action:具体要执行的异步操作
  • CompletionTaskCompletionSource,用于把结果返回给调用方
  • CancellationToken:调用方自己的取消令牌

RunAsync 通过 CancellationTokenSource.CreateLinkedTokenSource 把 Worker 的取消令牌和调用方的取消令牌联动,任何一个取消都能终止操作。


设计亮点总结

1. 惰性重连(Lazy Reconnection)

不做主动心跳检测,不做定时重连。断线后的重连发生在下一次业务请求到达时。这样做的好处:

  • 没有多余的网络开销
  • 重连失败不会产生"重连风暴"
  • 业务空闲期间不消耗资源

2. 串行化保证线程安全

所有对连接的操作(读、写、断开、重连)都在同一个 Worker 线程中串行执行。不需要 lock、不需要 SemaphoreSlim、不需要担心并发读写同一 Socket 的问题。

Channel 的 SingleReader = true 显式声明了这个约束,运行时可以利用这个信息做优化。

3. 模板方法模式隔离协议差异

ReconnectSuper 定义了重连的算法骨架

断开 → 连接 → 标记已连接

ConnectAsync / DisconnectCoreAsync 留给子类实现。Modbus TCP 子类实现 TCP 连接,OPC UA 子类实现会话建立,S7 子类实现 ISO-on-TCP 握手……但重连逻辑完全复用。

4. 优雅关闭

public override async ValueTask DisposeAsync()
{
    _channel.Writer.Complete();     // ① 停止接受新请求
    await _cts.CancelAsync();       // ② 取消 worker 循环
    if (_workerTask != null)
        await _workerTask;          // ③ 等待 worker 结束
    while (_channel.Reader.TryRead(out var request))
        request.TrySetCanceled(token); // ④ 清空队列中的请求
    await base.DisposeAsync();      // ⑤ 断开连接(3秒超时)
}

五步关闭,每一步都有明确的目的。特别是第④步——Worker 循环可能在 TryRead 前被取消,队列中残留的请求需要手动 Cancel,避免调用方永久挂起。


适用场景与局限性

适用场景

  • 工业通讯:PLC、传感器、执行器等设备通讯
  • 数据库连接:Redis、MongoDB 等 NoSQL 客户端
  • 消息队列消费者:RabbitMQ、Kafka 的 Consumer 重连
  • 任何"长连接 + 串行操作"的场景

局限性

  • 不适用同一连接上的并发操作:所有请求被 Channel 强制串行化。如果你需要在同一个 Socket/会话上并行发送多个请求(比如一边发心跳一边读数据),这个方案会让它们排队等待。但对于请求-响应式的工业协议(Modbus、S7、Fins 等),这通常不是问题——你完全可以创建多个 Worker 实例来连接不同设备,实现设备间的并行通讯。

  • 不适用无请求场景:如果连接空闲时需要主动发心跳保活(比如有些防火墙会切断空闲 TCP 连接),需要额外机制定时发空请求来触发 EnsureConnectedAsync,或者在外层加定时心跳。

  • 重连期间请求会排队:重连本身发生在 EnsureConnectedAsync 中,是串行链路的一部分。如果重连耗时 5 秒,队列中后续所有请求都会等 5 秒。队列满(1000)后生产者会阻塞,极端情况下可能导致调用方超时。


写在最后

这个方案的核心思想其实很简单:把"重连"从"后台任务"变成"请求的前置步骤"。不需要额外的线程、定时器、状态机——只需要一个 volatile 标志位 + 一个串行 Channel。

在工业现场跑了几个月,经历过网线被踢、交换机重启、PLC 断电等各种意外,这套机制始终能自动恢复。代码不多,但够用。


如果这篇文章对你有帮助,欢迎分享给更多做工业通讯的同学。

posted @ 2026-07-24 09:14  逸尘上位机分享  阅读(0)  评论(0)    收藏  举报