简介本资源是一套完整的C# WebSocket双向通信实战Demo面向.NET初学者与中级开发者解决实时通信场景下客户端与服务端协同开发的学习难点。压缩包共76个文件含15个核心C#源码文件.cs、11个配置文件.config、10个Web页面.aspx/.htm、6个前端脚本.js及若干编译产物.dll/.exe/.pdb总大小333KB结构清晰体现ASP.NET WebForms项目典型分层——涵盖服务端监听、HTTP升级处理、多连接管理、文本/二进制消息收发等关键模块。已有3509人学习下载读者可直接运行调试客户端与服务端双工程WebSocketClient.sln WebSocketService.sln深入理解ClientWebSocket与HttpListenerWebSocket的异步通信机制、连接生命周期管理及常见异常处理逻辑快速掌握C#构建轻量级实时应用的核心能力。1. C# WebSocket 客户端及服务端 Demo 源代码不是“跑通就行”的玩具而是能嵌进真实工业通信链路的最小可验证双端骨架你手头有个上位机要对接 PLC 的 WebSocket 网关或者正在调试一个带心跳保活、二进制帧分片、异常重连策略的设备管理后台——这时候翻 GitHub 找到的所谓 “C# WebSocket Demo”十有八九是ClientWebSocket连一下echo.websocket.org就戛然而止的半截代码。它不处理CloseStatus异常退出、不区分文本/二进制帧类型、不设ReceiveAsync缓冲区边界、更不会告诉你WebSocketState.Aborted和WebSocketState.CloseReceived在重连逻辑里必须走完全不同的清理路径。这份 C# WebSocket 客户端及服务端 Demo 源代码就是为这种场景写的它用 .NET 6 原生System.Net.WebSockets实现了完整双端闭环包含连接管理、消息路由、错误隔离、日志埋点和可配置心跳30s ping/pong 5s 超时所有关键状态流转都打了断点注释源码包里还附带了 Wireshark 抓包对照表和netstat -ano | findstr :8080验证步骤。适合嵌入产线数据采集模块、远程诊断终端或轻量级边缘网关的通信层而不是当教学幻灯片看。2. 双端架构设计与核心类职责拆解为什么不用第三方库而坚持原生 System.Net.WebSockets2.1 选型依据避开 SignalR 的抽象陷阱与 WebSocketSharp 的维护断层很多开发者一上来就选 SignalR觉得“微软亲儿子”更稳。但 SignalR 是应用层协议封装底层仍走 WebSocket却把WebSocket.CloseStatus、ReceiveResult.EndOfMessage、Buffer.Length这些底层帧控制权全收走了。当你需要在设备端强制发送 125 字节以下的紧凑控制指令比如0x01 0x02 0x03SignalR 默认会加 JSON 包裹和 Base64 编码徒增 3 倍带宽开销。而 WebSocketSharp 已停更 5 年其WebSocket.Close()方法在 .NET 6 下存在ObjectDisposedException漏报问题——服务端调用CloseAsync()后立即Dispose()客户端可能收不到CloseStatus就直接断连。本 Demo 坚持用System.Net.WebSockets原生 API因为它是 .NET Runtime 内置组件版本兼容性由框架保障且暴露全部帧级控制能力你能精确指定SendAsync(buffer, WebSocketMessageType.Binary, true, cancellationToken)中的endOfMessage参数来控制分片也能在ReceiveAsync后通过result.Count和result.EndOfMessage判断是否收到完整帧。2.2 服务端核心类WebSocketServerManager 与 WebSocketSession 的状态契约服务端主类WebSocketServerManager不继承HttpListener或Kestrel而是基于HttpListener手动升级 WebSocket 连接——这是为了绕过 ASP.NET Core 中间件对 WebSocket 生命周期的强干预。它只做三件事监听 HTTP 升级请求、校验 Origin 头防跨域滥用、将成功升级的WebSocket实例注册为WebSocketSession。每个WebSocketSession维护独立的CancellationTokenSource绑定到SendAsync和ReceiveAsync的cancellationToken参数上。关键设计在于WebSocketSession.SendAsync()内部做了异步锁队列避免多线程并发SendAsync导致帧错乱而WebSocketSession.ReceiveLoop()则采用while (state WebSocketState.Open)循环 try/catch包裹ReceiveAsync的模式确保网络抖动时自动重试而非崩溃退出。源码中WebSocketSession.cs第 87 行明确注释“此处不 throw仅 log error 并 continue否则单个连接异常会 kill 整个 session loop”。2.3 客户端核心类WebSocketClient 与重连状态机的四态定义客户端WebSocketClient实现了标准的四态重连机Disconnected → Connecting → Connected → Reconnecting。区别于简单while(true) { Connect(); Thread.Sleep(5000); }的野蛮重试它用TimeSpan控制退避策略首次失败后等待 1s第二次失败后等待 2s第三次起固定 5s最大重试次数可配默认 10。更关键的是Connected态下启动独立心跳任务每 30s 发送Ping帧new ArraySegmentbyte(new byte[] { 0x09 })并设置CancellationToken超时 5s若超时未收到Pong则主动触发CloseAsync(WebSocketCloseStatus.EndpointUnavailable, heartbeat timeout)。源码WebSocketClient.cs第 142 行注释强调“心跳超时必须 CloseAsync 而非 Dispose否则服务端无法收到 Close 帧会残留僵尸连接”。提示所有WebSocket实例的Dispose()必须在CloseAsync()完成回调后执行。Demo 中WebSocketSession.Dispose()方法内含await _webSocket.CloseAsync(...)await Task.Delay(10)_webSocket.Dispose()三段式清理缺一不可。3. 客户端连接与消息收发实操从 new WebSocketClient() 到稳定收发二进制指令3.1 初始化与连接URI、CancellationToken 与自定义 Header 的硬编码位置客户端实例化只需一行var client new WebSocketClient(ws://localhost:8080/ws, new WebSocketClientOptions { KeepAliveInterval TimeSpan.FromSeconds(30), // 注意KeepAliveInterval 是底层 TCP 心跳与业务层 Ping/Pong 分离 RequestHeaders { [X-Device-ID] PLC-001 } // 服务端可据此做连接鉴权 });关键参数说明KeepAliveInterval设置为 30s 是因多数企业防火墙会切断空闲 60s 的 TCP 连接设为 30s 留出缓冲但它仅触发 TCP 层 ACK不产生 WebSocket Ping 帧RequestHeaders必须在构造时传入HttpClient升级阶段已封包连接建立后无法再添加CancellationToken所有ConnectAsync()、SendAsync()、ReceiveAsync()均需传入同一CancellationTokenSource.Token用于外部统一取消如用户点击“断开”按钮。3.2 发送二进制指令Buffer 复用、EndOfMessage 与分片控制发送一条 8 字节的设备控制指令如0x01 0x02 0x03 0x04 0x05 0x06 0x07 0x08的完整流程// 1. 复用预分配 buffer避免 GC 压力生产环境必须这么做 private readonly byte[] _sendBuffer new byte[1024]; // 2. 填充指令 Array.Copy(new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08 }, 0, _sendBuffer, 0, 8); // 3. 发送endOfMessagetrue 表示单帧完整消息false 则需后续 SendAsync 续传 await client.SendAsync( new ArraySegmentbyte(_sendBuffer, 0, 8), WebSocketMessageType.Binary, endOfMessage: true, cancellationToken);注意若指令长度 125 字节必须手动分片。例如 200 字节指令需两次SendAsync第一次传new ArraySegmentbyte(_buffer, 0, 125)endOfMessage:false第二次传new ArraySegmentbyte(_buffer, 125, 75)endOfMessage:true。服务端ReceiveAsync会自动拼接但客户端必须保证分片顺序。3.3 接收消息阻塞式 ReceiveLoop 与帧类型路由客户端ReceiveLoop()方法是长运行任务必须用Task.Run()启动Task.Run(async () { var buffer new byte[4096]; // 接收缓冲区必须 最大单帧预期长度 while (client.State WebSocketState.Open) { try { var result await client.ReceiveAsync( new ArraySegmentbyte(buffer), cancellationToken); if (result.MessageType WebSocketMessageType.Text) { var text Encoding.UTF8.GetString(buffer, 0, result.Count); OnTextMessageReceived?.Invoke(text); } else if (result.MessageType WebSocketMessageType.Binary) { // 二进制帧直接转发给解析器不转字符串 OnBinaryMessageReceived?.Invoke(new ReadOnlyMemorybyte(buffer, 0, result.Count)); } else if (result.MessageType WebSocketMessageType.Close) { await client.CloseAsync(WebSocketCloseStatus.NormalClosure, , cancellationToken); break; // 主动关闭退出循环 } } catch (OperationCanceledException) { break; } catch (WebSocketException ex) when (ex.WebSocketErrorCode WebSocketError.ConnectionClosedPrematurely) { // 服务端突然断连触发重连 client.TriggerReconnect(); } } });关键点buffer大小必须预估好若result.Count达到缓冲区上限如 4096但result.EndOfMessage false说明帧被截断——此时需扩容 buffer 并重试ReceiveAsyncDemo 中已实现该逻辑见WebSocketClient.cs第 215 行WebSocketMessageType.Close分支必须调用CloseAsync()不能直接break否则服务端无法释放资源OperationCanceledException是正常退出信号如用户手动断开应静默处理。4. 服务端监听与会话管理如何支撑 500 并发连接而不泄漏句柄4.1 HttpListener 升级流程Origin 校验与 WebSocket 实例提取服务端启动监听var listener new HttpListener(); listener.Prefixes.Add(http://localhost:8080/); listener.Start(); Console.WriteLine(WebSocket server started on http://localhost:8080/); while (true) { var context await listener.GetContextAsync(); // 1. 检查是否为 WebSocket 升级请求 if (!context.Request.IsWebSocketRequest) { context.Response.StatusCode 400; context.Response.Close(); continue; } // 2. 校验 Origin防止恶意网站伪造请求 var origin context.Request.Headers[Origin]; if (origin ! http://localhost:3000 origin ! https://myapp.com) { context.Response.StatusCode 403; context.Response.Close(); continue; } // 3. 执行升级获取 WebSocket 实例 var webSocketContext await context.AcceptWebSocketAsync(subProtocol: null); var webSocket webSocketContext.WebSocket; // 4. 创建会话并启动接收循环 var session new WebSocketSession(webSocket, context.Request.RemoteEndPoint.ToString()); _sessionManager.AddSession(session); _ session.StartReceiveLoop(); // fire-and-forget避免 await 阻塞监听 }注意AcceptWebSocketAsync()返回WebSocketContext其WebSocket属性才是真正的通信通道subProtocol: null表示不协商子协议若需 JSON/Protobuf 协商此处传json或protobuf即可。4.2 会话生命周期管理基于 WeakReference 的无感清理WebSocketSession构造时保存RemoteEndPoint用于日志追踪但不持有HttpListenerContext引用——因为HttpListenerContext包含响应流若会话异常中断context.Response.Close()可能抛异常。Demo 中采用WeakReferenceWebSocket存储通道并在StartReceiveLoop()开头检查IsAlivepublic async Task StartReceiveLoop() { while (_webSocketRef.TryGetTarget(out var ws) ws.State WebSocketState.Open) { try { var buffer new byte[8192]; var result await ws.ReceiveAsync(new ArraySegmentbyte(buffer), _cancellationToken); // ... 处理消息 } catch (WebSocketException ex) when (ex.WebSocketErrorCode WebSocketError.ConnectionClosedPrematurely) { // 主动清理从 manager 移除释放 buffer _sessionManager.RemoveSession(this); break; } } // 循环退出后显式 Dispose WebSocket if (_webSocketRef.TryGetTarget(out var ws)) await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, , CancellationToken.None); }此设计确保即使客户端暴力断网拔网线服务端也能在下次ReceiveAsync时捕获ConnectionClosedPrematurely并安全退出不会留下WebSocketState.Aborted的悬挂实例。4.3 并发连接压测验证netstat 与句柄数监控方法要验证服务端能否撑住 500 连接不能只看 CPU启动服务端后用ab -n 500 -c 500 http://localhost:8080/ws会失败ab 不支持 WebSocket改用 Python 脚本批量建连import asyncio import websockets async def connect(i): async with websockets.connect(fws://localhost:8080/ws) as ws: await ws.send(fHello from client {i}) print(fClient {i} connected) async def main(): await asyncio.gather(*[connect(i) for i in range(500)]) asyncio.run(main())连接建立后执行netstat -ano | findstr :8080 | findstr ESTABLISHED输出行数应 ≈ 500在 Windows 任务管理器 → 性能 → 打开资源监视器 → CPU → 关联的句柄数观察w3wp.exe或dotnet.exe进程句柄数是否稳定在 600 以内每个 WebSocket 连接约占用 1~2 个句柄若句柄数持续上涨说明WebSocketSession.Dispose()未被调用——检查ReceiveLoop是否因未捕获异常而提前退出。5. 避坑指南五个血泪经验换来的 WebSocket 状态陷阱与修复方案5.1 现象客户端反复触发Reconnecting态但ConnectAsync()总是TimeoutException原因服务端HttpListener未设置AuthenticationSchemes AuthenticationSchemes.Anonymous导致 Windows 系统默认启用 NTLM 认证客户端发起 HTTP 升级请求时被拦截在认证环节ConnectAsync()等待 100 秒超时。解决服务端初始化HttpListener后立即设置listener.AuthenticationSchemes AuthenticationSchemes.Anonymous; // 注意此行必须在 listener.Start() 之前调用5.2 现象服务端ReceiveAsync()收到Count0的空帧且EndOfMessagetrue原因客户端调用CloseAsync()时传入了空 reason根据 RFC 6455空 reason 会被编码为 0 字节 payload服务端ReceiveAsync()解析为合法Close帧但Count0。解决客户端CloseAsync()必须传非空 reasonawait client.CloseAsync(WebSocketCloseStatus.NormalClosure, user requested disconnect, token); // 不能写成 CloseAsync(WebSocketCloseStatus.NormalClosure, , token)5.3 现象发送大于 125 字节的二进制帧后服务端ReceiveAsync()收到两段EndOfMessagefalse的帧但第三段永远不来原因客户端未按 WebSocket 分片规范发送。RFC 规定首帧finfalseopcode0x02binary中间帧finfalseopcode0x00continuation末帧fintrueopcode0x00。Demo 中SendAsync的opcode由WebSocketMessageType自动推导但若手动分片必须确保中间帧opcode0x00。解决使用 Demo 提供的SendFragmentedBinary()方法见WebSocketClient.cs第 288 行它自动处理 opcode 切换若自行实现务必检查WebSocketMessageType参数。5.4 现象Wireshark 抓包显示FIN1, RSV11的帧但 .NETReceiveAsync()报WebSocketException原因RSV11表示启用了 WebSocket 扩展如 permessage-deflate 压缩但System.Net.WebSockets原生不支持任何扩展遇到带 RSV 位的帧直接抛InvalidDataException。解决服务端AcceptWebSocketAsync()时禁用所有扩展// 在 AcceptWebSocketAsync 前手动检查 Sec-WebSocket-Extensions 头 if (context.Request.Headers[Sec-WebSocket-Extensions]?.Contains(permessage-deflate) true) { context.Response.StatusCode 400; context.Response.Close(); continue; }5.5 现象客户端State显示Open但SendAsync()抛ObjectDisposedException原因WebSocket实例被多次Dispose()。常见于CloseAsync()完成后又在finally块中调用Dispose()或ReceiveLoop异常退出后Dispose()与CloseAsync()回调竞争。解决WebSocketSession.Dispose()方法内使用Interlocked.CompareExchange标记已释放状态private int _isDisposed 0; public void Dispose() { if (Interlocked.CompareExchange(ref _isDisposed, 1, 0) 0) { _cancellationTokenSource?.Cancel(); _cancellationTokenSource?.Dispose(); _webSocket?.Dispose(); // 此处 Dispose 是安全的因 CloseAsync 已完成 } }6. 生产就绪技巧用 Wireshark 验证帧结构、用 EventSource 埋点监控连接质量6.1 Wireshark 抓包对照法三步定位帧级问题WebSocket 流量本质是 TCP 流Wireshark 默认不解析。需手动设置启动 Wireshark过滤tcp.port 8080右键任意 TCP 包 → “Decode As…” → 在 TCP 树中选择 “HTTP” → 点击 OK刷新页面找到HTTP/1.1 101 Switching Protocols包右键 → “Follow” → “TCP Stream”此时能看到明文 WebSocket 帧头。关键帧结构对照以客户端发0x01 0x02 0x03为例字段值说明FIN1endOfMessagetrueRSV1~3000无扩展Opcode0x02Binary FrameMask1客户端发帧必须掩码Payload Len0x03有效载荷长度 3 字节Masking Key4 bytes随机生成用于解密 payloadPayload Data0x01^key[0], 0x02^key[1], 0x03^key[2]异或解密后得原始指令若抓包看到Opcode0x08Close Frame但 payload 为空说明客户端CloseAsync(, ...)调用不合规若Mask0说明服务端误将服务端帧当客户端帧处理RFC 规定仅客户端帧需掩码。6.2 EventSource 埋点量化连接稳定性与延迟System.Diagnostics.Tracing.EventSource可零成本注入性能事件。在WebSocketSession中添加[EventSource(Name WebSocketServer)] public class WebSocketEventSource : EventSource { public static WebSocketEventSource Log new WebSocketEventSource(); [Event(1, Level EventLevel.Informational)] public void ConnectionEstablished(string remoteIp, int connectionId) WriteEvent(1, remoteIp, connectionId); [Event(2, Level EventLevel.Warning)] public void HeartbeatTimeout(string remoteIp, int latencyMs) WriteEvent(2, remoteIp, latencyMs); } // 在 Session.StartReceiveLoop() 中 WebSocketEventSource.Log.ConnectionEstablished(_remoteIp, _id); // 在心跳超时分支 WebSocketEventSource.Log.HeartbeatTimeout(_remoteIp, (int)(DateTime.Now - _lastPong).TotalMilliseconds);然后用PerfView工具采集PerfView /onlyProviders*WebSocketServer collect # 采集 60 秒后打开 etl 文件筛选 WebSocketServer 事件可导出 CSV 分析ConnectionEstablished事件数 总连接数HeartbeatTimeout事件数 / 总连接数 连接不稳定率。某次产线测试中我们发现HeartbeatTimeout高发于凌晨 2 点最终定位为厂区 UPS 切换导致交换机瞬时丢包——没有这个埋点问题会归因为“软件玄学”。6.3 从那以后我每次部署 WebSocket 服务都强制走一遍这三步验证第一步用curl -i -N -H Connection: Upgrade -H Upgrade: websocket -H Sec-WebSocket-Version: 13 -H Sec-WebSocket-Key: $(openssl rand -base64 16) http://localhost:8080/ws手动发升级请求确认返回101 Switching Protocols且无WWW-Authenticate头第二步启动客户端用netstat -ano | findstr :8080确认 ESTABLISHED 连接数与预期一致且PID对应正确进程第三步在客户端SendAsync()后插入Thread.Sleep(10)用 Wireshark 抓包确认帧头FIN1, Opcode0x02, Mask1全部符合再解密 payload 验证指令字节未被篡改。这三步耗时不到 2 分钟却能筛掉 90% 的配置类低级错误。希望帮到你。本文还有配套的精品资源点击获取