C# TCP/IP 服务端与客户端源码实战:从监听、粘包到断线重连

发布时间:2026/10/10 17:23:11
C# TCP/IP 服务端与客户端源码实战:从监听、粘包到断线重连
简介这份源码资源面向具备一定 C# 基础、希望深入理解网络通信机制的开发者与计算机专业学生围绕 TCP/IP 协议给出服务端与客户端的完整实现范例。内容基于 .NET 的 System.Net 与 System.Net.Sockets 命名空间涵盖 TcpListener 侦听端口、TcpClient 建立连接、NetworkStream 收发数据等核心流程并延伸至多客户端并发、多线程与 async/await 异步编程、SslStream 加密传输及错误处理等进阶主题可作为网络编程课程实验或项目原型的参考。压缩包共 273 个文件约 5.94MB以 77 个 cs 源码文件为主体辅以 csproj、sln 工程文件、resx 资源、exe 与 dll 编译产物及 txt 说明工程结构完整可直接在 Visual Studio 中打开调试。目前已有 69 人学习适合对照源码梳理通信流程、理解连接管理与资源释放思路。1. 从一份 C# TCP/IP 源码说起服务端与客户端到底该怎么写很多人第一次拿到「C# 写的 TCP/IP 服务端与客户端源码」这类东西是在做上位机、设备网关或者内部工具的时候。需求很朴素一台机器监听端口另一台机器连上来双方收发字节流最好还能同时带几十上百个连接。真动手才发现网上抄来的示例要么只能一对一要么粘包粘到怀疑人生要么客户端一断线服务端就抛异常。这篇笔记就围绕这份源码该有的骨架把服务端和客户端从监听、连接、收发到断线重连整条链路拆开讲清楚顺带把 C# 里TcpListener、TcpClient、NetworkStream这几个类的边界和参数说明白。适合正在写 C# 上位机、做服务端接口测试、或者需要自己搭一套轻量通信层的同学新手能照着跑通熟手能对照检查自己的实现有没有埋雷。2. 服务端骨架TcpListener 监听、AcceptTcpClient 与并发模型怎么选2.1 为什么服务端不能只写一个 Accept 循环最朴素的写法是listener.AcceptTcpClient()拿到一个连接处理完再 Accept 下一个。这种写法在只有一个客户端时没问题一旦第二个客户端连上来它只能排队等着前一个不断开就永远轮不到它。TCP/IP 服务端的本质是「一个监听端口 N 个独立会话」每个会话有自己的收发节奏所以必须把 Accept 和会话处理拆开。C# 里常见的三种并发模型模型写法适用场景代价同步阻塞 每连接一线程new Thread(HandleClient)连接数几十以内逻辑简单线程多上下文切换开销大异步回调BeginAcceptTcpClient老项目兼容回调嵌套难维护async/awaitAcceptTcpClientAsync现代项目首选需要理解异步语义我一般直接上async/await代码线性、异常好捕获连接数几百也没压力。下面是最小可运行的服务端骨架。using System; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; class TcpServer { private readonly TcpListener _listener; private readonly CancellationTokenSource _cts new CancellationTokenSource(); public TcpServer(int port) { // IPAddress.Any 表示监听本机所有网卡端口由外部传入 _listener new TcpListener(IPAddress.Any, port); } public async Task StartAsync() { _listener.Start(); Console.WriteLine($服务端已启动监听端口 {((IPEndPoint)_listener.LocalEndpoint).Port}); while (!_cts.IsCancellationRequested) { // AcceptTcpClientAsync 不会阻塞线程连接到来时才继续 TcpClient client await _listener.AcceptTcpClientAsync(); // 每个连接丢到独立任务里不阻塞 Accept 循环 _ HandleClientAsync(client, _cts.Token); } } private async Task HandleClientAsync(TcpClient client, CancellationToken token) { string remote client.Client.RemoteEndPoint?.ToString() ?? unknown; Console.WriteLine($客户端接入: {remote}); try { using (client) using (NetworkStream stream client.GetStream()) { byte[] buffer new byte[4096]; while (!token.IsCancellationRequested) { // ReadAsync 返回 0 表示对端正常关闭 int read await stream.ReadAsync(buffer, 0, buffer.Length, token); if (read 0) break; string text Encoding.UTF8.GetString(buffer, 0, read); Console.WriteLine($[{remote}] 收到: {text}); // 原样回显实际项目里换成业务处理 byte[] echo Encoding.UTF8.GetBytes($echo:{text}); await stream.WriteAsync(echo, 0, echo.Length, token); } } } catch (Exception ex) { // 客户端强制断开时这里会抛 IOException属正常现象 Console.WriteLine($连接 {remote} 异常结束: {ex.Message}); } finally { Console.WriteLine($客户端断开: {remote}); } } public void Stop() { _cts.Cancel(); _listener.Stop(); } }逻辑说明StartAsync里只做一件事——不断接受新连接把每个连接交给HandleClientAsync。HandleClientAsync用using保证TcpClient和NetworkStream一定释放ReadAsync返回 0 是判断对端关闭的标准做法不要用client.Connected去判断那个属性经常骗人。参数说明buffer大小 4096 是经验值太小会频繁触发读太大浪费内存IPAddress.Any换成IPAddress.Loopback就只允许本机连AcceptTcpClientAsync没有超时参数要控制接入速率得自己在外面加信号量。2.2 端口、 backlog 与 KeepAlive 三个必调参数TcpListener构造之后、Start之前有几个参数值得单独说。第一是端口。0 到 1023 是系统保留端口普通程序别碰1024 到 49151 是注册端口自己用选 8000 以上比较稳。端口被占用时Start()会抛SocketException错误码AddressAlreadyInUse这时候要么换端口要么查是谁占着。第二是 backlog。TcpListener.Start(int backlog)里的 backlog 是「已完成三次握手但还没被 Accept 的队列长度」默认值在不同平台不一样。高并发接入场景下如果 Accept 循环处理慢队列满了新连接会被系统拒绝。我一般显式写Start(512)配合异步 Accept 基本不会丢连接。第三是 KeepAlive。TCP 默认两小时才发一次保活探测对长连接业务来说太久了客户端拔网线服务端可能两小时才知道。可以在连接建立后设置client.Client.SetSocketOption( SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); // 前两个参数是 Windows 特有的单位毫秒 client.Client.IOControl( IOControlCode.KeepAliveValues, BitConverter.GetBytes(1).Concat(BitConverter.GetBytes(10000)) .Concat(BitConverter.GetBytes(5000)).ToArray(), null);这段设置的意思是10 秒没有数据往来就开始探测探测间隔 5 秒。注意IOControl这套在 Linux 上不生效跨平台项目得用应用层心跳代替这也是后面避坑章节要展开的点。3. 客户端实现TcpClient 连接、收发与断线重连的完整写法3.1 连接超时为什么必须自己控制TcpClient.Connect和ConnectAsync默认的超时跟操作系统有关Windows 上没连上的地址可能要等二十秒才返回。做上位机时这个体验是灾难性的——界面卡死二十秒。所以连接必须自己加超时。using System; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; class TcpClientWrapper { private TcpClient _client; private NetworkStream _stream; private readonly string _host; private readonly int _port; public TcpClientWrapper(string host, int port) { _host host; _port port; } public async Taskbool ConnectAsync(int timeoutMs 3000) { _client new TcpClient(); using (var cts new CancellationTokenSource(timeoutMs)) { try { // ConnectAsync 支持 CancellationToken超时会抛 OperationCanceledException await _client.ConnectAsync(_host, _port).WaitAsync(cts.Token); _stream _client.GetStream(); Console.WriteLine(连接成功); return true; } catch (OperationCanceledException) { Console.WriteLine($连接 {_host}:{_port} 超时({timeoutMs}ms)); _client?.Close(); return false; } catch (SocketException ex) { Console.WriteLine($连接失败: {ex.SocketErrorCode}); _client?.Close(); return false; } } } public async Task SendAsync(string message) { if (_stream null) throw new InvalidOperationException(尚未连接); byte[] data Encoding.UTF8.GetBytes(message); await _stream.WriteAsync(data, 0, data.Length); } public async Taskstring ReceiveAsync() { byte[] buffer new byte[4096]; int read await _stream.ReadAsync(buffer, 0, buffer.Length); if (read 0) return null; // 服务端关闭 return Encoding.UTF8.GetString(buffer, 0, read); } public void Close() { _stream?.Close(); _client?.Close(); } }逻辑说明WaitAsync(cts.Token)是 .NET 6 之后给任意 Task 加超时的通用写法比Task.WhenAny干净。ConnectAsync抛SocketException时看SocketErrorCodeConnectionRefused说明服务端没监听HostUnreachable说明网络不通这两个排查方向完全不同。参数说明timeoutMs默认 3000 是内网场景的经验值跨公网可以放到 5000 到 8000buffer同样 4096和服务端保持一致便于对照。3.2 断线重连别用 while(true) 硬怼客户端断线重连最常见的翻车写法是while(true) { try { Connect(); break; } catch { Thread.Sleep(1000); } }。这种写法在服务端长时间不可用时会把 CPU 和日志刷爆而且没有退避服务端刚恢复就被一堆客户端同时冲击。我一般用指数退避加最大间隔public async Task ReconnectLoopAsync(CancellationToken token) { int delay 1000; // 初始 1 秒 const int maxDelay 30000; // 最大 30 秒 while (!token.IsCancellationRequested) { if (await ConnectAsync()) { delay 1000; // 连上后重置退避 return; } Console.WriteLine(${delay}ms 后重试); await Task.Delay(delay, token); // 每次失败翻倍封顶 maxDelay delay Math.Min(delay * 2, maxDelay); } }逻辑说明连上就重置delay保证下次断线还是从 1 秒开始失败就翻倍避免服务端没起来时疯狂重试。token用来在程序退出时中断重连循环不然关窗口时线程还挂着。参数说明初始 1 秒适合内网公网可以放到 2 到 3 秒maxDelay30 秒是平衡「恢复速度」和「服务端压力」的结果再大用户感知就明显了。4. 粘包与拆包TCP 字节流为什么不能当消息用4.1 粘包的本质是 TCP 没有消息边界很多人第一次写 TCP 通信会踩这个坑客户端连续Send(AAA)、Send(BBB)服务端一次Read收到AAABBB或者反过来一条消息被拆成两次收到。这不是 bug是 TCP 的设计——它只保证字节顺序不保证「一次发对应一次收」。解决思路只有一条在应用层自己定边界。常见三种方案方案格式优点缺点固定长度每条消息 N 字节解析简单消息长度受限浪费带宽分隔符消息 \n可读性好消息里不能出现分隔符长度前缀4 字节长度 消息体通用、高效需要处理半包长度前缀是最通用的下面给一个完整的收发封装。// 发送先写 4 字节大端长度再写消息体 public static async Task SendMessageAsync(NetworkStream stream, byte[] payload) { byte[] lengthPrefix BitConverter.GetBytes(payload.Length); if (BitConverter.IsLittleEndian) Array.Reverse(lengthPrefix); // 统一用大端跨平台一致 await stream.WriteAsync(lengthPrefix, 0, 4); await stream.WriteAsync(payload, 0, payload.Length); } // 接收先读满 4 字节长度再按长度读满消息体 public static async Taskbyte[] ReadMessageAsync(NetworkStream stream) { byte[] lengthBuffer await ReadExactAsync(stream, 4); if (lengthBuffer null) return null; // 对端关闭 if (BitConverter.IsLittleEndian) Array.Reverse(lengthBuffer); int length BitConverter.ToInt32(lengthBuffer, 0); if (length 0 || length 10 * 1024 * 1024) throw new InvalidOperationException($非法消息长度: {length}); return await ReadExactAsync(stream, length); } // 辅助方法保证读满 count 字节处理半包 private static async Taskbyte[] ReadExactAsync(NetworkStream stream, int count) { byte[] buffer new byte[count]; int offset 0; while (offset count) { int read await stream.ReadAsync(buffer, offset, count - offset); if (read 0) return null; // 对端关闭 offset read; } return buffer; }逻辑说明ReadExactAsync是核心ReadAsync一次可能只返回部分数据必须循环读到offset count才算一条完整消息。长度前缀统一用大端避免大小端机器互通时出错。参数说明长度上限设 10MB 是防御性编程防止对端发个int.MaxValue让你直接 OOM实际项目按业务最大消息调整一般 1MB 以内够用。4.2 心跳包KeepAlive 不够用时的应用层方案前面提过 TCP KeepAlive 默认两小时跨平台还不好设。长连接业务里我一般加应用层心跳客户端每 30 秒发一个固定格式的心跳包服务端收到就更新「最后活跃时间」超过 90 秒没收到就主动断开。// 服务端维护每个连接的最后活跃时间 private readonly ConcurrentDictionarystring, DateTime _lastActive new(); // 收到任何数据都刷新 _lastActive[remote] DateTime.UtcNow; // 后台定时扫描清理超时连接 private async Task CleanupLoopAsync(CancellationToken token) { while (!token.IsCancellationRequested) { await Task.Delay(30000, token); var now DateTime.UtcNow; foreach (var kv in _lastActive) { if ((now - kv.Value).TotalSeconds 90) { Console.WriteLine($心跳超时断开 {kv.Key}); // 这里需要能根据 remote 找到对应 TcpClient 并关闭 } } } }逻辑说明心跳间隔 30 秒、超时 90 秒是「容忍两次丢包」的经验值。心跳包本身可以是一个特殊前缀的短消息业务层收到直接忽略。参数说明心跳间隔别小于 10 秒否则连接数一多网络全是心跳超时至少是心跳间隔的 2 到 3 倍给网络抖动留余量。5. 避坑与排查C# TCP/IP 源码里最容易翻车的 5 个点5.1 现象客户端断开后服务端抛 IOException原因客户端强制关闭比如直接杀进程时服务端正在ReadAsync会抛IOException: 远程主机强迫关闭了一个现有的连接。这是 TCP 的正常行为不是代码 bug。解决在HandleClientAsync里把IOException和ObjectDisposedException单独 catch 掉只记日志不往上抛。判断对端关闭的正确方式是ReadAsync返回 0而不是client.Connected。5.2 现象服务端跑一段时间后端口被占用重启失败原因TcpListener.Stop()之后端口会进入TIME_WAIT状态默认持续几十秒到几分钟期间再Start同一个端口会抛AddressAlreadyInUse。解决在Start之前设置ReuseAddress_listener.Server.SetSocketOption( SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); _listener.Start(512);注意这个选项在 Windows 和 Linux 上语义略有差异生产环境更稳妥的做法是让程序优雅退出别频繁重启。5.3 现象中文消息收到乱码原因发送端用Encoding.UTF8接收端用Encoding.Default或者反过来。Encoding.Default在中文 Windows 上是 GBK跨平台必翻车。解决两端统一用Encoding.UTF8并且长度前缀算的是「UTF-8 编码后的字节数」不是字符串的Length。你好.Length是 2但 UTF-8 编码后是 6 字节这个差异是乱码和截断的常见来源。5.4 现象连接数一多服务端内存暴涨原因每个连接分配了 4096 字节 buffer加上TcpClient和NetworkStream对象一万个连接就是几十 MB 起步。如果 buffer 开到 64KB直接几百 MB。解决buffer 按业务最大消息设别图省事开大连接数上千时考虑用SocketAsyncEventArgs做零分配收发或者上System.IO.Pipelines。普通上位机几十个连接现在的写法完全够用别过度设计。5.5 现象客户端连上了但收不到数据原因服务端WriteAsync之后没有Flush或者客户端ReadAsync的 buffer 太小一条消息被拆成多次读但客户端只读了一次。解决NetworkStream默认不缓冲WriteAsync直接发出不需要Flush问题多半在客户端没按长度前缀循环读。用第 4 章的ReadExactAsync替换裸ReadAsync这个坑基本就消失了。6. 进阶技巧用 Wireshark 和日志把通信问题钉死写完能跑只是第一步真正省时间的是出问题时能快速定位。我一般做两件事抓包和结构化日志。抓包用 Wireshark过滤条件直接写tcp.port 8888能看到三次握手、每次收发的字节数、FIN 和 RST。粘包问题在 Wireshark 里一目了然——你能看到两个应用层消息被合在一个 TCP 段里或者一条消息跨了两个段。这一步能省掉大量「到底是发送端还是接收端的问题」的扯皮。日志方面别用Console.WriteLine打天下。给每条收发记录加上连接标识、时间戳、字节数void LogTraffic(string connId, string direction, byte[] data) { // 只打前 64 字节避免大消息刷爆日志 int preview Math.Min(data.Length, 64); string hex BitConverter.ToString(data, 0, preview).Replace(-, ); Console.WriteLine(${DateTime.Now:HH:mm:ss.fff} [{connId}] {direction} {data.Length}B | {hex}); }connId用remote endpoint或者自增编号都行关键是同一条连接的所有日志能串起来。十六进制预览比直接打字符串有用因为二进制协议里很多字段不是可打印字符。还有一个我踩过的坑调试时把TcpClient.NoDelay设成false小消息会被 Nagle 算法攒着一起发看起来像「延迟很高」。对实时性有要求的场景连接建立后立刻设client.NoDelay true代价是每个小包都单独发网络利用率略降但延迟稳定。最后说个习惯每次改完收发逻辑先写一个「回环测试」——服务端和客户端跑在同一台机器上用127.0.0.1连发一万条随机长度的消息校验每条都完整收到。这个测试能在五分钟内暴露粘包、半包、编码、长度前缀的所有问题比在真实网络里瞎试高效得多。我现在的项目里这套回环测试是提交代码前的固定动作希望帮到你。本文还有配套的精品资源点击获取