多线程取流与线程同步:工业上位机的并发艺术

多线程取流与线程同步:工业上位机的并发艺术

一、引言

工业上位机往往需要同时处理多件事:相机持续取流、PLC状态监听、界面实时刷新、用户操作响应。如果所有操作都在一个线程里串行执行,界面会卡死,数据会丢失。因此,多线程编程是工业上位机的必修课。

本文从第一性原理出发,讲解多线程与线程同步的本质,并结合项目中的CameraView.csMainView.cs代码,深入剖析取流线程、监听线程、线程锁、跨线程UI更新等关键技术。

二、第一性原理:为什么需要多线程

2.1 单线程的困境

假设相机每秒采集30帧图像,每帧处理需要50毫秒。如果所有操作都在UI线程执行:

  • UI线程忙于处理图像,无法响应用户点击。
  • 界面卡死,用户体验极差。
  • 图像处理阻塞,导致丢帧。

2.2 多线程的解决方案

多线程的核心思想是并行:让不同的任务在不同的线程上执行。

  • UI线程:负责界面刷新和用户交互。
  • 取流线程:负责从相机获取图像。
  • 监听线程:负责监听PLC状态。

这样,即使取流线程忙于处理图像,UI线程依然能流畅响应。

2.3 多线程的代价

多线程不是免费的午餐,它带来了新的问题:

  1. 竞态条件(Race Condition):多个线程同时访问共享数据,导致数据不一致。
  2. 死锁(Deadlock):多个线程互相等待对方释放资源。
  3. 线程安全:需要额外的同步机制保护共享数据。

三、项目中的多线程应用

3.1 取流线程

CameraView.cs中,相机取流使用独立线程:

csharp 复制代码
private void bnStartGrab_Click(object sender, EventArgs e)
{
    m_bGrabbing = true;  // 取流标志位置true
    m_hReceiveThread = new Thread(ReceiveThreadProcess);  // 创建取流线程
    m_hReceiveThread.Start();  // 启动线程
    // 开始采集
    int nRet = Mv3dLpSDK.MV3D_LP_StartMeasure(m_DevHandle);
    if ((int)Mv3dLpSDK.MV3D_LP_OK != nRet)
    {
        m_bGrabbing = false;
        m_hReceiveThread.Join();  // 等待线程结束
        ShowErrorMsg("Start Measure Fail!", nRet);
        return;
    }
    SetCtrlWhenStartGrab();
}

3.2 取流线程的工作循环

ReceiveThreadProcess是取流线程的执行体:

csharp 复制代码
public void ReceiveThreadProcess()
{
    int nRet = (int)Mv3dLpSDK.MV3D_LP_OK;
    UInt32 nTimeOut = 5;
    MV3D_LP_IMAGE_DATA stImageData = new MV3D_LP_IMAGE_DATA();
    while (this.m_bGrabbing)  // 循环直到停止标志位为false
    {
        nRet = Mv3dLpSDK.MV3D_LP_GetImage(m_DevHandle, stImageData, nTimeOut);
        if (nRet == (int)Mv3dLpSDK.MV3D_LP_OK)
        {
            count++;
            // 处理深度图数据
            if (stImageData.enImageType == Mv3dLpSDK.ImageType_Depth)
            {
                // ... 深度数据处理
            }
            nRet = DisplayImage(stImageData);  // 显示图像
        }
        else
        {
            continue;  // 获取失败,继续循环
        }
    }
}

3.3 线程停止机制

线程通过标志位 m_bGrabbing控制停止:

csharp 复制代码
private void bnStopGrab_Click(object sender, EventArgs e)
{
    m_bGrabbing = false;  // 标志位置false
    m_hReceiveThread.Join();  // 等待线程结束
    // 停止采集
    int nRet = Mv3dLpSDK.MV3D_LP_StopMeasure(m_DevHandle);
    ...
}

这里的关键是Join()方法:它阻塞当前线程,直到目标线程结束。这样确保取流线程完全停止后,才执行后续的停止采集操作。

3.4 为什么用标志位而不是Abort

.NET的Thread.Abort()可以强制终止线程,但非常危险:

  • 可能在任意位置中断,导致资源未释放。
  • 可能破坏共享数据的一致性。

因此,工业软件普遍采用协作式取消:通过标志位让线程自己检查并退出。这是更安全、更可控的做法。

四、线程同步:Monitor锁

4.1 为什么需要锁

取流线程和UI线程可能同时访问共享数据(如m_stImageInfom_pcDataBuf)。如果不加锁,可能出现:

  • 取流线程正在写入数据,UI线程同时读取,读到一半的数据。
  • 两个线程同时修改同一个变量,导致数据错乱。

4.2 Monitor锁的使用

项目使用Monitor.Enter/Exit保护共享数据:

csharp 复制代码
private static readonly object Lock = new object();  // 锁对象

private int DisplayImage(MV3D_LP_IMAGE_DATA pstImage)
{
    int nRet = (int)Mv3dLpSDK.MV3D_LP_OK;
    {
        Monitor.Enter(Lock);  // 加锁
        m_stImageInfo.nWidth = pstImage.nWidth;
        m_stImageInfo.nHeight = pstImage.nHeight;
        m_stImageInfo.nDataLen = pstImage.nDataLen;
        m_stImageInfo.enImageType = pstImage.enImageType;
        m_stImageInfo.nFrameNum = pstImage.nFrameNum;
        if (m_MaxImageSize < pstImage.nDataLen)
        {
            m_pcDataBuf = new byte[pstImage.nDataLen];
            m_MaxImageSize = pstImage.nDataLen;
        }
        m_stImageInfo.pData = Marshal.UnsafeAddrOfPinnedArrayElement(m_pcDataBuf, 0);
        Marshal.Copy(pstImage.pData, m_pcDataBuf, 0, (int)pstImage.nDataLen);
        Monitor.Exit(Lock);  // 解锁
    }
    IntPtr hWnd = pictureBox1.Handle;
    nRet = Mv3dLpSDK.MV3D_LP_DisplayImage(pstImage, hWnd, Mv3dLpSDK.DisplayType_Auto, 0, 0);
    return nRet;
}

4.3 Monitor与lock的关系

Monitor.Enter/Exitlock关键字是等价的:

csharp 复制代码
// 方式一:lock关键字
lock (Lock) { ... }

// 方式二:Monitor
Monitor.Enter(Lock);
try { ... }
finally { Monitor.Exit(Lock); }

lockMonitor的语法糖,编译器会自动生成try/finally确保解锁。项目中使用Monitor.Enter/Exit,但没有用try/finally包裹,这是一个潜在风险------如果加锁后抛出异常,锁永远不会释放,导致死锁。

4.4 锁的粒度

锁的粒度影响性能:

  • 粗粒度:锁住大段代码,安全但性能低。
  • 细粒度:只锁住关键代码,性能高但复杂。

项目中对DisplayImage的数据复制部分加锁,而对MV3D_LP_DisplayImage(耗时操作)不加锁,这是合理的细粒度设计。

五、跨线程UI更新

5.1 问题:UI线程安全

WinForms的UI控件只能在UI线程访问。如果取流线程直接修改UI控件,会抛出异常。

5.2 项目的处理方式

项目在构造函数中关闭了跨线程检查:

csharp 复制代码
System.Windows.Forms.Control.CheckForIllegalCrossThreadCalls = false;

这行代码关闭了.NET的跨线程UI访问检查。这样,取流线程可以直接操作UI控件,不会抛出异常。

5.3 这种做法的利弊

优点

  • 代码简单,无需Invoke
  • 性能好,没有跨线程调用的开销。

缺点

  • 隐藏了线程安全问题,可能导致UI状态不一致。
  • 不推荐在生产环境使用。

5.4 推荐的替代方案

更规范的做法是使用Invoke/BeginInvoke

csharp 复制代码
if (pictureBox1.InvokeRequired)
{
    pictureBox1.BeginInvoke(new Action(() =>
    {
        pictureBox1.Image = image;
    }));
}
else
{
    pictureBox1.Image = image;
}

Invoke把UI更新操作封送到UI线程执行,保证线程安全。

六、Task与CancellationToken

6.1 现代异步编程

除了Thread,项目还使用了TaskCancellationToken。在MainView.csreadStop方法中:

csharp 复制代码
Task stopTask;
CancellationTokenSource tokensource = new CancellationTokenSource();
bool startGrabFlag = false;
bool closeGrabFlag = false;

private void readStop()
{
    CancellationToken cancellationToken = tokensource.Token;
    stopTask = new Task(() =>
    {
        while (!cancellationToken.IsCancellationRequested)
        {
            int x = ReadPlc("M100");
            if (x == 1)
            {
                if (closeGrabFlag == false)
                {
                    closeGrab();
                    closeGrabFlag = true;
                    startGrabFlag = false;
                }
            }
            else
            {
                if (startGrabFlag == false)
                {
                    openGrab();
                    startGrabFlag = true;
                    closeGrabFlag = false;
                }
            }
        }
    }, cancellationToken);
    stopTask.Start();
}

6.2 CancellationToken的优势

CancellationToken是.NET提供的协作式取消机制:

  • 通过tokensource.Cancel()发出取消请求。
  • 线程通过cancellationToken.IsCancellationRequested检查是否被取消。
  • 相比标志位,CancellationToken更规范,支持超时、注册回调等高级功能。

6.3 关闭时的清理

在关闭程序时,需要取消任务并等待:

csharp 复制代码
if (ret)
{
    ClosePlc();
    tokensource.Cancel();  // 发出取消请求
    //stopTask.Wait();      // 等待任务结束
}

七、多线程编程的最佳实践

7.1 线程安全原则

  1. 共享数据加锁:所有被多线程访问的共享数据都要加锁保护。
  2. 锁内不调用耗时操作:避免长时间持有锁,导致其他线程阻塞。
  3. 使用try/finally确保解锁:防止异常导致死锁。

7.2 线程生命周期管理

  1. 用标志位或CancellationToken停止线程:不要用Abort。
  2. 用Join等待线程结束:确保资源正确释放。
  3. 避免线程泄漏:确保所有线程都能正常退出。

7.3 性能优化

  1. 减少锁的粒度:只锁关键代码。
  2. 避免频繁创建线程:使用线程池或Task。
  3. 合理设置超时:避免无限阻塞。

八、总结

本文从第一性原理出发,讲解了多线程取流与线程同步技术:

  1. 多线程的必要性:并行处理取流、监听、UI,避免卡顿。
  2. 线程停止机制:用标志位或CancellationToken协作式取消。
  3. 线程同步:用Monitor/lock保护共享数据。
  4. 跨线程UI:用Invoke规范更新,或关闭检查(不推荐)。
  5. Task与CancellationToken:现代异步编程范式。

多线程是工业上位机的核心能力,掌握它才能构建流畅、稳定的系统。下一篇文章将深入讲解深度图多帧拼接算法。

相关推荐
波特率1152005 天前
pthread库函数解析
linux·线程·pthread
xxy222416 天前
Linux 系统编程(6)——线程基础
线程·pthread
j7~21 天前
【Linux】二十八.线程篇五《Linux多线程编程:线程同步之条件变量》---详解
linux·运维·c++·学习·条件变量·线程同步
Nebula_g24 天前
Java实现本地Socket通信(二)
java·开发语言·socket·线程·chat
xiaoye-duck1 个月前
《Linux系统编程》Linux 系统多线程(六):<线程同步与互斥>线程同步(下):POSIX 信号量与环形队列生产者消费者模型详解
linux·线程
xiaoye-duck1 个月前
《Linux系统编程》Linux 系统多线程(五):<线程同步与互斥>线程互斥(上):从条件变量到生产者消费者模型详解
linux·线程
xiaoye-duck1 个月前
《Linux系统编程》Linux 系统多线程(二):进程 vs 线程资源划分 + 线程控制
linux·线程
欢醉1 个月前
一次P0 故障:微服务线程数莫名暴涨 10 倍,线程问题踩坑
springboot·线程·springcloud
是个西兰花1 个月前
Linux:深入解析Linux线程原理与实现
linux·运维·c++·线程·互斥锁