工业通讯中的断线重连:一个优雅的 .NET 实现方案
在工业自动化领域,PLC 通讯断线是家常便饭。网络抖动、设备重启、网线被踢掉……如何在 .NET 中优雅地实现断线重连?本文分享一个经过生产验证的设计方案。
背景
在项目中,我们需要同时与多种 PLC 通讯协议打交道:Modbus TCP/RTU、OPC UA、S7、CIP (EtherNet/IP)、Fins UDP 等。无论底层协议如何,它们都有一个共同痛点:连接不可靠。
一个合格的断线重连方案需要解决几个核心问题:
- 如何检测断线? —— 不能轮询心跳,浪费资源
- 何时重连? —— 立即?定时?还是按需?
- 重连期间请求怎么处理? —— 排队?丢弃?报错?
- 如何保证线程安全? —— 重连和业务读写不能冲突
下面逐一展开。
整体架构:两层分离
我们把方案拆成两层,各司其职:
┌─────────────────────────────────────────┐
│ 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。所以断线后的处理是:
- 请求 A 执行时发现断线 →
MarkDisconnected() - 请求 B 从 Channel 中出队
- 请求 B 的 Action 第一行:
EnsureConnectedAsync()→ 检测到_isConnected == false→ 断开旧连接 → 建立新连接 → 执行操作
没有专门的"重连线程"、没有定时器、没有心跳包。重连是按需的、惰性的。
请求抽象:IWorkRequest
private interface IWorkRequest
{
Task<Exception?> RunAsync(CancellationToken workerToken);
void TrySetCanceled(CancellationToken cancellationToken);
}
两个实现:ReadWorkRequest<T> 和 WriteWorkRequest,封装了:
- Action:具体要执行的异步操作
- Completion:
TaskCompletionSource,用于把结果返回给调用方 - 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 断电等各种意外,这套机制始终能自动恢复。代码不多,但够用。
如果这篇文章对你有帮助,欢迎分享给更多做工业通讯的同学。

浙公网安备 33010602011771号