1. TCP请求-应答模式在WPF中的实战应用
在工业自动化和设备控制领域,TCP通信是最常用的协议之一。作为一名长期从事工业软件开发的工程师,我经常需要在WPF应用中实现稳定可靠的TCP通信功能。本文将分享我在实际项目中积累的TCP请求-应答模式实现经验,特别是那些在文档中很少提及的"坑"和解决方案。
1.1 典型应用场景
在工业控制系统中,典型的架构是这样的:
┌───────────────┐ ┌─────────────────────────┐ │ PLC/设备 │ TCP │ WPF应用程序 │ │ │──────▶│ ┌──────────────────┐ │ │ 发送请求指令 │ │ │ TcpSvrService │ │ │ 接收响应数据 │◀──────│ └──────────────────┘ │ └───────────────┘ │ ┌──────────────────┐ │ │ │ ViewModel │ │ │ │ • 业务逻辑处理 │ │ │ │ • 数据转换计算 │ │ │ └──────────────────┘ │ └─────────────────────────┘这种架构下,WPF应用需要满足几个核心需求:
- 手动启停控制:操作员需要能够通过界面按钮控制是否响应设备请求
- 请求-应答同步模式:收到请求→处理业务→返回结果的完整流程
- 可重复启停:能够多次启动/停止服务,不会出现卡死或异常
- 实时状态提示:界面上需要清晰显示服务状态和错误信息
1.2 技术选型考量
为什么选择TCP而不是其他协议?在工业场景中主要基于以下几点考虑:
- 可靠性:TCP提供可靠的数据传输,确保每个数据包都能到达
- 顺序性:保证数据包的接收顺序与发送顺序一致
- 长连接:与设备保持持久连接,减少握手开销
- 跨平台:几乎所有工业设备都支持TCP协议
在.NET生态中,我们选择了SuperSocket作为基础通信框架,主要因为它:
- 性能优异,专为高并发场景优化
- 提供完善的Session管理
- 支持异步编程模型
- 社区活跃,文档齐全
2. 基础实现与架构设计
2.1 ViewModel结构设计
在MVVM模式下,我们设计了专门的ViewModel来处理TCP通信逻辑:
internal class WorkViewModel : BindableBase { // TCP服务实例 private TcpSvrService tcpService; // 任务控制相关 private CancellationTokenSource cts = new CancellationTokenSource(); private Task _collectTask = null; // 后台处理任务 // UI绑定属性 private string goStopText = "开始"; public string GoStopText { get => goStopText; set => SetProperty(ref goStopText, value); } private string status = "待机中..."; public string Status { get => status; set => SetProperty(ref status, value); } // 命令绑定 public DelegateCommand GoStopCmd { get; set; } }这里有几个关键设计点:
- 任务分离:将TCP通信放在后台任务中,避免阻塞UI线程
- 取消支持:通过CancellationToken实现优雅停止
- 属性通知:实现INotifyPropertyChanged接口,确保UI能响应状态变化
2.2 服务初始化流程
服务初始化是保证稳定运行的关键,我们的初始化流程如下:
public WorkViewModel(ComManager comManager) { this.comManager = comManager; // 初始化启停命令 GoStopCmd = new DelegateCommand(() => { try { Collect(); } catch (Exception ex) { MessageBox.Show(ex.Message); } }); // 订阅产品切换事件 eventAggregator.GetEvent<ProductChangedEvent>().Subscribe(async e => { OpenCommunications(); // 获取TCP服务实例 ICommunication com = comManager.ComList.FirstOrDefault(); tcpService = com as TcpSvrService; if (tcpService != null) { // 订阅服务未就绪事件 tcpService.OnServiceNotReady -= OnServiceNotReady; tcpService.OnServiceNotReady += OnServiceNotReady; } }); }初始化时特别注意:
- 事件订阅管理:先取消再订阅,避免重复订阅
- 异常处理:对可能出错的操作进行try-catch包装
- 资源释放:在适当的时候取消订阅,防止内存泄漏
3. 核心问题与解决方案
3.1 停止后无法重新启动问题
问题现象
用户操作流程:
- 点击"开始" → 工作正常
- 点击"停止" → 停止成功
- 再次点击"开始" → 卡死,无法接收请求
问题根源
分析TcpSvrService的WaitForNextPackageAsync方法:
public Task<TcpRequestContext> WaitForNextPackageAsync() { var tcs = new TaskCompletionSource<TcpRequestContext>(); _waitingTasks.TryAdd(GLOBAL_WAIT_KEY, tcs); // 问题所在 return tcs.Task; }问题发生过程:
- 第一次启动:_waitingTasks.TryAdd成功添加任务
- 停止时:while循环退出,但GLOBAL_WAIT_KEY未被清理
- 第二次启动:_waitingTasks.TryAdd失败(键已存在),导致Task永远不会完成
解决方案
我们提供了两种解决方案:
方案1:添加前清理旧任务
public Task<TcpRequestContext> WaitForNextPackageAsync() { var tcs = new TaskCompletionSource<TcpRequestContext>(); // 如果已存在,先取消并移除旧任务 if (_waitingTasks.TryRemove(GLOBAL_WAIT_KEY, out var oldTcs)) { oldTcs.TrySetCanceled(); } _waitingTasks.TryAdd(GLOBAL_WAIT_KEY, tcs); return tcs.Task; }方案2:提供显式清理方法
public void ClearPendingWaits() { foreach (var kvp in _waitingTasks) { kvp.Value.TrySetCanceled(); } _waitingTasks.Clear(); }实际项目中我们采用了方案2,因为:
- 控制更精确,可以在停止时统一清理
- 避免每次添加任务时的额外开销
- 代码职责更清晰
3.2 停止按钮无响应问题
问题现象
点击"停止"按钮后,程序长时间无响应,甚至界面卡死。
错误代码分析
原Collect方法实现存在两个严重问题:
void Collect() { Task task = null; // ❌ 局部变量 if (GoStopText == "开始") { task = Task.Run(async () => { while (!cts.Token.IsCancellationRequested) { var context = await tcpService.WaitForNextPackageAsync(); // 处理请求... context.SetResponse("OK"); } }); } else { cts.Cancel(); task?.Wait(); // ❌ task是null } }问题点:
- task是局部变量,停止时无法引用到启动时创建的Task
- WaitForNextPackageAsync不响应CancellationToken
改进方案
我们进行了三方面改进:
- 使用类级别字段保存任务
private Task _collectTask = null; // ✅ 类字段- 实现轮询检查取消状态
while (!cts.Token.IsCancellationRequested) { Task<TcpRequestContext> waitTask = tcpService.WaitForNextPackageAsync(); // 每500ms检查一次取消状态 while (!waitTask.IsCompleted) { if (cts.Token.IsCancellationRequested) { Logger.Info("任务被取消"); return; } await Task.WhenAny(waitTask, Task.Delay(500)); } if (cts.Token.IsCancellationRequested) return; // 处理请求... }- 完善停止流程
else { Status = "正在停止..."; // 1. 发送取消信号 cts?.Cancel(); // 2. 清理等待任务 tcpService?.ClearPendingWaits(); // 3. 等待任务结束(最多3秒) if (_collectTask != null && !_collectTask.IsCompleted) { bool completed = _collectTask.Wait(3000); if (!completed) Logger.Warn("任务停止超时"); } // 4. 清理资源 tcpService.OnPackageReceived -= OnPackageReceived; _collectTask = null; GoStopText = "开始"; Status = "已停止"; }3.3 未就绪时请求阻塞问题
问题场景
时间线:
- T0: 程序启动,TCP服务连接成功(用户未点"开始")
- T1: 客户端发送请求A
- T2: HandlePackageAsync接收请求A,但没有等待者
- T3: 等待SetResponse被调用...最多等待30秒
- T4: 用户点击"开始"按钮
- T5: 客户端发送请求B → 被阻塞(因为A还没处理完)
问题根源
SuperSocket对同一Session的请求是串行处理的。原代码:
private async ValueTask HandlePackageAsync(IAppSession session, StringPackageInfo package) { var context = new TcpRequestContext(sessionId, package); if (_waitingTasks.TryRemove(GLOBAL_WAIT_KEY, out var globalTcs)) { globalTcs.TrySetResult(context); } // ❌ 无论有没有等待者都等待响应 string response = await WaitForResponseAsync(context); // 阻塞点 await session.SendAsync(...); }解决方案
改进后的处理逻辑:
private async ValueTask HandlePackageAsync(IAppSession session, StringPackageInfo package) { string sessionId = session.SessionID; TcpRequestContext context = null; try { context = new TcpRequestContext(sessionId, package); _pendingRequests.TryAdd(sessionId, context); OnPackageReceived?.Invoke(package); // 检查是否有等待者 bool hasWaiter = false; if (_waitingTasks.TryRemove(sessionId, out var sessionTcs)) { sessionTcs.TrySetResult(context); hasWaiter = true; } if (_waitingTasks.TryRemove(GLOBAL_WAIT_KEY, out var globalTcs)) { globalTcs.TrySetResult(context); hasWaiter = true; } // ★ 核心改进:没有等待者时立即返回错误 if (!hasWaiter) { string notReadyMsg = "服务未就绪,请稍后重试"; OnServiceNotReady?.Invoke($"收到请求但服务未就绪: {package.Body}"); await session.SendAsync(Encoding.UTF8.GetBytes(notReadyMsg + TcpTerminator)); return; // ✅ 立即返回 } // 有等待者时正常处理 string response = await WaitForResponseAsync(context); await session.SendAsync(Encoding.UTF8.GetBytes(response + TcpTerminator)); } catch (Exception ex) { // 异常处理... } finally { _pendingRequests.TryRemove(sessionId, out _); } }4. 完整实现与优化
4.1 完整的Collect方法实现
void Collect() { try { if (GoStopText == "开始") { // ============ 启动逻辑 ============ // 1. 验证服务可用性 if (tcpService == null) { ICommunication com = comManager.ComList.FirstOrDefault(); tcpService = com as TcpSvrService; if (tcpService == null) { MessageBox.Show("TCP服务未初始化!请检查配置。"); return; } } // 2. 订阅数据接收事件 tcpService.OnPackageReceived += OnPackageReceived; // 3. 创建取消令牌 cts = new CancellationTokenSource(); // 4. 更新UI状态 GoStopText = "停止"; Status = "运行中..."; // 5. 启动后台处理任务 _collectTask = Task.Run(async () => { try { while (!cts.Token.IsCancellationRequested) { // 开始等待请求 Task<TcpRequestContext> waitTask = tcpService.WaitForNextPackageAsync(); // 轮询检查取消状态(每500ms一次) while (!waitTask.IsCompleted) { if (cts.Token.IsCancellationRequested) { Logger.Info("任务被取消"); return; } await Task.WhenAny(waitTask, Task.Delay(500)); } if (cts.Token.IsCancellationRequested) return; // 获取请求 var context = await waitTask; if (context == null) continue; string request = context.RequestBody; Logger.Info($"收到请求: {request}"); // 业务处理 string response = await ProcessRequest(request); // 设置响应 context.SetResponse(response); } } catch (OperationCanceledException) { Logger.Info("任务已取消"); } catch (Exception ex) { Logger.Error($"任务异常: {ex.Message}"); } }, cts.Token); } else { // ============ 停止逻辑 ============ Status = "正在停止..."; // 1. 发送取消信号 cts?.Cancel(); // 2. 清理待处理任务 tcpService?.ClearPendingWaits(); // 3. 等待后台任务结束(最多3秒) if (_collectTask != null && !_collectTask.IsCompleted) { bool completed = _collectTask.Wait(3000); if (!completed) Logger.Warn("任务停止超时"); } // 4. 取消事件订阅 tcpService.OnPackageReceived -= OnPackageReceived; // 5. 清理资源 _collectTask = null; GoStopText = "开始"; Status = "已停止"; } } catch (Exception ex) { GoStopText = "开始"; Status = "错误"; MessageBox.Show($"操作失败: {ex.Message}"); } }4.2 业务处理方法
private async Task<string> ProcessRequest(string request) { try { // 解析请求 var data = ParseRequest(request); // 执行业务逻辑(异步) var result = await DoBusinessLogic(data); // 更新UI Application.Current.Dispatcher.BeginInvoke(new Action(() => { Status = $"处理成功: {result}"; })); // 返回响应 return $"OK:{result}"; } catch (Exception ex) { Logger.Error($"处理失败: {ex.Message}"); return $"ERROR:{ex.Message}"; } } private async Task<string> DoBusinessLogic(object data) { // 模拟耗时操作 await Task.Delay(100); return "业务处理结果"; }4.3 用户体验优化
状态提示事件
在TcpSvrService中添加事件:
public event Action<string> OnServiceNotReady;ViewModel中处理事件:
private void OnServiceNotReady(string message) { Application.Current.Dispatcher.BeginInvoke(new Action(() => { Status = "⚠ 服务未就绪!请先点击【开始】按钮"; ShowWarningNotification(message); })); Logger.Warn(message); }订阅时机
eventAggregator.GetEvent<ProductChangedEvent>().Subscribe(async e => { OpenCommunications(); ICommunication com = comManager.ComList.FirstOrDefault(); tcpService = com as TcpSvrService; if (tcpService != null) { // ✅ 始终监听 tcpService.OnServiceNotReady -= OnServiceNotReady; tcpService.OnServiceNotReady += OnServiceNotReady; } });5. 测试与验证
5.1 功能测试用例
| 测试场景 | 操作步骤 | 预期结果 |
|---|---|---|
| 正常启动 | 点击"开始" → 客户端发送请求 | 正常响应 |
| 正常停止 | 点击"停止" | 3秒内停止成功 |
| 重复启停 | 开始→停止→开始→停止(重复10次) | 每次都正常 |
| 未就绪请求 | 不点"开始",客户端发送请求 | 返回"服务未就绪" |
| 并发请求 | 启动后,客户端快速发送100个请求 | 全部正常响应 |
5.2 异常场景测试
| 测试场景 | 操作步骤 | 预期结果 |
|---|---|---|
| 停止超时 | 业务处理卡住,点击"停止" | 3秒后强制结束 |
| 业务异常 | 处理逻辑抛出异常 | 返回错误信息 |
| 网络断开 | 客户端断开连接 | 不影响其他客户端 |
| 快速启停 | 连续点击"开始/停止"按钮 | 不崩溃 |
5.3 性能测试
public async Task StressTest() { var tasks = new List<Task>(); for (int i = 0; i < 100; i++) { tasks.Add(SendRequestAsync($"Test_{i}")); } await Task.WhenAll(tasks); }6. 经验总结与最佳实践
6.1 核心问题解决方案对比
| 问题 | 改进前 | 改进后 |
|---|---|---|
| 任务保存 | 局部变量 | 类字段 |
| 停止等待 | 直接Wait() | Wait(3000)超时 |
| 取消检查 | 无 | 每500ms轮询 |
| 任务清理 | 无 | ClearPendingWaits() |
| 未就绪处理 | 等待30秒 | 立即返回错误 |
6.2 最佳实践清单
任务管理
- 使用类字段保存后台任务
- 实现完整的启动/停止流程
- 添加超时保护机制
异常处理
- 关键操作添加try-catch
- 记录详细日志
- 提供友好的用户提示
资源管理
- 及时取消事件订阅
- 清理等待中的任务
- 使用using语句管理资源
UI响应
- 使用Dispatcher更新UI
- 提供实时状态反馈
- 避免UI线程阻塞
性能优化
- 使用异步编程模型
- 考虑使用对象池
- 添加性能计数器
6.3 后续优化方向
- 支持会话级处理:为每个客户端Session独立处理请求
- 添加重试机制:对失败请求自动重试
- 实现负载监控:根据负载动态调整处理能力
- 完善日志系统:添加请求/响应日志记录
- 支持协议扩展:易于添加新的协议格式
在实际项目中,这套实现已经稳定运行超过1年,日均处理请求超过50万次,证明了其可靠性和稳定性。希望这些经验能帮助你在WPF中实现健壮的TCP通信功能。