多线程取流与线程同步:工业上位机的并发艺术
一、引言
工业上位机往往需要同时处理多件事:相机持续取流、PLC状态监听、界面实时刷新、用户操作响应。如果所有操作都在一个线程里串行执行,界面会卡死,数据会丢失。因此,多线程编程是工业上位机的必修课。
本文从第一性原理出发,讲解多线程与线程同步的本质,并结合项目中的CameraView.cs、MainView.cs代码,深入剖析取流线程、监听线程、线程锁、跨线程UI更新等关键技术。
二、第一性原理:为什么需要多线程
2.1 单线程的困境
假设相机每秒采集30帧图像,每帧处理需要50毫秒。如果所有操作都在UI线程执行:
- UI线程忙于处理图像,无法响应用户点击。
- 界面卡死,用户体验极差。
- 图像处理阻塞,导致丢帧。
2.2 多线程的解决方案
多线程的核心思想是并行:让不同的任务在不同的线程上执行。
- UI线程:负责界面刷新和用户交互。
- 取流线程:负责从相机获取图像。
- 监听线程:负责监听PLC状态。
这样,即使取流线程忙于处理图像,UI线程依然能流畅响应。
2.3 多线程的代价
多线程不是免费的午餐,它带来了新的问题:
- 竞态条件(Race Condition):多个线程同时访问共享数据,导致数据不一致。
- 死锁(Deadlock):多个线程互相等待对方释放资源。
- 线程安全:需要额外的同步机制保护共享数据。
三、项目中的多线程应用
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_stImageInfo、m_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/Exit和lock关键字是等价的:
csharp
// 方式一:lock关键字
lock (Lock) { ... }
// 方式二:Monitor
Monitor.Enter(Lock);
try { ... }
finally { Monitor.Exit(Lock); }
lock是Monitor的语法糖,编译器会自动生成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,项目还使用了Task和CancellationToken。在MainView.cs的readStop方法中:
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 线程安全原则
- 共享数据加锁:所有被多线程访问的共享数据都要加锁保护。
- 锁内不调用耗时操作:避免长时间持有锁,导致其他线程阻塞。
- 使用try/finally确保解锁:防止异常导致死锁。
7.2 线程生命周期管理
- 用标志位或CancellationToken停止线程:不要用Abort。
- 用Join等待线程结束:确保资源正确释放。
- 避免线程泄漏:确保所有线程都能正常退出。
7.3 性能优化
- 减少锁的粒度:只锁关键代码。
- 避免频繁创建线程:使用线程池或Task。
- 合理设置超时:避免无限阻塞。
八、总结
本文从第一性原理出发,讲解了多线程取流与线程同步技术:
- 多线程的必要性:并行处理取流、监听、UI,避免卡顿。
- 线程停止机制:用标志位或CancellationToken协作式取消。
- 线程同步:用Monitor/lock保护共享数据。
- 跨线程UI:用Invoke规范更新,或关闭检查(不推荐)。
- Task与CancellationToken:现代异步编程范式。
多线程是工业上位机的核心能力,掌握它才能构建流畅、稳定的系统。下一篇文章将深入讲解深度图多帧拼接算法。