尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
.NET WebSocket实战:从握手原理到心跳保活与避坑指南
简介这是一份面向.NET/WinForms开发者的WebSocket通信示例包覆盖客户端、服务端与网页测试端。资源共174个文件约984KB以C#源码、工程配置、可执行文件、HTML测试页及说明文档为主包含WinformServer、WinformClient、easyHtmlClient三个模块。示例涵盖ClientWebSocket连接与收发、HttpListener服务端、JavaScript页面联调、SSL安全与连接管理优化等关键点方便对照学习完整实时通信流程。已有453人浏览学习适合正在做聊天室、在线游戏、实时图表或快速搭建实时通信原型的开发者参考。1. websocket 在 .NET 里并不是黑匣子先搞清它解决什么你手里这份 .NET WebSocket 资源解决的是 HTTP 长轮询压不住实时交互的问题。我在做某个跨平台系统的时候要推行情数据一开始用 HTTP 轮询一秒拉一次服务器 CPU 直接飙到 80%后来换成 WebSocket长连接一次建立服务器主动推数据CPU 降到 2% 以下。WebSocket 不像 HTTP 那样每次都握手而是在一次握手之后保持连接双向发送消息帧延迟在毫秒级。适合做在线聊天、协作编辑、股票行情、游戏对战这类场景。新手跟着本文能从零搭出一个可用的服务端熟手可以从参数调整和避坑部分直接拿经验。本文的代码基于 .NET 8如果你用的是 .NET 6 或 7API 基本没变化可以放心照做。2. 原生 WebSocket vs SignalR选型背后的参数与边界2.1 协议基础握手、帧、掩码WebSocket 协议建立在 TCP 之上复用 HTTP 的握手流程。客户端发起一个带有 Upgrade: websocket 头的请求服务器返回 101 Switching Protocols之后双方就可以互相发送数据帧。帧结构里有 FIN、opcode、payload length、mask 和 payload 数据。opcode 决定帧类型比如 0x1 是文本帧0x2 是二进制帧0x8 是关闭帧0x9 是 Ping0xA 是 Pong。解析帧的时候最容易被忽视的是掩码客户端发送给服务器的数据帧必须带掩码而服务器发给客户端的帧不能带掩码。这是一个硬性规定如果你自己写客户端没做掩码处理服务器会直接断开连接。握手过程中的状态码也需要留意。101 是正常升级400 是请求头缺少关键字段比如缺少 Sec-WebSocket-Key403 通常是服务器拒绝跨域请求。Sec-WebSocket-Key 是客户端生成的一个随机 base64 字符串服务器需要用固定算法算出一个 Sec-WebSocket-Accept 返回。这个算法的输入是 key 加上一个固定 GUID 字符串再取 SHA1 后做 base64。不少新手在这里翻车以为只要把 key 原样返回就行实际上必须经过特定处理。帧的读写还有一个容易被忽略的点TCP 是流式协议WebSocket 的帧边界不一定和 TCP 包边界对齐。也就是说你一次 Receive 可能只读到一个帧的一部分也可能一次收到了两个帧。所以服务端循环读取时必须自己拼接 buffer并且根据 payload length 判断是否读完了整个帧。很多自己封装协议的开发者在这一步容易做错导致粘包或者半包问题。WebSocket 标准帧结构本身已经做了长度编码所以在应用层你只需要按照帧格式解析即可但如果你拿到的是一个自定义二进制协议帧边界往往要在 data 字段里自己再加一个消息长度头。2.2 选型对比什么时候用原生什么时候用 SignalR.NET 生态里做实时通信无非两条路原生的 System.Net.WebSockets或者 ASP.NET Core SignalR。SignalR 是高层封装它内置了连接管理、自动重连、分组、服务端推送而且还做了传输降级机制如果 WebSocket 不可用会自动换成 Server-Sent Events 或长轮询。这一点在复杂网络环境下很实用因为有些老版本浏览器或企业代理可能会拦截 WebSocket 的 Upgrade 请求。SignalR 的好处是省心前端有现成的 JS 库后端有 Hub 模型写起来像调用本地方法一样。但缺点也很明显抽象层太重出了问题很难排查。你发一条消息中间经过序列化、协议缓冲、传输调度最后到你手里已经是多层转换后的结果一旦出现延迟异常或连接状态不对你很难定位是哪一环出了问题。原生 WebSocket 则完全相反它给你的是最底层的帧读写接口。你需要自己处理 HWorldshake 校验、消息格式定义、心跳机制、断线重连逻辑。这些工作看起来繁琐但换来的是绝对可控每条消息的发送时机、帧类型、时序都由你掌握。我一般这么判断如果你的场景是纯粹的广播或聊天室类功能实时性要求不是极度严苛直接用 SignalR 可以节省大量开发时间但如果你是做股票行情、实时协同编辑、游戏位置同步这类要求低延迟和高吞吐的项目原生 WebSocket 更值得选。SignalR 在内部会额外增加 JSON 序列化和协议头通常比裸 WebSocket 多出 10-20% 的性能开销。我有一次在某个模拟项目X里用 SignalR 推送高频行情数据每秒钟 2000 条结果 CPU 跑满换成原生 WebSocket 二进制帧后同样的数据量占用只有原来的 40%。还有一个比较关键的参数KeepAliveInterval。SignalR 的默认 WebSocket 心跳间隔是 15 秒原生 WebSocket 需要在服务器端手动画这个值。在某些中转代理环境中代理设备会关闭空闲连接如果你没设置心跳客户端和服务器之间看起来连接还在但实际数据已经不互通了。关于心跳的具体配置我会在第 5 章展开。3. 用 C# 写一个可复现的 WebSocket 服务器从 Handshake 到消息推送3.1 环境准备与项目结构我用的是 .NET 8 SDK创建一个 ASP.NET Core 空项目。你不需要额外的 NuGet 包因为 WebSocket 中间件在框架里已经有了。项目结构很简单就两个文件Program.cs 负责配置中间件WebSocketHandler.cs 负责处理连接和消息广播。如果你用的是 Visual Studio 或命令行都可以通过以下命令创建项目dotnet new web -n MyWebSocketDemo cd MyWebSocketDemo然后添加一个 WebSocketHandler.cs 类文件。这个类主要是管理所有的 WebSocket 连接把它们放在一个 ConcurrentDictionary 里方便做广播和状态检查。3.2 服务器端代码实现下面是 Program.cs 的关键实现注意我是把 WebSocket 中间件挂到了路径 /ws 上。这里有一个容易踩坑的地方WebSocket 中间件必须在 MapGet 或 UseRouting 之前使用否则请求可能被普通管道处理掉返回 404 而不是 101 状态码。using System.Net.WebSockets; using System.Text; var builder WebApplication.CreateBuilder(args); var app builder.Build(); var wsHandler new WebSocketHandler(); app.UseWebSockets(new WebSocketOptions { KeepAliveInterval TimeSpan.FromSeconds(10), AllowedOrigins { http://localhost:3000 } // 注意如果前端在 http://localhost:3000这个配置才有效 }); app.Map(/ws, async context { if (!context.WebSockets.IsWebSocketRequest) { context.Response.StatusCode 400; return; } using var socket await context.WebSockets.AcceptWebSocketAsync(); string clientId Guid.NewGuid().ToString(); try { await wsHandler.Handle(socket, clientId); } catch (WebSocketException ex) { Console.WriteLine($连接异常: {ex.Message}); } }); app.Run();这里要注意 WebSocketOptions 的几个参数。KeepAliveInterval 控制的是 Ping/Pong 帧的发送间隔每 10 秒发一次确保中间代理不会判定空闲而切断连接。AllowedOrigins 用于限制跨域来源如果不设置默认允许所有来源。如果你是用 Postman 或自建客户端来测要确认 Origin 头符合这个配置否则握手会被拒绝状态码是 403。我在做某个项目时前端调试地址是 http://localhost:3000后端跑在 5000忘了配置 AllowedOrigins整整查了一天才反应过来。WebSocketHandler 类的主要职责是接收消息并把消息广播给所有连接。我在这里加入了 ReceiveAsync 循环每次分配一个 4KB 的 buffer但实际消息可能超过这个长度所以需要循环读取直到 EndOfMessage 为 true。看下面的代码using System.Net.WebSockets; using System.Collections.Concurrent; using System.Text; public class WebSocketHandler { private static readonly ConcurrentDictionarystring, WebSocket _clients new(); public async Task Handle(WebSocket socket, string clientId) { _clients.TryAdd(clientId, socket); Console.WriteLine($客户端 {clientId} 接入当前连接数: {_clients.Count}); var buffer new byte[4096]; try { while (socket.State WebSocketState.Open) { using var ms new MemoryStream(); WebSocketReceiveResult result; do { result await socket.ReceiveAsync(buffer, CancellationToken.None); ms.Write(buffer, 0, result.Count); } while (!result.EndOfMessage); if (result.MessageType WebSocketMessageType.Text) { string received Encoding.UTF8.GetString(ms.ToArray()); Console.WriteLine($来自 {clientId}: {received}); await Broadcast($echo: {received}, socket); } else if (result.MessageType WebSocketMessageType.Close) { await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, 客户端关闭, CancellationToken.None); } } } finally { _clients.TryRemove(clientId, out _); Console.WriteLine($客户端 {clientId} 断开当前连接数: {_clients.Count}); } } private async Task Broadcast(string message, WebSocket exclude) { var bytes Encoding.UTF8.GetBytes(message); foreach (var kvp in _clients) { if (kvp.Value ! exclude kvp.Value.State WebSocketState.Open) { await kvp.Value.SendAsync(bytes, WebSocketMessageType.Text, true, CancellationToken.None); } } } }这段代码的核心是 ReceiveAsync 循环。注意我在 do-while 里反复调用 ReceiveAsync直到 result.EndOfMessage 为 true确保多帧消息能完整拼合。buffer 大小为 4096 字节但实际不限制消息长度因为 MemoryStream 会动态增长。如果你把 buffer 设为 8192可能减少循环次数但内存占用更高。在实际高并发场景我习惯把这个 buffer 设为 4096因为绝大多数即时消息不会超过这个大小如果你需要传图片或大包可以考虑用 16KB 或者按消息长度动态分配。Broadcast 方法在广播时排除了自己这样客户端不会收到自己的回显如果需要知道是哪个客户端发的可以把 clientId 放进消息体里。3.3 客户端测试与参数调整测试客户端我用 C# 控制台直接写因为这样最容易复现问题。下面这段代码可以连接本地 /ws 端点并收发消息using System.Net.WebSockets; using System.Text; var client new ClientWebSocket(); client.Options.SetRequestHeader(Origin, http://localhost:3000); // 必须与服务器 AllowedOrigins 匹配 var cts new CancellationTokenSource(TimeSpan.FromSeconds(30)); await client.ConnectAsync(new Uri(ws://localhost:5000/ws), cts.Token); Console.WriteLine(连接成功); _ Task.Run(async () { // 接收线程 var buffer new byte[4096]; while (client.State WebSocketState.Open) { var result await client.ReceiveAsync(buffer, cts.Token); if (result.MessageType WebSocketMessageType.Text) { string message Encoding.UTF8.GetString(buffer, 0, result.Count); Console.WriteLine($收到服务器消息: {message}); } } }); while (true) { string input Console.ReadLine(); if (input exit) break; var bytes Encoding.UTF8.GetBytes(input); await client.SendAsync(bytes, WebSocketMessageType.Text, true, cts.Token); } await client.CloseAsync(WebSocketCloseStatus.NormalClosure, 用户退出, CancellationToken.None);这里有个关键参数SendAsync 的第三个参数 setEndOfMessage 必须设为 true表示这是一条完整消息。如果你设为 false服务器端会在 EndOfMessage 为 false 的情况下一直等待后续数据。还有 Origin 头必须设置否则服务器端 AllowedOrigins 会拒绝。我记得有一次忘掉设置这个头服务器端无限抛异常客户端又没报错卡死了很久。通过调整 KeepAliveInterval 和 buffer 大小你会发现连接稳定性和延迟都会有明显变化但这个变化需要结合第 4 章的踩坑点来看。4. 避坑指南连接断开、粘包、心跳失效的常见问题排查4.1 客户端接收不到消息但连接没有断开现象连接一直显示 Open但消息发过来客户端就是收不到。一次抓包发现 TCP 连接还在但数据包没有经过应用层。原因某个网关设备或代理服务器因为空闲把连接状态忽略了而双方都不知道。解决在服务器端设置 KeepAliveInterval 小于网关超时时间比如设为 10 秒并且客户端也要定期发送 Ping 帧。尤其是移动网络环境下运营商会释放超 120 秒的空闲连接所以心跳间隔不能大于 120 秒。4.2 自己写的客户端总是刚连上就被断开现象Process 显示已连接但下一秒服务器就关闭了连接状态码是 1008。原因客户端发送的数据帧没有掩码。浏览器自动处理掩码所以拿浏览器测没问题但自己用裸 Socket 撸代码时往往忘记这一点。解决用 ClientWebSocket 来写客户端它会自动加掩码。除非你要在非常底层的地方自己拼帧否则不要手动构造帧数据。4.3 消息出现半包和粘包现象客户端收到一条消息内容只有一半或者两个消息拼在一起。原因错误地把一个 TCP 包当成了完整 WebSocket 消息。WebSocket 帧有边界但 TCP 包没有你需要根据帧头里的 payload length 来处理。解决确保每次 Receive 循环到 EndOfMessage 为 true 再处理。如果你在服务端用了 StreamSocket 这类面向流 API那更要小心必须自己处理边界。我见过有人在服务端把 TCP 流直接截断后当消息用结果出现诡异的乱码非常坑。4.4 高并发下连接数暴增内存崩溃现象一段时间后连接数不减内存持续增长最后 OOM。原因没有在 finally 里清理 WebSocket 对象或者客户端异常退出后没有触发 Close 事件。解决我在前面的 Handler 代码里用了 finally 块确保在异常或者客户端断开时把 socket 从字典里移除。特别是客户端浏览器标签页直接关闭时服务端不会立即收到 Close 帧此时必须依赖心跳机制检测死连接比如连续多次 Ping 没有响应就主动关闭。4.5 AllowedOrigins 配置导致连接被拒现象浏览器控制台报 403但抓包看到握手请求已发出。原因跨域限制。ASP.NET Core 的 WebSocket 中间件默认 AllowedOrigins 为空表示允许所有来源但我这里配置了 localhost:3000所以其它来源的 Origin 会被拒绝。解决如果你有多个来源把它们都加进 AllowedOrigins 数组或者直接不设置。不设置在生产环境有安全隐患但开发环境方便。5. 进阶技巧用心跳保活和自动重连机制让 WebSocket 更可靠很多人以为 WebSocket 只要连上了就能一直用实际上网络环境稍微复杂一点连接说断就断。心跳不只是发 Ping/Pong 帧更关键的是用应用层心跳来判断业务是否正常。我习惯的做法是客户端每 30 秒发一个 JSON 消息{type:ping,timestamp:...}服务器收到后回一个{type:pong,timestamp:...}。如果客户端连续 3 次没收到 pong就主动断开重连。这样做比协议层心跳更可靠因为协议层 Ping/Pong 只证明 TCP 通但应用层心跳能证明整个处理链路没有死锁或卡死。在服务器端除了 KeepAliveInterval 协议层心跳我还用定时器扫描所有连接记录每个连接最后一次收到消息的时间。如果超过 3 分钟没收到任何消息直接关闭那个连接然后在客户端触发重连逻辑。这样能防止数据库连接池、内存等资源被无效连接占用。重连时要注意指数退避不能无脑每 1 秒重试一次否则服务器可能被刷爆。通常首次重连等待 1 秒失败后等待 2 秒、4 秒、8 秒最多等 60 秒然后循环。实现自动重连的客户端代码可以这样写public async Task ConnectWithRetryAsync() { int attempt 0; while (true) { try { using var client new ClientWebSocket(); client.Options.SetRequestHeader(Origin, http://localhost:3000); await client.ConnectAsync(new Uri(ws://localhost:5000/ws), CancellationToken.None); attempt 0; // 连接成功后重置重试计数 Console.WriteLine(连接成功); await ReceiveLoop(client); // 阻塞直到连接断开 } catch (Exception ex) { attempt; int delay Math.Min(60, (int)Math.Pow(2, attempt)); Console.WriteLine($连接失败{delay} 秒后重试: {ex.Message}); await Task.Delay(TimeSpan.FromSeconds(delay)); } } }每次重连的间隔是指数增长的那以后我再做 WebSocket 项目都会强制加入这套心跳加重连的机制不再有“莫名其妙断线”的玄学问题。希望这种脚踏实地的做法能帮到你别把时间浪费在调不通的连接上。本文还有配套的精品资源点击获取
RELATED

相关推荐

css系列----css 实现选项卡功能:用 TaoToken 统一 Key 调试多环境样式切换

css系列----css 实现选项卡功能:用 TaoToken 统一 Key 调试多环境样式切换

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📅 2026/10/12 4:07:37
【Vue】【uni-app】【小程序】微信小程序点击蓝色高亮与浮色:从 cursor 样式到 TaoToken 调试链路

【Vue】【uni-app】【小程序】微信小程序点击蓝色高亮与浮色:从 cursor 样式到 TaoToken 调试链路

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📅 2026/10/12 4:07:37
UE5硬件抽象层(HAL)原理与跨平台开发实战

UE5硬件抽象层(HAL)原理与跨平台开发实战

1. 项目概述:HAL到底在UE5里扮演什么角色?如果你刚接触Unreal Engine 5的底层架构,看到“Core模块”“HAL”这些词,第一反应可能是——这又是个抽象概念?其实不然。HAL(Hardware Abstraction Layer&#xf…

📅 2026/10/12 4:07:37
MORE NEWS

更多资讯

📰

【港口与特殊场景电缆防盗篇-多场景适配的安防方案】

港口作为重要基础设施,其电缆系统面临着独特的安全挑战。港口区域通常面积广大、设备分布分散、人员流动性强,电缆设施容易成为盗窃目标。港口电缆防盗报警监控系统是一种专门为保障港口电缆设施安全而设计的综合安防系统。 沃思智能电缆防盗装置具备报警…

📰

YOLO盲道及障碍物检测数据集 深度学习框架目标检测算法训练盲道及障碍物检测数据集

使用深度学习框架目标检测算法训练盲道及障碍物检测数据集,并通过建立gui界面检测道路中的盲道及障碍物识别。 文章目录使用深度学习框架目标检测算法训练盲道及障碍物检测数据集,并通过建立gui界面检测道路中的盲道及障碍物识别。安装依赖数据集准备配置…

📰

小白程序员必看:轻松入门医疗大模型,开启智能医疗新机遇!

医疗大模型作为通用AI与医疗场景的深度融合产物,已具备辅助诊疗、药物研发等全链条赋能能力。中国医疗大模型产业政策完善、规模快速扩容、技术实力跃升,市场渗透率持续攀升。尽管面临合规、数据治理等挑战,但其在全球评测、场景落地中形成优…

📰

YOLO26 单卡 RTX 4090 24GB 训练教程:自定义数据集、显存优化与云 GPU 部署

摘要: YOLO26 如何使用自己的数据集训练?单张 RTX 4090 24GB 显存够不够?本文基于 Ultralytics YOLO26,介绍从 CUDA/PyTorch 环境配置、数据集标注、data.yaml 编写,到模型训练、mAP 验证、CUDA OOM 优化及断点续训的完…

📰

FDE(前沿部署工程师)年入百万?收藏!深度解析这个岗位为何最长火不过五年及应对策略

本文分析了FDE岗位的五个结构性死穴,指出其设计问题而非技术问题,导致效率低下和混乱。文章还揭示了行业对FDE岗位的认知盲区,包括可用性启发式、近因偏差、锚定效应、基率忽视、自动化偏见和功能固着。对于尚未设立FDE岗位的企业&#xff0c…

📰

SpringBoot+Vue+MySQL美食网站毕设实战:从架构设计到部署上线全解析

每年到毕业季,总有人被选题折磨得睡不着觉。如果你正打算做一套“SpringBootVueMySQL 的BS架构美食网站平台”作为毕业设计,或者已经选了类似的题目但脑子里还是一团乱麻,那么这篇内容就是为你准备的。我把自己从选题、拆需求、搭数据库、写后…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬