TCP请求-应答模式的WPF应用实战
前言
在 《【C#高级】TCP服务器并发优化与错误处理改进实战》 中,我们实现了一个生产级的 TcpSvrService。本文将展示如何在 WPF 应用中使用这个服务,以及实战中踩过的坑和解决方案。
💡 本文适合已掌握基础 TCP 通信和 WPF MVVM 模式的读者
一、应用场景
1.1 典型架构
┌─────────────────┐ ┌─────────────────────────────┐
│ 客户端设备 │ TCP │ WPF 应用程序 │
│ (PLC/上位机) │ ─────→ │ ┌────────────────────┐ │
│ │ │ │ TcpSvrService │ │
│ 发送请求 │ ←───── │ └────────────────────┘ │
│ 接收响应 │ │ ┌────────────────────┐ │
└─────────────────┘ │ │ ViewModel │ │
│ │ • 业务处理 │ │
│ │ • 数据计算 │ │
│ └────────────────────┘ │
└─────────────────────────────┘
1.2 核心需求
| 需求 | 说明 |
|---|---|
| 手动启停 | 用户点击按钮控制是否响应客户端请求 |
| 请求-应答 | 同步模式:收到请求 → 处理 → 返回结果 |
| 可重复启停 | 能够多次启动/停止,不出现卡死或异常 |
| 状态提示 | 实时显示服务状态和错误提示 |
二、基础实现
2.1 ViewModel 结构设计
csharp
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 { return goStopText; }
set { SetProperty(ref goStopText, value); }
}
private string status = "...";
public string Status
{
get { return status; }
set { SetProperty(ref status, value); }
}
// 命令
public DelegateCommand GoStopCmd { get; set; }
}
2.2 服务初始化
csharp
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;
}
});
}
三、实战问题与解决方案
问题 1:停止后再开始失败 🔥
3.1 问题现象
用户操作:
1. 点击"开始" → 工作正常
2. 点击"停止" → 停止成功
3. 再次点击"开始" → 卡死!无法接收请求
3.2 问题根源
查看 TcpSvrService 的原始代码:
csharp
public Task<TcpRequestContext> WaitForNextPackageAsync()
{
var tcs = new TaskCompletionSource<TcpRequestContext>();
_waitingTasks.TryAdd(GLOBAL_WAIT_KEY, tcs); // ❌ 问题所在
return tcs.Task;
}
问题分析:
第一次开始:
_waitingTasks.TryAdd("__GLOBAL_WAIT__", tcs1) ✅ 成功
停止:
cts.Cancel() → while 循环退出
但是!字典中的 "__GLOBAL_WAIT__" 键没有被清理
第二次开始:
_waitingTasks.TryAdd("__GLOBAL_WAIT__", tcs2) ❌ 失败(键已存在)
返回 tcs2.Task → 这个 Task 永远不会完成 → 程序卡死
3.3 解决方案
方案 1:在添加前清理旧任务
csharp
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:提供清理方法
csharp
/// <summary>
/// 清理所有待处理的等待任务(供外部停止时调用)
/// </summary>
public void ClearPendingWaits()
{
foreach (var kvp in _waitingTasks)
{
kvp.Value.TrySetCanceled();
}
_waitingTasks.Clear();
}
问题 2:点击停止按钮无响应 🔥
3.4 问题现象
点击"停止"按钮后,程序长时间无响应,甚至界面卡死。
3.5 错误代码示例
csharp
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是局部变量 :每次调用Collect()时都是新变量,点"停止"时无法引用到启动时创建的 TaskWaitForNextPackageAsync()不响应CancellationToken:即使调用了cts.Cancel(),while 循环也无法退出
3.6 解决方案
改进 1:使用类级别字段
csharp
private Task _collectTask = null; // ✅ 类字段,不会被重置
改进 2:轮询检查取消状态
由于 WaitForNextPackageAsync() 不支持 CancellationToken,我们使用轮询方式:
csharp
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;
// 业务处理...
context.SetResponse("OK: " + request);
}
改进 3:优雅停止流程
csharp
else
{
Status = "正在停止...";
// 步骤 1:发送取消信号
cts?.Cancel();
// 步骤 2:清理 TCP 服务的等待任务(让 WaitForNextPackageAsync 立即返回)
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.7 问题场景
时间线:
T0: 程序启动,TCP 服务连接成功(但用户未点"开始")
T1: 客户端发送请求 A
T2: HandlePackageAsync 接收请求 A,但没有等待者
T3: 等待 SetResponse() 被调用... 最多等待 30 秒 ⏱
T4: 用户点击"开始"按钮
T5: 客户端发送请求 B → ❌ 被阻塞!因为 A 还没处理完
T6: 用户:"为什么点了开始还收不到数据?"
3.8 根本原因
SuperSocket 对同一个 Session 的请求是串行处理的(保证 TCP 流的顺序性)。
原始代码的问题:
csharp
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); // 有等待者
}
// ❌ 无论有没有等待者,都等待响应(最多 30 秒)
string response = await WaitForResponseAsync(context); // 阻塞在这里
await session.SendAsync(...);
}
3.9 解决方案:立即拒绝
核心思路:没有等待者时,不要等待 30 秒,立即返回错误响应。
csharp
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.1 添加状态提示事件
在 TcpSvrService 中:
csharp
/// <summary>
/// 当收到请求但服务未就绪时触发
/// </summary>
public event Action<string> OnServiceNotReady;
4.2 ViewModel 中处理事件
csharp
private void OnServiceNotReady(string message)
{
// 确保在 UI 线程更新界面
Application.Current.Dispatcher.BeginInvoke(new Action(() =>
{
Status = "⚠ 服务未就绪!请先点击【开始】按钮";
ShowWarningNotification(message);
}));
Logger.Warn(message);
}
4.3 订阅时机
csharp
// 在服务初始化时订阅(不是在点"开始"时)
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.1 完整的 Collect 方法
csharp
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}");
}
}
5.2 业务处理方法
csharp
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 "业务处理结果";
}
5.3 事件处理方法
csharp
// 预处理事件(可选)
private void OnPackageReceived(StringPackageInfo package)
{
Logger.Info($"收到数据包: {package.Body}");
}
// 服务未就绪提示
private void OnServiceNotReady(string message)
{
Application.Current.Dispatcher.BeginInvoke(new Action(() =>
{
Status = "⚠ 服务未就绪!请先点击【开始】按钮";
ShowWarning(message);
}));
Logger.Warn(message);
}
六、测试验证
6.1 功能测试用例
| 编号 | 测试场景 | 操作步骤 | 预期结果 |
|---|---|---|---|
| 1 | 正常启动 | 点击"开始" → 客户端发送请求 | 正常响应 |
| 2 | 正常停止 | 点击"停止" | 3 秒内停止成功 |
| 3 | 重复启停 | 开始 → 停止 → 开始 → 停止(重复 10 次) | 每次都正常 |
| 4 | 未就绪请求 | 不点"开始",客户端发送请求 | 返回"服务未就绪",界面显示警告 |
| 5 | 并发请求 | 启动后,客户端快速发送 100 个请求 | 全部正常响应 |
6.2 异常场景测试
| 编号 | 测试场景 | 操作步骤 | 预期结果 |
|---|---|---|---|
| 1 | 停止超时 | 业务处理卡住,点击"停止" | 3 秒后强制结束,记录警告 |
| 2 | 业务异常 | 处理逻辑抛出异常 | 返回错误信息,程序继续运行 |
| 3 | 网络断开 | 客户端断开连接 | 不影响其他客户端 |
| 4 | 快速启停 | 连续点击"开始/停止"按钮 | 不崩溃,状态正确 |
6.3 性能测试
csharp
// 压力测试示例
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);
}
七、常见问题 FAQ
Q1: 为什么使用轮询而不是直接支持 CancellationToken?
A: 因为 TcpSvrService.WaitForNextPackageAsync() 的设计是基于 TaskCompletionSource,不支持 CancellationToken。轮询是一种折中方案,每 500ms 检查一次取消状态,确保能在 1 秒内响应停止请求。
Q2: ClearPendingWaits() 什么时候调用?
A: 在点击"停止"按钮时调用。它会取消所有等待中的任务,让 WaitForNextPackageAsync() 立即返回(抛出 OperationCanceledException),从而让 while 循环退出。
Q3: 为什么停止要等待 3 秒?
A:
- 正常情况:任务应该在几十毫秒内退出
- 异常情况:如果业务逻辑卡住,不能让界面永远等待
- 3 秒设计:给任务足够时间退出,同时防止界面卡死
Q4: 如何避免重复订阅事件?
A: 在订阅前先取消订阅:
csharp
tcpService.OnServiceNotReady -= OnServiceNotReady; // 先取消
tcpService.OnServiceNotReady += OnServiceNotReady; // 再订阅
Q5: 能否支持多个客户端并发处理?
A: 可以。当前使用 GLOBAL_WAIT_KEY 是单任务处理。如果需要为每个客户端独立处理,可以:
csharp
// 使用会话级别的等待
var context = await tcpService.WaitForNextPackageAsync(sessionId);
八、优化建议
8.1 性能优化
1. 减少 Delay 时间
如果对停止响应时间要求高:
csharp
await Task.WhenAny(waitTask, Task.Delay(100)); // 100ms 检查一次
2. 使用对象池
减少 GC 压力:
csharp
private static readonly ArrayPool<byte> _bufferPool = ArrayPool<byte>.Shared;
8.2 代码健壮性
1. 添加日志
关键位置记录日志:
csharp
Logger.Info($"[{DateTime.Now:HH:mm:ss.fff}] 收到请求: {request}");
Logger.Info($"[{DateTime.Now:HH:mm:ss.fff}] 处理完成,耗时: {elapsed}ms");
2. 添加计数器
统计请求数量:
csharp
private long _requestCount = 0;
Interlocked.Increment(ref _requestCount);
8.3 用户体验
1. 进度提示
csharp
Status = $"运行中 | 已处理: {_requestCount} 个请求";
2. 错误恢复
csharp
if (consecutiveErrors > 10)
{
// 自动重启或报警
}
九、总结
9.1 核心要点
| 问题 | 解决方案 | 关键代码 |
|---|---|---|
| 停止后重启失败 | 清理旧的等待任务 | TryRemove + TrySetCanceled |
| 停止无响应 | 轮询检查 + 超时保护 | Task.WhenAny + Wait(3000) |
| 未就绪时阻塞 | 无等待者立即返回 | if (!hasWaiter) return |
| 界面卡顿 | 后台任务 + Dispatcher | Task.Run + BeginInvoke |
9.2 最佳实践清单
- ✅ 使用类字段保存任务和取消令牌
- ✅ 实现完整的启动/停止流程
- ✅ 添加超时保护(避免永久等待)
- ✅ 提供用户友好的状态提示
- ✅ 完整的异常处理和日志记录
- ✅ UI 更新使用 Dispatcher
- ✅ 避免重复订阅事件
- ✅ 及时清理资源
9.3 完整流程图
用户点击"开始"
↓
验证服务可用性
↓
订阅事件
↓
创建 CancellationTokenSource
↓
启动后台任务 ────────────────────┐
│ │
│ ┌────▼────┐
│ │ while │
│ │ ├─ 等待请求
│ │ ├─ 检查取消
│ │ ├─ 处理业务
│ │ └─ 返回响应
│ └────┬────┘
│ │
用户点击"停止" │
↓ │
cts.Cancel() ──────────────────→│ 检测到取消
↓ ↓
ClearPendingWaits() 退出循环
↓
Wait(3000) 等待任务完成
↓
取消事件订阅
↓
清理资源
↓
完成
9.4 关键代码对比
| 改进点 | 改进前 ❌ | 改进后 ✅ |
|---|---|---|
| 任务保存 | Task task = null; |
private Task _collectTask; |
| 停止等待 | task?.Wait(); |
_collectTask?.Wait(3000); |
| 取消检查 | 无 | 每 500ms 轮询 |
| 任务清理 | 无 | ClearPendingWaits() |
| 未就绪处理 | 等待 30 秒 | 立即返回错误 |
版本: 1.0
更新日期: 2026-01-16
相关文章: 【C#高级】TCP服务器并发优化与错误处理改进实战