简介:本资源是一套完整的C# WebSocket双向通信实战Demo,面向.NET初学者与中级开发者,解决实时通信场景下客户端与服务端协同开发的学习痛点,适用于在线聊天、实时通知、数据推送等典型应用。压缩包共76个文件,包含15个核心C#源码文件(含客户端WebSocketClient与服务端WebSocketService实现)、11个配置文件(如web.config、app.config)、10个ASPX页面及2个SLN解决方案,辅以JS交互脚本、CSS样式与调试用PDB文件,整体333KB,结构清晰、开箱即用。已有3508人学习下载,读者可直接运行调试双端代码,深入理解HTTP升级握手、异步收发、连接管理、多客户端并发处理等关键机制,并通过源码注释与模块划分掌握生产级WebSocket服务的构建逻辑与常见陷阱规避方法。
1. C# WebSocket 客户端及服务端 Demo 源代码:为什么你写的连接总在 30 秒后静默断开、发消息收不到回执、或一并发就卡死?
这不是一个“跑通 HelloWorld 就算成功”的玩具项目。真实业务里,C# 做 WebSocket 通信常踩三类坑:服务端用System.Net.WebSockets手写时漏掉心跳保活逻辑,客户端用ClientWebSocket未正确 await 异步读写导致线程阻塞,以及跨线程 UI 更新引发的InvalidOperationException。我曾在一个某高校实验室的实时设备监控系统中复现过——前端 Vue 页面每 5 秒发一条指令,C# 后端服务端接收后需调用本地串口驱动并返回结果,但上线第三天凌晨开始批量掉线,日志只显示WebSocketException: The remote party closed the WebSocket,而实际是客户端没发 ping、服务端超时主动关了连接。这个 Demo 源代码不是教你怎么new ClientWebSocket(),而是把生产环境里必须处理的连接生命周期管理、二进制/文本帧混合收发、异常重连退避、线程安全的消息分发全部拆成可调试、可打断点、可改参数的最小闭环。适合正在做工业网关对接、IoT 设备远程控制、或需要替代 SignalR 轻量级实时通道的 .NET 开发者——尤其当你发现 NuGet 上搜 “WebSocket client” 出来的包要么文档缺失、要么依赖过重、要么不支持 .NET 6+ 的IAsyncEnumerable<T>流式读取时。
2. 用System.Net.WebSockets从零搭服务端:不依赖第三方库,只靠 .NET 原生 API 实现可调试的 WebSocket 服务
2.1 为什么选HttpListener+AcceptWebSocketAsync()而非 Kestrel 中间件?
Kestrel 内置 WebSocket 支持虽方便,但调试黑盒化严重:你无法在OnConnectedAsync之前拦截原始 HTTP 升级请求头(比如验证Origin或提取AuthorizationBearer Token),也无法在连接关闭瞬间捕获底层 socket 错误码(如1006表示底层连接异常中断而非正常关闭)。而HttpListener提供完全可控的 HTTP 生命周期钩子。常见做法是:启动一个独立监听器,对/ws路径的GET请求执行AcceptWebSocketAsync(),其余路径返回 404。这样做的代价是失去 Kestrel 的 HTTPS 自动协商和反向代理兼容性,但换来的是——你能用 Visual Studio 直接 Attach 到AcceptWebSocketAsync()调用前,观察context.Request.Headers["Sec-WebSocket-Protocol"]是否符合预期,这是排查“协议协商失败却报 500 错误”的关键入口。
2.2 服务端核心循环:ReceiveAsync和SendAsync必须成对 await,且永不阻塞主线程
下面这段代码是服务端处理单个连接的最小可靠骨架:
private async Task HandleWebSocketConnection(HttpListenerContext context) { var webSocket = await context.AcceptWebSocketAsync(subProtocol: null); var socket = webSocket.WebSocket; // 用 CancellationTokenSource 控制连接生命周期,避免无限等待 using var cts = new CancellationTokenSource(); cts.Token.Register(() => socket?.Abort()); // 异常时强制终止 try { while (socket.State == WebSocketState.Open) { // 【关键】每次 ReceiveAsync 必须配对 SendAsync,且 await 不可省略 var buffer = new byte[4096]; var receiveResult = await socket.ReceiveAsync( new ArraySegment<byte>(buffer), cts.Token); if (receiveResult.MessageType == WebSocketMessageType.Close) { await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "", cts.Token); break; } // 解析收到的数据(此处简化为 UTF8 字符串) var message = Encoding.UTF8.GetString(buffer, 0, receiveResult.Count); Console.WriteLine($"[Server] Received: {message}"); // 【关键】发送响应必须用同一 socket 实例,且 await var responseBytes = Encoding.UTF8.GetBytes($"Echo: {message}"); await socket.SendAsync( new ArraySegment<byte>(responseBytes), WebSocketMessageType.Text, endOfMessage: true, cancellationToken: cts.Token); } } catch (OperationCanceledException) { // 正常取消,比如客户端断开 Console.WriteLine("[Server] Connection cancelled gracefully"); } catch (WebSocketException wse) when (wse.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely) { // 客户端非正常断开,如网络闪断 Console.WriteLine($"[Server] Premature close: {wse.WebSocketErrorCode}"); } catch (Exception ex) { Console.WriteLine($"[Server] Unexpected error: {ex.Message}"); } finally { // 确保资源释放 if (socket.State != WebSocketState.Aborted && socket.State != WebSocketState.Closed) { await socket.CloseAsync(WebSocketCloseStatus.InternalServerError, "Server error", CancellationToken.None); } } }逻辑说明与参数说明
ReceiveAsync的ArraySegment<byte>必须预分配足够大小(4KB 是平衡内存与性能的常见值),否则频繁 GC 影响吞吐;endOfMessage: true表示该帧是完整消息(WebSocket 协议要求文本/二进制帧必须标记 EOF);CancellationToken不仅用于超时控制,更是线程安全的中断信号——当 UI 线程点击“停止服务”按钮时,调用cts.Cancel()即可让所有await抛出OperationCanceledException并退出循环;WebSocketCloseStatus枚举值必须精确匹配场景:NormalClosure表示双方协商关闭,InternalServerErrror表示服务端崩溃,浏览器开发者工具 Network 面板会据此显示不同断开原因。
3. 用ClientWebSocket构建健壮客户端:解决连接挂起、消息丢失、UI 线程冻结三大玄学问题
3.1 连接阶段:ConnectAsync必须设超时,且HttpClient不是必需品
很多教程错误地用HttpClient发起 WebSocket 升级请求,这是冗余操作。ClientWebSocket内置完整 HTTP 升级流程,只需传入ws://或wss://URL:
private async Task ConnectToServerAsync(string url) { var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); // 【必设】10秒连接超时 try { await _webSocket.ConnectAsync(new Uri(url), cts.Token); Console.WriteLine($"[Client] Connected to {url}"); // 启动接收循环(见 3.2) _ = Task.Run(() => ReceiveLoopAsync(cts.Token)); } catch (OperationCanceledException) when (cts.IsCancellationRequested) { throw new TimeoutException($"Connection to {url} timed out after 10 seconds"); } catch (WebSocketException wse) when (wse.WebSocketErrorCode == WebSocketError.InvalidState) { // 已连接状态下重复调用 ConnectAsync Console.WriteLine("[Client] Already connected, skip"); } catch (Exception ex) { Console.WriteLine($"[Client] Connect failed: {ex.Message}"); throw; } }参数说明
CancellationTokenSource的TimeSpan是硬性熔断点,防止 DNS 解析失败或防火墙拦截导致ConnectAsync永久挂起;WebSocketError.InvalidState是高频错误:用户点击“重连”按钮时,若前次连接未完全 Dispose,_webSocket状态仍为Connecting或Open,此时再调ConnectAsync会直接抛此异常,需在调用前加状态判断if (_webSocket.State == WebSocketState.None)。
3.2 接收循环:用IAsyncEnumerable<WebSocketReceiveResult>替代手动while(true),彻底规避线程饥饿
.NET 6+ 支持WebSocket的ReceiveAsync返回IAsyncEnumerable,这是解决“UI 线程被while(true)占满”的后悔药:
private async IAsyncEnumerable<string> ReceiveMessagesAsync([EnumeratorCancellation] CancellationToken ct) { var buffer = new byte[4096]; while (!ct.IsCancellationRequested) { var result = await _webSocket.ReceiveAsync( new ArraySegment<byte>(buffer), ct); if (result.MessageType == WebSocketMessageType.Close) { await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "", ct); yield break; } yield return Encoding.UTF8.GetString(buffer, 0, result.Count); } } // 在 WinForms/WPF 中安全消费(不阻塞 UI 线程) private async void StartReceivingButton_Click(object sender, EventArgs e) { await foreach (var msg in ReceiveMessagesAsync(_cts.Token)) { // 【关键】UI 更新必须调度到主线程 this.Invoke((MethodInvoker)delegate { messageLogTextBox.AppendText($"[Client] {msg}\r\n"); }); } }逻辑说明
IAsyncEnumerable让await foreach自动处理异步迭代,内部已封装try/catch和CancellationToken传播,比手写while(socket.State == Open)更可靠;this.Invoke是 WinForms 场景下更新 TextBox 的唯一安全方式,WPF 则用Application.Current.Dispatcher.Invoke;若忘记这步,程序会在第二次收到消息时抛InvalidOperationException: Cross-thread operation not valid。
4. 心跳保活与异常重连:没有 ping/pong 的 WebSocket 就是纸糊的通道
4.1 服务端主动发 ping:用WebSocket.SendAsync发送空 ping 帧,客户端必须响应 pong
WebSocket 协议规定:ping 帧(Opcode 0x9)必须由接收方自动回复 pong 帧(Opcode 0xA),但 .NET 的System.Net.WebSockets不自动处理 ping/pong,需手动实现:
// 服务端定时任务(每 25 秒发一次 ping) private async Task StartPingLoopAsync(WebSocket socket, CancellationToken ct) { var pingTimer = new PeriodicTimer(TimeSpan.FromSeconds(25)); while (await pingTimer.WaitForNextTickAsync(ct)) { try { if (socket.State == WebSocketState.Open) { // 发送空 ping 帧(长度为 0 的二进制帧) await socket.SendAsync( new ArraySegment<byte>(Array.Empty<byte>()), WebSocketMessageType.Ping, endOfMessage: true, ct); } } catch (Exception ex) when (ex is WebSocketException or OperationCanceledException) { // ping 失败通常意味着连接已断,跳出循环 break; } } }为什么是 25 秒?
- IETF RFC 6455 建议心跳间隔小于服务器配置的超时时间(常见为 30~60 秒),留 5 秒缓冲避免误判;
- 若客户端不响应 pong,服务端在下次
ReceiveAsync时会收到WebSocketException并触发断开逻辑,这是比 TCP KeepAlive 更精准的链路探测。
4.2 客户端重连策略:指数退避 + 最大重试次数,拒绝“一秒一连”的自杀式重试
private int _retryCount = 0; private readonly TimeSpan _baseDelay = TimeSpan.FromSeconds(1); private async Task ReconnectWithBackoffAsync(string url) { while (_retryCount < 5) // 最多重试 5 次 { try { await ConnectToServerAsync(url); _retryCount = 0; // 成功则重置计数 return; } catch (Exception ex) when (ex is WebSocketException or TimeoutException) { _retryCount++; var delay = _baseDelay * (int)Math.Pow(2, _retryCount); // 1s → 2s → 4s → 8s → 16s Console.WriteLine($"[Client] Retry {_retryCount}/5 after {delay.TotalSeconds}s: {ex.Message}"); await Task.Delay(delay, _cts.Token); } } Console.WriteLine("[Client] Gave up reconnecting after 5 attempts"); }参数说明
Math.Pow(2, _retryCount)实现标准指数退避,避免服务端被雪崩式重连压垮;_cts.Token传入Task.Delay,确保用户点击“停止”时重连立即终止,而非傻等 16 秒。
5. 避坑指南:5 条血泪经验总结,每条都对应一个线上事故现场
5.1 现象:客户端SendAsync后服务端收不到任何数据,Wireshark 显示只有 SYN 包
原因:SendAsync的endOfMessage参数设为false,但后续未调用第二次SendAsync发送剩余帧,导致 WebSocket 协议层认为消息未结束,服务端ReceiveAsync永远阻塞等待 EOF。
解决:除非你明确要分片发送大文件,否则所有普通文本/JSON 消息必须设endOfMessage: true;若需分片,必须按 WebSocket 规范发送连续的Continuation帧。
5.2 现象:服务端 CPU 占用 100%,dotnet-dump显示大量System.Net.WebSockets.ManagedWebSocket对象堆积
原因:HttpListenerContext未调用context.Response.Close(),导致 HTTP 连接未释放,ManagedWebSocket依附于未关闭的响应流而无法 GC。
解决:在HandleWebSocketConnection方法末尾(finally块中)显式调用context.Response.Close(),即使 WebSocket 已关闭。
5.3 现象:客户端在 .NET 5 环境下连接wss://地址时报AuthenticationException: The remote certificate is invalid
原因:ClientWebSocket默认校验服务器证书,而测试环境常用自签名证书。
解决:创建ClientWebSocket前设置ClientWebSocketOptions.RemoteCertificateValidationCallback:
_webSocket.Options.RemoteCertificateValidationCallback = (sender, cert, chain, sslPolicyErrors) => true; // 仅测试环境!注意:生产环境必须实现严格证书校验逻辑,不可设为
true。
5.4 现象:WinForms 客户端发送多条消息后,UI 响应迟钝甚至假死
原因:SendAsync调用未await,导致大量异步任务堆积在ThreadPool,挤占 UI 线程调度资源。
解决:所有SendAsync必须await,若需并发发送多条,用Task.WhenAll包裹:
await Task.WhenAll( _webSocket.SendAsync(...), _webSocket.SendAsync(...), _webSocket.SendAsync(...) );5.5 现象:服务端日志显示WebSocketException: An invalid argument was supplied,发生在ReceiveAsync调用时
原因:ArraySegment<byte>的Offset或Count超出buffer数组边界,常见于多次复用同一 buffer 但未重置Count。
解决:每次ReceiveAsync前确保ArraySegment的Count为 buffer 长度,或改用Memory<byte>(.NET Core 2.1+)自动管理边界:
var memory = new Memory<byte>(buffer); var result = await socket.ReceiveAsync(memory, ct);6. 进阶技巧:用WebSocketReceiveResult的EndOfMessage和Count字段做消息粘包/半包处理
6.1 为什么 WebSocket 也需要处理粘包?它不是基于帧的协议吗?
是的,WebSocket 协议层保证帧边界,但应用层仍可能遇到“半帧”问题:当客户端发送一个 10KB 的 JSON 消息,而服务端ReceiveAsync的 buffer 只有 4KB 时,第一次ReceiveAsync返回Count=4096且EndOfMessage=false,表示这只是消息的一部分;第二次调用才返回剩余字节且EndOfMessage=true。若你的代码假设每次ReceiveAsync都拿到完整消息,就会解析出错。
6.2 安全拼接多帧消息的通用模式(支持文本与二进制)
private async Task<string> ReadCompleteTextMessageAsync(WebSocket socket, CancellationToken ct) { var buffer = new List<byte>(); while (true) { var segment = new ArraySegment<byte>(new byte[4096]); var result = await socket.ReceiveAsync(segment, ct); buffer.AddRange(segment.Array.Take(result.Count)); if (result.EndOfMessage) // 【关键判断】只有 EndOfMessage=true 才是完整帧 { return Encoding.UTF8.GetString(buffer.ToArray()); } // 否则继续循环,接收下一帧 } } // 使用示例 try { var fullMessage = await ReadCompleteTextMessageAsync(socket, ct); ProcessJson(fullMessage); } catch (WebSocketException wse) when (wse.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely) { // 客户端在消息传输中途断开 Console.WriteLine("Incomplete message received, connection dropped"); }参数说明
result.EndOfMessage是 WebSocket 协议层提供的权威标志,比检查\0或换行符更可靠;buffer用List<byte>动态扩容,避免预估错误导致OutOfMemoryException;catch块捕获ConnectionClosedPrematurely,这是处理半包的最后防线——若客户端断开时消息未发完,服务端必须感知并清理不完整数据。
我习惯在每个ReceiveAsync调用后立刻检查result.EndOfMessage,而不是等整个 while 循环结束再判断。这让我能在调试时一眼看出是单帧还是多帧消息,少花 3 小时查“为什么 JSON 解析失败”。这个 Demo 源代码里所有接收逻辑都遵循这一原则,你可以直接复制ReadCompleteTextMessageAsync方法到自己的项目中,替换掉那些假设“一次收完”的危险代码。希望帮到你。
本文还有配套的精品资源,点击获取