☰
C# TcpClient 发送与接收实战:粘包、心跳与断线重连
2026/10/16 3:23:31 网站建设 项目流程

简介:这份资源面向具备一定C#基础、希望掌握TCP网络通信的开发者,围绕TcpClient类讲解客户端发送与接收数据的完整实现。内容涵盖命名空间导入、连接远程服务器、通过NetworkStream读写字节流、关闭连接释放资源,并延伸至异常处理与BeginConnect、BeginRead等异步操作思路,适合网络编程入门与实战练习。压缩包共31个文件,以9个cs源码文件为核心,配合sln解决方案、csproj工程文件、config配置、resx资源与exe可执行程序等,整体约51KB,结构紧凑便于直接运行调试。目前已有7156人学习下载,说明其参考价值获得较多认可。借助其中的示例代码,读者可对照理解连接建立、数据编解码与缓冲区读取等关键环节,并在此基础上扩展异步通信与异常重连逻辑,为构建稳定的TCP客户端应用打下基础。

1. 从一次“连上了却收不到数据”的调试说起

如果你正在用 C# 写上位机、设备网关或者内网工具,大概率绕不开TcpClient。我见过太多人第一次写 TCP 通信时,代码跑起来显示连接成功,可数据就是收不到,或者收一半卡死,最后怀疑是“网络玄学”。其实问题往往出在粘包处理、异步读取和超时设置这几个点上。这份资源围绕c# tcpClient的发送和接收展开,把连接建立、数据收发、异常断开、心跳保活这几件事拆成了可以直接抄的代码片段。它适合两类人:一类是刚接触 Socket 编程、想找一个能跑通的最小闭环的新手;另一类是在项目里维护老旧通信模块、需要快速补上断线重连和粘包处理的熟手。下面我按“先跑通、再拆解、最后改造”的顺序,把这份资源里的关键实现讲清楚。

2. 环境准备与最小可运行示例:把连接、发送、接收串起来

2.1 项目类型与依赖选择

这份资源基于 .NET 平台,推荐用 .NET 6 或 .NET 8 的控制台项目来验证,因为TcpClient在 .NET Core 之后跨平台支持已经比较稳定。如果你维护的是 .NET Framework 4.x 的老项目,代码结构基本一致,只是异步 API 的写法略有差别。新建项目时不需要额外 NuGet 包,System.Net.Sockets是标准库自带的。我一般会先建一个控制台程序,把发送端和接收端放在两个不同的进程里跑,这样能直观看到数据流向,而不是在一个进程里自问自答。

dotnet new console -n TcpDemo cd TcpDemo dotnet add package System.Net.Sockets

上面这条dotnet add package其实可以省略,因为命名空间已经内置。写出来是为了提醒新手:不要看到TcpClient就以为要装第三方库。真正需要额外引入的,通常是日志组件或者 JSON 序列化库,通信本身不依赖它们。

2.2 服务端监听与客户端连接

先看服务端。资源里给的是一个最简监听循环,绑定本机端口,等待客户端接入。这里有个细节:TcpListener的Start()只是开始监听,真正拿到连接要靠AcceptTcpClientAsync()。很多人写成同步的AcceptTcpClient(),结果界面直接卡死,这就是没有理解阻塞调用的代价。

// 服务端:监听指定端口,接受一个客户端连接 var listener = new TcpListener(IPAddress.Any, 9000); listener.Start(); Console.WriteLine("服务端已启动,等待连接..."); // 异步等待,避免阻塞主线程 using var client = await listener.AcceptTcpClientAsync(); Console.WriteLine($"客户端已接入:{client.Client.RemoteEndPoint}"); // 拿到网络流,后续收发都基于它 NetworkStream stream = client.GetStream();

IPAddress.Any表示监听本机所有网卡地址,端口 9000 是示例值,实际项目里建议从配置文件读取。AcceptTcpClientAsync()返回的TcpClient对象封装了底层 Socket,GetStream()拿到的是双向流,既能读也能写。注意using关键字,它保证连接释放时底层句柄被回收,否则反复连接会耗尽端口。

客户端这边对应的是ConnectAsync()。资源里特意强调了连接超时的问题:默认的ConnectAsync在某些网络环境下可能等很久才抛异常,所以最好配合CancellationToken做超时控制。

// 客户端:连接服务端,设置 3 秒超时 using var client = new TcpClient(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); try { await client.ConnectAsync("127.0.0.1", 9000, cts.Token); Console.WriteLine("连接成功"); } catch (OperationCanceledException) { Console.WriteLine("连接超时,请检查服务端是否启动"); }

CancellationTokenSource传入 3 秒,超时后ConnectAsync会抛出OperationCanceledException。这里不要用Socket.Connect的同步版本去硬等,异步加取消令牌是目前比较稳妥的写法。连接成功后,客户端和服务端就各自持有一个NetworkStream,接下来就是收发数据。

2.3 发送与接收的基本写法

发送数据用stream.WriteAsync(),接收用stream.ReadAsync()。资源里给的是一个循环读取的模板,因为 TCP 是流式协议,一次ReadAsync不保证拿到完整消息。

// 发送:把字符串转成字节数组写入流 string message = "hello tcp"; byte[] sendBuffer = Encoding.UTF8.GetBytes(message); await stream.WriteAsync(sendBuffer, 0, sendBuffer.Length); Console.WriteLine($"已发送:{message}"); // 接收:循环读取,直到对方关闭连接 byte[] recvBuffer = new byte[1024]; int bytesRead; while ((bytesRead = await stream.ReadAsync(recvBuffer, 0, recvBuffer.Length)) > 0) { string received = Encoding.UTF8.GetString(recvBuffer, 0, bytesRead); Console.WriteLine($"收到:{received}"); } Console.WriteLine("连接已断开");

WriteAsync的第三个参数是写入长度,这里就是整个字节数组。ReadAsync返回实际读到的字节数,如果返回 0,说明对方正常关闭了连接。注意Encoding.UTF8.GetString只转换实际读到的部分,不要用整个recvBuffer,否则会带出上一次残留的数据。这个最小闭环跑通后,你就能看到客户端发一句、服务端收一句的效果。但真实项目里不会这么简单,下一章就讲粘包、断线和并发的问题。

3. 核心模块拆解:粘包处理、心跳保活与断线重连

3.1 粘包与半包:长度前缀法的实现

TCP 是字节流,没有消息边界。你发两次 “abc” 和 “def”,对方可能一次收到 “abcdef”,也可能分两次收到 “ab” 和 “cdef”。资源里采用的是长度前缀法:先发 4 个字节表示消息体长度,再发消息体。接收端先读 4 字节,解析出长度,再按长度读满整个消息。

// 发送带长度前缀的消息 async Task SendMessageAsync(NetworkStream stream, string message) { byte[] body = Encoding.UTF8.GetBytes(message); byte[] lengthPrefix = BitConverter.GetBytes(body.Length); // 4 字节长度头 // 先发长度头,再发消息体 await stream.WriteAsync(lengthPrefix, 0, lengthPrefix.Length); await stream.WriteAsync(body, 0, body.Length); } // 接收端:先读 4 字节长度头,再读消息体 async Task<string> ReadMessageAsync(NetworkStream stream) { byte[] lengthBuffer = new byte[4]; int totalRead = 0; // 确保读满 4 字节长度头 while (totalRead < 4) { int read = await stream.ReadAsync(lengthBuffer, totalRead, 4 - totalRead); if (read == 0) throw new IOException("连接已关闭"); totalRead += read; } int bodyLength = BitConverter.ToInt32(lengthBuffer, 0); byte[] bodyBuffer = new byte[bodyLength]; totalRead = 0; // 确保读满整个消息体 while (totalRead < bodyLength) { int read = await stream.ReadAsync(bodyBuffer, totalRead, bodyLength - totalRead); if (read == 0) throw new IOException("连接已关闭"); totalRead += read; } return Encoding.UTF8.GetString(bodyBuffer); }

BitConverter.GetBytes把 int 转成 4 字节数组,默认是小端序,收发两端保持一致即可。两个while循环是关键:ReadAsync可能只返回部分数据,必须循环读到指定长度。如果省略这个循环,就会遇到“半包”问题,表现为消息解析到一半报错。资源里还提醒,长度头本身也可能分两次到达,所以第一个循环同样不能省。

3.2 心跳机制:判断对方是否还活着

TCP 连接建立后,如果网线被拔掉,双方可能都感知不到,直到下一次发送数据才发现异常。资源里加了一个心跳包:客户端每隔 5 秒发一个固定格式的短消息,服务端收到后回复一个确认。如果连续 3 次没收到确认,就判定连接失效,主动关闭并触发重连。

// 心跳发送循环 async Task HeartbeatLoopAsync(NetworkStream stream, CancellationToken token) { while (!token.IsCancellationRequested) { try { await SendMessageAsync(stream, "PING"); await Task.Delay(5000, token); // 每 5 秒一次 } catch (Exception ex) { Console.WriteLine($"心跳失败:{ex.Message}"); break; // 跳出循环,交给外层重连 } } }

Task.Delay传入取消令牌,外部取消时能及时退出。心跳消息本身也走长度前缀协议,所以服务端收到 “PING” 后回复 “PONG”。这里要注意:心跳不能和业务消息共用一个发送锁,否则业务繁忙时心跳被阻塞,会误判掉线。常见做法是给发送操作加一个SemaphoreSlim,保证同一时刻只有一个写入,但心跳和业务消息按优先级排队。

3.3 断线重连:指数退避与状态管理

断线重连不是简单地while(true)里反复ConnectAsync,那样会在服务端没恢复时疯狂重试,把资源耗尽。资源里用的是指数退避:第一次等 1 秒,第二次 2 秒,第三次 4 秒,最多等到 30 秒,并且每次重连成功后重置计数。

// 带指数退避的重连逻辑 async Task ReconnectAsync(TcpClient client, string host, int port) { int retryDelay = 1000; // 初始 1 秒 const int maxDelay = 30000; // 最大 30 秒 while (true) { try { await client.ConnectAsync(host, port); Console.WriteLine("重连成功"); return; // 成功后退出重连循环 } catch (Exception ex) { Console.WriteLine($"重连失败:{ex.Message},{retryDelay / 1000} 秒后重试"); await Task.Delay(retryDelay); retryDelay = Math.Min(retryDelay * 2, maxDelay); // 翻倍,但不超过上限 } } }

Math.Min保证延迟不会无限增长。实际项目里还会加一个最大重试次数,超过后通知上层人工介入。另外,重连前要先把旧的TcpClient释放掉,否则句柄泄漏。资源里建议把连接状态封装成一个枚举:Disconnected、Connecting、Connected、Reconnecting,界面或日志根据状态变化做提示,避免用户看到“假在线”。

4. 避坑与排查:那些让 TCP 连接“假死”的细节

4.1 现象:连接成功但收不到数据,日志停在 ReadAsync

原因通常是接收端没有循环读取,或者读取缓冲区太小导致半包被丢弃。解决方法是按 3.1 的长度前缀法,先读长度头再读消息体,并且每次ReadAsync都检查返回值。如果返回值是 0,说明对方关闭了连接,不要继续循环。

4.2 现象:发送大量数据时程序卡死或内存暴涨

原因是在 UI 线程上同步调用了Write,或者没有做发送队列,导致数据堆积。解决方法是所有收发都走异步,并且给发送加一个Channel或有界队列,超过容量时丢弃或等待。资源里用SemaphoreSlim控制并发写入,避免多个线程同时写同一个流。

4.3 现象:服务端重启后客户端一直连不上

原因是客户端没有处理SocketException,或者重连延迟太短导致端口被占用。解决方法是捕获具体异常码,比如ConnectionRefused时等待后重试,AddressAlreadyInUse时先释放旧连接。指数退避能缓解这个问题,但根本还是要确保TcpClient被正确Dispose。

4.4 现象:心跳正常但业务消息超时

原因是心跳和业务消息共用了同一个发送锁,业务消息阻塞时心跳也发不出去。解决方法是把心跳独立成一个轻量任务,或者用优先级队列让心跳插队。资源里建议心跳消息不参与业务排队,直接走单独的发送通道。

4.5 现象:中文乱码或消息截断

原因是收发两端编码不一致,或者长度头按字符数而不是字节数计算。解决方法是统一用Encoding.UTF8,并且长度头始终是字节数组的长度。如果消息体包含二进制数据,不要用字符串转换,直接传字节数组。

5. 二次开发改造:把示例代码变成可复用的通信组件

5.1 封装成独立类库

示例代码跑通后,下一步是把它抽成一个TcpClientWrapper类,对外暴露ConnectAsync、SendAsync、ReceiveAsync和事件OnDisconnected。这样业务层不需要关心NetworkStream和粘包细节。我一般会把长度前缀的编解码放在一个MessageCodec静态类里,方便单元测试。

public class TcpClientWrapper : IDisposable { private TcpClient _client; private NetworkStream _stream; private CancellationTokenSource _cts; public event Action<string> OnMessageReceived; public event Action OnDisconnected; public async Task ConnectAsync(string host, int port) { _client = new TcpClient(); await _client.ConnectAsync(host, port); _stream = _client.GetStream(); _cts = new CancellationTokenSource(); // 启动接收循环,收到消息后触发事件 _ = Task.Run(() => ReceiveLoopAsync(_cts.Token)); } private async Task ReceiveLoopAsync(CancellationToken token) { try { while (!token.IsCancellationRequested) { string msg = await ReadMessageAsync(_stream); OnMessageReceived?.Invoke(msg); } } catch { OnDisconnected?.Invoke(); } } public void Dispose() { _cts?.Cancel(); _stream?.Dispose(); _client?.Dispose(); } }

事件机制让业务代码解耦,收到消息后直接更新界面或写数据库。注意ReceiveLoopAsync里捕获异常后触发OnDisconnected,外层可以订阅这个事件来启动重连。

5.2 增加配置化与日志

把主机地址、端口、心跳间隔、重连上限放到appsettings.json里,用IConfiguration读取。日志用ILogger接口,方便替换成文件或控制台。资源里没有强制用某个日志框架,但建议至少记录连接、断开、重连、异常四类事件,排查问题时能还原时间线。

5.3 验证方法:用回环测试和压力测试

改造完成后,不要只靠手动点按钮验证。写一个回环测试:启动服务端,客户端连上后连续发送 1000 条随机长度消息,服务端逐条校验内容是否一致。再写一个断线测试:服务端中途关闭,观察客户端是否在预期时间内重连成功。压力测试可以用Parallel.For模拟多客户端并发,检查是否有内存泄漏或线程安全问题。

5.4 一个具体技巧:用SocketAsyncEventArgs替代ReadAsync

如果项目对性能要求高,比如每秒上万条消息,ReadAsync的Task分配会成为瓶颈。这时可以改用SocketAsyncEventArgs做零分配接收。资源里没有展开这部分,但思路是:预先分配一个大的接收缓冲区,用事件回调处理数据到达,避免每次读取都创建Task。改造时先保证功能正确,再逐步替换热点路径。

从那以后我每次写 TCP 通信,都会先把长度前缀和心跳循环跑一遍,再往上叠业务逻辑。这个顺序能省掉很多“连上了却收不到”的返工。希望帮到你。

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

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

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

立即咨询