WinForm上位机接入WebSocket:异步线程模型与断线重连实战
我最近在给一台设备做上位机原来的数据链路是串口轮询服务端改成 WebSocket 主动推送后实时性一下子提上去了。但接进去的过程比预想痛苦得多——C# WinForm 里接入 WebSocket 客户端网上搜出来的教程十个有八个是控制台代码一贴真正要命的根本不是ClientWebSocket怎么 new、怎么连接而是异步编程模型这套东西到了 WinForm 的消息泵环境里粘上就是一身坑界面卡死、消息不刷新、断开不会自动重连、关窗体时直接假死。这篇文章就是围绕这个场景写的。它不是ClientWebSocket的 API 字典而是把WinForm 上位机 WebSocket 客户端 异步编程模型这三件事放在一起时真正需要想清楚的配置思路和实现骨架。适合正在做桌面端实时数据接入、被跨线程更新 UI 折磨过、以及想在设计初期就避开线程模型坑的人。看完你可以直接照着第六节的骨架写代码。1. 先理解 WinForm 的 UI 线程为什么同样的代码到这里就卡死很多人在控制台里写 WebSocket 客户端逻辑是这样的一个async方法里ConnectAsync然后while循环ReceiveAsync数据到了就输出。在控制台里跑得好好的粘到 WinForm 里界面直接白屏。这不是代码的问题是你还没理解 WinForm 的线程模型。1.1 消息泵与线程亲和的天然冲突WinForm 的 UI 线程不是一个普通线程它等于一个不停消费消息队列的循环。鼠标点击、控件重绘、定时器回调全部以 Windows 消息的形式塞进队列UI 线程一件件取出来处理。只要 UI 线程还在忙别的事消息队列就一直堆着表现就是窗口无响应。异步编程模型在这儿的麻烦是async/await在 WinForm 里默认会在 await 之后尝试回到调用线程的 SynchronizationContext。我举个具体场景private async void buttonStart_Click(object sender, EventArgs e) { using var ws new ClientWebSocket(); await ws.ConnectAsync(new Uri(ws://127.0.0.1:9000), CancellationToken.None); var buffer new byte[4096]; while (ws.State WebSocketState.Open) { var result await ws.ReceiveAsync(new ArraySegmentbyte(buffer), CancellationToken.None); // 这里死循环的每次 await 都会回到 UI 线程 } }第一次ConnectAsync完成之后代码确实返回 UI 线程继续走。但while循环里的ReceiveAsync也会多次 await你以为是让出了线程实际上每次恢复执行都回到 UI 线程然后立刻又发起下一次ReceiveAsync。如果服务器推送频繁这个循环几乎把 UI 线程占满了消息泵根本没机会处理重绘和点击界面不卡才怪。在控制台里没有这个上下文捕获的问题所以你觉得代码没问题。1.2 你真正要设计的不是通信层而是消息流动链路所以从架构上想WinForm 里的 WebSocket 客户端核心设计对象不是那个ClientWebSocket对象本身而是一条从网络线程到 UI 线程的完整消息链路。在这条链路上至少有三个位置需要你做配置决策谁发起连接、谁负责接收循环大概率不能是 UI 线程或者不能在 UI 线程上下文里长期循环数据从接收线程到达之后怎么安全地交到 UI 线程Invoke、Progress、还是 Channel连接断开、窗体关闭时这条链路上的任务如何有序停掉取消令牌的传递比你想的更关键把这三个问题在动手前想清楚比背 API 有价值得多。接下来的章节其实是围绕这三个问题的展开。2. 异步编程模型选型三种常见写法与最终取舍ClientWebSocket本身不规定你怎么组织异步代码。同一套 API可以有三种写法。它们不完全是竞争关系适合的场景不一样但 WinForm 里必须分清主次。2.1 全 async/await 的命令式链路这是最直观的一种把连接、发送、接收、关闭全部写成async方法从上往下顺序推进。代码可读性最好异常栈也清晰。为了不让接收循环卡死 UI 线程我会把整个接收循环丢到一个独立 Task 里跑private Task RunReceiveLoopAsync(ClientWebSocket ws, CancellationToken token) { return Task.Run(() ReceiveLoopCoreAsync(ws, token), token); } private async Task ReceiveLoopCoreAsync(ClientWebSocket ws, CancellationToken token) { var buffer new byte[16384]; while (ws.State WebSocketState.Open !token.IsCancellationRequested) { var result await ws.ReceiveAsync(new ArraySegmentbyte(buffer), token); if (result.MessageType WebSocketMessageType.Close) { await ws.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, client close, token); break; } HandlePayload(buffer.AsSpan(0, result.Count), result.EndOfMessage); } }注意Task.Run只负责启动ReceiveAsync的 await 延续会发生在 I/O 完成线程上这和上下文捕获就没关系了。这个写法是我日常最常用的连接管理用 async/await 串联接收循环用后台 Task 隔离。HandlePayload内部再去决定怎么跨线程交付数据第四节的方案。2.2 事件驱动模型对外暴露 OnMessage 更友好第二种做法是把内部循环藏起来对外只暴露事件比如OnMessageReceived(byte[] payload)、OnStateChanged(WebSocketState state)。界面层订阅事件通信层不知道 UI 的存在耦合低。典型的内部实现还是上面那个循环只是把HandlePayload换成MessageReceived?.Invoke(this, args)。这套模型的优点是后续换通信协议比如 SignalR、TCP 自封装时界面层代码不用改。缺点是事件回调抛出的异常容易变成未观察异常而且事件一旦在性能敏感的路径上做重活会把接收线程拖慢。2.3 后台线程 队列什么时候值得引入第三种写法是接收线程只做一件事把原始数据丢进队列然后立刻回到 ReceiveAsync 等下一个消息。消费端可能是 UI 线程的定时器也可能是一个专门的处理线程从队列里取数据做解析。典型实现是System.Threading.Channels里的ChannelTprivate Channelbyte[] _channel Channel.CreateUnboundedbyte[](); // 接收循环里 await _channel.Writer.WriteAsync(payload, token); // UI 定时器或消费 Task 里 await foreach (var item in _channel.Reader.ReadAllAsync(token)) { // 处理业务 }这套方案适合高频数据比如设备每 20ms 推一条遥测每条都直接调 BeginInvokeUI 消息队列会爆炸。队列就像一个水库可以把洪峰削掉。代价是增加了一层异步边界调试时链路变长延迟也略增。2.4 我的选型建议维度全 async/await 命令式事件驱动后台线程 Channel可读性最好流程清晰中需要理解事件时序低链路长线程可控性高能明确每个 await 的位置中回调线程不定高写入端和消费端都明确UI 集成难度低配合 Progress 很顺低订阅事件即可中消费端要考虑 UI 线程复杂度低低偏高最适合场景普通上位机、中低频实时数据模块化架构、组件复用高频遥测、需要削峰个人结论WinForm 项目里90% 的场景用第一种为主、第二种包装对外接口就足够。第三种除非你确实碰到 UI 线程被高频 Invoke 挤爆否则不需要提前引入。异步编程模型的配置本质上不是选最先进的而是选你能向同事讲清楚、出了 bug 能最快定位的。3. ClientWebSocket 关键配置项逐个拆解这些参数直接决定成败ClientWebSocket的Options属性看起来没几个可配的但每一条都可能让你在线上挂半天。下面按我实际踩过的顺序说。3.1 连接握手相关的配置超时、代理、Header 与 HTTP 版本先看一段真实的初始化代码var ws new ClientWebSocket(); ws.Options.KeepAliveInterval TimeSpan.FromSeconds(10); ws.Options.SetRequestHeader(Authorization, $Bearer {token}); ws.Options.Proxy null; // 这是重点下面解释 using var cts new CancellationTokenSource(TimeSpan.FromSeconds(5)); await ws.ConnectAsync(new Uri(ws://192.168.1.100:9000/ws), cts.Token);几个坑按重要程度排第一连接超时没有专用的 Timeout 属性。所以你看到的using var cts new CancellationTokenSource(TimeSpan.FromSeconds(5))是业界通用做法ConnectAsync外面包一个 5 秒取消令牌。这个 5 秒怎么定内网设备可以给 3 到 5 秒跨公网建议 10 秒。太短了服务端稍微慢一点就握手失败太长了用户体验差。第二代理是最阴间的坑。ClientWebSocket默认会读系统代理IE 代理设置。如果你的程序跑在内网而系统配了一个失效的外部代理ConnectAsync会一直挂着直到超时。我遇到过一台工控机因为系统代理配了个不存在的地址WebSocket 三个小时连不上日志里还看不出异常最后抓包才发现流量根本没走本机网卡。内网项目直接在Options.Proxy null一劳永逸。第三Header 位置不要放错。Token、设备标识这类自定义头必须在ConnectAsync之前用SetRequestHeader设置。有些人在HttpClient里习惯了DefaultRequestHeaders这里没有这个东西漏了之后服务端鉴权直接 401报错信息还不明显。第四HTTP 版本。默认版本是 1.1如果你的服务端只支持 HTTP/2 的 WebSocket比较少见但存在需要设置ws.Options.HttpVersion System.Net.HttpVersion.Version20;。绝大多数局域网服务不用动知道有这个开关就行。3.2 缓冲区与 KeepAlive默认值先别乱改ClientWebSocket的接收缓冲区默认是 16KB。这个值的意思不是一条消息不能超过 16KB而是单次 ReceiveAsync 最多给你拷 16KB。一条 50KB 的消息你会分多次拿到片段需要通过result.EndOfMessage判断当前片段是不是一条消息的结尾using var ms new MemoryStream(); var buffer new byte[ws.ReceiveBufferSize]; // 用配置的缓冲大小 while (true) { var result await ws.ReceiveAsync(new ArraySegmentbyte(buffer), token); ms.Write(buffer, 0, result.Count); if (result.EndOfMessage) break; if (result.MessageType WebSocketMessageType.Close) break; }这就是为什么我建议小消息场景下保留默认 16KB 就够了——做大了无非是省几次循环但每次分配大数组也是一笔开销。只有确知服务器会推大图、大文件流式消息时再考虑调大。缓冲区相关的经验不要用byte[65536]去接收然后以为一条消息就完整地躺在一个数组里。必须把EndOfMessage循环当成标准配置写。再讲KeepAliveInterval。它控制的是协议层的 Ping/Pong 帧目的是让网络中间设备路由器、云网关知道这个连接还活着防止空闲超时被掐断。默认 30 秒对公有云部署的 WebSocket 服务来说往往太长了很多云网关的空闲断开阈值在 60 秒左右链路稍有抖动就断了。我会根据目标网络环境设到 10 到 15 秒。局域网内设备如果稳定性足够保持默认也没问题。注意如果设置成TimeSpan.Zero表示完全禁用协议层 KeepAlive只在你确认链路不会空闲断开时才这么做。3.3 发送侧同一个 WebSocket 同时只能有一个发送和一个接收在途这是我被InvalidOperationException折磨过一下午的配置点。ClientWebSocket的规范是同一时间只允许一个发送操作和一个接收操作。如果界面层有两个按钮一个发控制指令一个发查询指令用户手快点了一下两个按钮两个SendAsync同时发起后一个直接抛异常。解决方案不是加锁那么简单粗暴而是用一个SemaphoreSlim串行化所有发送private readonly SemaphoreSlim _sendLock new SemaphoreSlim(1, 1); public async Task SendTextAsync(string text, CancellationToken token) { await _sendLock.WaitAsync(token); try { var segment new ArraySegmentbyte(Encoding.UTF8.GetBytes(text)); await _ws.SendAsync(segment, WebSocketMessageType.Text, true, token); } finally { _sendLock.Release(); } }这个SemaphoreSlim初始容量为 1本质上就是一个异步锁。注意它比lock关键字好在哪等待期间不会阻塞任何线程不会和 UI 线程抢消息泵。发送并发问题一旦出现报错时机非常随机所以最好在写第一行发送代码时就把它配置上而不是等出了 bug 再加。3.4 业务心跳 vs 协议层 KeepAlive两码事都要配置很多人以为KeepAliveInterval设置了服务端就不会把连接断了。我举一个真实场景服务端和客户端之间的网络是通的但服务端应用层因为某个业务模块卡死不再处理任何消息。TCP 层面的 Ping/Pong 是操作系统和网络设备处理的根本发现不了应用层假死。所以生产环境必须加业务心跳客户端每隔固定时间比如 30 秒发一条应用层心跳消息服务端收到后回复。客户端连续几秒没收到任何响应不一定是心跳响应任何消息都算就判定连接已死主动断开重连。WinForm 里实现这个最简单的方式是System.Windows.Forms.Timer它的 Tick 跑在 UI 线程上适合做低频率的检查和发心跳如果不想让心跳逻辑掺进 UI 线程可以独立跑一个Task循环。配置要点是心跳间隔必须比协议层 KeepAlive 间隔短且要小于服务端的心跳超时阈值。三者关系最好明确写在设计文档里不然换人维护时很容易只留一个。4. 数据回 UI三种跨线程更新方案避免界面卡死与假死WebSocket 数据到达的线程是 I/O 完成线程你在那个线程里直接操作TextBox.Text不是偶尔才能碰到InvalidOperationException而是必然碰到。跨线程回 UIWinForm 里方案就三四种但要选得明白。4.1 BeginInvoke最简单但最容易把消息队列塞爆private void OnMessageArrived(byte[] payload) { if (txtLog.IsDisposed) return; txtLog.BeginInvoke(() { txtLog.AppendText(Encoding.UTF8.GetString(payload) Environment.NewLine); }); }BeginInvoke是异步投递它把委托扔进 UI 线程的消息队列就立刻返回接收线程不会被 UI 卡住。注意这里绝不建议用InvokeInvoke是同步等待 UI 线程处理完如果 UI 线程正忙接收线程就挂在那儿反过来又影响后续数据处理网络缓冲区越积越多。BeginInvoke最大的坑是高频消息堆积。设备每 50ms 推一条你的消息队列里瞬间攒了几十个更新委托UI 线程一个个处理界面看起来像幻灯片。更恐怖的是窗口关闭那一瞬间队列里还挂着 N 个回调UI 线程已经销毁了控件BeginInvoke就会抛ObjectDisposedException。所以调用方必须先判IsDisposed并且在关闭时要有个流程把积压消息清掉。我的判断标准是每条消息都是重要的人机交互信息频率不超过每秒 5 条BeginInvoke完全够用。高频场景看下面的方案。4.2 ProgressT更优雅的上下文捕获ProgressT是 .NET 里为 UI 异步更新设计的一个小巧好用的类。它的核心机制是在哪个线程创建ProgressT实例它内部就捕获那个线程的SynchronizationContext之后你从任意线程调用Report回调都会自动回到被捕获的上下文执行。// 在 UI 线程创建 private readonly Progressstring _progress new Progressstring(msg { txtLog.AppendText(msg Environment.NewLine); }); // 接收循环里任意地方 _progress.Report($收到:{payload.Length}字节);这个方案相比BeginInvoke的好处是接收线程根本不知道 UI 存在不需要判断InvokeRequired、不需要关心控件是否销毁ProgressT的回调如果发生在 UI 上下文销毁之后它会安全地什么都不做实际上会有一些边界情况但远比裸调BeginInvoke安全。代价是它本质上还是在 UI 线程同步执行回调高频数据一样会积压。所以我把它列为推荐优先使用的方案比裸写Invoke干净得多。4.3 Channel UI 定时器高频数据的削峰配置如果数据频率高到Progress也扛不住比如波形采集、实时曲线每 10ms 来一条UI 控件重绘根本追不上。这个场景我推荐第二节提到的 Channel配合一个 UI 定时器做批量消费private Channelstring _dataChannel Channel.CreateUnboundedstring(); // 接收循环写入 await _dataChannel.Writer.WriteAsync(message, token); // UI 线程定时器每 200ms 拉一批 private void timerBatch_Tick(object sender, EventArgs e) { while (_dataChannel.Reader.TryRead(out var msg)) { listBoxWave.Items.Add(msg); } }核心思路是用缓冲区把高频网络流量转换成低频 UI 刷新。200ms 刷新一次UI 线程不会卡用户视觉上也不觉得延迟。这属于配置上的取舍牺牲一点单条延迟换来整个界面的流畅度。不要觉得多一层队列是过度设计波形这类场景不削峰就是必卡。4.4 跨线程更新最容易翻车的地方可变状态的竞争还有一个经常被忽略的配置问题多个线程同时访问同一个 List、Dictionary、队列。比如接收线程往ListDeviceData _cache里 AddUI 线程定时器遍历这个列表刷新界面两个线程没有任何同步内存里数据错乱、偶发ArgumentOutOfRangeException甚至和 WebSocket 本身无关了。跨线程数据交付的黄金法则是数据在跨线程边界上传递时要么只传只读的不可变对象要么把所有权彻底交出去比如 Channel 的写入端和读取端。不要两个线程同时握着一个 List 一个往里写一个往外读。如果必须共享就上ConcurrentQueueT。这个点虽然不是 WebSocket 配置项但每次排查为什么界面数据偶尔乱一下的时候八成最后都落在这。5. 生产环境必须处理的异常与生命周期断线重连和窗体关闭这一节全是真实项目里的血泪。API 调用顺序背得再熟没处理好这四类场景软件一到现场就原形毕露。5.1 断线自动重连固定间隔轮询是灾难指数退避才是正解很多初版实现会写一个定时器每 10 秒检查一次连接状态断开了就重连。这个方案在单设备、单人调试时毫无问题部署到现场几十台设备时就是灾难服务端一重启几十个客户端同时检测到断开同时发起重连服务端刚起来就被高频握手请求打垮陷入启动→被打垮→再启动→再被打垮的循环这就是重连风暴。指数退避的核心是让每次重连的等待时间随失败次数翻倍并加上随机抖动打散峰值private async Task ReconnectLoopAsync(CancellationToken token) { var retryCount 0; while (!token.IsCancellationRequested) { try { await ConnectInternalAsync(token); retryCount 0; await ReceiveLoopAsync(token); } catch (OperationCanceledException) { break; } catch (Exception ex) { Log($连接异常: {ex.Message}); } var delay TimeSpan.FromSeconds(Math.Min(60, Math.Pow(2, retryCount))) TimeSpan.FromMilliseconds(Random.Shared.Next(0, 1000)); await Task.Delay(delay, token); } }配置逻辑是第一次断开等 2 秒左右第二次 4 秒第三次 8 秒封顶 60 秒加一个 0 到 1 秒的随机抖动。这个抖动很关键没有它同一个局域网里的设备还是会不约而同地在同一秒发起重连。注意重连循环里的ReceiveLoopAsync如果正常退出了比如服务端发来 Close 帧也要按重连逻辑处理而不是直接当异常抛掉。5.2 窗体关闭假死的真相不要在任何地方同步等待异步操作这是 WinForm 关闭 WebSocket 时最常见的病FormClosing事件里写了ws.CloseAsync(...).Wait()然后界面就卡在关闭上一辈子。原因是 UI 线程在Wait()而CloseAsync的完成需要 I/O 线程回调到 UI 线程的上下文UI 线程被自己堵死了典型的死锁。正确流程应该反过来先用取消令牌打断接收循环让它退出再发送关闭帧并给关闭操作一个超时兜底。private async Task StopAsync() { try { _cts?.Cancel(); if (_receiveTask ! null) { await Task.WhenAny(_receiveTask, Task.Delay(2000)); } if (_ws.State WebSocketState.Open) { using var closeCts new CancellationTokenSource(TimeSpan.FromSeconds(2)); await _ws.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, form closing, closeCts.Token); } } catch { /* 关闭阶段的异常一律不往 UI 抛 */ } finally { _ws?.Abort(); _ws?.Dispose(); } }窗体侧建议这样接private async void Form1_FormClosing(object sender, FormClosingEventArgs e) { if (_client ! null) { e.Cancel true; // 先取消关闭异步清理完再真正关 Enabled false; // 防止用户在清理期间再点 await _client.StopAsync(); e.Cancel false; } }async void事件处理器在 WinForm 里是允许的但只有在UI 生命周期事件这种场景才值得用因为它没法被 await 追踪。整个关闭流程的设计原则是所有异步操作都要有超时所有异常都不能因为关闭而抛出去。宁可Abort()硬切连接也绝不让用户看着窗体卡在关闭动画上。5.3 内存泄漏三宗罪事件未注销、CTS 未释放、接收循环异常退出后无人接管第一条事件未注销。WinForm 里你用事件订阅了WebSocketClient.MessageReceived窗体关闭时如果没退订接收线程还可能往已经被销毁的窗体回调轻则 ObjectDisposedException重则窗体还被一个后台线程强引用Dispose也回收不了内存越占越多。第二条CancellationTokenSource未释放。_cts.Cancel()只是把 token 置为已取消状态它占用的 Timer 等资源要Dispose后才释放。正确姿势是每次重连都 new 一个 CTS停止时Cancel()后再Dispose()。第三条接收循环因为未处理的异常退出后连接对象留在State ! Closed状态但已经没人读了。服务端那边连接还开着TCP 句柄泄漏。所以接收循环内部必须是 try/catch/finally 完整包住异常记录日志然后走到重连逻辑。我现在的做法是在状态流转的地方都打日志任何一只“僵尸连接”都能从日志时间线里看出来。5.4 调试工具与排查路径别只靠眼睛看代码遇到连接不稳定第一反应不要是怀疑心跳参数先抓证据。我自己固定用的排查路径第一步ClientWebSocket的State变化全部打进日志带上时间戳和线程 ID。CloseAsync被调用、ReceiveAsync抛出异常、Abort()触发这些都是判断问题的关键锚点。第二步抓包看WebSocket帧。Windows 下用 Wireshark 过滤websocket或者tcp.port 9000能直接看到 Ping/Pong 帧有没有在双方之间按预期频率流动。心跳问题通常一眼就明白。第三步确认握手的 HTTP 状态码。很多握手失败的异常信息是 IOException但InnerException或日志里的HttpStatusCode会直接告诉你 401 还是 403 还是 404。不要只贴外层异常给同事看拿状态码说话。6. 可复用的 WinForm WebSocket 客户端骨架前面讲思路这一节给能直接抄的代码。我会把异步模型配置、连接管理、心跳、重连、消息通知整合到一个WebSocketClientManager里窗体只跟它打交道。6.1 一个连接管理类连接、接收循环、发送锁、事件出口public sealed class WebSocketClientManager : IDisposable { private ClientWebSocket _ws; private CancellationTokenSource _cts; private Task _receiveTask; private readonly SemaphoreSlim _sendLock new SemaphoreSlim(1, 1); private readonly TimeSpan _connectTimeout TimeSpan.FromSeconds(5); public event Actionbyte[] MessageReceived; public event ActionWebSocketState StateChanged; public async Task StartAsync(Uri uri, string token, CancellationToken appToken default) { await StopInternalAsync(); _ws?.Dispose(); _ws new ClientWebSocket(); _ws.Options.KeepAliveInterval TimeSpan.FromSeconds(10); _ws.Options.Proxy null; if (!string.IsNullOrEmpty(token)) _ws.Options.SetRequestHeader(Authorization, $Bearer {token}); _cts CancellationTokenSource.CreateLinkedTokenSource(appToken); try { using var connectCts CancellationTokenSource.CreateLinkedTokenSource(_cts.Token); connectCts.CancelAfter(_connectTimeout); await _ws.ConnectAsync(uri, connectCts.Token); StateChanged?.Invoke(_ws.State); } catch (Exception ex) { Log($连接失败: {ex.Message}); throw; } _receiveTask RunReceiveLoopAsync(_ws, _cts.Token); } private async Task RunReceiveLoopAsync(ClientWebSocket ws, CancellationToken token) { var buffer new byte[ws.ReceiveBufferSize]; while (ws.State WebSocketState.Open !token.IsCancellationRequested) { using var ms new MemoryStream(); WebSocketReceiveResult result; do { result await ws.ReceiveAsync(new ArraySegmentbyte(buffer), token); if (result.MessageType WebSocketMessageType.Close) { await ws.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, server close, token); return; } ms.Write(buffer, 0, result.Count); } while (!result.EndOfMessage); var payload ms.ToArray(); MessageReceived?.Invoke(payload); } } public async Task SendTextAsync(string text, CancellationToken token default) { if (_ws null || _ws.State ! WebSocketState.Open) throw new InvalidOperationException(连接未就绪); await _sendLock.WaitAsync(token); try { var segment new ArraySegmentbyte(Encoding.UTF8.GetBytes(text)); await _ws.SendAsync(segment, WebSocketMessageType.Text, true, token); } finally { _sendLock.Release(); } } private async Task StopInternalAsync() { try { if (_cts ! null) { _cts.Cancel(); if (_receiveTask ! null) await Task.WhenAny(_receiveTask, Task.Delay(2000)); } if (_ws ! null _ws.State WebSocketState.Open) { using var closeCts new CancellationTokenSource(TimeSpan.FromSeconds(2)); await _ws.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, stop, closeCts.Token); } } catch { } finally { _ws?.Abort(); _cts?.Dispose(); } } public void Dispose() { StopInternalAsync().GetAwaiter().GetResult(); _sendLock.Dispose(); _ws?.Dispose(); } }几个设计说明。这个类里我用事件对外暴露MessageReceived但你仍然可以在内部把MessageReceived?.Invoke换成ProgressT.Report损失不大。_sendLock就是第三节说的发送并发护栏。所有await后面都没有ConfigureAwait(false)因为这个类根本不依赖 UI 上下文I/O 完成线程直接继续跑完整个循环就好事件的回调会抛给订阅方去处理线程问题耦合最干净。6.2 窗体接入一个完整的对接代码窗体的职责只剩三件事创建、订阅、启动把广播交到 UI关闭时按顺序清理。用ProgressT做跨线程交付public partial class MainForm : Form { private WebSocketClientManager _client; private readonly Progressstring _uiProgress; public MainForm() { InitializeComponent(); _uiProgress new Progressstring(msg txtLog.AppendText(msg Environment.NewLine)); } private async void buttonConnect_Click(object sender, EventArgs e) { _client new WebSocketClientManager(); _client.MessageReceived OnMessageReceived; try { await _client.StartAsync(new Uri(ws://192.168.1.100:9000/ws), tokenTextBox.Text.Trim()); _uiProgress.Report(连接成功); } catch (Exception ex) { _uiProgress.Report($连接失败: {ex.Message}); } } private void OnMessageReceived(byte[] payload) { // 这个方法跑在 I/O 线程绝对不碰控件 var text Encoding.UTF8.GetString(payload); _uiProgress.Report(text); } private async void buttonSend_Click(object sender, EventArgs e) { if (_client null) return; try { await _client.SendTextAsync(txtCommand.Text); } catch (Exception ex) { _uiProgress.Report($发送失败: {ex.Message}); } } private async void MainForm_FormClosing(object sender, FormClosingEventArgs e) { if (_client ! null) { e.Cancel true; Enabled false; await Task.Run(() _client.Dispose()); _client null; e.Cancel false; } } }注意FormClosing里的Task.Run(() _client.Dispose())。为什么要多包一层 Task.Run因为Dispose内部有一个Wait()样的阻塞调用放在 UI 线程上还是有风险。包一层后台线程可以保证 UI 线程永远不被异步清理阻塞。这个细节比它看起来重要我在这上面吃过亏。6.3 跑起来之后的验证点怎么证明异步模型配置是成功的代码写完别急着点连接先跑起来做四个验证任何一个不满足都说明异步模型配置有问题启动连接后界面立刻变流畅。如果连接建立瞬间窗口拖拽都卡接收循环大概率跑在 UI 线程上下文里了。高频推送下界面仍然每 200ms 刷新一批。如果打开 Channel 方案界面刷新频率和定时器一致而不是和消息频率一致。断开服务器网线客户端在预期时间内走完重连流程。日志里能看到状态转变、退避等待、重新握手而不是一圈一圈的空转。关闭窗体时进程内线程数开始下降几十秒后进程完全退出。如果关闭后进程还赖在系统里大概率是 ReconnectLoop 没退token 链哪儿断了。我每次接入一个新设备的 WebSocket 服务都会按这四条过一遍过了基本不用担心现场出大问题。最后再分享一个小习惯日志里记录线程 ID 是排查异步问题成本最低的手段一行Environment.CurrentManagedThreadId就能在日志时间线上看出数据在哪些线程之间流动WebSocket 相关的现场问题十有八九靠这个定位。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →