ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C# TCP/IP服务端与客户端源码:解决粘包、心跳与断线重连实战

C# TCP/IP服务端与客户端源码:解决粘包、心跳与断线重连实战 简介一套C#编写的TCP/IP服务端与客户端源码面向网络编程初学者与初中级开发者。整套实现围绕TcpListener、TcpClient、NetworkStream等核心API展开可帮助理解服务端监听、客户端连接、数据收发以及多客户端并发处理的完整流程。压缩包共273个文件大小约5.94MB以cs源文件为主77个并包含exe可执行程序、dll依赖库、sln工程文件、resx与resources资源文件以及txt说明文档、ico/png/jpg/gif图标图片等结构完整便于直接打开工程查看或编译运行。当前已有69人学习适合作为C#网络编程的入门与进阶参考。通过源码可学习服务端循环监听、客户端连接请求处理、NetworkStream数据交换以及多线程/异步操作、SSL安全传输等实现思路配套的工程文件与可执行程序也让调试和效果验证更加直观能够为后续开发更复杂的网络应用提供扎实基础。1. 你的项目卡在哪儿c#写的tcpip服务端与客户端源码为什么总是一边通一边断你苦苦搜到的“c#写的tcpip服务端与客户端源码”大概率是能跑通、但一换现场就翻车的那种。我在工控上位机上踩过的坑基本都和TCP这个“可靠协议”的错觉有关一边服务端在等数据一边客户端在等回应两边都没有超时最后连接像僵尸一样挂着。适合谁看正在做上位机、设备数据采集、服务端接口测试的C#开发尤其是被“客户端连不上”“数据粘包”“服务端卡死”这三件事困住的人。这篇不谈抽象理论直接拆出一套能复现、能调参、能排错的落地写法。2. 先想清楚架构TcpListener和Socket不是二选一而是分工2.1 从一次“服务端接口测试”翻车说起同步阻塞为什么让UI卡死前阵子帮朋友调一个C#上位机他的服务端用的是TcpListener但接收数据直接丢在UI线程里一收到设备上报就开始卡窗。后来一抓包连接是通的数据也到了就是界面转圈。根子在于AcceptTcpClient()和Read()都是阻塞调用放在UI线程里等于把整个消息循环堵死。常见做法是把监听和接收全部丢到后台线程。但很多人分不清线程和连接的关系一个线程负责accept每个客户端连进来之后必须分配一个独立的接收线程或者异步循环。这不是性能问题是“一个客户端断线不能影响其他客户端”的底线问题。2.2 连接会话模型给每个客户端一个独立的ReceiveLoop我一般会把服务端拆成三层监听器TcpListener只负责Accept不读数据。会话对象每个连接一个TcpClient封装接收循环、发送队列、心跳计时。消息处理器把收到的字节流解析成帧再交给业务逻辑。这样设计之后客户端断开、重连、粘包、重传都聚焦在会话层而不是满屏if判断。伪代码结构如下public class ClientSession { private TcpClient _client; private NetworkStream _stream; private CancellationTokenSource _cts new(); public void Start() { _stream _client.GetStream(); _ Task.Run(ReceiveLoop, _cts.Token); } private async Task ReceiveLoop() { var buffer new byte[4096]; while (!_cts.IsCancellationRequested) { int readBytes await _stream.ReadAsync(buffer, 0, buffer.Length); if (readBytes 0) break; // 连接关闭 OnDataReceived(buffer, readBytes); } } }这个模型里最关键的是ReadAsync返回值等于0时说明对端已经FIN关闭必须主动释放会话资源而不是继续等。很多源码卡死在这Read返回0被当成普通数据继续拼拼到最后缓冲区越界或者死循环。2.3 同步和异步的选型边界别一上来就用async/await给新手的忠告如果你的设备数量少、数据频率低比如每秒几十个点同步线程模型反而更好排查设备一多或者单客户端高频推送才需要async/await。async/await虽然是C#的强项但一旦你的业务逻辑里混入数据库写入异步的上下文切换和锁问题会让你怀疑人生。我做过一个对接Modbus TCP客户端的服务端那设备每100ms推一次寄存器数据刚开始用同步双层循环CPU不高但线程池占用会涨。后来改成异步接收、同步处理业务稳定跑了一周。记住一个原则网络收发的IO用异步业务处理尽量保持同步和串行别在回调里开太多Task.Run。3. 跑通最小闭环服务端监听、客户端连接、按帧收发源码3.1 服务端源码TcpListener 每连接一个线程先给一份能直接跑的服务端。它做的事情是监听9100端口每个客户端连上来之后在独立线程里读数据并把收到的原始字节回显给客户端。using System; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; class TcpEchoServer { static void Main(string[] args) { int port 9100; var listener new TcpListener(IPAddress.Any, port); listener.Start(10); // backlog为10 Console.WriteLine($服务端启动监听端口 {port}); while (true) { TcpClient client listener.AcceptTcpClient(); Console.WriteLine($客户端连入: {client.Client.RemoteEndPoint}); new Thread(() HandleClient(client)).Start(); } } static void HandleClient(TcpClient client) { using (client) using (NetworkStream stream client.GetStream()) { byte[] buffer new byte[4096]; int read; while ((read stream.Read(buffer, 0, buffer.Length)) 0) { string hex BitConverter.ToString(buffer, 0, read); Console.WriteLine($收到 {read} 字节: {hex}); stream.Write(buffer, 0, read); // 回显 } } Console.WriteLine(客户端断开); } }这段代码的逻辑AcceptTcpClient阻塞等待连接每来一个连接就开一个线程处理读写处理完就释放。这里的listener.Start(10)指定连接队列长度超过10个连入请求时内核会拒绝额外的SYN客户端会看到“连接被重置”而不是等待。参数说明缓冲区的4096不是越大越好TCP/IP在以太网环境下的最大分段大小MSS通常是1460字节缓冲区只要不低于MSS就能跑。真正决定收包完整性的是协议而不是缓冲区所以下一节会讲帧格式。3.2 客户端源码TcpClient.Connect / Send / Receive对应的最小客户端代码using System; using System.Net.Sockets; using System.Text; class TcpClientDemo { static void Main(string[] args) { string serverIp args.Length 0 ? args[0] : 127.0.0.1; int port 9100; using var client new TcpClient(); client.Connect(serverIp, port); // 同步连接 Console.WriteLine($已连接 {serverIp}:{port}); NetworkStream stream client.GetStream(); byte[] data Encoding.UTF8.GetBytes(hello tcp server); stream.Write(data, 0, data.Length); byte[] recv new byte[4096]; int n stream.Read(recv, 0, recv.Length); Console.WriteLine($服务端回显: {Encoding.UTF8.GetString(recv, 0, n)}); } }这里有个容易翻车的地方stream.Read只读一次如果服务端把回显分成了两段TCP报文客户端可能只收到前半段。你拿这个代码测试时如果发现回显内容不完整别怪服务端是因为没做“按帧读取”。3.3 启动顺序与验证先服务端后客户端用日志打印首包跑起来之前一定要确认启动顺序。先启动服务端再启动客户端客户端Connect的端口没监听时会抛SocketException提示目标计算机积极拒绝。服务端的输出里应该能看到“客户端连入”客户端看到“已连接”和“服务端回显”。如果看不到优先检查防火墙和端口占用。Windows下用netstat -ano | findstr 9100查看端口有进程监听说明服务端起来过没有就是你程序哪一环节崩了。先拿到这一份能互通的源码再继续加协议和容错。4. 把参数调明白缓冲区、超时、粘包半包这四件事决定稳定性4.1 缓冲区大小和接收缓冲为什么4KB够用64KB也会丢缓冲区大小的取舍和TCP窗口、发送频率有关。我做设备对接时大多数仪表一帧报文不超过256字节所以4KB缓冲区绰绰有余。但有些上位机软件会把Socket.ReceiveBufferSize设成64KB甚至更大反而给了自己一个幻觉觉得缓冲大了就能容纳更多数据却忽略了业务侧的帧解析。实际会丢数据的点在于接收速度慢于发送速度时TCP的接收窗口会逐渐变为0发送端会阻塞但如果此时你手动关闭了连接或者重启接收线程缓冲区里的数据直接没。所以别把缓冲区当成仓库它只是流水线上的暂存箱。4.2 粘包/半包长度前缀帧协议是TCPIP源码里最不能省的这是所有TCPIP源码的生死线。TCP是字节流不是消息流你发送两次Write对端可能一次Read读到所有字节也可能两次才读完一个消息。解决方式是自定义帧协议最常见的是“4字节长度前缀 载荷”。发送端代码public void SendFrame(NetworkStream stream, byte[] payload) { byte[] lengthPrefix BitConverter.GetBytes(payload.Length); if (BitConverter.IsLittleEndian) { // 约定网络字节序大端 Array.Reverse(lengthPrefix); } byte[] frame new byte[4 payload.Length]; Buffer.BlockCopy(lengthPrefix, 0, frame, 0, 4); Buffer.BlockCopy(payload, 0, frame, 4, payload.Length); stream.Write(frame, 0, frame.Length); }接收端做状态解析先读满4字节头解析出载荷长度再读满载荷。如果一次Read只到了2字节必须攒够4字节再取长度值如果一次Read拿到了完整帧还多出8字节多余的字节要缓存到下一帧拼接。这就是“半包”和“粘包”的应对办法。我在实际项目里就用一个简单的MemoryStream累积接收数据循环解析只要缓存区长度大于4就尝试读长度长度合法且缓存足够再取载荷。解析完一帧后把剩余字节前移继续循环直到缓存不足一个帧头为止。4.3 心跳与超时服务端多久没消息判定客户端死掉一个经常被忽略的参数是保活时间。默认情况下TCP连接如果双方都不发数据连接可能因为网络设备空闲超时被断开而双方进程都不知道。所以要在应用层做心跳。心跳设计分两层客户端定时比如每10秒向服务端发一个心跳帧字段可以复用帧类型长度可以为0。服务端记录每个会话的最后活跃时间定时扫描超过30秒没活跃的会话主动Close并释放资源。注意心跳不能和业务数据共用同一个“最后活跃时间”我建议不共用。有些设备业务数据稀疏虽然有数据流动但无法证明对端还活着所以心跳帧独立标记。扫描间隔设为心跳间隔的三倍避免误杀。超时参数表见下方这是我从现场经验里总结的取值参数建议值说明心跳间隔10s设备频繁连接时用15~30s服务端超时3倍心跳间隔没收到任何数据就断开连接超时3s客户端Connect时防止长时间卡死接收缓冲4096字节一帧不大于256字节时足够SendTimeout/ReceiveTimeout5s防止流读写无限阻塞客户端连接超时设置代码client.ConnectAsync(host, port).Wait(TimeSpan.FromSeconds(3)); if (!client.Connected) { throw new TimeoutException(连接超时); }注意ConnectAsync().Wait()在UI线程会阻塞但至少有个超时保护。生产环境建议用await Task.WhenAny(connectAsync, Task.Delay(3000))。5. 避坑手记5条被客户现场反复蹂躏的TCPIP通信坑5.1 现象连接一会儿就断开客户端报“现有连接被远程主机强制关闭”原因多半不是服务端主动杀你而是服务端程序里Read返回0后没有正确处理就释放了资源。比如客户端断开后服务端循环读到一个0没跳出循环继续读抛了异常异常没捕获线程挂掉。或者反过来服务端一个会话崩溃主监听虽然还在但客户端对应的连接被RST。解决这种问题首先统一HandleClient里的异常出口把会话生命周期放进try-catch-finally其次断网时不一定能收到Read0要配合心跳。客户端遇到这个错误时要做自动重连并且重连前Dispose掉旧TcpClient。5.2 现象服务端收到一堆乱码字符串被截断或合并在一起这基本上是粘包/半包没处理。有人图省事直接按Read返回的字节数转字符串结果在分帧边界上全乱了。原因不是TCP丢数据而是TCP不保证消息边界。解决方案就是统一框架所有发送出去的报文必须走同一个SendFrame方法所有接收方必须走同一个FrameParser解析。我见过一个偷懒的写法发送端用StreamWriter.WriteLine加换行符接收端用ReadLine。这个方案在文本协议里能用但遇到二进制数据会翻车而且如果数据里本来就有换行符直接错位。5.3 现象程序一收发数据UI就卡死点击窗口转圈原因非常简单你在UI线程里调用了Read或者Connect。很多教程示例代码是控制台程序没有UI线程问题但搬到WinForm或WPF就出事。解决办法是把所有阻塞网络操作放到后台线程或者用awaitConfigureAwait(false)。但新手用了async后还是会卡原因是用Control.Invoke做跨线程更新UI时UI线程正被网络操作阻塞形成了一个互相等待的僵尸锁。所以我写上位机时网络线程只负责收发和解析把解析后的对象通过Queue丢给UI线程定时取避免频繁Invoke。5.4 现象同一台机器测试没问题跨机器就连接超时原因主要是防火墙。Windows默认会拦截外部IP对TCP端口的访问。解决不一定是关防火墙而是加一条入站规则开放指定端口或者把程序加入防火墙例外列表。测试跨机器时用物理IP而不是127.0.0.1并且确认两台机器在同一网段或者路由可达。还有一个容易被忽略的坑服务端绑定IPAddress.Loopback127.0.0.1外部机器当然连不上。需要绑定IPAddress.Any或内网IP。5.5 现象服务端重启后客户端连不上必须重启客户端原因是服务端进程刚启动端口处于TIME_WAIT或者未完全释放。TCP连接关闭后主动断开方会进入TIME_WAIT状态默认等待2分钟4分钟取决于系统配置如果服务端重启时有个旧连接未完全释放TcpListener.Start会报“地址已在使用中”。解决方法是设置ReuseAddress选项listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);但这必须在Start之前设置。如果客户端那边一直连着服务端重启后客户端的旧连接就断了需要客户端写重连机制不能指望用户手动重启客户端。6. 进阶验证用PowerShell和抓包工具检验你的源码是否耐打6.1 用PowerShell造一个最小客户端做服务端接口测试很多时候手边没写完C#客户端或想快速验证服务端可以用PowerShell直接发TCP数据。这是一个不依赖自己客户端源码的测试手段$tcp New-Object Net.Sockets.TcpClient $tcp.Connect(127.0.0.1, 9100) $stream $tcp.GetStream() $bytes [Text.Encoding]::UTF8.GetBytes(test frame) $stream.Write($bytes, 0, $bytes.Length) $buffer New-Object byte[] 4096 $n $stream.Read($buffer, 0, $buffer.Length) [Text.Encoding]::UTF8.GetString($buffer, 0, $n) $tcp.Close()这个脚本能验证服务端的监听和回显但如果你的服务端用的是长度前缀帧协议PowerShell这边也要按同样协议组帧。我一般把这个脚本保存成tcp_test.ps1配合服务端日志做接口用例。6.2 抓包看三次握手——验证你的源码真的走了TCPIP协议栈别相信“能互相发数据就是没问题”。要确认连接建立、数据交互的细节用Wireshark抓包过滤表达式写tcp.port 9100。你能看到三次握手、PSH标志、ACK确认。如果服务端程序在数据到达前就回了ACK说明内核已经接收如果客户端发一个数据包服务端回了多个数据段那是正常分段不是丢包。抓包时最值得关注的一个细节你的帧协议分界是否和TCP报文段一致。不要指望TCP按你的Write次数分包Wireshark里出现的相邻数据段可能正好是同一个业务帧。这时就能验证长度前缀会不会被半包卡住。6.3 断线重连与序列号去重给客户端装个“后悔药”最后加一个客户端重连的骨架这也是生产环境“能自动恢复”的关键。private static async Task RunWithRetryAsync(string ip, int port, CancellationToken ct) { while (!ct.IsCancellationRequested) { try { using var client new TcpClient(); await client.ConnectAsync(ip, port).WaitAsync(TimeSpan.FromSeconds(3)); await HandleConnectionAsync(client, ct); } catch (Exception ex) when (ex is SocketException or OperationCanceledException) { Console.WriteLine($连接失败: {ex.Message}5秒后重试); await Task.Delay(5000, ct); } } }注意重连时携带身份信息服务端靠身份识别是“正常重连”还是“业务重发”。我把上行帧里加了一个递增序列号服务端记录最近收到的序号重复的帧直接丢弃。这个方法极其朴素但能挡住TCP重传带来的业务重复处理避免把非法报文写进数据库。这也是我多年现场调试最管用的一招网络层不用哲学要给客户端留后悔药。希望这篇源码级拆解能帮你在C#的TCPIP服务端和客户端之间少走几个来回把现场的“偶发断连”变成可复现、可解释、可处理的工程问题。本文还有配套的精品资源点击获取
返回列表