ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C# WebSocket实战:从握手到心跳重连的完整Demo解析

C# WebSocket实战:从握手到心跳重连的完整Demo解析

简介:这是一套面向C#开发者的WebSocket通信完整示例,适合需要实现实时双向通信、研究服务端与客户端交互逻辑的中高级程序员,也可作为ASP.NET项目集成WebSocket功能的参考。资源包内含客户端与服务端两个完整工程,共76个文件,主要由C#源文件、ASPX页面、配置文件、JavaScript与CSS前端资源构成,压缩包仅333KB,轻量易用。目前已有3509人学习下载,属于较受关注的经典Demo。示例完整展示了服务端基于HttpListener与WebSocket类完成HTTP升级请求处理、Sec-WebSocket-Key验证、AcceptWebSocketAsync建立连接,以及ReceiveAsync/SendAsync异步收发和关闭连接的流程;客户端则通过ClientWebSocket实现ConnectAsync、消息发送、持续监听与关闭。源码还涉及多连接管理、异步编程、数据编码解码等关键点,便于开发者深入理解WebSocket通信机制,快速移植到自己的实时通信、消息推送或在线协作项目中。

1. WebSocket Demo 源代码其实是通讯骨架:一个能跑起来的长连接样本

手头这份 C# WebSocket 客户端及服务端 Demo 源代码,把长连接里最核心的三件事——HTTP 握手升级、双向收发、断开清理——用原生类库写完,下载后改改端口就能跑。它解决的问题很具体:HTTP 请求问答模式做不了服务端主动推送,轮询又浪费资源,WebSocket 把 TCP 连接升级成一条全双工通道,弹幕、通知推送、远程控制这类场景都靠它撑。适合刚补完 C# 基础、想搞懂长连接到底怎么工作的新手,也适合需要在现有系统里塞一个通讯模块、又不想引第三方依赖的开发者。服务端和客户端两部分各自独立,拆开看、合起来跑都可以。

2. 先看协议再动手:握手、帧格式与 C# 实现选型

2.1 握手不是玄学:HTTP 如何升级成 WebSocket

很多人第一次抓包看到 WebSocket 流量也是一堆 HTTP 头,就以为 WebSocket 是 HTTP 的某个模式。严格说,建立过程借用 HTTP 通道,建立完成之后协议就切走了。客户端先发一个 GET 请求,带着 Upgrade、Connection、Sec-WebSocket-Key 这几个头,服务端校验通过后回 101,连接从这一秒起不再按 HTTP 报文格式解析。一个典型的握手请求长这样:

GET /ws HTTP/1.1 Host: 192.168.1.10:9999 Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13

Sec-WebSocket-Key 是 16 字节随机数做 Base64,每次连接都不同。服务端拿到这个 Key,拼上协议固定 GUID258EAFA5-E914-47DA-95CA-C5AB0DC85B11,做一次 SHA-1 再 Base64,得到 Sec-WebSocket-Accept 返回。注意这个 GUID 是规范里写死的,不是自造的私货。响应长这样:

HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

如果服务端回的不是 101,而是一个 400 或 403,那握手就没成,连接进不了 WebSocket 状态。调试时把这些头对照着看,基本能判断问题出在哪一端。

握手完成之后的数据交换是二进制帧。一帧里 FIN 位表示是不是最后一个分片;OpCode 区分 Text(0x1)、Binary(0x2)、Close(0x8)、Ping(0x9)、Pong(0xA);Mask 位决定载荷是否掩码。协议强制要求客户端发给服务端的帧必须有掩码,服务端发给客户端的帧不能有掩码,这条是校验项,两边不遵守就会直接断链。好消息是 C# 里这些位运算和掩码处理都被封装好了,不用自己碰,但理解这些规则能让你看到 1002、1003 这类关闭码时心里有数。

还有一个容易忽视的细节:当对端收到不合法帧(掩码错误、OpCode 未定义)或格式错误的消息时,会返回一个携带 1002 协议错误的 Close 帧。如果你在异常日志里看到 1002,说明问题出在帧构造上,而不是网络不通;1003 则说明消息类型不支持。这两类错误码在排查时比看 TCP 层更有指向性。

2.2 C# 方案的选型思路:为什么这份 Demo 用原生类库

C# 做 WebSocket 服务端有几条路。第一条用 HttpListener 加 AcceptWebSocketAsync,这是 .NET 自带能力,一个控制台程序就能跑,不需要拉起一整套 Web 项目;缺点是没有连接池、心跳、并发控制,都要自己补。第二条用 ASP.NET Core 的 WebSocket 中间件,路由、鉴权、依赖注入、日志都是现成的,适合集成进正式 Web API,但为一个 Demo 起一个 Web 项目,样板代码有点多。第三条引用轻量第三方库,API 封装得简单,但多一层依赖,升级节奏和项目维护情况不可控。第四条直接用 SignalR 这类高层框架,重连、分组、扩展都有,但抽象度高,想理解协议细节反而隔着帘子。

客户端这边没什么悬念,ClientWebSocket 是内置类,连接、发送、接收全部异步化,跟 async/await 配合很顺手,不需要绕第三方。

这份 Demo 用的是 HttpListener 加 ClientWebSocket 的组合。选择原因很直接:零第三方依赖,编译环境干净,代码里能清楚看到「监听 → 接收 → 回复 → 关闭」的边界。当你需要在两台内网机器之间做实时通讯时,这套组合是最小的可运行单位;如果项目将来要接入复杂鉴权、多租户、横向扩容,再考虑搬进 ASP.NET Core 中间件也不迟。选型没有绝对最优,只有跟场景是否匹配。

方案依赖情况典型场景需要自己补的东西
HttpListener + AcceptWebSocketAsync.NET 自带轻量服务、Demo、内部工具连接管理、心跳、并发控制
ASP.NET Core WebSocket 中间件框架自带正式 API、需要鉴权相对少,但项目结构较重
第三方轻量库NuGet 引用快速集成、偏脚本化依赖更新、协议兼容性
SignalR 高层框架框架自带自动重连、分组推送定制底层行为较麻烦

如果你只是先验证一个协议想法,强烈建议第一条路,代码量最少,踩坑时也最容易定位。

3. 服务端 Demo 落地:HttpListener 从握手到广播

3.1 服务端骨架:监听前缀、握手认证与连接注册

服务端第一个关键点是监听前缀。HttpListener 的前缀格式决定了程序绑在哪些网卡、哪些端口、哪些路径上,写错一个字符,表现可能完全不一样。Demo 里的典型写法如下:

public class WsServer { private readonly HttpListener _listener = new HttpListener(); private readonly ConcurrentDictionary<string, WebSocket> _clients = new(); public void Start() { // 加号代表监听本机所有 IP,路径 /ws 是握手地址 _listener.Prefixes.Add("http://+:9999/ws"); _listener.Start(); Console.WriteLine("服务已启动,等待 WebSocket 连接..."); while (true) { // GetContext 阻塞等待 HTTP 请求进入 HttpListenerContext context = _listener.GetContext(); // 不是 WebSocket 升级请求,直接拒绝 if (!context.Request.IsWebSocketRequest) { context.Response.StatusCode = 400; context.Response.Close(); continue; } // 握手和后续收发交给异步方法,主循环继续接请求 _ = ProcessWebSocketAsync(context); } } }

前缀里的加号等价于监听所有本机 IP,包括回环地址和局域网网卡;如果你写http://localhost:9999/ws,另一台设备用局域网 IP 访问时,TCP 能通到机器,但 HttpListener 会认为没有匹配的前缀而拒绝。路径/ws尽量保留,它相当于一个路由标识,未来同一端口上可以再挂/api、/admin。IsWebSocketRequest 的判断必须做,因为不是所有访问者都懂 WebSocket 协议,普通 HTTP 客户端访问时应该拿到一个明确的 400 而不是挂死。

Windows 上还有一个暗坑:非管理员进程监听http://+:9999/ws可能因为 URL ACL 权限不足而直接抛地址访问拒绝。常见做法是管理员执行一次netsh http add urlacl url=http://+:9999/ws user=Everyone,或者干脆用管理员身份跑程序。要注意 .NET Framework 和 .NET Core 在下层的实现不同,权限表现也有差异,换环境跑通不了时优先查这个。

3.2 接收循环与广播:连接字典、分片拼接和发送锁

连接建立之后的注册和接收逻辑,我一般放在异步方法里:

private async Task ProcessWebSocketAsync(HttpListenerContext context) { WebSocket ws = null; string clientId = Guid.NewGuid().ToString("N"); try { // 第二个参数 null 表示不强制子协议 WebSocketContext wsContext = await context.AcceptWebSocketAsync(null); ws = wsContext.WebSocket; _clients.TryAdd(clientId, ws); Console.WriteLine($"客户端 {clientId} 连接,在线数 {_clients.Count}"); // 接收缓冲 4KB:短消息够用,长消息需要分片拼接 byte[] buffer = new byte[4 * 1024]; while (ws.State == WebSocketState.Open) { WebSocketReceiveResult result = await ws.ReceiveAsync( new ArraySegment<byte>(buffer), CancellationToken.None); if (result.MessageType == WebSocketMessageType.Close) { await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "客户端主动关闭", CancellationToken.None); break; } // result.Count 表示本次实际接收的字节数 // 4KB 缓冲接不下长消息,业务中要改成 MemoryStream 拼接 string text = Encoding.UTF8.GetString(buffer, 0, result.Count); Console.WriteLine($"收到 {clientId}: {text}"); await BroadcastAsync($"客户端 {clientId} 说: {text}"); } } catch (Exception ex) { Console.WriteLine($"连接异常 {clientId}: {ex.Message}"); } finally { _clients.TryRemove(clientId, out _); ws?.Dispose(); Console.WriteLine($"客户端 {clientId} 离线,在线数 {_clients.Count}"); } }

AcceptWebSocketAsync 的第二个参数是子协议协商列表,Demo 传 null,表示不参与协商;如果两端要协商自定义协议,客户端 Options.AddSubProtocol 和服务端这里要能对上,对不上握手直接失败。ReceiveAsync 的 buffer 用 4KB 是为了内存占得少,代价是长消息会被拆成多次返回,result.EndOfMessage 为 false 时还需要继续读,Demo 里为了主线清晰没有拼接,真正用到业务上需要补循环,后面客户端那一章给了完整写法。finally 里把连接从字典移除并 Dispose,这一步不能省,否则断开的连接一直躺在字典里,在线数越积越多,最后广播性能直接下降。

广播部分我一般这样写:

private async Task BroadcastAsync(string message) { byte[] bytes = Encoding.UTF8.GetBytes(message); ArraySegment<byte> segment = new ArraySegment<byte>(bytes); // 先做快照,避免遍历时字典被并发修改 foreach (var pair in _clients.ToArray()) { if (pair.Value.State != WebSocketState.Open) continue; try { // 同一个 WebSocket 并发 SendAsync 会抛异常 // 工程中要为每个连接配 SemaphoreSlim 串行化发送 await pair.Value.SendAsync( segment, WebSocketMessageType.Text, true, CancellationToken.None); } catch (Exception ex) { Console.WriteLine($"向 {pair.Key} 广播失败: {ex.Message}"); } } }

广播的细节:这里把消息回给了发送者自己,实际业务里通常要在广播前判断 clientId 不等于当前连接 ID。某个连接发送失败时先打日志,不要立刻移除,保留连接让心跳机制去判断更稳,因为网络抖动只是一瞬间,移除后客户端会自动重连,但服务端这边会多一次不必要的断连日志。

4. 客户端 Demo 落地:ClientWebSocket 连接、发送与接收

4.1 连接与发送:Proxy、KeepAliveInterval 和 endOfMessage 三件事

客户端最烦的问题往往不在 WebSocket 本身,而在系统环境。ClientWebSocket 默认会走系统代理,哪怕目标地址是内网 IP,代理设置也可能插手;有线网卡和无线网卡切换时,代理配置还经常变。所以局域网直连时第一件事是把 Proxy 关掉:

public class WsClient { private ClientWebSocket _ws = new ClientWebSocket(); private readonly Uri _uri = new Uri("ws://192.168.1.10:9999/ws"); public async Task ConnectAsync() { // 绕过系统代理,内网直连必须关,否则可能超时 _ws.Options.Proxy = null; // 协议层心跳:15 秒发一次 Ping 帧,防止 NAT 空闲回收 _ws.Options.KeepAliveInterval = TimeSpan.FromSeconds(15); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); await _ws.ConnectAsync(_uri, cts.Token); Console.WriteLine($"连接成功,当前状态: {_ws.State}"); } public async Task SendTextAsync(string text) { if (_ws.State != WebSocketState.Open) { Console.WriteLine("连接未就绪,消息未发送"); return; } byte[] bytes = Encoding.UTF8.GetBytes(text); ArraySegment<byte> segment = new ArraySegment<byte>(bytes); // 第三个参数 true 表示这是一条完整消息的结束帧 await _ws.SendAsync(segment, WebSocketMessageType.Text, true, CancellationToken.None); } }

KeepAliveInterval 是 ClientWebSocket 自己控制的协议层 Ping,它保证底层 TCP 连接在空闲时也有数据帧走动,避免路由器和防火墙把连接当垃圾回收。但它不保证业务逻辑正常——业务死锁时连接照样是开的,所以工程里还要有业务心跳。SendAsync 的第三个参数叫 endOfMessage,传 false 时接收方会认为消息还没完,直到某个分片传 true,接收方才会把整段数据拼起来当作一条消息。新手最容易翻车的位置就在这里,发送时漏掉 true,服务端就会一直在等后续分片。另外,每次发送的字节数组最好新分配;如果为了性能复用同一个 byte[],要等上一次 SendAsync 真正完成后再覆盖,否则数据会被写坏。

4.2 接收循环:分片拼接、Close 帧和优雅退出

接收端和 TCP 那种「自己找分隔符拆包」的思路不同,WebSocket 帧自带消息边界,不需要你操心粘包问题。真正需要处理的是分片:ReceiveAsync 把缓冲填满就返回,一条消息超过缓冲长度就分多次返回,所以接收循环要拼到 EndOfMessage 为 true 才结束。一份能直接用的接收循环长这样:

public async Task ReceiveLoopAsync() { // 缓冲给 64KB,比服务端 4KB 大,大消息分片次数少 byte[] buffer = new byte[64 * 1024]; while (_ws.State == WebSocketState.Open) { using var ms = new MemoryStream(); while (true) { WebSocketReceiveResult result = await _ws.ReceiveAsync( new ArraySegment<byte>(buffer), CancellationToken.None); // 对端发来 Close 帧,回一个 Close 再退出,才算优雅关闭 if (result.MessageType == WebSocketMessageType.Close) { await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "收到关闭请求", CancellationToken.None); return; } ms.Write(buffer, 0, result.Count); // EndOfMessage 为 true,本条消息收完 if (result.EndOfMessage) break; } string text = Encoding.UTF8.GetString(ms.ToArray()); Console.WriteLine($"收到消息: {text}"); // 这里把消息交给业务逻辑处理,不要阻塞循环 } }

MemoryStream 在这里承担拼接器的作用,每次收到数据就往里写,收到完整帧才取出来转字符串。对 Text 消息这样做是安全的,因为 UTF-8 编码在分片边界上可能有字节不完整的情况,如果直接把每一片转字符串再接起来,多字节字符会被切坏;先拼字节再转字符串就不会出现半个汉字的问题。这是很多人忽略的细节:分片拼接的是字节,不是字符串。

接收循环通常放在独立后台 Task 里跑,不要和 UI 线程混在一起;消息到达后在任务里触发事件或写入队列,界面层再订阅更新。这样连接管理和界面响应互不阻塞,断线重连时只需要取消旧的接收 Task,重建连接后再启动新的循环。

5. 避坑指南:Demo 跑通后仍然会遇到的五个典型问题

5.1 握手 400:前缀、URL ACL 与路径三连击

现象:客户端调用 ConnectAsync 时抛 WebSocketException,异常信息明确写着服务器返回 400 Bad Request;有些环境还没走到握手,直接提示连接被重置。

原因:多半出在三处。第一,服务端前缀写成了 localhost 而你用局域网 IP 访问;第二,Windows 下普通权限进程监听加号前缀,URL ACL 没授权,访问被系统拒绝;第三,客户端地址少写了路径,服务端前缀是http://+:9999/ws,客户端却连ws://192.168.1.10:9999/,路径匹配不上。

解决:统一服务端前缀为http://+:9999/ws;Windows 上用管理员执行一次netsh http add urlacl url=http://+:9999/ws user=Everyone;客户端地址严格对齐/ws。用抓包工具看服务端返回的响应体,里面通常写着具体拒绝原因,比盲猜快得多。

5.2 中文乱码与消息被截断:编码和缓冲一起背锅

现象:服务端收到的中文变成问号,或者一条长消息只收到一半,日志打印出来的长度和发送端对不上。

原因:乱码几乎都是编码不一致,一端用了 UTF-8,另一端用了 Default 或 GB2312。截断则是接收缓冲只有 4KB,而接收循环没有做分片拼接,第二次 ReceiveAsync 返回的数据被丢掉。

解决:两端锁定 Encoding.UTF8,不要依赖系统默认编码;接收循环改成 4.2 那种 MemoryStream 拼接写法。排查时在两端分别打印字节长度,长度一致说明传输没丢,不一致就去查缓冲和分片处理。

5.3 断网后连接依然 Open:TCP 死了应用层不知道

现象:拔掉网线、重启路由器后,客户端 State 长时间显示 Open,SendAsync 不报错,但对方已经收不到消息。

原因:TCP 在没有活跃数据时,物理断链不会立刻反馈到应用层。WebSocket 的状态是根据本地收发情况维护的,底层链路断了,应用层还标记 Open。KeepAliveInterval 只是对抗空闲回收,不负责探测死亡。

解决:应用层做业务心跳。客户端每 30 秒发一个{"type":"ping"},服务端收到回{"type":"pong"};客户端记录最近一次 pong 的时间,超过 60 秒没更新就调用 Abort 强制断开,触发重连流程。具体代码在第 6 章。

5.4 局域网设备连不上:三个检查点按顺序排

现象:手机或者另一台电脑连ws://192.168.x.x:9999/ws,要么超时,要么 ConnectionRefused。

原因:监听地址绑错、防火墙拦入站、AP 隔离挡终端互访,三个原因任意一个都能造成这个表面现象。

解决:按顺序排查。先telnet 192.168.x.x 9999,TCP 都不通就先查监听前缀和防火墙;TCP 通但 WebSocket 握手失败,再去查路径和升级头;手机和电脑不在同一网段或开了 AP 隔离,则需要调整网络环境。这一步用排除法,不要在代码里瞎试。

5.5 多线程同时 SendAsync:一帧已经在进行中

现象:多个线程同时向同一连接发消息,某个 SendAsync 调用抛出 InvalidOperationException,提示已经有发送操作在进行中。

原因:WebSocket 连接不是线程安全的,同一时刻只能有一个发送操作,并发调用会把帧边界撕碎。广播线程、业务线程、心跳线程如果都直接调 SendAsync,迟早撞车。

解决:为每个连接配一个信号量,发送全部走同一个锁:

private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1); public async Task SendSafeAsync(string text) { await _sendLock.WaitAsync(); try { byte[] bytes = Encoding.UTF8.GetBytes(text); ArraySegment<byte> segment = new ArraySegment<byte>(bytes); await _ws.SendAsync(segment, WebSocketMessageType.Text, true, CancellationToken.None); } finally { _sendLock.Release(); } }

锁粒度控制在单连接内,不同连接的信号量互不干扰,广播给多个连接时每个连接各自走自己的锁,整体吞吐不会因为一把大锁被拖垮。

6. 把 Demo 接进工程:业务心跳、指数退避重连和补消息

6.1 业务心跳:Ping/Pong 与超时判活

Demo 跑通只是起点,长连接真正难在连接断掉之后怎么恢复。我一般在收发循环外面套一个生命周期管理循环,把「连接、接收、异常处理、重连」串成一条线。

心跳做两层。底层 KeepAliveInterval 管 NAT 保活;上层业务心跳管业务链路的健康度。业务心跳的典型写法是客户端定时发{"type":"ping"},服务端收到后回{"type":"pong"},客户端每次收到 pong 都刷新本地时间戳。心跳 Task 检查时间戳,超过 60 秒没有更新就主动 Abort 当前连接。Abort 比 CloseAsync 直接,因为这时候连接已经不可信,走正常关闭流程可能卡住。

6.2 自动重连与消息补偿:指数退避背后的参数

重连用指数退避,防止服务端重启时客户端以 1 秒一次的频率轰炸。常见策略是第一次 1 秒、第二次 2 秒、第三次 4 秒,到 30 秒封顶:

private async Task RunWithReconnectAsync(CancellationToken stopToken) { int attempt = 0; while (!stopToken.IsCancellationRequested) { try { await ConnectAsync(); attempt = 0; // 连接成功,退避计数归零 await ReceiveLoopAsync(); // 阻塞,连接断开后自然退出 } catch (Exception ex) { Console.WriteLine($"连接异常: {ex.Message}"); } finally { _ws.Dispose(); // 必须换新对象,旧对象状态已损坏 _ws = new ClientWebSocket(); } int delaySeconds = Math.Min(30, 1 << attempt); attempt++; Console.WriteLine($"{delaySeconds} 秒后重连..."); await Task.Delay(TimeSpan.FromSeconds(delaySeconds), stopToken); } }

这段代码的细节:连接成功后 attempt 清零,否则服务端只是闪断一下,客户端也要等满 30 秒才重连,恢复速度太慢;重连前必须丢掉旧 ClientWebSocket 并 new 一个新的,已进入 Aborted 状态的对象无法复用。断线期间产生的业务消息,重连后要处理补偿逻辑——要么把所有离线消息重发,要么主动拉取服务端离线缓存,具体取决于业务是推送型还是拉取型。我习惯加一个待发送队列,重连成功先清队列再开始正常收发。

最后留一个习惯:凡是长连接客户端,我都会把心跳、重连、收发日志三件事绑定在一起,日志里带时间戳和当前连接状态,排障时一眼能看出连接是断了重连的,还是静默卡死的。这个习惯救过我很多次,有一回线上问题定位全靠重连日志里的时间线。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进