我最近在给一台设备做上位机,原来的数据链路是串口轮询,服务端改成 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 ArraySegment<byte>(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 ArraySegment<byte>(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里的Channel<T>:
private Channel<byte[]> _channel = Channel.CreateUnbounded<byte[]>(); // 接收循环里 await _channel.Writer.WriteAsync(payload, token); // UI 定时器或消费 Task 里 await foreach (var item in _channel.Reader.ReadAllAsync(token)) { // 处理业务 }这套方案适合高频数据,比如设备每 20ms 推一条遥测,每条都直接调 BeginInvoke,UI 消息队列会爆炸。队列就像一个水库,可以把洪峰削掉。代价是增加了一层异步边界,调试时链路变长,延迟也略增。
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 ArraySegment<byte>(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 ArraySegment<byte>(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,而是必然碰到。跨线程回 UI,WinForm 里方案就三四种,但要选得明白。
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 卡住。注意这里绝不建议用Invoke,Invoke是同步等待 UI 线程处理完,如果 UI 线程正忙,接收线程就挂在那儿,反过来又影响后续数据处理,网络缓冲区越积越多。
BeginInvoke最大的坑是高频消息堆积。设备每 50ms 推一条,你的消息队列里瞬间攒了几十个更新委托,UI 线程一个个处理,界面看起来像幻灯片。更恐怖的是窗口关闭那一瞬间,队列里还挂着 N 个回调,UI 线程已经销毁了控件,BeginInvoke就会抛ObjectDisposedException。所以调用方必须先判IsDisposed,并且在关闭时要有个流程把积压消息清掉。
我的判断标准是:每条消息都是重要的人机交互信息,频率不超过每秒 5 条,BeginInvoke完全够用。高频场景看下面的方案。
4.2 Progress<T>:更优雅的上下文捕获
Progress<T>是 .NET 里为 UI 异步更新设计的一个小巧好用的类。它的核心机制是:在哪个线程创建Progress<T>实例,它内部就捕获那个线程的SynchronizationContext,之后你从任意线程调用Report,回调都会自动回到被捕获的上下文执行。
// 在 UI 线程创建 private readonly Progress<string> _progress = new Progress<string>(msg => { txtLog.AppendText(msg + Environment.NewLine); }); // 接收循环里任意地方 _progress.Report($"收到:{payload.Length}字节");这个方案相比BeginInvoke的好处是:接收线程根本不知道 UI 存在,不需要判断InvokeRequired、不需要关心控件是否销毁,Progress<T>的回调如果发生在 UI 上下文销毁之后,它会安全地什么都不做(实际上会有一些边界情况,但远比裸调BeginInvoke安全)。代价是它本质上还是在 UI 线程同步执行回调,高频数据一样会积压。所以我把它列为"推荐优先使用的方案",比裸写Invoke干净得多。
4.3 Channel + UI 定时器:高频数据的削峰配置
如果数据频率高到Progress也扛不住,比如波形采集、实时曲线,每 10ms 来一条,UI 控件重绘根本追不上。这个场景我推荐第二节提到的 Channel,配合一个 UI 定时器做批量消费:
private Channel<string> _dataChannel = Channel.CreateUnbounded<string>(); // 接收循环写入 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、队列。比如接收线程往List<DeviceData> _cache里 Add,UI 线程定时器遍历这个列表刷新界面,两个线程没有任何同步,内存里数据错乱、偶发ArgumentOutOfRangeException,甚至和 WebSocket 本身无关了。
跨线程数据交付的黄金法则是:数据在跨线程边界上传递时,要么只传只读的不可变对象,要么把所有权彻底交出去(比如 Channel 的写入端和读取端)。不要两个线程同时握着一个 List 一个往里写一个往外读。如果必须共享,就上ConcurrentQueue<T>。这个点虽然不是 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 Action<byte[]> MessageReceived; public event Action<WebSocketState> 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 ArraySegment<byte>(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 ArraySegment<byte>(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换成Progress<T>.Report,损失不大。_sendLock就是第三节说的发送并发护栏。所有await后面都没有ConfigureAwait(false),因为这个类根本不依赖 UI 上下文,I/O 完成线程直接继续跑完整个循环就好,事件的回调会抛给订阅方去处理线程问题,耦合最干净。
6.2 窗体接入:一个完整的对接代码
窗体的职责只剩三件事:创建、订阅、启动;把广播交到 UI;关闭时按顺序清理。用Progress<T>做跨线程交付:
public partial class MainForm : Form { private WebSocketClientManager _client; private readonly Progress<string> _uiProgress; public MainForm() { InitializeComponent(); _uiProgress = new Progress<string>(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 相关的现场问题,十有八九靠这个定位。