☰
C# WebSocket长连接封装实战:心跳、重连与并发写调优
2026/10/14 17:52:47 网站建设 项目流程

简介:这份资源是作者自行改写的 WebSocket SDK,基于 websocket-sharp 构建,面向需要在 C# .NET Framework 2.0 环境下实现 WebSocket 通信的开发者。它解决了低版本 .NET 平台缺乏完善 WebSocket 支持的问题,支持多种安全校验方式,可用于搭建 wss 服务器,并额外提供了作为 API 服务器的客户端实现,适合有一定 C# 网络编程基础、需要兼容旧框架的工程人员参考使用。压缩包共 103 个文件,约 1.51MB,以 81 个 cs 源码文件为核心,辅以 5 个 dll 与 5 个 pdb 调试文件,另含 csproj 工程文件、snk 签名文件及少量缓存与配置文本,整体结构完整,便于直接编译与二次开发。目前已有 731 人学习下载。读者可从中获取 WebSocket 服务端与客户端的完整实现思路,包括会话管理、HTTP 服务、请求头处理与行为扩展等模块,并借助调试符号与工程配置快速定位问题,适合作为旧版 .NET 下 WebSocket 通信的实践参考。

1. 从 NormanWebsocket-Sharp.zip 说起:一个 C# 长连接方案到底值不值得落地

如果你在 C# 项目里做过实时通信,大概率绕不开 WebSocket。但原生System.Net.WebSockets用起来有多难受,踩过的人都懂:连接状态要自己管、心跳要自己写、断线重连要自己兜、多线程并发写还要加锁。所以当有人把NormanWebsocket-Sharp.zip这类封装方案丢到群里时,第一反应往往是——它到底解决了哪一层问题,还是只是把官方库包了一层壳?

这个标题指向的是一套基于 C# 的 WebSocket 客户端/服务端封装方案,核心价值在于把连接生命周期、心跳保活、重连策略、消息分发这几件重复劳动收敛成可复用的组件。它适合三类人:做 Unity 实时对战或推送的客户端开发、做 .NET 服务端长连接网关的后端、以及需要在 WinForm/WPF 里接实时数据的桌面端工程师。下面我按「先搞清它封装了什么 → 怎么跑通最小闭环 → 参数怎么调 → 坑在哪」的顺序拆开讲,中间给可直接抄的代码和参数表。

2. 先看清封装边界:NormanWebsocket-Sharp 到底替你做了哪些事

2.1 原生 WebSocket 的四个重复劳动

在 .NET 里直接用ClientWebSocket,一个能上生产的连接至少要处理四件事。第一是状态机:Connecting / Open / Closing / Closed四个状态之间的迁移必须自己维护,稍不留神就会出现「以为还连着、实际早断了」的玄学问题。第二是接收循环:ReceiveAsync返回的可能是分片消息,EndOfMessage为 false 时你得自己拼缓冲区,否则大消息直接截断。第三是心跳:TCP 层的 KeepAlive 默认两小时才探测一次,业务层必须自己发 Ping 帧或自定义心跳包。第四是并发写:WebSocket 协议规定同一时刻只能有一个 Send 在飞,多线程直接调SendAsync会抛InvalidOperationException。

NormanWebsocket-Sharp这类封装的核心思路,就是把上面四件事收进一个WebSocketService或WebSocketClient类里,对外只暴露Connect / Send / OnMessage / OnClose几个事件。你拿到的不是新协议,而是一层「状态可控、事件驱动」的壳。

2.2 客户端与服务端的职责划分

落地前先明确一点:这套方案通常客户端和服务端是两套 API。客户端负责「连上去、保持住、断了我重连」,服务端负责「接进来、管理会话、广播或定向推」。两者的公共部分是消息编解码和心跳协议。

角色核心类(常见命名)主要职责不该做的事
客户端WebSocketClient连接、重连、心跳、收消息不管业务路由
服务端WebSocketServer监听、会话管理、广播不写业务逻辑
会话WebSocketSession单连接读写、绑定用户标识不持有全局状态
编解码MessageCodec序列化/反序列化、粘包处理不碰网络 IO

这张表的意义在于:很多人翻车是因为把业务逻辑塞进了 Session 里,结果一个用户断线重连后旧 Session 没清,内存里挂了一堆僵尸对象。职责划清,后面排查才有抓手。

2.3 最小可运行闭环:客户端连上并收到一条消息

先跑通客户端。下面这段代码假设你已经把NormanWebsocket-Sharp的源码或 DLL 引入项目,命名空间按常见约定写成Norman.WebSocket,实际以你拿到的包为准。

using System; using Norman.WebSocket; // 按实际命名空间调整 class Program { static void Main() { // 1. 创建客户端实例,传入服务端地址 var client = new WebSocketClient("ws://127.0.0.1:8181/ws"); // 2. 注册事件:收到消息、连接关闭、发生错误 client.OnMessage += (sender, msg) => { // msg 通常是 string 或 byte[],看封装约定 Console.WriteLine($"[收到] {msg}"); }; client.OnClose += (sender, e) => { Console.WriteLine($"[关闭] code={e.Code} reason={e.Reason}"); }; client.OnError += (sender, ex) => { Console.WriteLine($"[错误] {ex.Message}"); }; // 3. 发起连接(内部一般会启动接收循环和心跳定时器) client.Connect(); // 4. 发一条测试消息 client.Send("hello from client"); // 5. 阻塞主线程,避免程序退出 Console.ReadLine(); client.Close(); } }

逻辑说明:Connect()内部通常做三件事——建立 TCP、完成 WebSocket 握手、启动接收循环和心跳定时器。Send()一般会加锁保证同一时刻只有一个写操作。参数说明:地址里的ws://是明文,生产环境用wss://;端口8181是示例,按你服务端实际监听改;OnMessage回调在哪个线程执行很关键,如果是 IO 线程,里面别做耗时操作,否则会阻塞接收。

2.4 服务端最小闭环:监听并回显

服务端代码结构类似,但多了一层会话管理。

using System; using Norman.WebSocket; class ServerDemo { static void Main() { // 1. 创建服务端,指定监听端口 var server = new WebSocketServer(8181); // 2. 新连接进来时触发,session 代表这条连接 server.OnNewSession += (session) => { Console.WriteLine($"[新连接] id={session.Id}"); // 3. 收到该连接的消息 session.OnMessage += (msg) => { Console.WriteLine($"[{session.Id}] {msg}"); // 4. 原样回显 session.Send($"echo: {msg}"); }; // 5. 连接关闭时清理 session.OnClose += () => { Console.WriteLine($"[断开] id={session.Id}"); }; }; // 6. 启动监听 server.Start(); Console.WriteLine("服务端已启动,按回车退出"); Console.ReadLine(); server.Stop(); } }

逻辑说明:OnNewSession是每条新连接的回调,session.Id用来区分不同客户端。参数说明:WebSocketServer(8181)里的端口要和客户端一致;Start()内部一般会起一个 accept 循环,注意它是否阻塞——如果阻塞,要放到独立线程或 Task 里。回显只是验证链路,真实业务里session.Send前通常要判断session.IsConnected,否则对已断开的连接写会抛异常。

3. 心跳、重连、并发写:三个决定稳定性的参数怎么调

3.1 心跳间隔:太短费电,太长假死

心跳是长连接的命根子。间隔设多少,取决于你的网络环境和业务容忍度。常见做法是客户端每 20~30 秒发一次 Ping,服务端超过 2~3 个周期没收到就判定掉线。

参数常见取值调大后果调小后果
心跳间隔20~30s掉线发现慢流量和电量上升
心跳超时间隔×2~3误判断线僵尸连接堆积
重连基础间隔1~3s恢复慢服务端被重连风暴打崩
重连最大间隔30~60s长时间连不上恢复不及时

血泪经验:心跳超时不要设成和间隔一样。比如间隔 30 秒、超时也 30 秒,网络抖一下就被判死,然后触发重连,重连又失败,进入死循环。留 2~3 倍余量是底线。

3.2 指数退避重连:别让断线变成雪崩

断线重连最忌讳固定间隔猛冲。服务端重启时,几百个客户端同时每秒重连一次,直接把服务端打挂。正确做法是指数退避加随机抖动。

// 重连延迟计算:基础 1s,每次翻倍,上限 30s,加 0~500ms 抖动 private int GetReconnectDelay(int attempt) { int baseDelay = 1000; // 基础 1 秒 int maxDelay = 30000; // 上限 30 秒 int delay = Math.Min(baseDelay * (int)Math.Pow(2, attempt), maxDelay); // 抖动:避免所有客户端同一时刻重连 var random = new Random(); return delay + random.Next(0, 500); }

逻辑说明:attempt是第几次重连,从 0 开始。Math.Pow(2, attempt)让延迟按 1s、2s、4s、8s 增长,到 30 秒封顶。参数说明:抖动范围 0~500ms 是经验值,客户端数量越多抖动范围可以越大。注意重连成功后要把attempt归零,否则下次断线直接从 30 秒起步。

3.3 并发写加锁:一个 Send 锁省不掉

前面提过,WebSocket 同一时刻只能有一个 Send 在飞。封装库一般内部用SemaphoreSlim或lock保证,但如果你绕过封装直接调底层,就必须自己加。

private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1); public async Task SendAsync(string message) { await _sendLock.WaitAsync(); // 拿锁 try { // 这里调用底层 SendAsync await _webSocket.SendAsync( new ArraySegment<byte>(Encoding.UTF8.GetBytes(message)), WebSocketMessageType.Text, true, // endOfMessage CancellationToken.None); } finally { _sendLock.Release(); // 必须放 finally,否则异常后死锁 } }

逻辑说明:SemaphoreSlim(1,1)等价于一把互斥锁,但支持异步等待,不会阻塞线程。参数说明:endOfMessage: true表示这条消息不分片;如果要发大消息,得分片发送并保证最后一片为 true。Release()放finally是硬性要求,任何在锁内抛异常的路径都不能漏掉释放,否则后续所有 Send 全部卡死。

4. 避坑与排查:五条真实踩过的记录

4.1 现象:客户端显示已连接,但收不到任何消息

原因:接收循环没启动,或者启动在了一个已经退出的线程上。有些封装把接收循环放在Connect()里同步执行,如果调用方没开新线程,主线程被占住,看起来连上了实际没在收。

解决:确认Connect()是否阻塞。如果阻塞,用Task.Run(() => client.Connect())包一层。另外检查OnMessage是否在连接建立之前就注册了,事件注册晚于消息到达会丢消息。

4.2 现象:服务端跑几小时后内存持续上涨

原因:断开的 Session 没从会话集合里移除。客户端异常断开时,服务端的OnClose不一定触发,尤其是网络层直接 RST 的情况。

解决:除了依赖OnClose,再加一个定时扫描,把LastActiveTime超过心跳超时 3 倍的 Session 主动清理。会话集合用ConcurrentDictionary,清理时先标记再移除,避免并发遍历出错。

4.3 现象:中文消息收到后变成乱码

原因:发送端用 UTF-8 编码,接收端按默认编码解码,或者分片消息拼接时按字节切断了多字节字符。

解决:两端统一 UTF-8。分片拼接时不要按固定长度切,要按EndOfMessage标志拼完整再解码。如果封装返回的是byte[],自己用Encoding.UTF8.GetString转,别用Encoding.Default。

4.4 现象:重连后收到重复消息

原因:重连时旧连接的接收循环没停,新连接又起了一个,两个循环同时往同一个回调里灌消息。

解决:重连前先确保旧连接彻底关闭——取消接收循环的 CancellationToken、释放 Socket、等待接收任务结束,再建新连接。封装里如果有Close()和Dispose()两个方法,两个都要调,顺序是先 Close 再 Dispose。

4.5 现象:高并发下 Send 抛 InvalidOperationException

原因:绕过了封装的发送锁,或者封装本身没加锁。错误信息通常是「已有发送操作正在进行」。

解决:所有发送走同一个入口方法,方法内加SemaphoreSlim。如果业务上确实需要高频发送,考虑在应用层做发送队列,单线程消费队列,从根上避免并发写。

5. 进阶:把 NormanWebsocket-Sharp 接进真实业务的两个技巧

5.1 用消息协议头区分业务类型

裸 WebSocket 只传字符串,业务一多就乱。常见做法是在消息前面加一个固定长度的头,比如 4 字节消息类型 + 4 字节长度 + 消息体。

// 打包:4字节类型 + 4字节长度 + UTF8消息体 public static byte[] Pack(int msgType, string body) { var bodyBytes = Encoding.UTF8.GetBytes(body); var buffer = new byte[8 + bodyBytes.Length]; BitConverter.GetBytes(msgType).CopyTo(buffer, 0); // 前4字节:类型 BitConverter.GetBytes(bodyBytes.Length).CopyTo(buffer, 4); // 4-8字节:长度 bodyBytes.CopyTo(buffer, 8); // 8字节后:消息体 return buffer; } // 解包:先读8字节头,再按长度读消息体 public static (int msgType, string body) Unpack(byte[] data) { int msgType = BitConverter.ToInt32(data, 0); int len = BitConverter.ToInt32(data, 4); string body = Encoding.UTF8.GetString(data, 8, len); return (msgType, body); }

逻辑说明:固定 8 字节头让接收端能先读头、再按长度读体,解决 TCP 粘包问题。参数说明:BitConverter默认小端序,两端必须一致;如果消息体可能超过 2GB,长度字段要换long。注意Unpack假设data已经是一个完整包,实际接收时要先缓冲、按头里的长度判断包是否收全。

5.2 用连接状态做业务降级

长连接不是永远可靠的。我的习惯是在客户端维护一个ConnectionState枚举,业务层发消息前先看状态:Open就直接发,Reconnecting就进本地队列,Closed就提示用户或走 HTTP 兜底。

状态业务动作用户感知
Open直接发送实时
Reconnecting入本地队列,连上后补发略有延迟
Closed走 HTTP 接口或提示重试明确提示

这个降级策略的价值在于:用户不会因为一次网络抖动就丢操作。队列要有上限,比如 100 条,超了就丢最旧的,避免内存无限涨。

5.3 验证连接是否真的活着

别信IsConnected属性,它可能只是上次操作的结果。真正可靠的验证是发一个 Ping 并等 Pong,带超时。

public async Task<bool> PingAsync(int timeoutMs = 3000) { var tcs = new TaskCompletionSource<bool>(); // 注册一次性 Pong 回调 EventHandler onPong = null; onPong = (s, e) => { _client.OnPong -= onPong; // 收到就注销,避免重复触发 tcs.TrySetResult(true); }; _client.OnPong += onPong; _client.SendPing(); // 超时或收到 Pong,谁先到算谁 var completed = await Task.WhenAny(tcs.Task, Task.Delay(timeoutMs)); return completed == tcs.Task && tcs.Task.Result; }

逻辑说明:TaskCompletionSource把事件回调转成可 await 的 Task,Task.WhenAny实现超时。参数说明:timeoutMs默认 3 秒,内网可以调到 1 秒,移动网络建议 5 秒。注意onPong必须在收到后注销,否则多次 Ping 会累积回调,内存泄漏。

我自己的习惯是:任何长连接方案上线前,先写一个「断网—恢复—再断网」的循环测试脚本,跑够 500 次重连不崩、不泄漏、不重复投递,才敢往生产放。NormanWebsocket-Sharp这类封装能帮你省掉大量样板代码,但心跳参数、重连策略、并发写这三件事,最终还得按你自己的网络环境调。希望帮到你。

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

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

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

立即咨询