ARTICLE DETAIL

资讯详情

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

C#手写TCP/IP服务端与客户端源码详解:从Socket到心跳与断线重连

C#手写TCP/IP服务端与客户端源码详解:从Socket到心跳与断线重连 简介一套基于C#编写的TCP/IP服务端与客户端通信源码面向网络编程初学者、C#开发者及需要快速搭建Socket通信原型的学习者。资料围绕服务端监听、客户端连接、NetworkStream数据收发、多线程并发处理等核心环节展开源码中保留了TcpListener与TcpClient的典型用法并涉及错误处理、资源释放等工程细节适合对照代码理解TCP/IP通信流程。压缩包共273个文件约5.94MB以77个cs源码文件为骨架辅以csproj/sln工程文件、exe可执行程序、dll类库、txt说明文档、XML配置以及图片图标资源可视作一套完整的Visual Studio解决方案。这套源码已有69人学习在课程设计、毕业设计或企业内部通信模块开发中具有一定参考价值可直接运行调试也便于二次修改扩展。1. 为什么要手写“c#写的tcpip服务端与客户端源码”框架帮不了你的场景不少C#开发者一听到“手写TCP/IP服务端与客户端源码”第一反应是现在SignalR、Grpc、甚至HTTP都能通信何必回到Socket层面自己造轮子。但真到了c#上位机对接PLC、嵌入式设备或者强制走私有报文协议的政企项目里你会发现通用框架要么协议太重要么没法控制字节流细节最后还是要回到TCP/IP这一层自己收发。这篇文章就是把一套能直接跑通的C# TCP/IP服务端与客户端源码拆开讲从监听、接收、封包拆包到断线重连与心跳每一步给出可复现代码和参数说明并把实际项目中必踩的坑一并列出来。适合需要掌握底层通信的开发者照着做也适合刚开始接触Socket的新手按步骤复现。2. TCP通信的底层逻辑与C#选型先搞清楚再动手2.1 Socket、TcpListener与TcpClient三套API怎么选C#里做TCP通信有三层API可以用选错层级会直接影响后续代码的复杂度和可控性。API封装层次适用场景需要自己处理的事Socket原生套接字最底层网关、高性能转发、自定义协议栈协议类型、地址族、异步模型全部自己管TcpListener / TcpClient对Socket的TCP模式封装绝大多数业务服务端与客户端监听、连接、收发边界处理仍需自己做NetworkStream在TcpClient之上的流式读写与TcpClient搭配使用读写循环、缓冲管理我一般会这样选服务端用TcpListener接受连接连接建立后通过GetStream()拿NetworkStream做读写客户端直接用TcpClient它内部已经把Connect和底层Socket管理好了。这种组合代码量最少又能保留对字节流的完全控制做c#上位机与设备通信时最常用。只有确认了性能瓶颈在Socket层时我才会把TcpClient换成原生Socket SocketAsyncEventArgs但那属于另一个量级的优化前期不建议碰。选择TcpListener/TcpClient还有一个现实原因System.Net.Sockets是.NET内置程序集不需要从NuGet安装任何第三方包这意味着源码拷到哪台机器都能编译不会因为包源问题翻车。2.2 TCP的流边界问题黏包与半包为什么必然发生TCP是流协议不是消息协议。这句话听起来像教科书概念但它是整个手写服务端与客户端源码过程中最核心的认知理解了它后续封包拆包代码才有依据。发送端调用WriteAsync把字节交给操作系统接收端ReadAsync读到的只是字节流TCP本身不保证一次Write对应一次Read。实际表现有两种发送端连续写了多条消息接收端可能一次Read就把它们全部读出来这叫黏包发送端写了一条大消息接收端一次Read可能只读到其中一部分这叫半包。举个例子// 发送端连续写入两条消息 byte[] msg1 Encoding.UTF8.GetBytes(hello); byte[] msg2 Encoding.UTF8.GetBytes(world); await stream.WriteAsync(msg1, 0, msg1.Length); await stream.WriteAsync(msg2, 0, msg2.Length);接收端如果用一个4KB的buffer去Read很可能一次就同时读到“hello”和“world”的10个字节也可能是先读到“hell”再加后续的“oworld”。Nagle算法会把小包合并后再发MTU分片则会把大包拆成多个段这两个机制叠加导致消息边界必然被打乱。所以手写TCP服务端与客户端时协议设计的第一步不是定义消息里有哪些字段而是定义“消息边界怎么标出来”。常见做法是自定义一个报文帧开头固定4字节表示正文长度后面跟着正文。拆包时先读够4字节再按长度读正文。这个思路贯穿下面第二章到第四章的全部代码。2.3 本地回环测试环境准备两条命令拉起最小工程在动手写服务端前先准备一套最小测试环境。用.NET SDK自带命令创建两个控制台工程一个当服务端、一个当客户端dotnet new console -n TcpDemo.Server -o Server dotnet new console -n TcpDemo.Client -o Client参数说明-n指定项目名-o指定输出目录。创建后两个工程里各有一个Program.cs默认是打印Hello World的模板我们后面会替换成真正的TCP代码。这两个工程不需要任何额外NuGet包因为Socket相关类型都在系统命名空间里你只需要在代码顶部写using System.Net.Sockets和using System.Net。测试端口我建议用9000到10000之间的端口避开常见的MySQL 3306、Redis 6379等容易被本地服务占用的端口。本地回环调试时客户端连接地址填127.0.0.1即可如果想验证服务端是不是真的在监听可以直接用系统自带命令telnet 127.0.0.1 9000如果服务端已经Starttelnet会进入一个黑窗口表示端口可达如果连接被拒绝会立即返回错误。注意telnet只能验证端口通不通它不会帮你做业务收发真正的协议测试还是要靠我们写的客户端源码。3. 手写完整服务端源码从Accept到业务处理的骨架3.1 异步监听与连接接受主循环不阻塞服务端的核心是一个死循环不断调用AcceptTcpClientAsync等待新连接拿到连接后立即丢到后台任务里处理循环本身立刻回到Accept状态。这样新连接到来时不会因为上一个连接的业务逻辑还没跑完而被晾在一边。using System.Collections.Concurrent; using System.Net; using System.Net.Sockets; var listener new TcpListener(IPAddress.Any, 9000); listener.Start(); Console.WriteLine($[服务端] 启动监听 {listener.LocalEndpoint}); var clients new ConcurrentDictionarystring, TcpClient(); while (true) { TcpClient client await listener.AcceptTcpClientAsync(); string clientId Guid.NewGuid().ToString(N); clients[clientId] client; Console.WriteLine($[服务端] {clientId} 接入当前连接数 {clients.Count}); _ HandleClientAsync(clientId, client, clients); }逻辑说明IPAddress.Any表示监听本机所有网卡地址这样局域网内其他机器也能连而不能只填127.0.0.1只允许本机访问。clients是一个ConcurrentDictionary用连接ID作为键便于后续做连接管理和强制断开。代码里_ HandleClientAsync(...)是关键写法它表示“把这个异步任务交给后台执行不await它”这样主循环才能第一时间回去Accept下一个连接。参数说明9000是监听端口可以按项目需要改Buffer大小和端口号是两个最常调的参数后者在联调时尤其要确认两边一致端口不一致是客户端连不上服务端最朴素的原因之一。3.2 接收循环与数据分发Read返回0说明对端关闭HandleClientAsync是每个连接的独立生命周期。它做的事情很简单循环Read读到数据就处理读不到就退出循环。static async Task HandleClientAsync(string clientId, TcpClient client, ConcurrentDictionarystring, TcpClient clients) { NetworkStream stream null; try { stream client.GetStream(); byte[] buffer new byte[4096]; while (true) { int read await stream.ReadAsync(buffer, 0, buffer.Length); if (read 0) { Console.WriteLine($[服务端] {clientId} 主动断开); break; } byte[] chunk buffer.AsSpan(0, read).ToArray(); Console.WriteLine($[服务端] 收到 {clientId}: {BitConverter.ToString(chunk)}); } } catch (Exception ex) { Console.WriteLine($[服务端] {clientId} 异常: {ex.Message}); } finally { client.Close(); stream?.Dispose(); clients.TryRemove(clientId, out _); Console.WriteLine($[服务端] 已清理 {clientId}剩余连接 {clients.Count}); } }逻辑说明read 0是TCP协议里“对端优雅关闭”的信号。当客户端调用了Close或者进程正常退出时服务端Read会返回0这代表可以结束循环了。如果客户端是拔网线、断电这种异常消失Read不会返回0而是会抛异常所以catch里也要做清理。finally块负责在两种退出路径上统一释放连接和移除连接记录这是新手写Socket最容易漏掉的地方。参数说明buffer长度4096是单次Read的最大读取字节数它不代表消息长度限制。TCP是流一次Read读到的可能是半条消息也可能是多条消息所以这个值只是“每次最多取多少字节出来处理”真正的消息边界要靠后续的报文协议来切分而不是依赖这个buffer大小。3.3 连接生命周期管理Shutdown与Close不要混用上面代码里用了client.Close()但在服务端主动断开连接时建议先Shutdown再Close。static void ForceClose(TcpClient client) { try { client.Client.Shutdown(SocketShutdown.Both); } catch (Exception) { // 对端可能已经断开Shutdown会抛异常忽略即可 } client.Close(); }逻辑说明Shutdown(SocketShutdown.Both)会向对端发送一个FIN告诉对方“我不再发送也不再接收数据了”这是一个优雅的关闭过程。Close则是立即释放当前进程持有的Socket资源不会主动通知对端。顺序应该是先Shutdown、再Close否则对端可能还傻等数据直到超时才意识到连接断了。这个细节很容易被忽略但在c#线程和连接数多的服务端里资源释放不干净会直接表现为句柄数上涨。用我们上面的ConcurrentDictionary存连接时还有两个操作值得注意第一服务端要遍历所有连接主动断开时直接对每个client调用ForceClose第二客户端断开后服务端一定要在finally里调用TryRemove把连接从字典里拿掉否则字典只增不减最后内存和句柄都会被拖垮。into a service, you need to turn your single demo into something production-ready.4. 客户端源码与报文协议光能连通不算完成4.1 客户端连接与断线重连重试要用指数退避不要死循环客户端的起步代码比服务端简单创建一个TcpClient调用ConnectAsync连上去然后拿NetworkStream收发数据。但现实中客户端一定会遇到服务端重启、网络闪断所以连接逻辑要加重试。static async TaskTcpClient ConnectWithRetryAsync(string host, int port, int maxRetries 5) { for (int attempt 1; attempt maxRetries; attempt) { try { var client new TcpClient(); await client.ConnectAsync(host, port); Console.WriteLine($[客户端] 第 {attempt} 次尝试连接成功); return client; } catch (SocketException ex) { Console.WriteLine($[客户端] 第 {attempt} 次连接失败: {ex.Message}); // 指数退避1秒、2秒、4秒、8秒封顶8秒 int delay Math.Min(8, (int)Math.Pow(2, attempt - 1)); await Task.Delay(TimeSpan.FromSeconds(delay)); } } throw new SocketException((int)SocketError.TimedOut); }逻辑说明ConnectAsync的host参数既可以传“127.0.0.1”这样的IP字符串也可以传“my-server.com”这样的域名内部会做DNS解析。重试不能写成while true的紧凑循环否则服务端还没起来客户端已经用高频连接把本机端口和CPU都打满了。指数退避的意思是第1次失败等1秒第2次等2秒第3次等4秒到第5次等8秒封顶给服务端留出恢复时间。参数说明maxRetries设为5只是保守值。如果是c#上位机这类需要长时间值守的程序我更倾向于把重试上限去掉改成“无限重试但间隔逐步加到30秒封顶”这样服务端半夜重启时客户端能在几分钟内自动恢复。4.2 自定义报文帧封包与拆包的完整写法这是整篇源码里最核心的一段它直接解决第二章说的黏包半包问题。协议格式很简单4字节的正文长度 正文。发送端在写入原始数据前先封一层接收端每读到一段数据就追加到缓冲区然后不停尝试从缓冲区里拆出完整帧。static byte[] PackFrame(byte[] payload) { byte[] header BitConverter.GetBytes(payload.Length); // 4字节长度 byte[] frame new byte[header.Length payload.Length]; Buffer.BlockCopy(header, 0, frame, 0, header.Length); Buffer.BlockCopy(payload, 0, frame, header.Length, payload.Length); return frame; }逻辑说明BitConverter.GetBytes(Int32)在.NET Core及之后版本上固定使用小端字节序所以服务端和客户端只要都是.NET就不需要手动处理字节序。Buffer.BlockCopy是高效的内存拷贝比循环赋值快适合在收发热点路径上用。拆包是更复杂的一端因为接收到的数据不知道是不是刚好凑够一帧class FrameDecoder { private readonly MemoryStream _buffer new MemoryStream(); public void Append(byte[] data) { _buffer.Write(data, 0, data.Length); } public bool TryDecode(out byte[] frame) { frame null; byte[] buff _buffer.GetBuffer(); int readable (int)_buffer.Length; if (readable 4) return false; // 连长度头都没凑齐 int frameLen BitConverter.ToInt32(buff, 0); if (frameLen 0 || frameLen 64 * 1024) { // 长度异常说明协议被破坏或对端是乱写的重置缓冲 _buffer.SetLength(0); return false; } if (readable 4 frameLen) return false; // 正文还没到齐继续等 frame new byte[frameLen]; Array.Copy(buff, 4, frame, 0, frameLen); // 把已拆出的部分从缓冲区里移除 int restLength readable - 4 - frameLen; byte[] rest new byte[restLength]; Array.Copy(buff, 4 frameLen, rest, 0, restLength); _buffer.SetLength(0); _buffer.Write(rest, 0, rest.Length); return true; } }逻辑说明TryDecode的核心是“能拆就拆拆不了就攒着”。Append把每次Read到的字节追加到MemoryStreamTryDecode先判断长度头是否凑齐再判断正文是否到齐。如果一次Append里包含了三条完整帧这个while循环会连续拆出三条如果一条帧只收到一半TryDecode返回false程序继续等下一段数据。frameLen上限设计成64KB是为了防止对端发来一个巨大的恶意长度值导致程序尝试分配几百MB内存直接OOM。在使用时接收循环要配合TryDecode反复拆解var decoder new FrameDecoder(); byte[] buffer new byte[4096]; while (true) { int read await stream.ReadAsync(buffer, 0, buffer.Length); if (read 0) break; decoder.Append(buffer.AsSpan(0, read).ToArray()); while (decoder.TryDecode(out byte[] frame)) { Console.WriteLine($[客户端] 完整帧: {Encoding.UTF8.GetString(frame)}); } }这个模式是整个源码可复现的关键我建议直接照抄。它能同时应对黏包和半包不需要在Read循环里自己去判断消息长度逻辑上大大降低了排查难度。4.3 心跳机制TCP keepalive救不了你很多新手写完收发就觉得大功告成结果设备放在现场第二天服务端发现客户端已经掉线但连接对象还活着。这是因为TCP的keepalive默认要等数小时才探测一次而且系统级超时不可控不适合作为业务层的断线依据。正确做法是业务层心跳客户端每隔10秒发送一个约定好的心跳帧服务端记录每个连接的最后活动时间超过30秒没有收到任何帧就判定超时并主动断开。static async Task HeartbeatLoopAsync(NetworkStream stream, CancellationToken ct) { byte[] heartbeat PackFrame(new byte[] { 0x01 }); // 约定0x01为心跳 while (!ct.IsCancellationRequested) { await stream.WriteAsync(heartbeat, 0, heartbeat.Length, ct); await Task.Delay(TimeSpan.FromSeconds(10), ct); } }服务端这边要同步做超时检测最简单的方式是启动一个定时任务定期扫描连接字典static void CheckTimeout(ConcurrentDictionarystring, (TcpClient Client, DateTime LastActive) clients, int timeoutSeconds) { DateTime now DateTime.UtcNow; foreach (var pair in clients) { if ((now - pair.Value.LastActive).TotalSeconds timeoutSeconds) { Console.WriteLine($[服务端] {pair.Key} 心跳超时强制断开); pair.Value.Client.Close(); } } }逻辑说明心跳帧也走PackFrame封包这样服务端拆包逻辑不需要单独区分帧类型只需要在拆出完整帧后判断内容是0x01还是业务数据。LastActive在每次读到完整帧时更新为当前UTC时间注意用UtcNow而不是Now避免部署环境时区不一致导致超时判断偏差。参数说明心跳间隔10秒、超时30秒是一组比较保守的组合适合局域网环境。如果是跨公网通信建议把心跳间隔缩到5秒、超时放到15秒因为公网丢包率高太长的超时会让服务端等很久才感知到设备掉线。5. 服务端与客户端联调排坑5个必踩的坑与对应解法5.1 现象连上后发一次数据客户端立刻报“远程主机强迫关闭了一个现有的连接”这个报错几乎每个写过TCP的人都会遇到一次。现象是客户端连接成功第一次发数据没问题第二条数据发出去就抛异常。原因分两类一是服务端在收到第一条数据后处理逻辑抛了异常导致HandleClientAsync直接退出但没有finally释放连接客户端还在写数据时被服务端那边的RST包打断二是服务端Read返回0后break退出客户端不知道连接已断继续Write返回错误。解决方式是最小但完整的服务端骨架就是第三章的HandleClientAsync原样跑起来。关键点有三个业务处理必须包在try里Read返回0必须breakfinally里必须Close。如果你发现服务端控制台没有打任何日志但客户端还是报错那大概率是服务端进程直接被kill了这时候客户端应该做指数退避重连而不是直接崩溃。5.2 现象两条消息粘在一起数据长度对不上如果你没有做报文帧直接按Read的字节数当消息长度去解析一定会在连续发数据时出现第一条消息长度变长第二条消息开头是“hello”的残留尾巴。原因就是第二章说的黏包接收端一次Read读到多个Write的数据或者一个Write的数据被拆到多次Read。解决方式没有第二条路必须定义消息边界。用上面4.2节的FrameDecoder替换原始的裸Read循环然后每次把decoder.Append的结果包在while TryDecode里。我见过有人试图用Thread.Sleep让两条数据分得更开这属于玄学TCP不保证时序切分睡再久也可能黏包尽早换用帧协议才是靠谱做法。5.3 现象服务端重启时立刻报“地址已被占用”CtrlC停掉服务端马上重新启动抛SocketException“通常每个套接字地址协议/网络地址/端口只允许使用一次”。原因是TCP协议本身的TIME_WAIT状态。主动关闭连接的一方会进入TIME_WAIT并持续约2倍MSL时间Linux默认60秒左右期间这个四元组不能被立刻复用。服务端重启太快操作系统还没释放上一个监听套接字。解决方式是在listener.Start()之前设置端口复用选项var listener new TcpListener(IPAddress.Any, 9000); listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); listener.Start();注意设置必须在Start之前否则不生效。这个选项允许监听端口在TIME_WAIT未结束前重新绑定开发期非常有用省去了每次重启等一分钟的尴尬。5.4 现象局域网能连跨网段或跨机器就超时本机用127.0.0.1测没问题同一台机器的局域网IP却连不上或者从另一台机器连超时。原因基本是三个第一服务端绑定了IPAddress.Loopback而不是Any这样只会监听127.0.0.1外部IP根本进不来第二Windows防火墙或Linux防火墙默认拦截入站TCP第三如果部署在云服务器上还有云安全组那一层要放通端口。解决方式第一步是确认监听地址启动日志里已经打了listener.LocalEndpoint如果是127.0.0.1:9000就是绑定错了改成IPAddress.Any。第二步是放通防火墙端口# Linux 使用 ufw sudo ufw allow 9000/tcp # Windows 需要管理员权限 netsh advfirewall firewall add rule nameTcpDemo dirin actionallow protocolTCP localport9000做完这一步再做“服务端接口测试”顺序应该是本机telnet、局域网机器telnet、跨网段telnet逐层定位卡在哪一跳。这里最容易翻车的是只放通了服务器防火墙但漏了云安全组导致跨网段还是不通。5.5 现象服务端内存持续上涨连接数没变内存上涨但连接数稳定说明问题不在连接泄漏而在数据缓冲区或业务处理队列没被消费。最常见的情况是FrameDecoder的MemoryStream只Append不TryDecode或者TryDecode里length字段解析失败导致缓冲被越撑越大。另一个常见场景是业务处理用Task.Run丢给线程池消费者太少数据在无限队列里堆积。解决方式有三层第一层FrameDecoder里要加上文那个frameLen 64 * 1024的合法性校验非法长度直接重置缓冲而不是继续攒第二层接收循环务必“边拆边处理”不要先攒够一批再统一处理第三层如果业务处理确实比网络接收慢就要引入背压System.Threading.Channels.Channelbyte[] channel System.Threading.Channels.Channel.CreateBoundedbyte[]( new BoundedChannelOptions(1000) { FullMode BoundedChannelFullMode.Wait });逻辑说明Channel.CreateBounded创建的是一个容量上限1000的队列当队列满时FullMode设为Wait负责写数据的接收循环会停下来等待消费者取走元素从源头限制内存增长。这是把单机Demo推向可用框架时最值得做的一个改造能避免服务端在业务峰值时无声无息地OOM。6. 从Demo到可用框架线程模型、背压与可观测性6.1 三步把最小Demo改造成能上线的通信框架前三章的代码能跑通但从开发环境到生产环境还差三个关键改造线程模型、背压、可观测性。线程模型方面现有代码用async/awaitIO等待期间不占线程这已经足够应对几千个长连接。但要注意如果收到数据后要做CPU密集型处理比如解析大JSON、图像识别直接await会阻塞当前处理流应该用Task.Run把密集计算丢到线程池避免阻塞后续帧的拆包。c#线程的默认线程池最小值是环境处理器数量业务任务过多时线程池会动态扩容你只需要确保没有无限提交任务即可。背压就是5.5节那个Channel改造接收循环只负责接收和拆包拆出来的完整帧写入Channel消费者从Channel里取帧做业务处理。这里有个取舍BoundedChannelFullMode.Wait会降低吞吐但能保证服务端不被瞬时流量冲垮如果业务允许丢弃非关键数据也可以用DropOldest腾出空间给新帧。可观测性是生产环境最容易欠的债。最小可行方案是在三个位置打日志连接建立与断开时打连接ID和当前连接数每收到一帧打帧长度和帧类型每次异常打堆栈和客户端ID。再用一个计数器记录总收帧数和总发帧数var receivedCounter new System.Diagnostics.PerformanceCounter(TCP Demo, Frames Received, false);如果不想引入外部监控系统至少把计数器和日志一起输出到控制台做压测时能看到收帧数是否持续增长。没有这个基础线上出问题时你只能对着黑匣子猜。验证方法上我建议写一个简单的并发压测脚本模拟100个客户端同时连接、各发100条消息观察服务端的连接数、内存占用和收帧计数是否匹配var tasks Enumerable.Range(0, 100).Select(async i { var client new TcpClient(); await client.ConnectAsync(127.0.0.1, 9000); byte[] msg PackFrame(Encoding.UTF8.GetBytes($msg-{i})); await client.GetStream().WriteAsync(msg, 0, msg.Length); client.Close(); }); await Task.WhenAll(tasks);这套压测代码能快速暴露两个问题服务端是否会因为并发连接而崩溃、连接清理是否及时。我做过一次网关项目当时只在本机单连接测了三次就上了测试环境结果压测时发现客户端把服务端的连接字典撑到了5万内存直接被拉爆那就是背压缺失和连接清理不及时叠加导致的翻车。从那以后我定了个习惯任何TCP服务端代码合并代码前必须先跑并发压测再忙也要至少验证一次。希望帮到你。本文还有配套的精品资源点击获取
返回列表