C# 上位机多线程采集重构实录:从界面卡死到 8 路稳定并发

分类:dev  |  时间:2026-10-10 20:41:07  |  阅读:40
场景背景

接手一个 C# WinForms 上位机项目:8 台设备通过串口 + TCP 混合接入,每台设备 500ms 上报一次数据。原代码是"主线程里 while + Sleep",结果只要有一台设备断线,整个界面就卡成白屏,鼠标拖都拖不动。

这篇文章记录我从"主线程阻塞"重构到"8 路稳定并发"的完整过程,包含调度模型、线程安全队列、UI 跨线程更新的实测对比。

多线程调试

问题根源:把 IO 等待放在了 UI 线程

原代码大致是这样:

csharp
// ❌ 反模式:所有采集都在 UI 线程里同步跑
private void timer1_Tick(object sender, EventArgs e)
{
    foreach (var dev in devices)
    {
        var frame = dev.ReadFrame();      // 同步阻塞,断线时最长等 3 秒
        UpdateUi(dev, frame);             // 直接刷控件
        SaveToDb(dev, frame);             // 同步写库,还要等磁盘
    }
}


Timer 的 Tick 本身跑在 UI 线程上,ReadFrame() 一阻塞,消息泵就停了,界面必然卡死。断线重连超时叠加 8 台设备,卡满 20 秒都不稀奇。

重构一:采集与 UI 彻底解耦(生产者-消费者)

采集放后台线程,UI 只负责"取走并显示",中间用一个线程安全队列过渡:

csharp
// ✅ 有界阻塞队列:采集线程写,UI 线程读
private readonly BlockingCollection<DeviceFrame> _queue =
    new BlockingCollection<DeviceFrame>(new ConcurrentQueue<DeviceFrame>(), 2000);

// 采集线程:每台设备一个独立 Task,互不阻塞
private void StartCollectors()
{
    foreach (var dev in devices)
    {
        var d = dev;   // 闭包捕获:不捕获会全部指向最后一个
        Task.Run(() => CollectLoop(d));
    }
}

private void CollectLoop(Device device)
{
    while (!_cts.IsCancellationRequested)
    {
        try
        {
            DeviceFrame frame = device.ReadFrame();   // 阻塞只影响本线程
            if (!_queue.TryAdd(frame, 100))            // 队列满则丢弃,绝不阻塞采集
                Interlocked.Increment(ref _dropCount);
        }
        catch (Exception ex)
        {
            Log.Error(device.Id, ex);                  // 单台异常不拖垮全局
            Thread.Sleep(500);                         // 退避,避免疯狂重连
        }
    }
}


重构二:UI 更新用 Invoke + 批量刷新

后台线程不能直接碰控件。但每收一帧就 Invoke 一次,8 台 × 每秒 2 帧 = 每秒 16 次跨线程调度,界面照样卡。正确做法是"攒一批再刷":

csharp
// ✅ 用 WinForms Timer 每 200ms 批量取空队列,合并刷新
private void uiTimer_Tick(object sender, EventArgs e)
{
    var batch = new List<DeviceFrame>();
    DeviceFrame f;
    int n = 0;
    while (n++ < 500 && _queue.TryTake(out f))   // 一次最多取 500 条
        batch.Add(f);

    if (batch.Count == 0) return;

    foreach (var item in batch)                  // 这里已经在 UI 线程
        UpdateRow(item);

    lblRate.Text = (_queue.Count).ToString();    // 队列积压可视化
}


注意 System.Windows.Forms.Timer 的 Tick 本身就在 UI 线程,所以直接在回调里刷控件是安全的;而 System.Threading.Timer 的回调在线程池线程,必须 Invoke。这两个 Timer 搞混是新手最容易踩的坑。

重构三:优雅退出,别让进程杀不掉

后台线程不退出,主窗口关了进程还挂着。用 CancellationToken 统一收口:

csharp
private readonly CancellationTokenSource _cts = new CancellationTokenSource();

private void Form1_FormClosing(object sender, FormClosingEventArgs e)
{
    _cts.Cancel();                     // 通知所有采集线程退出
    foreach (var t in _collectors)
        t.Wait(TimeSpan.FromSeconds(2)); // 最多等 2 秒
    _queue.CompleteAdding();
    _queue.Dispose();
}


多路并发采集

实测对比

同一台工控机(i5-8250U / 8G),8 台设备 × 500ms 上报:

原始版本(UI 线程同步采集)
  界面响应            : 断线时卡死 3~20 秒
  CPU 占用            :   8% 但单核打满
  数据完整性          : 断线丢包无感知

重构后(8 路 Task + 有界队列 + 批量刷新)
  界面响应            : 始终流畅(< 16ms)
  CPU 占用            :  12%(多核分摊)
  数据完整性          : 队列积压可视化,丢包可统计
  单台断线影响        : 仅该台退避重连,其余 7 台不受影响


踩过的坑

foreach 里起 Task 一定要在循环内用局部变量接一下,否则闭包捕获的循环变量会全部指向最后一个元素——这个 bug 会表现成"8 台设备实际只采了同一台"。

BlockingCollection 的容量是必须的。用无界版本在设备持续高频上报、UI 又卡顿时,内存会一路涨到 OOM。有界 + TryAdd 超时丢弃,才是真正的"最后防线"。

写数据库另起一个消费者线程,别在 UI 批量刷新里同步写。磁盘的抖动会直接反馈到界面流畅度上。

跨线程更新控件尽量用 Control.BeginInvoke 而不是 Invoke,前者是异步投递,不会阻塞后台线程。

小结

上位机的多线程,本质是把"等待"从 UI 线程里彻底剥离,然后用有界队列做背压保护。采集、显示、入库三者解耦之后,任何一路出问题都只是局部降级,不会再拖着整个界面一起卡死。
C#多线程WinForms并发上位机