【C#高级】TCP请求-应答模式的WPF应用实战

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,无法等待
    }
}

两个致命问题:

  1. task 是局部变量 :每次调用 Collect() 时都是新变量,点"停止"时无法引用到启动时创建的 Task
  2. WaitForNextPackageAsync() 不响应 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服务器并发优化与错误处理改进实战

相关推荐
..过云雨26 分钟前
多路转接select系统调用详解
网络·网络协议·tcp/ip
听麟1 小时前
HarmonyOS 6.0+ APP AR文旅导览系统开发实战:空间定位与文物交互落地
人工智能·深度学习·华为·ar·wpf·harmonyos
bugcome_com2 小时前
阿里云 OSS C# SDK 使用实践与参数详解
阿里云·c#
强风7942 小时前
Linux-传输层协议TCP
linux·网络·tcp/ip
科技块儿2 小时前
如何选择合适的IP查询工具?精准度与更新频率全面分析
网络·tcp/ip·安全
Zach_yuan2 小时前
传输层之TCP/UDP 核心原理全解析:从协议基础到实战机制
linux·网络协议·tcp/ip·udp
云姜.2 小时前
TCP协议特性
服务器·网络·tcp/ip
懒人咖12 小时前
缺料分析时携带用料清单的二开字段
c#·金蝶云星空
bugcome_com12 小时前
深入了解 C# 编程环境及其开发工具
c#
wfserial14 小时前
c#使用微软自带speech选择男声仍然是女声的一种原因
microsoft·c#·speech