"采集周期一缩短,界面就开始一顿一顿的"——这种卡顿九成不是界面控件的问题,而是采集、算报警、写数据库、刷界面挤在同一条线程里排队。数据库一慢,采集周期就被拖着变慢;界面一刷新,采集就得等。解法是把链路拆开:采集线程只管收,收完丢进队列,入库和刷界面各开消费者慢慢消化。这套生产者-消费者管道是我们所有上位机项目的标准骨架,这篇完整讲一遍。

为什么不能一条线程干到底?

先看一条典型的"单线程流水线"每圈要干的事:读 300 个点位(约 50ms)→ 逐点判断报警 → 拼 INSERT 语句写库(约 30~200ms,看磁盘脸色)→ 更新 40 个 Label 和表格。任何一环变慢,整个周期就变慢,而且数据库写入耗时是最不稳定的——杀毒软件扫一下、机械盘抖一下,单条 INSERT 能从 5ms 飙到 300ms,采集周期跟着坐过山车。

拆成管道之后,各环节只通过队列交换数据,互不阻塞:

生产者采集线程按周期读 PLC,打包成快照对象入队,绝不做重活
缓冲有界队列BlockingCollection,容量 5000,满了触发背压策略
消费者 1入库线程攒批 200 条或 1 秒,一次批量 INSERT
消费者 2界面刷新500ms 定时取最新快照刷 UI,旧帧直接丢
消费者 3报警引擎逐帧判断越限,触发事件走独立通道

▲ 关键原则:生产者永远不等待消费者。采集线程入队后立即进入下一轮,数据库再慢也只影响"数据晚几秒落库",不影响实时性。

队列选什么?有界是底线

C# 里能用的线程安全队列有好几个,选型其实不纠结:

容器特点用不用
BlockingCollection<T>自带阻塞式 Take/GetConsumingEnumerable,支持有界容量,CompleteAdding 优雅关闭默认选择,管道场景就是为它设计的
ConcurrentQueue<T>无锁无界,入队出队不阻塞单独用要自己写等待和限流,一般作底层容器
Channel<T>异步友好,背压策略丰富,适合 async 体系新项目用 async 全链路时可选,WinForm 老项目 BlockingCollection 更顺手
lock + Queue手写锁能用但容易写出死锁和忙等,不推荐

队列必须有界。无界队列在数据库持续变慢时会无限积压,内存越涨越高,最后 OutOfMemory 整个程序崩掉——这比丢几秒数据严重得多。有界队列把问题控制在"满了怎么办"这一个决策点上。

队列满了怎么办?三种策略按数据价值选:丢最旧(监控类数据,只要最新值);② 阻塞生产者(配方、追溯类一条都不能丢的数据,宁可采集降速);③ 丢最新并报警(折中,配合"数据缺口"日志)。我们默认用①,同时在队列水位超过 80% 时触发警告级报警——队列持续满,说明消费端出了问题,必须让人知道。

入库别一条一条写:攒批是十倍级优化

单条 INSERT 的开销大头是事务提交和磁盘往返,攒成一批写,吞吐量能上一个数量级。消费者代码骨架:

private void DbConsumer()   // 独立线程
{
    var batch = new List<Snapshot>(200);
    var sw = Stopwatch.StartNew();

    foreach (var item in _queue.GetConsumingEnumerable(_cts.Token))
    {
        batch.Add(item);
        // 攒够200条 或 距上次写库超过1秒,就落一批
        if (batch.Count >= 200 || sw.ElapsedMilliseconds > 1000)
        {
            Flush(batch);          // SqlBulkCopy 或单事务多行INSERT
            batch.Clear(); sw.Restart();
        }
    }
    if (batch.Count > 0) Flush(batch);  // 收尾:停机前把余量写完
}

private void CollectProducer()   // 采集线程
{
    while (!_cts.IsCancellationRequested)
    {
        var snap = ReadAllPoints();      // 只读PLC,别的不干
        if (_queue.Count > _queue.BoundedCapacity * 0.8)
            RaiseQueueHighAlarm();        // 水位预警
        if (!_queue.TryAdd(snap, 0))      // 满了不阻塞采集
            Interlocked.Increment(ref _dropped);
        _latest = snap;                   // 供界面定时取用
        WaitUntilNextCycle();
    }
}

两个容易忽略的点:一是"条数 + 时间"双条件触发落批——只按条数,低产时段数据会迟迟不落库,断电就丢了;只按时间,高峰期又攒不够批。两个条件取先到者。二是程序退出时要 CompleteAdding 并把队列余量 Flush 完,不然每次停机都丢最后几秒数据,追溯时对不上账。

界面刷新:别订阅每一条,定时取最新

采集 100ms 一轮,界面没必要也不应该每轮都刷——人眼分辨不出来,控件重绘还会吃 CPU。我们的做法:界面开一个 500ms 的 Timer,每次触发时读取 _latest 最新快照统一刷新。中间被跳过的帧对显示毫无损失,界面负载降为原来的五分之一。报警弹窗和声音走事件通道实时推送,不受刷新节流影响——该快的快,该省的省。

怎么验证管道设计是否健康?盯三个指标:① 队列水位长期应接近 0,持续上涨说明消费跟不上;② 采集周期抖动应小于设定周期的 ±10%,抖动大说明生产者被拖住了;③ 丢弃计数应为 0,非零就要查消费端。这三个数我们直接放在诊断页面上,现场一眼能看。

收尾:一张参数表,照着配

参数典型值说明
队列容量5000 帧约等于"消费端最慢一次卡顿 × 采集频率"的 3 倍余量
批量大小200 条机械盘可降到 100,SSD 可到 500
落批超时1000 ms保证低产时数据延迟可控
界面刷新500 ms趋势曲线可单独 1s
水位预警线80%触发警告级报警并记日志

管道这套东西不神秘,本质就是"谁也别等谁,队列做缓冲,满了有预案"。如果你的上位机现在还是采集入库刷界面一条龙,建议先做最小改造:把写数据库挪到独立线程加个队列——通常这一步做完,界面卡顿就消失大半。