简介这份源码资源面向具备一定C#与网络编程基础的开发者聚焦Windows平台下高性能TCP通信的实现与验证。核心采用完成端口IOCP模型编写服务器端并配套完整客户端可用于高并发场景下的收发性能测试与网络通讯类封装参考。包内共59个文件以cs源码、exe可执行程序、config配置、resx资源及csproj工程文件为主另有dll、pdb、sln等压缩包约122KB结构上分为IocpServer与IocpClient两个独立工程便于对照阅读。开发环境为Visual Studio 2010与.NET 4.0默认监听9900端口调试时需将IP改为本机若提示积极拒绝可尝试关闭防火墙。据描述在双核2G内存测试环境下CPU占用较低服务器可支撑5000以上客户端连接上限取决于机器性能。目前已有1303人学习适合需要理解IOCP收发机制、复用通讯封装类或搭建压测环境的读者参考。1. 从一份 C# 完全端口 TCP 源码说起它到底解决了什么很多人第一次接触「完全端口」这个词是在找 C# TCP 服务器源码的时候。搜出来的东西要么是TcpListener几十行的玩具要么是动辄几万行、依赖一堆第三方库的框架中间那层「能直接抄、能扛住真实连接、又不至于看不懂」的代码几乎断层。所谓完全端口指的是服务端监听一个端口后把该端口上的连接、收发、异常、断线全部自己接管不依赖 IIS、不依赖任何 Web 容器纯靠Socket把 TCP 协议栈的能力用满。它解决的是「我想自己控制每一条连接的生命周期」这个诉求适合做上位机通信、内网设备网关、GB28181 类信令转发、自定义二进制协议的场景。下面这份笔记就是围绕这样一份 C# 完全端口高性能 TCP 服务器和客户端源码把选型、参数、踩坑和验证方法讲透让你能照着跑起来也能判断它值不值得投进生产。2. 完全端口 TCP 服务端的骨架从 Socket 到收发循环2.1 为什么不用 TcpListener 而直接抓 SocketTcpListener本质是对Socket的封装它把Accept、Bind、Listen包了一层用起来省事但一旦你要控制NoDelay、ReceiveBufferSize、LingerState、ReuseAddress这些参数或者想在Accept之后立刻把连接丢进自己的对象池封装反而成了阻碍。完全端口方案里服务端通常直接new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)然后手动Bind到IPEndPoint再Listen。这样做的好处是每一步都可控坏处是你要自己处理Accept的异步回调和异常。我一般会先把监听 Socket 的几个关键参数定下来再进入接受循环。下面这段是服务端启动的最小骨架注意IOControl和NoDelay的位置很多人把NoDelay设在监听 Socket 上那是无效的必须设在Accept出来的连接 Socket 上。// 服务端监听与接受连接的核心骨架 var listenSocket new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); listenSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); listenSocket.Bind(new IPEndPoint(IPAddress.Any, 9000)); listenSocket.Listen(512); // backlog 设为 512避免瞬时连接被拒 // 开始异步接受回调里拿到的是每条独立连接 listenSocket.BeginAccept(AcceptCallback, listenSocket); void AcceptCallback(IAsyncResult ar) { var listener (Socket)ar.AsyncState; var client listener.EndAccept(ar); // 关键NoDelay 必须设在连接 Socket 上禁用 Nagle 算法 client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.NoDelay, true); client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReceiveBufferSize, 64 * 1024); client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.SendBufferSize, 64 * 1024); // 把连接交给会话对象管理继续接受下一条 var session new TcpSession(client); session.StartReceive(); listener.BeginAccept(AcceptCallback, listener); }逻辑说明ReuseAddress让服务重启时不必等 TIME_WAIT 释放端口这在调试阶段能省很多等待。Listen(512)的 backlog 是内核排队长度设太小会在压测时出现连接被拒。NoDelay关闭 Nagle 算法对实时性要求高的上位机指令、信令转发是必须的代价是可能增加小包数量。ReceiveBufferSize和SendBufferSize设 64KB 是常见起点具体要看单条消息大小和并发量后面第 5 章会讲怎么调。2.2 会话对象与异步收发的状态机完全端口方案的核心不是监听而是每条连接对应一个会话对象会话自己维护接收缓冲区、发送队列和关闭状态。很多人写 TCP 服务器翻车就翻在「粘包」和「半包」上——Receive返回的字节数不等于一条完整消息你必须自己拼。常见做法是给每个会话一个Listbyte或环形缓冲区收到数据先追加再按协议头里的长度字段切分。下面是一个简化的会话接收循环用BeginReceive/EndReceive实现注意每次回调后要重新挂起接收否则只能收一次。public class TcpSession { private readonly Socket _socket; private readonly byte[] _buffer new byte[8 * 1024]; private readonly MemoryStream _pending new MemoryStream(); public TcpSession(Socket socket) { _socket socket; } public void StartReceive() { _socket.BeginReceive(_buffer, 0, _buffer.Length, SocketFlags.None, ReceiveCallback, null); } private void ReceiveCallback(IAsyncResult ar) { try { int bytesRead _socket.EndReceive(ar); if (bytesRead 0) { Close(); return; } // 对端正常关闭 _pending.Write(_buffer, 0, bytesRead); ParseMessages(); // 按协议切分完整消息 StartReceive(); // 重新挂起形成循环 } catch (SocketException ex) { // 10054 连接被重置10053 本地中止都属于常见断线 Close(); } } private void ParseMessages() { // 假设协议头 4 字节表示消息体长度大端 var data _pending.ToArray(); int offset 0; while (data.Length - offset 4) { int bodyLen (data[offset] 24) | (data[offset 1] 16) | (data[offset 2] 8) | data[offset 3]; if (data.Length - offset - 4 bodyLen) break; // 半包等下次 var body new byte[bodyLen]; Array.Copy(data, offset 4, body, 0, bodyLen); OnMessage(body); offset 4 bodyLen; } // 把剩余不完整数据保留 _pending.SetLength(0); _pending.Write(data, offset, data.Length - offset); } private void OnMessage(byte[] body) { /* 业务处理 */ } private void Close() { try { _socket.Shutdown(SocketShutdown.Both); } catch { } _socket.Close(); } }逻辑说明_pending累积所有到达的字节ParseMessages按「4 字节长度头 消息体」的格式切分。切不完整就保留等下一次Receive。这里用MemoryStream是为了代码好读生产环境建议换成环形缓冲区避免每次ToArray都分配新数组。OnMessage里不要做耗时操作否则会阻塞接收循环正确做法是丢进业务线程池。参数说明接收缓冲区_buffer设 8KB 是折中值太小会增加系统调用次数太大会浪费内存。协议头长度字段的字节序要和客户端约定一致C# 默认小端网络协议通常用大端这里手动移位就是按大端解析。SocketException的 10054 和 10053 要区分对待前者是对端强制断开后者是本地主动关闭日志里记下来对排查很有用。2.3 发送队列别在接收回调里直接 Send新手最容易犯的错是在OnMessage里直接_socket.Send(response)。单连接低频率没问题一旦并发上来Send会阻塞把接收循环卡死表现就是「服务器突然不收数据了」。完全端口方案里发送必须走独立队列由专门的发送线程或异步发送回调驱动。常见做法是给会话加一个ConcurrentQueuebyte[]收到业务响应就入队然后检查是否已有发送任务在跑没有就启动BeginSend。发送回调里继续取队列直到队列空为止。这样接收和发送解耦任何一端慢都不会拖死另一端。private readonly ConcurrentQueuebyte[] _sendQueue new ConcurrentQueuebyte[](); private int _sending 0; public void EnqueueSend(byte[] data) { _sendQueue.Enqueue(data); if (Interlocked.CompareExchange(ref _sending, 1, 0) 0) StartSendNext(); } private void StartSendNext() { if (!_sendQueue.TryDequeue(out var data)) { Interlocked.Exchange(ref _sending, 0); return; } _socket.BeginSend(data, 0, data.Length, SocketFlags.None, SendCallback, data); } private void SendCallback(IAsyncResult ar) { try { _socket.EndSend(ar); StartSendNext(); // 继续发下一条 } catch (SocketException) { Close(); } }逻辑说明Interlocked.CompareExchange保证同一时刻只有一个发送循环在跑避免多条BeginSend并发导致数据交错。SendCallback里不判断发送字节数是否完整是因为EndSend返回的字节数可能小于请求长度严格来说要处理「部分发送」但 TCP 的Send在阻塞模式下会尽量发完异步模式下小包通常一次发完大包需要循环。生产代码里应该记录已发偏移这里为了可读性做了简化。参数说明发送队列没有上限是危险的如果对端一直不收队列会撑爆内存。建议给队列设一个阈值比如 1000 条或 16MB超过就主动断开该连接日志里标记「发送积压」。这个阈值要根据业务消息大小来定信令类消息小可以设大一点文件传输类消息大要设小一点。3. 客户端源码怎么写连接、重连与心跳3.1 客户端连接与断线重连的节奏控制客户端比服务端多一层麻烦网络环境不可控断线是常态。完全端口客户端源码里连接逻辑不能只写一次Connect要包一层重连状态机。常见做法是用一个Timer或Task.Delay循环检测到Socket未连接就尝试重连重连间隔采用退避策略比如 1 秒、2 秒、4 秒、8 秒上限 30 秒避免服务端刚重启就被大量客户端瞬间打满。private async Task ConnectLoopAsync(CancellationToken token) { int delaySeconds 1; while (!token.IsCancellationRequested) { try { _socket new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); _socket.NoDelay true; await _socket.ConnectAsync(_serverEndPoint); delaySeconds 1; // 连上后重置退避 StartReceive(); await SendHeartbeatLoopAsync(token); // 进入心跳 } catch (SocketException ex) { // 连接失败按退避等待后重试 await Task.Delay(TimeSpan.FromSeconds(delaySeconds), token); delaySeconds Math.Min(delaySeconds * 2, 30); } } }逻辑说明ConnectAsync失败会抛SocketException捕获后不退出循环而是等待再试。delaySeconds在连接成功后重置为 1保证下次断线还是从短间隔开始。SendHeartbeatLoopAsync是连接建立后的正常业务循环一旦它内部检测到断线会抛出异常回到外层重连。参数说明退避上限 30 秒是经验值内网环境可以设到 5 秒公网或弱网环境设到 60 秒也合理。CancellationToken用于程序退出时优雅停止重连不然关不掉进程。注意ConnectAsync没有超时参数如果服务端 IP 不可达系统默认超时可能长达 20 秒以上需要自己用Task.WhenAny加超时控制。3.2 心跳包的设计间隔、超时与假死检测TCP 连接「假死」是上位机和网关场景里最恶心的问题网线还插着但中间设备挂了Socket状态还是Connected实际数据发不出去。完全端口方案必须自己加应用层心跳。心跳包不用复杂一个固定字节序列加时间戳就够服务端收到后原样回或回一个确认。心跳间隔和超时倍数要配套。常见配置是 5 秒发一次心跳连续 3 次没收到回应判定断线也就是 15 秒检测窗口。间隔太短会增加无效流量太长则故障发现慢。下面是一个心跳循环的写法注意发送和检测要分开不要在一个循环里既发又等。private async Task SendHeartbeatLoopAsync(CancellationToken token) { var lastRecv DateTime.UtcNow; _lastHeartbeatReply DateTime.UtcNow; while (!token.IsCancellationRequested) { // 发送心跳 var hb BuildHeartbeatPacket(); EnqueueSend(hb); await Task.Delay(5000, token); // 5 秒间隔 // 检查是否超时15 秒没收到任何数据就判定断线 if ((DateTime.UtcNow - _lastHeartbeatReply).TotalSeconds 15) { Close(); throw new SocketException((int)SocketError.TimedOut); } } }逻辑说明_lastHeartbeatReply在接收回调里每次收到数据就更新不限于心跳回复任何业务数据都算「连接活着」。这样即使心跳回复丢了但有业务数据在跑也不会误判断线。Close之后抛异常让外层ConnectLoopAsync捕获并进入重连。参数说明心跳间隔 5 秒、超时 15 秒是通用起点。如果业务本身有高频数据心跳可以拉长到 30 秒靠业务数据保活。如果业务是低频的比如几分钟才发一次指令心跳必须短否则中间设备会主动断开空闲连接。有些路由器 NAT 表项默认 60 秒过期心跳间隔要小于这个值。3.3 客户端接收与服务端接收的差异客户端接收循环和服务端结构一样但有一个区别客户端通常只连一个服务端所以不需要会话池直接用一个Socket加一个接收缓冲区就行。但客户端要处理「服务端主动断开」和「服务端重启」两种情况前者是正常关闭后者是连接被重置日志里要能区分。另外客户端发送队列可以简化因为客户端一般不会像服务端那样面对大量并发响应。但如果你做的是压力测试客户端那还是要用队列否则Send阻塞会拖慢整个测试节奏。我一般会在客户端也保留发送队列代码复用服务端的会话类只是把Accept换成Connect。4. 避坑与排查完全端口 TCP 最容易翻车的 5 个点4.1 现象压测到几千连接后新连接被拒绝原因Listen的 backlog 设得太小或者Accept回调处理太慢导致内核排队溢出。另一个常见原因是文件描述符上限Linux 下默认 1024Windows 下虽然高一些但端口耗尽也会出问题。解决把Listen(512)提到Listen(1024)或更高Accept回调里只做最轻量的初始化把会话注册丢到线程池。Linux 部署时检查ulimit -n必要时调到 65535。客户端侧如果大量短连接注意 TIME_WAIT 堆积服务端开ReuseAddress只能缓解监听端口客户端端口耗尽要靠连接池或长连接解决。4.2 现象消息偶尔少一段或者两条消息粘在一起原因TCP 是字节流没有消息边界。Receive返回的数据可能包含半条消息也可能包含一条半。很多新手直接假设「一次 Receive 就是一条完整消息」在低频率下碰巧能跑一上量就乱。解决必须实现应用层切分。协议头里带长度字段是最简单的做法固定长度消息也可以但灵活性差。切分逻辑要处理三种情况刚好一条、多条粘一起、半条。第 2 章的ParseMessages就是按这个思路写的。测试时故意把发送端拆成随机大小的块发送看接收端能不能正确还原。4.3 现象服务端运行一段时间后内存持续上涨原因会话对象没有正确释放或者发送队列积压。常见的是连接断开后会话还挂在某个字典里没移除或者MemoryStream只增不减。解决给每个会话加Dispose在Close时从会话池移除并清空发送队列。MemoryStream在切分完消息后要SetLength(0)不要一直Write不清理。用dotnet-counters或任务管理器观察 GC 和内存曲线如果内存阶梯式上涨不回落基本就是对象泄漏。4.4 现象客户端显示已连接但发数据没反应原因TCP 假死中间设备断开了连接但两端Socket状态没更新。没有应用层心跳的话这个问题可以拖到几分钟甚至几小时才暴露。解决加心跳并且心跳超时要基于「最后一次收到任何数据」的时间而不是「最后一次收到心跳回复」。第 3 章的心跳循环就是这么做的。另外Socket.Poll和Available在某些情况下也不可靠不要依赖它们判断连接是否活着。4.5 现象NoDelay设了但延迟还是高原因NoDelay只关闭 Nagle 算法不解决接收端延迟。如果接收端处理慢或者发送端一次发太多小包网络栈和接收缓冲区都会引入延迟。另一个容易忽略的是SendBufferSize太小导致Send频繁阻塞。解决先确认NoDelay设在连接 Socket 上不是监听 Socket。然后检查发送逻辑尽量合并小包比如把多条指令拼成一个缓冲区再发。接收端要保证Receive循环不被业务阻塞业务处理丢线程池。用 Wireshark 抓包看实际发送间隔如果间隔和业务预期不符再回头查代码。5. 进阶用配置化参数把这份源码调成生产可用5.1 把硬编码参数抽成配置对象前面代码里的端口、缓冲区大小、心跳间隔、backlog 都是硬编码实际部署时不同项目要求不一样。我一般会定义一个TcpServerOptions类把可调参数集中管理启动时从 JSON 或环境变量加载。这样同一份源码可以适配内网低延迟场景和公网弱网场景不用改代码重新编译。public class TcpServerOptions { public int Port { get; set; } 9000; public int Backlog { get; set; } 512; public int ReceiveBufferSize { get; set; } 64 * 1024; public int SendBufferSize { get; set; } 64 * 1024; public int MaxSendQueueLength { get; set; } 1000; public int HeartbeatIntervalSeconds { get; set; } 5; public int HeartbeatTimeoutSeconds { get; set; } 15; }逻辑说明这些参数覆盖了连接建立、数据传输、断线检测三个环节。MaxSendQueueLength是保护性参数超过就断开慢消费者。HeartbeatIntervalSeconds和HeartbeatTimeoutSeconds要满足「超时 间隔 × 2」否则网络抖动就会误判断线。参数说明ReceiveBufferSize和SendBufferSize在 Windows 上实际生效值可能被系统调整设置后可以用Socket.GetSocketOption读回来确认。Backlog在 Linux 上受somaxconn限制设太大也没用sysctl net.core.somaxconn可以查看实际上限。5.2 用压测验证参数是否合理参数调完之后必须压测验证。我一般会写一个简单的压测客户端开 N 条连接每条连接按固定频率发消息服务端回显统计吞吐和延迟。观察三个指标连接建立成功率、消息往返延迟 P99、内存和 CPU 占用。如果 P99 延迟突然跳高通常是发送队列积压或 GC 触发回头调MaxSendQueueLength或缓冲区大小。验证心跳是否有效可以模拟断网压测过程中把服务端网线拔掉看客户端多久检测到断线并开始重连。如果超过 30 秒还没反应说明心跳超时设太大或者检测逻辑有 bug。这个测试比看代码管用得多血泪经验是「心跳逻辑一定要实际拔网线测不要靠读代码确认」。5.3 一个具体技巧用SocketAsyncEventArgs替代BeginReceiveBeginReceive/EndReceive写起来直观但每次回调都会分配IAsyncResult对象高并发下 GC 压力大。完全端口高性能方案里更常见的做法是用SocketAsyncEventArgs它支持对象池复用能显著降低分配。改造时把接收和发送都换成SocketAsyncEventArgs每个会话持有一对收发事件参数回调里直接读e.BytesTransferred。这个改造的收益在几千连接以上才明显如果连接数只有几百BeginReceive完全够用不必为了性能而增加复杂度。我一般会先跑通BeginReceive版本确认业务逻辑正确再按需替换。替换时注意SocketAsyncEventArgs的Completed事件可能同步完成要判断Completed返回值同步完成时不能再次挂起否则会栈溢出。这个坑我踩过表现是压测到一定量级直接崩溃日志里看不到异常最后用dotnet-dump才定位到。希望帮到你。本文还有配套的精品资源点击获取