☰
C# TCP通信封装库:解决粘包拆包、心跳保活与异常重连
2026/10/18 1:57:53 网站建设 项目流程

简介:这是一份面向C#初学者与.NET开发者的Socket通信实战封装组件,聚焦TCP协议下的稳定网络通信实现,解决自定义通信模块重复开发、连接管理混乱、收发逻辑耦合度高等常见问题。资源共50个文件,包含16个核心C#源码(如SocketHelper.cs、WindowClient.xaml.cs等)、3个可执行程序(exe)、1个动态链接库(dll)、3个配置文件(config)及配套的项目文件(sln、csproj)和UI资源(xaml、resx),整体压缩包仅266KB,轻量易集成。已有531人学习下载,适合快速嵌入桌面应用或教学演示项目。读者可直接复用已封装的Socket连接、数据收发、异常处理与资源释放逻辑,无需从零编写底层API调用;代码结构清晰,含客户端主窗口、辅助工具类与配置管理模块,同时提供完整VS解决方案,支持开箱即调、断点调试与二次扩展。

1. 这不是又一个“Hello World” Socket 示例:它把 .NET 中最易翻车的连接管理、粘包拆包、心跳保活、异常重连全封装进了一个可直接new调用的类里

你有没有在调试 C# Socket 时,刚跑通单次收发就喜出望外,结果一加多线程立刻ObjectDisposedException;一上真实网络,30 秒没数据就断连,日志里全是WSAETIMEDOUT却找不到在哪设超时;客户端断电重连后服务端还傻等,队列越积越多最后OutOfMemoryException?这不是玄学——是原始SocketAPI 暴露了太多黑匣子。这个「C# Socket Demo 已经封装成共通」项目,就是某开发者在三个工业级通信模块踩坑两年后,把所有血泪经验打包进一个TcpClientWrapper和一个TcpServerHost里:不依赖第三方库(纯 .NET Standard 2.0),无反射黑魔法,所有状态可监控、可打断、可替换。它适合正在做设备对接、PLC 通信、自定义协议网关,或需要快速验证 TCP 可靠性边界的新手和中级开发——你不用再从BeginReceive的回调地狱里自己爬出来,而是直接client.SendAsync(buffer),然后专注业务逻辑。


2. 项目结构与技术选型:为什么是 .NET Standard 2.0 + 同步/异步双模式 + 手动内存池

2.1 目录结构:5 个核心文件撑起整个通信骨架

项目解压后只有 7 个文件,但每一份都直击生产痛点:

/SocketCommon/ ├── TcpClientWrapper.cs // 客户端主类:连接/发送/接收/重连/状态监听 ├── TcpServerHost.cs // 服务端主类:监听/接受/会话管理/批量关闭 ├── Session.cs // 会话实体:含唯一ID、远程地址、在线时长、收发计数器 ├── ProtocolHelper.cs // 协议工具:含固定头长度解包、分隔符拆包、心跳帧生成 ├── MemoryPoolBufferManager.cs // 内存池管理器:复用 4KB~64KB 缓冲区,避免 GC 压力 ├── ConnectionConfig.cs // 配置类:超时、重试次数、心跳间隔、最大连接数等 └── SocketCommon.csproj // .NET Standard 2.0,显式禁用 `<LangVersion>latest</LangVersion>`

提示:没有appsettings.json,所有配置通过ConnectionConfig实例传入构造函数——这是为嵌入式场景和单元测试留的后门,避免配置中心耦合。

2.2 技术选型依据:拒绝“新即正义”,只选能压住现场的组合

  • 不选System.Net.Http或HttpClient:它们是 HTTP 协议栈,无法处理自定义二进制协议、无状态粘包、非 HTTP 心跳。
  • 不选StreamSocket(UWP)或SocketsHttpHandler:前者平台受限,后者仍绕不开底层Socket控制权。
  • 坚持Socket原生 API +Task封装:.NET Standard 2.0兼容 .NET Framework 4.6.1+ 和 .NET Core 2.0+,覆盖老旧工控机(Win7 SP1)和新容器环境。
  • 同步/异步双模式并存:TcpClientWrapper.Send()是同步阻塞(适合命令行调试),SendAsync()是ValueTask(零分配,适合高吞吐)。二者底层共用同一套缓冲区和状态机,不是简单包装。

2.3 核心设计原则:状态驱动 + 显式生命周期 + 可观测性

所有类都实现IDisposable,且Dispose()不仅释放Socket,还会:

  • 触发OnDisconnected事件(带断开原因枚举:NetworkError/Timeout/UserClosed/ProtocolError)
  • 清空内存池中该会话专属缓冲区(避免跨会话污染)
  • 记录最终收发字节数到Session.Statistics

这种设计让“连接突然消失”不再是黑匣子——你可以直接在OnDisconnected里写日志、告警、触发补偿任务。


3. 客户端核心:TcpClientWrapper 的三次握手、粘包拆包与智能重连

3.1 创建与连接:一行代码背后的状态流转

var config = new ConnectionConfig { RemoteIp = "192.168.1.100", RemotePort = 8080, ConnectTimeoutMs = 5000, ReceiveTimeoutMs = 30000, MaxRetryCount = 3, RetryDelayMs = 2000 }; var client = new TcpClientWrapper(config); await client.ConnectAsync(); // 非阻塞,返回 ValueTask

这段代码实际触发了 5 个内部状态检查:

  1. State == Disconnected→ 允许连接;
  2. Socket == null→ 新建Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
  3. 设置NoDelay = true(禁用 Nagle 算法,小包不攒);
  4. 设置SendTimeout/ReceiveTimeout(注意:Socket层超时 ≠ConnectAsync超时,后者由CancellationTokenSource控制);
  5. 调用socket.ConnectAsync()并监听Completed事件。

关键参数说明:ConnectTimeoutMs是CancellationTokenSource的毫秒数,不是Socket.ConnectAsync()的原生参数(该方法本身不支持超时)。这是封装层做的“软超时”——超时后主动socket.Close()并抛出OperationCanceledException。

3.2 发送逻辑:自动分包与零拷贝写入

// 假设你要发一条带 4 字节长度头的二进制消息 byte[] payload = Encoding.UTF8.GetBytes("CMD:REBOOT"); byte[] packet = new byte[4 + payload.Length]; BitConverter.TryWriteBytes(packet, (uint)payload.Length); // 小端序写入长度 Array.Copy(payload, 0, packet, 4, payload.Length); await client.SendAsync(packet); // 内部调用 socket.SendAsync(),但做了缓冲区复用

SendAsync()的关键行为:

  • 若当前Socket.Connected == false,立即返回失败ValueTask.FromException(...),不尝试重发;
  • 使用MemoryPool<byte>.Shared.Rent(packet.Length)获取缓冲区,避免每次new byte[];
  • 若packet.Length > 64KB,自动切片为多个SendAsync调用(保证单次不超过Socket.SendBufferSize);
  • 发送完成后,将缓冲区归还内存池,不等待SendAsync().IsCompletedSuccessfully—— 因为Socket的SendAsync在数据拷贝到内核缓冲区后即返回成功,真正失败(如网线拔掉)会在下次SendAsync或ReceiveAsync时暴露。

3.3 接收与粘包处理:ProtocolHelper 如何把“一锅粥”还原成“一道菜”

TcpClientWrapper不直接暴露ReceiveAsync(),而是提供StartReceiving()方法,内部启动一个长生命周期Task:

private async Task ReceiveLoopAsync() { var buffer = _bufferManager.Rent(4096); // 从内存池租一块 4KB try { while (_state == ConnectionState.Connected) { var received = await _socket.ReceiveAsync(buffer.Memory, SocketFlags.None); if (received == 0) { /* 对端优雅关闭 */ break; } // 关键:交给 ProtocolHelper 拆包 var messages = _protocolHelper.ParseMessages(buffer.Memory[..received]); foreach (var msg in messages) { OnMessageReceived?.Invoke(this, msg); // 业务方在此处理完整消息 } } } finally { _bufferManager.Return(buffer); // 归还内存池 } }

ProtocolHelper.ParseMessages()支持两种模式(通过构造函数注入):

  • LengthHeaderMode:假设前 4 字节是uint表示后续有效载荷长度(默认);
  • DelimiterMode:按\r\n或自定义字节序列分割(需传入delimiter = new byte[]{13,10})。

拆包逻辑是状态机实现,不依赖string解析,全程ReadOnlySequence<byte>操作,避免 UTF8 编码/解码开销。例如 LengthHeader 模式下,它会:

  1. 检查buffer.Length >= 4,否则缓存等待下次接收;
  2. 读取前 4 字节,解析出payloadLength;
  3. 检查buffer.Length >= 4 + payloadLength,否则缓存剩余数据,等待补全;
  4. 截取[4..4+payloadLength]返回ReadOnlyMemory<byte>,原缓冲区继续复用。

4. 服务端核心:TcpServerHost 的连接池、会话隔离与优雅关闭

4.1 启动监听:如何避免AddressAlreadyInUse

var server = new TcpServerHost(new ConnectionConfig { LocalPort = 8080, MaxConnections = 100, IdleTimeoutMs = 60000 // 60秒无数据则断开 }); server.OnClientConnected += (host, session) => { Console.WriteLine($"New client: {session.RemoteEndPoint}, ID={session.Id}"); session.OnMessageReceived += (s, msg) => HandleCommand(s, msg); }; await server.StartListeningAsync();

StartListeningAsync()的关键防护:

  • 创建Socket时设置socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true),允许TIME_WAIT状态端口快速复用;
  • 绑定前检查端口占用(通过IPGlobalProperties.GetIPGlobalProperties().GetActiveTcpListeners()),冲突时抛出明确异常"Port {port} is already in use";
  • AcceptAsync()循环中,若MaxConnections已达上限,直接socket.Close()并记录RejectedConnection事件,不排队——避免连接堆积耗尽句柄。

4.2 会话管理:Session 如何成为连接的“身份证”

每个客户端连接对应一个Session实例,其字段全部公开可读:

public class Session : IDisposable { public Guid Id { get; } // 全局唯一,非时间戳 public IPEndPoint RemoteEndPoint { get; } public DateTime ConnectedAt { get; } public TimeSpan Uptime => DateTime.Now - ConnectedAt; public long TotalBytesReceived { get; private set; } public long TotalBytesSent { get; private set; } public ConnectionState State { get; private set; } // Connected/Disconnecting/Disconnected public object Tag { get; set; } // 业务方自定义绑定对象,如 DeviceId // 事件 public event EventHandler<Session> OnDisconnected; public event EventHandler<ReadOnlyMemory<byte>> OnMessageReceived; }

Tag字段是二次开发的关键入口——你可以session.Tag = deviceRepo.FindByIp(session.RemoteEndPoint.Address),后续所有消息处理都可直接拿到设备实体,无需查表。

4.3 优雅关闭:为什么server.Stop()比socket.Close()更安全

调用server.Stop()会触发三阶段关闭:

  1. 拒绝新连接:_acceptSocket.Close(),停止AcceptAsync循环;
  2. 通知现存会话:对每个Session调用session.CloseGracefully(),发送 FIN 包并等待对方 ACK(最多ShutdownTimeoutMs);
  3. 强制清理:若session.State != Disconnected,执行session.ForceClose()(socket.Shutdown(SocketShutdown.Both)+socket.Close())。

注意:Graceful不等于“无限等待”。CloseGracefully()内部使用CancellationTokenSource控制总耗时,默认 5 秒。超时后自动降级为ForceClose。


5. 避坑指南:那些让你加班到凌晨三点的真实错误与解法

5.1 现象:SocketException: An existing connection was forcibly closed by the remote host频繁出现,但日志里找不到客户端主动断开记录

原因:客户端进程崩溃、断电、或防火墙主动 kill 连接(如企业级防火墙 300 秒空闲断连),而服务端未开启KeepAlive。Socket默认KeepAlive是关闭的,不会主动探测死连接。
解决:在TcpServerHost和TcpClientWrapper的ConnectionConfig中启用:

config.KeepAliveEnabled = true; config.KeepAliveTimeMs = 60000; // 空闲 60 秒后开始发心跳 config.KeepAliveIntervalMs = 5000; // 心跳失败后每 5 秒重试

同时,ProtocolHelper会自动识别并丢弃心跳帧(默认0x00单字节),不触发OnMessageReceived。

5.2 现象:发送大文件(>1MB)时,客户端SendAsync()返回成功,但服务端只收到前 64KB

原因:Socket.SendBufferSize默认约 64KB,当内核发送缓冲区满时,SendAsync()会阻塞等待空间(但Task看似已完成),而你的代码误以为已发完,接着发下一段,导致数据覆盖或丢弃。
解决:不要假设单次SendAsync()能发完所有数据。正确做法是循环发送直到bytesSent == totalBytes:

int totalSent = 0; while (totalSent < data.Length) { var sent = await socket.SendAsync(data.AsMemory(totalSent), SocketFlags.None); totalSent += sent; }

本项目TcpClientWrapper.SendAsync()已内置此逻辑,但如果你绕过封装直接操作Socket,必须手动实现。

5.3 现象:多客户端连接后,GC.Collect()频繁触发,Gen2堆持续增长

原因:每次new byte[4096]分配缓冲区,短生命周期对象大量进入Gen2,而Socket的ReceiveAsync()回调中又频繁newArraySegment<byte>。
解决:本项目MemoryPoolBufferManager已解决。但若你修改了ProtocolHelper.ParseMessages(),务必避免ToArray()或MemoryMarshal.ToArray()。应始终使用ReadOnlySequence<byte>的Slice()和FirstSpan方法操作视图。

5.4 现象:OnMessageReceived事件中抛出异常,整个ReceiveLoopAsync()任务崩溃,连接静默断开

原因:ReceiveLoopAsync()是async void风格的后台任务,未捕获OnMessageReceived中的异常,导致Task进入Faulted状态后被丢弃。
解决:TcpClientWrapper内部已用try-catch包裹事件调用,并将异常转为OnClientError事件:

try { OnMessageReceived?.Invoke(this, msg); } catch (Exception ex) { OnClientError?.Invoke(this, new ClientErrorEventArgs(ex, "OnMessageReceived")); // 连接保持,不中断 ReceiveLoop }

你必须订阅OnClientError并记录日志,否则异常会被吞掉。

5.5 现象:在 .NET 6+ 上运行时报错System.PlatformNotSupportedException: Operation is not supported on this platform

原因:项目.csproj中<TargetFramework>netstandard2.0</TargetFramework>,但你在 .NET 6+ 项目中直接PackageReference引用,而未在宿主项目中启用System.Net.Sockets兼容模式。
解决:在你的主项目.csproj中添加:

<PropertyGroup> <EnableDefaultCompileItems>false</EnableDefaultCompileItems> <EnableDefaultEmbeddedResourceItems>false</EnableDefaultEmbeddedResourceItems> </PropertyGroup> <ItemGroup> <PackageReference Include="System.Net.Sockets" Version="4.3.0" /> </ItemGroup>

或者更简单:将本项目源码直接Add Existing Project到你的解决方案,而非 NuGet 引用。


6. 二次开发实战:如何接入 Modbus RTU over TCP 并添加 CRC 校验

6.1 场景还原:工业现场的真实需求

某自动化产线需对接 12 台 PLC,协议为 Modbus TCP(RFC 1993),但要求:

  • 每个连接独占一个Session,Tag字段存PlcDevice实体;
  • 接收帧必须校验 MBAP 头(7 字节)+ PDU(功能码+数据)的 CRC16(Modbus 标准);
  • 发送前自动计算并追加 CRC,失败时返回ErrorResponse;
  • 所有ReadHoldingRegisters请求需记录RequestID用于审计。

这正是本项目封装的价值——你不用碰Socket,只改协议层。

6.2 步骤一:继承ProtocolHelper实现 Modbus 解析

public class ModbusProtocolHelper : ProtocolHelper { public ModbusProtocolHelper() : base(LengthHeaderMode.Length4) { } public override List<ReadOnlyMemory<byte>> ParseMessages(ReadOnlySequence<byte> buffer) { var messages = new List<ReadOnlyMemory<byte>>(); var reader = new SequenceReader<byte>(buffer); while (reader.Remaining >= 7) // MBAP 最小长度:7字节头 { // 读 MBAP 头:事务标识(2)、协议标识(2)、长度(2)、单元标识(1) if (!reader.TryReadLittleEndian(out ushort transactionId) || !reader.TryReadLittleEndian(out ushort protocolId) || !reader.TryReadBigEndian(out ushort length) || // 注意:Modbus 长度是大端! !reader.TryRead(out byte unitId)) break; // 长度字段表示后续 PDU 字节数(不含 MBAP 头),总帧长 = 7 + length int frameLength = 7 + length; if (reader.Remaining < length) break; // 数据不全,等待下次 // 提取完整帧(MBAP + PDU) var frame = reader.Sequence.Slice(reader.Position - 7, frameLength); // 校验 CRC16(CRC 位于 PDU 末尾 2 字节) if (IsValidModbusFrame(frame)) { // 剥离 CRC,返回 MBAP+PDU(不含 CRC) var pduWithoutCrc = frame.Slice(0, frame.Length - 2); messages.Add(pduWithoutCrc); } else { // 丢弃非法帧,记录告警 OnInvalidFrame?.Invoke(this, frame); } reader.Advance(frameLength); } return messages; } private bool IsValidModbusFrame(ReadOnlySequence<byte> frame) { // CRC16 计算逻辑(略,标准 Modbus CRC16 查表法) // 注意:CRC 计算范围是 MBAP+PDU,不含最后 2 字节 CRC 自身 return crcCalculated == crcInFrame; } }

6.3 步骤二:在 Session 中注入 Modbus 逻辑

server.OnClientConnected += (host, session) => { // 绑定 PLC 设备 var plc = plcRepo.FindByUnitId(session.RemoteEndPoint.Port); // 假设端口映射 UnitId session.Tag = plc; // 替换协议处理器 session.ProtocolHelper = new ModbusProtocolHelper(); session.OnMessageReceived += async (s, msg) => { try { var response = await HandleModbusRequest(plc, msg); await s.SendAsync(response); // 自动追加 CRC } catch (ModbusException ex) { await s.SendAsync(BuildErrorResponse(ex.FunctionCode, ex.ExceptionCode)); } }; };

6.4 步骤三:发送时自动追加 CRC(改造TcpClientWrapper.SendAsync)

在TcpClientWrapper.SendAsync()内部,增加判断:

if (_protocolHelper is ModbusProtocolHelper && data.Length >= 7 && // 至少有 MBAP 头 !_isResponse) // 响应帧已由业务层计算好 CRC { var withCrc = AppendModbusCrc(data); // 后续走原发送流程 }

这样,业务层只需构造MBAP+PDU,发送时自动补 CRC;而响应帧(如0x03 0x00 0x00 0x00 0x0A ...)由HandleModbusRequest()返回时已含 CRC,跳过追加。

从那以后我每次接入新协议,都强制走一遍「协议头分析 → 解包状态机 → CRC/校验注入点 → 错误帧隔离」四步 checklist,再对照 Wireshark 抓包验证。这套流程跑通三次,基本就告别Socket层的玄学问题了。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询