☰
C# 完全端口 TCP 服务器与客户端源码实战:从 Socket 到生产级参数调优
2026/10/9 3:34:10 网站建设 项目流程

简介:这份源码资源面向具备一定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 而直接抓 Socket

TcpListener本质是对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返回的字节数不等于一条完整消息,你必须自己拼。常见做法是给每个会话一个List<byte>或环形缓冲区,收到数据先追加,再按协议头里的长度字段切分。

下面是一个简化的会话接收循环,用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会阻塞,把接收循环卡死,表现就是「服务器突然不收数据了」。完全端口方案里,发送必须走独立队列,由专门的发送线程或异步发送回调驱动。

常见做法是给会话加一个ConcurrentQueue<byte[]>,收到业务响应就入队,然后检查是否已有发送任务在跑,没有就启动BeginSend。发送回调里继续取队列,直到队列空为止。这样接收和发送解耦,任何一端慢都不会拖死另一端。

private readonly ConcurrentQueue<byte[]> _sendQueue = new ConcurrentQueue<byte[]>(); 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 下默认 1024,Windows 下虽然高一些,但端口耗尽也会出问题。

解决:把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替代BeginReceive

BeginReceive/EndReceive写起来直观,但每次回调都会分配IAsyncResult对象,高并发下 GC 压力大。完全端口高性能方案里,更常见的做法是用SocketAsyncEventArgs,它支持对象池复用,能显著降低分配。改造时把接收和发送都换成SocketAsyncEventArgs,每个会话持有一对收发事件参数,回调里直接读e.BytesTransferred。

这个改造的收益在几千连接以上才明显,如果连接数只有几百,BeginReceive完全够用,不必为了性能而增加复杂度。我一般会先跑通BeginReceive版本,确认业务逻辑正确,再按需替换。替换时注意SocketAsyncEventArgs的Completed事件可能同步完成,要判断Completed返回值,同步完成时不能再次挂起,否则会栈溢出。这个坑我踩过,表现是压测到一定量级直接崩溃,日志里看不到异常,最后用dotnet-dump才定位到。

希望帮到你。

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

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

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

立即咨询