简介:面向C#网络编程学习者与需要构建高并发通信服务的开发者,这套完整工程源码演示了基于Socket类的TCP/IP通信实现,覆盖服务器端口监听、多线程与async/await异步处理客户端请求、数据收发、连接断开后的重连处理及异常处理等完整链路。资源共431个文件,压缩包仅4.1MB,以63个cs源代码文件为核心,配套res资源、dll动态库、exe可执行程序、config配置文件、工程解决方案文件,以及txt/doc说明文档,便于按模块阅读、编译和二次开发。目前已有989人学习/下载。通过阅读并运行源码,可深入理解高并发场景下异步编程与多线程模型的性能差异、Socket通信的底层流程及常见网络异常的处理思路;该项目既适合初学者作为第一套可上手的网络编程示例,也适合有经验的开发者作为优化服务端并发性能的参考。
1. 高并发 Socket 不是玄学:这套 C# 完整工程源码让新手直接跑通,让熟手看到更优解
刚开始啃网络编程的人,最容易把高并发当成玄学。网上讲异步、讲 IOCP 的文章一抓一大把,但真正能下载下来编译通过、连上两台机器互发消息的完整工程很少。这套 C# 高并发 SOCKET 服务器和客户端完整工程实例源码,恰好补上这个缺口:服务器端有监听、会话管理和收发逻辑,客户端有连接管理、重连和 UI 集成,源码结构完整,还带着 ClickOnce 部署配置和界面资源。适合两类人:刚入门、想找一份能跑通全流程示例的新手;以及被并发性能问题卡住、想找一个参考模型做压测和改造的熟手。
2. 拆解工程结构与 TCP 通信骨架:从文件清单到 Socket 收发模型
压缩包解压后,我没有先打开任何代码文件,而是先扫了一遍完整文件列表。这个习惯是从某次被一个“缺文件导致解决方案打不开”的工程坑过之后养成的。这套工程的文件列表比较杂,混着部署清单、历史工程配置和界面位图。先把每个文件是干什么的弄清楚,后面编译和跟踪代码的时候就不会被带偏。
2.1 工程文件清单与配置说明
文件列表里最显眼的是两个 .application 文件:NTUploadClient.application 和 NTUploadClient.vshost.application。前者是 ClickOnce 部署清单,记录了客户端程序的版本、入口程序集和更新位置;后者是 Visual Studio 调试宿主生成的部署清单,只在开发机上出现,发布时不需要打包进去。看到这两个文件可以判断,这个客户端工程是带界面的 Windows 桌面程序,而且发布方式用的是 ClickOnce。
那个 BorlndMM.bdsproj 文件很容易让新手误以为工程坏了。BDS 是 Borland Developer Studio 的项目描述格式,跟 C# 的 .csproj 不是一回事,Visual Studio 根本不会加载它。它出现在这份 C# 源码包里,大概率是工程从旧环境迁移时残留的配置文件,可以直接忽略。真正参与编译的是 .sln 和 .csproj 文件,如果某个解决方案文件提示找不到项目,检查一下是不是漏了这两个。
剩下的几张位图,VT XP.bmp、VT tick light.bmp、VT check light.bmp、VT tick dark.bmp、VT check dark.bmp、VT utilities.bmp,是界面控件用的状态图标。从命名能看出是两种状态灯:勾选状态和未选状态,亮色和暗色分别对应启用和禁用。这说明客户端界面里应该有连接指示灯或者数据收发状态指示区,调试时观察这些灯的闪烁情况,比盯着日志直观得多。
2.2 TCP 通信的关键机制:三次握手、数据流与消息边界
Socket 本身是一个抽象层,向上提供类似文件读写的流式接口,向下封装了操作系统协议栈的细节。在 TCP 模式下,通信双方先要完成三次握手:客户端发 SYN,服务器回 SYN-ACK,客户端再回 ACK。握手完成后,连接进入 ESTABLISHED 状态,可以双向发送数据。C# 里不需要手动构造这些报文,Socket 类把这些都藏起来了,但理解握手过程对排查“连接为什么建立不起来、建立起来为什么马上断开”很有帮助。
TCP 是字节流协议,不是消息协议。发送方调用 Send 方法传了 1000 字节,接收方一次 Receive 不一定能收到完整的 1000 字节,可能分两次收到 300 字节和 700 字节,也可能一次收到 2000 字节——包含两次 Send 的一部分。这就是网络编程里著名的粘包和半包问题。所有高并发 Socket 工程都必须在这个基础上设计应用层协议,否则数据一多必然出错。这套工程既然被称为“完整工程实例”,在数据收发部分应该已经处理了消息边界,后面我会拆开讲。
2.3 核心 Socket 对象:绑定、监听、连接与收发的最小闭环
服务器端最基础的流程是:创建 Socket、绑定端口、监听、循环 Accept、为每个连接建立一个收发循环。把这套最小闭环跑通,再谈高并发优化才有意义。下面是创建 TCP 监听器的典型写法:
using System.Net; using System.Net.Sockets; // 1. 创建 IPv4 的流式套接字(TCP) var listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); // 2. 绑定本地监听地址和端口。IPAddress.Any 表示监听所有本机网卡,9000 是示例端口 var endPoint = new IPEndPoint(IPAddress.Any, 9000); listener.Bind(endPoint); // 3. 开始监听,backlog 表示内核中等待 Accept 的连接队列长度 listener.Listen(backlog: 128); Console.WriteLine($"listening on 0.0.0.0:9000, backlog=128"); // 4. 循环接收客户端连接。这里是简化示例,生产代码里不会在单线程里这样写 while (true) { var client = listener.Accept(); Console.WriteLine($"client connected: {client.RemoteEndPoint}"); // 为每个连接启动一个线程去做收发(后面章节会展开) Thread t = new Thread(() => HandleClient(client)); t.IsBackground = true; t.Start(); } static void HandleClient(Socket client) { byte[] buffer = new byte[4096]; try { int received = 0; while ((received = client.Receive(buffer)) > 0) { // 这里只做回显,生产环境应做业务处理 client.Send(buffer, received, SocketFlags.None); } } catch (SocketException ex) { Console.WriteLine($"socket error: {ex.SocketErrorCode}"); } finally { client.Close(); } }这里有几个参数值得展开说。AddressFamily.InterNetwork 指定的是 IPv4 地址族,如果要支持 IPv6,要用 AddressFamily.InterNetworkV6,对应的地址绑定也要改成 IPAddress.IPv6Any。SocketType.Stream 和 ProtocolType.Tcp 是配套使用的,表示面向连接的可靠字节流;改成 SocketType.Dgram 加 ProtocolType.Udp 就是 UDP 套接字,但那种模式没有连接概念,API 形态完全不同。
backlog 参数表示内核中等待应用程序调用 Accept 的连接队列长度。如果客户端连接请求来得太快,服务器来不及 Accept,这些连接会排在这个队列里。队列满了之后,新的连接请求会被内核拒绝,客户端会收到 ECONNREFUSED 或者连接超时。Windows 上这个值的上限受注册表影响,通常设置在 128 到 512 之间足够用,我很少把它调过 1024。
HandleClient 里用的是同步阻塞 Receive,模型虽然简单,但代价是每个连接占一个线程。线程的默认栈大小是 1MB,1000 个连接意味着 1GB 虚拟内存,再加上线程切换的开销,服务器很快会卡在上下文切换上。这套工程要支持真正的“高并发”,一定不会只做到这一步,下一章我拆解它的并发模型。
3. 高并发服务器:多线程与异步 I/O 的选型对比与代码落地
高并发服务器设计的核心问题不是“怎么接收数据”,而是“怎么让接收数据这件事不成为瓶颈”。单线程 Accept、每连接一线程、异步事件驱动,这三种形态对应着不同的性能和复杂度。这套工程的价值在于它把两种主流思路都放进了同一份源码里,可以直接对比学习。
3.1 多线程模型:每连接一线程,为什么在达到某个量级后就会撑不住
上一章末尾给出的同步代码就是典型的“每连接一线程”模型。每次 Accept 返回一个新 Socket,就 new Thread 启动一个接收循环。这个模型的优势非常明显:逻辑简单,所有数据收发都是顺序执行,不需要考虑状态机、缓冲区复用和回调嵌套,写起来最接近人类直觉。
但它的瓶颈也出在“每连接一线程”。Windows 上 .NET 默认线程栈大小是 1MB,一个 2000 连接的应用光是线程栈就要预留 2GB 虚拟内存。更严重的是线程调度开销:假设系统有 8 个逻辑核心,2000 个线程都在等待数据时不会占用 CPU,但只要其中一部分有数据到达,线程调度器就要频繁执行上下文切换,每次切换要保存和恢复寄存器、页表缓存等状态,这些开销会直接压掉有效吞吐量。
我实际测试过的某跨平台系统,用同步多线程模型跑到了 350 个长连接的时候,CPU 占用率已经到了 60% 以上,响应时间开始跳动。换用异步模型后,同样的机器能扛到 2000 个连接,CPU 占用反而降到 20% 出头。所以多线程模型并不是不能用,而是要认清它的适用边界:连接数小于 200、每次收发数据量小、并发尖峰不明显的内部工具,同步模型反而是最优解,因为代码可维护性更好,出问题好排查。
如果必须在同步模型上扩展,有一个折中方案:用线程池代替裸线程。把 HandleClient 改成 ThreadPool.QueueUserWorkItem 或 Task.Run,至少复用线程,避免每次创建和销毁线程的开销。但线程池的线程数是跟着 CPU 核心数走的,大量阻塞在 Receive 上的业务会把池子占满,一旦池子里所有线程都阻塞在等待数据上,整个应用就瘫了。所以只要做长连接服务,最优解还是异步 I/O。
3.2 异步 I/O:Begin/End 模式与 async/await 封装
异步 I/O 在 Windows 上的底层支撑是 IOCP,全称 I/O Completion Port。它的核心思想是:应用程序发起一个接收操作后,线程立即返回去处理别的事情,数据到达时操作系统通过完成端口通知应用程序。整个等待过程没有线程阻塞,高并发场景下线程数可以保持在很低水平。
C# 里最经典的异步 Socket 写法是 BeginAccept、EndAccept、BeginReceive、EndReceive 这一组 API。虽然新代码建议用 async/await,但工程源码里如果是早期 .NET 项目,大概率用的还是这组 API,有必要看得懂:
public class AsyncServer { private readonly Socket _listener; public AsyncServer(int port) { _listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); _listener.Bind(new IPEndPoint(IPAddress.Any, port)); _listener.Listen(backlog: 128); } public void Start() { _listener.BeginAccept(OnAccept, null); } private void OnAccept(IAsyncResult ar) { try { Socket client = _listener.EndAccept(ar); // 继续等待下一个连接,形成链式异步 _listener.BeginAccept(OnAccept, null); // 为当前连接创建收包状态 var state = new ClientState { WorkSocket = client, Buffer = new byte[4096] }; client.BeginReceive(state.Buffer, 0, state.Buffer.Length, SocketFlags.None, OnReceive, state); } catch (SocketException ex) { Console.WriteLine($"accept error: {ex.SocketErrorCode}"); } } private void OnReceive(IAsyncResult ar) { var state = (ClientState)ar.AsyncState; try { int received = state.WorkSocket.EndReceive(ar); if (received > 0) { // 处理收到的数据(解析协议、更新心跳时间、业务逻辑) ProcessData(state.WorkSocket, state.Buffer, received); // 继续读下一次 state.WorkSocket.BeginReceive(state.Buffer, 0, state.Buffer.Length, SocketFlags.None, OnReceive, state); } else { // received == 0 表示对端关闭连接 state.WorkSocket.Close(); } } catch (SocketException) { state.WorkSocket.Close(); } catch (ObjectDisposedException) { // Socket 已被主动关闭,忽略 } } private class ClientState { public Socket WorkSocket { get; set; } public byte[] Buffer { get; set; } } }关键点在于 ClientState 对象。BeginReceive 的最后一个参数把状态对象传给回调,回调里通过 ar.AsyncState 取回来。每个连接持有自己独立的 Buffer,绝对不能多个连接共享一个字节数组,否则并发收包时数据会互相覆盖。每次 EndReceive 返回后,必须重新调用一次 BeginReceive,形成链式异步,这相当于在事件驱动模型里注册下一次读取。
新版 .NET 用 async/await 封装后,代码更接近同步写法:
while (true) { Socket client = await _listener.AcceptAsync(); _ = ProcessClientAsync(client); } async Task ProcessClientAsync(Socket client) { byte[] buffer = new byte[4096]; while (true) { int received = await client.ReceiveAsync(buffer, SocketFlags.None); if (received == 0) break; // 业务处理 } }但皮囊变了,内里没变。AcceptAsync 和 ReceiveAsync 底层仍然是 SocketAsyncEventArgs,仍然走 IOCP。需要注意的是不要在这里用 Task.Run 包一层,因为 Task.Run 会把逻辑压到线程池上执行,等于退回到同步模型。直接让 async 方法在 IO 完成回调的上下文中继续执行才是正确姿势。
3.3 关键参数配置:Backlog、缓冲区、超时与 KeepAlive
很多“看起来能跑,一压测就崩”的 Socket 服务,问题往往不在业务逻辑,而在几个基础参数没设对。
监听端 Backlog 前面已经提过,它是排队等待 Accept 的连接队列容量。假设服务器每秒只能 Accept 100 个连接,但客户端每秒发起 200 个连接请求,没被及时 Accept 的连接就会进入 backlog 队列。这个值设得太小,客户端会连接失败;设得太大,队列里的连接迟迟得不到 Accept,客户端等超时。常见的经验值是 128 到 512,具体要看连接的到达速率和 Accept 的处理速度。
Socket 的 SendBufferSize 和 ReceiveBufferSize 分别对应内核为发送和接收分配的缓冲区大小。Windows 默认的 TCP 缓冲区一般是 8KB 到 64KB 之间。接收缓冲区太小,数据到达时如果应用层不及时调用 Receive,内核缓冲区满了之后会触发 TCP 零窗口,发送方会暂停发送,造成吞吐量下降。发送缓冲区太小会限制单次发送的数据量。我一般的做法是保持默认值跑一轮压测,再根据实际吞吐量决定是否调大,不要凭感觉一口气设到 1MB,那会白白吃掉大量内存。
超时参数 SendTimeout 和 ReceiveTimeout 的作用对象是同步 Send/Receive 调用。如果设置成 5000,同步调用阻塞超过 5 秒会抛 SocketException。异步调用不受这两个超时影响,异步场景要自己用 CancellationToken 或者 SocketAsyncEventArgs 的完成回调配合时钟来做超时控制。
KeepAlive 是 TCP 层的心跳机制,但默认参数很差劲:Windows 上首次探测要等 2 小时,之后每隔 1 秒探测一次,探测失败重试 10 次才断开。对大多数业务来说,系统级 TCP KeepAlive 根本不能用来判断连接是否存活。真正有用的是应用层心跳,这个我在 3.4 节展开写。
还有一个容易被忽略的参数是 NoDelay。默认情况下 TCP 启用 Nagle 算法,把小包合并后发送,减少网络上小数据包的数量。但 Nagle 算法和延迟确认 ACK 机制相互作用,会让小数据包的传输延迟达到 40ms 甚至更多,这对实时性要求高的业务是致命的。设置 socket.NoDelay = true 可以禁用 Nagle 算法,代价是网络上会出现更多小包,适合消息频率不高的长连接业务。
3.4 心跳检测与粘包拆包:代码级解决 TCP 流式传输的两个难题
只要走 TCP,粘包和半包就是躲不掉的。粘包指多个应用层消息被合并到一次 Receive 里返回,半包指一条完整的应用层消息被拆成多次 Receive 才收齐。产生原因很简单:TCP 不关心应用层消息边界,它只负责把字节流可靠地从一端搬到另一端。
最常见的解法是长度前缀协议:发送前在每条消息前面加上 4 字节的消息体长度,接收方先读 4 字节,再按长度读取消息体。这套工程里的通信协议大概率就是这种结构,我自己写网络层的时候也一直用这个方案,简单、好调试、出错容易定位。
public class LengthPrefixProtocol { private readonly List<byte> _cache = new List<byte>(); private readonly object _lock = new object(); public IEnumerable<byte[]> Decode(byte[] data) { List<byte[]> packets = new List<byte[]>(); lock (_lock) { _cache.AddRange(data); // 循环解析:至少有 4 字节头,且总长 >= 头 + 体 while (_cache.Count >= 4) { // 这里约定用大端序读取消息体长度 byte[] lengthBytes = _cache.GetRange(0, 4).ToArray(); if (BitConverter.IsLittleEndian) { Array.Reverse(lengthBytes); } int bodyLength = BitConverter.ToInt32(lengthBytes, 0); if (bodyLength <= 0 || bodyLength > 10 * 1024 * 1024) { // 非法长度,说明协议错乱,直接清空缓存防止死循环 _cache.Clear(); break; } if (_cache.Count - 4 < bodyLength) { // 数据还没到齐,等下一条 break; } // 取出完整消息,移除已消费的数据 byte[] packet = _cache.GetRange(4, bodyLength).ToArray(); _cache.RemoveRange(0, 4 + bodyLength); packets.Add(packet); } } return packets; } }这段代码的逻辑是一个带缓冲的拆包器。每次从 Socket 拿到原始字节流,先丢进 _cache,然后反复检查缓存里是否有完整的一条消息:先读 4 字节长度,如果缓存里剩余数据不够这个长度,就跳到下一次 Receive 再拼;如果长度合法且数据齐了,就切出这条消息并从缓存中移除。
有两点处理是血泪经验。第一,必须给 bodyLength 设置上限,否则异常数据流里出现一个巨大的长度值,程序会一直等数据攒到那个长度,缩短业务逻辑的死等状态。第二,解析时要处理字节序。Windows 上 BitConverter 默认按小端序解析,但网络协议里习惯用大端序(也就是网络字节序),所以我在读长度前先判断主机是否小端序,是的话就反转数组。如果你跟其他语言写的服务端对接,一定要先确认双方协议约定的是大端还是小端。
心跳检测解决的是另一类问题:连接断了但程序不知道。拔网线、机器断电、路由器重启,TCP 连接不会立刻通知应用层,如果应用层不设超时,这个“僵尸连接”会一直占着服务器资源。应用层心跳的做法是客户端每隔 30 秒发一个心跳包,服务器记录每个连接的最后活跃时间,然后定时扫描,把超过 90 秒没有活跃的连接关掉:
// 服务器启动时注册一个定时器,每 15 秒扫描一次 Timer timer = new Timer(CheckHeartbeat, null, TimeSpan.Zero, TimeSpan.FromSeconds(15)); static void CheckHeartbeat(object state) { DateTime now = DateTime.UtcNow; foreach (var pair in _lastActiveMap) { if ((now - pair.Value).TotalSeconds > 90) // 90 秒没消息就断开 { try { pair.Key.Shutdown(SocketShutdown.Both); } catch { } pair.Key.Close(); } } }心跳间隔和超时阈值要成比例。我一般习惯心跳间隔 30 秒、超时阈值 90 秒,也就是连续 3 次心跳没有收到就断开。间隔太短会增加网络负担,太长会导致断线发现延迟。这套工程里如果带了客户端连接灯状态,你会在界面上看到“连接正常”和“连接断开”两种灯的切换,它们背后的驱动逻辑就是心跳检测。
4. 客户端工程与 UI 集成:连接管理、断线重连与线程安全
服务器端写得再好,客户端如果卡死、断线不重连,这套系统依然没法用。客户端工程的文件列表里出现了 ClickOnce 部署清单和状态位图,说明这不是一个无界面的测试客户端,而是一个带 UI 的桌面程序。工程源码的价值在这里就体现出来了:它把网络通信和界面交互放在了一个可运行的完整场景里。
4.1 客户端连接生命周期:Connect、Send、Receive 的同步与异步写法
客户端建立连接的接口是 Socket.Connect,同步版本会阻塞调用线程直到连接成功或失败。在控制台应用里这没问题,但在 UI 应用里,直接在按钮点击事件里调用 Connect 会造成界面假死,因为 UI 线程被阻塞了。正确做法是使用异步连接。
下面是用 SocketAsyncEventArgs 实现异步连接的一段代码,这也是这套工程最可能采用的模型:
Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); IPEndPoint server = new IPEndPoint(IPAddress.Parse("192.168.1.100"), 9000); var args = new SocketAsyncEventArgs(); args.RemoteEndPoint = server; args.Completed += (s, e) => { if (e.SocketError == SocketError.Success) { Console.WriteLine("connected"); // 连接成功后启动接收循环 SocketAsyncEventArgs recvArgs = new SocketAsyncEventArgs(); recvArgs.SetBuffer(new byte[4096], 0, 4096); recvArgs.Completed += OnReceiveCompleted; socket.ReceiveAsync(recvArgs); } else { Console.WriteLine($"connect failed: {e.SocketError}"); // 这里应该触发重连 } }; // ConnectAsync 返回 false 表示操作同步完成,true 表示等待异步回调 bool completedSync = socket.ConnectAsync(args); if (completedSync) { // 极少出现,但如果同步完成,Completed 事件不会触发 Console.WriteLine("connected synchronously"); }ConnectAsync 的返回值是个容易踩坑的细节。它返回 true 表示连接操作正在异步进行,完成时会触发 Completed 事件;返回 false 表示操作已经同步完成,这时候 Completed 事件不会触发,必须立刻检查 SocketError。很多初学者只在 Completed 回调里做连接成功判断,结果遇到返回 false 的情况连接永远走不进后续逻辑。
连接成功后,接收循环和服务器端的异步接收类似,也是用 SocketAsyncEventArgs 完成链式读取。Send 操作要注意线程安全。如果业务层有多个线程同时往同一个 Socket 发数据,两个线程的 Send 可能被 TCP 层交错组合,接收方会看到数据错乱。解决办法是给发送操作加锁,或者维护一个发送队列,所有线程把数据丢进队列,由一个线程或者异步循环统一发送。
4.2 UI 线程交互:SynchronizationContext 与 Invoke 的正确姿势
Windows 窗体应用里,UI 控件只能在创建它的线程上操作,后台线程直接改控件属性会抛出跨线程异常,或者更隐蔽的,不抛异常但界面表现怪异。Socket 接收回调是在线程池线程上执行的,不可能直接去更新界面上那个“连接状态”灯的颜色。
标准做法是捕获 UI 线程的 SynchronizationContext,用 Post 方法把数据切回 UI 线程:
private SynchronizationContext _syncContext; public void StartClient() { // 在窗体构造函数或 Load 事件里调用,确保拿到的是 UI 线程上下文 _syncContext = SynchronizationContext.Current ?? new SynchronizationContext(); // 假设 socket 在后台线程收数据 Thread thread = new Thread(ReceiveLoop); thread.IsBackground = true; thread.Start(); } private void ReceiveLoop() { byte[] buffer = new byte[4096]; while (_socket.Connected) { int received = _socket.Receive(buffer); if (received == 0) break; byte[] data = new byte[received]; Array.Copy(buffer, data, received); // 切回 UI 线程更新文本框或状态灯 _syncContext.Post(state => { txtLog.AppendText(Encoding.UTF8.GetString((byte[])state) + Environment.NewLine); }, data); } }这段代码里,Post 和 Send 的区别值得说明。Post 是异步投递,Post 之后立即返回,不会等 UI 线程处理完;Send 是同步投递,会一直阻塞到 UI 线程执行完回调。在后台线程里使用 Send,如果 UI 线程同时又在等待后台线程完成某个操作,就会形成死锁。我写客户端代码几乎只用 Post,除非确定不会发生交叉等待。
这里顺带提一个 UI 更新频率的问题。如果服务器每秒推送几百条数据,每条数据都 Post 到 UI 线程去 AppendText,界面会响应不过来,表现为日志区域滚动卡顿。工程里如果有人封装了 UI 消息分发,通常会包含合并或限流逻辑。没有的话,建议自己加一个数据缓冲队列,定时批量刷新,比如每 100 毫秒刷新一次界面。
4.3 断线重连状态机:真实工程里的连接状态与重连策略
生产环境的网络不可能永远稳定,客户端必须有断线重连能力。很多初学者把重连简单地理解成“连接失败后再调一次 Connect”,但这样做会导致两个问题:一是重连失败时可能死循环,二是网络抖动恢复后所有客户端同时重连,把服务器瞬间打爆。
规范的做法是给连接定义一个状态机。Disconnected 表示初始状态,Connecting 表示正在连接,Connected 表示连接正常,Reconnecting 表示断线后进入重连等待。状态转换由一个定时器驱动:
public enum ClientState { Disconnected, Connecting, Connected, Reconnecting } private ClientState _state = ClientState.Disconnected; private Timer _reconnectTimer; private int _reconnectAttempt; public void Connect() { if (_state == ClientState.Connecting || _state == ClientState.Connected) return; _state = ClientState.Connecting; // 执行异步连接,成功则状态转到 Connected,失败则转到 Reconnecting } public void OnDisconnected() { _state = ClientState.Reconnecting; _reconnectAttempt++; int delaySeconds = Math.Min(60, 5 * _reconnectAttempt); // 退避:5s、10s、15s...封顶 60s _reconnectTimer.Change(TimeSpan.FromSeconds(delaySeconds), Timeout.InfiniteTimeSpan); }重连间隔建议采用退避策略:第一次失败等 5 秒重试,第二次等 10 秒,逐次递增到 60 秒封顶,然后一直每 60 秒重试一次。如果多个客户端同时掉线,固定间隔会让所有客户端在同一时刻发起重连,造成服务器连接尖峰;因此可以在退避时间基础上加一个随机扰动,比如乘以 0.8 到 1.2 之间的随机数。
还有一个常被忽略的细节:重连前必须把旧 Socket 的所有资源释放干净。只要旧 Socket 没有 Close,它的句柄和缓冲区就一直占着内存;如果重连了几百次都不释放,内存会持续增长。我见过一个客户端程序跑了一整夜后内存涨到 2GB,原因就是每次重连都 new Socket,失败后只改了状态没有 Close 旧对象。
5. 避坑与排查:高并发 Socket 工程中常见的五个真实问题
网络程序的故障有一个共同特点:表面现象和根因之间隔着一层系统抽象,不踩几次坑根本不会往那个方向想。这一章把我遇到过的、以及这套工程里可能隐藏的五个典型问题整理出来,每一条都按“现象、原因、解决”的顺序写,方便你直接对照排查。
5.1 现象:客户端连接后马上被断开,服务器没有任何消息
现象是客户端 Connect 成功,但下一秒连接就断开了,服务器端只打印了一条“client connected”就再也没有输出,客户端报错是“远程主机强迫关闭了一个现有的连接”。
原因大概率是 Accept 出来的 Socket 没有被任何对象长期持有。如果代码在 Accept 之后只是打印一条日志就完了,或者新建的线程还没来得及执行 Receive,这个 Socket 对象就被垃圾回收器回收了。Socket 的析构函数会释放底层句柄,表现为连接被强制关闭。
解决方式是为每个连接建立一个强引用的状态对象,并且在 Accept 后立即进入接收循环,不要留给 GC 任何回收窗口。服务器端每条连接都要保存一个引用,可以用 List 或 ConcurrentDictionary 维护起来,断线后再移除。
注意:这个问题在 .NET 里特别隐蔽,因为 Socket 类重写了 Finalize,对象不可达时会关闭底层句柄,但你不会看到任何异常。
5.2 现象:并发数超过 200 后 CPU 占用飙升,吞吐量直线下降
现象是压测客户端从 100 个连接加到 300 个连接,服务器 CPU 从 10% 直接跳到 70%,而且每秒钟处理的消息数反而比 100 连接时更低。
原因是每连接一线程的模型在连接数增加后,大部分 CPU 时间花在线程上下文切换上,而不是花在处理数据上。还有一个容易被忽略的因素是,接收循环里的业务代码如果包含 Console.WriteLine,输出操作会触发控制台锁,多个线程抢锁时会造成严重串行化。
解决方式:一是切换到异步 I/O 模型,让线程数不再跟随连接数增长;二是如果暂时改不了异步,把每个客户端连接的最大数量用 SemaphoreSlim 限制住,超过上限的新连接直接拒绝;三是把日志从 Console.WriteLine 换成异步文件日志,或者直接去掉循环里的日志输出。
5.3 现象:接收数据出现乱码、粘包、半包,尤其是长消息时
现象是客户端发短字符串时服务器能正确收到,但连续发送多条消息时,服务器有时候一条消息收到两次值,有时候一次收到两条拼在一起的字符串,长消息还会被截断成几段。
根因就是 TCP 是字节流,没有应用层消息边界。发送方调用两次 Send,接收方可能用一次 Receive 就全部收走;发送方调用一次 Send 一个很大的对象,接收方可能分多次才收完。乱码通常是字节序或者编码不一致导致,比如发送方用 UTF-8 而接收方按 Default 编码解析。
解决方式是使用我在 3.4 节给出的长度前缀协议:发送时在每个消息前加密 4 字节长度,接收端用带缓冲的拆包器解析。这是解决粘包半包最直接有效的方式,不要用线程 Sleep 或者拼接字符串再判断结尾标志的方式,那些方案在网络状况差时都会翻车。
5.4 现象:WinForms 客户端数据量一上来界面就卡死、假死
现象是客户端接收数据时界面无响应,数据量少时还好,数据量一多整个窗口拖不动,过一会儿弹出“程序未响应”。
根因有两层:一是后台线程直接操作了 UI 控件,没有通过 Invoke 切回 UI 线程;二是即使切回 UI 线程,每条消息都触发一次界面刷新,UI 线程被更新操作淹没。窗体程序是单 UI 线程模型,所有绘制、输入处理、控件事件都在同一线程上排队,一旦这个线程被大量操作占住,界面就失去响应。
解决方式是严格按照 4.2 的方式,用 SynchronizationContext.Post 切回 UI 线程,并且做刷新合并。我一般会做一个 UI 消息队列,后台数据先入队,UI 线程每 100 毫秒批量取出并更新一次文本框和状态灯,这样数据量再大也不会卡死。
5.5 现象:服务器运行几天后句柄数持续增长,连接泄漏
现象是服务器刚启动时正常跑,但运行几个小时后,进程的句柄数和线程数一路增长,最后达到系统上限,新连接无法建立,所有客户端集体掉线。
原因几乎都是资源没释放:Receive 返回 0 时没有调用 Close;SocketException 分支里没有 finally;SocketAsyncEventArgs 每次都 new 而没有复用,导致每个连接分配的内存在连接断开后不能及时释放。
解决方式是统一封装连接关闭流程,把所有退出路径都落到同一个 finally 块里,确保 Close 被调用。SocketAsyncEventArgs 对象在异步模型里建议复用,每次连接建立时申请,连接关闭时清空状态再重新投用,避免反复创建和 GC 压力。服务器上线前可以用一段压测脚本连续创建和断开几千个连接,监控句柄数是否回到基线,这是检查连接泄漏最有效的手段。
6. 进阶验证:用压测脚本和抓包验证,把这套工程的并发参数调到最优
源码跑通只是第一步,真正检验它能不能扛住高并发,要动手压测。我每拿到一份网络通信工程,都会先写一个简单的压测客户端,用它来验证改进前后的性能差异。
压测脚本本身就是个 C# 控制台程序,模拟 500 个客户端同时连接服务器,每个连接发送 1000 条消息并等待回显:
int concurrent = 500; // 同时连接数 int totalMessages = 1000; // 每个连接发送的消息次数 var tasks = new List<Task>(); for (int i = 0; i < concurrent; i++) { tasks.Add(Task.Run(() => { using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); socket.Connect("127.0.0.1", 9000); for (int j = 0; j < totalMessages; j++) { byte[] msg = Encoding.UTF8.GetBytes($"hello-{j}"); socket.Send(msg); byte[] buf = new byte[1024]; int received = socket.Receive(buf); if (received == 0) break; } })); } Task.WaitAll(tasks.ToArray());这个脚本里的连接数和消息数就是你的压测参数。我一般从 50 个连接开始,逐步加到 100、200、500、1000,每跑一轮记录三个指标:服务器进程的线程数、CPU 占用、每秒处理的消息数。线程数能直接反映并发模型的质量——同步多线程模型下线程数会跟连接数同比例增长,异步模型下线程数基本稳定在个位数到几十之间。
压测同时要打开网络抓包工具观察连接状态。重点关注两点:一是看三次握手是否正常,有没有大量重传;二是看 TCP 窗口有没有出现零窗口通告,如果出现了,说明服务器接收缓冲区已经满了,应用层消费数据的速度跟不上网络传输速度,这时候需要提高 Receive 调用的频率或者加大接收缓冲区。
还有一个容易被忽略的验证项:拔线测试。压测过程中把客户端所在的网线断开或者关掉客户端机器,观察服务器多久能发现连接断开并清理掉这个会话。如果超过 90 秒还发现不了,说明心跳阈值没设对或者清理定时器没有生效。
我在做模拟项目X的性能优化时,曾经被一个现象困了整整一个晚上:异步模型压测 1000 连接时内存持续攀升,但连接数和 CPU 都正常。后来用抓包加内存分析才定位到,是每个连接都 new 了一个 SocketAsyncEventArgs,断开后没有归还给对象池,导致 GC 来不及回收。从那以后我每次压测都强制走一遍标准化流程:先看连接建立和断开是否对称,再看线程数和句柄数曲线,再看 CPU 和内存趋势,最后才看业务消息的成功率。这套流程帮我避开了很多隐藏的坑,希望也能帮到你。
本文还有配套的精品资源,点击获取