第二阶段 · 第 7 课:C++ 多线程与线程安全
本课从头开始,系统学习 C++ 多线程的基础知识,并结合你使用的 Qt 5.12、C++14、红外相机采集、设备通信线程和 BlockingQueue 进行讲解。
学习目标是:理解线程的运行方式、掌握共享数据保护、学会线程间通信,并能够编写安全退出的 Qt 工作线程。
一、什么是线程?为什么需要多线程?
线程是操作系统调度执行任务的基本单位。一个进程可以包含多个线程,这些线程通常共享进程的内存空间和资源。
例如,你的红外检测程序可能同时需要:
- 采集线程:从红外相机获取图像。
- 图像处理线程:计算温度、图像清晰度和自动对焦得分。
- 设备通信线程:与电源、转盘或运动控制器通信。
- UI 线程:显示图像、响应按钮和更新状态。
如果所有任务都放在 UI 线程执行,耗时操作就可能导致界面卡顿。
UI 线程
显示图像、响应用户操作
采集线程
获取相机帧
处理线程
计算与分析图像
通信线程
控制设备与收发数据
任务队列
在线程之间传递任务
需要注意:多线程不一定能让每项任务都更快。它的主要价值是让任务并行或异步执行,避免一个耗时操作阻塞其他工作。
二、创建一个 C++ 线程
标准 C++11 开始提供 std::thread,Qt 则提供 QThread。
cpp
1. 使用 std::thread
#include <thread>
#include <iostream>
void worker()
{
std::cout << "Worker thread is running\n";
}
int main()
{
std::thread t(worker);
t.join();
return 0;
}
这里有两个关键操作:
- std::thread t(worker):创建线程并开始执行 worker()。
- t.join():等待线程执行结束。
如果线程对象析构时仍然处于 joinable 状态,程序会调用 std::terminate()。因此,需要明确调用 join() 或 detach();对于需要可靠退出和资源管理的工程,通常优先考虑 join()。
cpp
2. 使用 Qt 的 QThread
#include <QThread>
#include <QDebug>
class MyThread : public QThread
{
Q_OBJECT
protected:
void run() override
{
qDebug() << "Worker thread:"
<< QThread::currentThreadId();
}
};
调用:
MyThread thread;
thread.start();
thread.wait();
start() 启动新线程,执行 run();wait() 等待线程结束。
这只是最简单的示例。真实项目还需要考虑线程退出、共享数据和设备资源释放。
三、Qt 中的两种线程设计方式
这是 Qt 多线程中非常重要的知识点。
方式一:继承 QThread
适用于有明确执行循环的专用任务,例如持续采集图像。
cpp
class CameraThread : public QThread
{
Q_OBJECT
protected:
void run() override
{
while (!isInterruptionRequested())
{
captureFrame();
}
}
private:
void captureFrame()
{
// Capture one frame
}
};
停止线程:
cameraThread.requestInterruption();
cameraThread.wait();
这里的 requestInterruption() 只是发出停止请求,run() 必须主动检查它。
如果 captureFrame() 长时间阻塞,线程可能无法及时退出,因此还需要为相机 SDK 设置合理的超时或取消机制。
方式二:QObject + QThread
适用于需要事件循环、信号槽、定时器等功能的工作对象。
cpp
class Worker : public QObject
{
Q_OBJECT
public slots:
void doWork()
{
qDebug() << "Work started";
emit sigFinished();
}
signals:
void sigFinished();
};
创建并启动:
QThread* thread = new QThread(this);
Worker* worker = new Worker;
worker->moveToThread(thread);
connect(thread, &QThread::started,
worker, &Worker::doWork);
connect(worker, &Worker::sigFinished,
thread, &QThread::quit);
connect(thread, &QThread::finished,
worker, &QObject::deleteLater);
connect(thread, &QThread::finished,
thread, &QObject::deleteLater);
thread->start();
需要注意:如果 doWork() 是一个长时间运行、不返回的循环,工作线程就无法及时处理其他排队槽函数和定时器事件。因此,这种设计通常需要将工作拆成较短的任务,或者使用明确的停止机制。
如何选择?
场景 建议
专用的持续采集循环 可继承 QThread,重写 run()
需要事件循环、定时器、异步槽函数 优先考虑 QObject + QThread
大量独立计算任务 可以考虑线程池
UI 更新 在 UI 线程执行
特别注意:QThread 对象本身通常属于创建它的线程,而 run() 在新线程执行。继承 QThread 并不会让这个 QThread 对象的普通槽函数自动在新线程执行。
四、什么是线程安全?
线程安全是指代码在多个线程同时访问时,仍然能按照设计正确运行。
假设两个线程同时执行:
cpp
int g_count = 0;
void worker()
{
for (int i = 0; i < 100000; ++i)
++g_count;
}
主线程创建两个工作线程:
cpp
std::thread t1(worker);
std::thread t2(worker);
t1.join();
t2.join();
理论上希望 g_count 最终为 200000,但这个程序存在数据竞争。
因为:
++g_count;
并不是一个天然原子的整体操作,而是涉及读取、修改和写入。
两个线程可能同时读取相同的旧值,导致更新丢失。更重要的是,在 C++ 内存模型下,对同一个普通变量进行无同步的并发读写会导致未定义行为。
解决办法有两种:
- 使用互斥锁保护共享变量。
- 使用适合该场景的原子变量。
五、使用 QMutex 保护共享数据
QMutex 是 Qt 提供的互斥锁。任意时刻,只有成功获得同一把锁的线程才能进入对应的临界区。
cpp
#include <QMutex>
#include <QMutexLocker>
class Counter
{
public:
void increment()
{
QMutexLocker locker(&m_mutex);
++m_count;
}
int value()
{
QMutexLocker locker(&m_mutex);
return m_count;
}
private:
QMutex m_mutex;
int m_count = 0;
};
这里:
- m_mutex 保护 m_count。
- QMutexLocker 构造时加锁。
- 函数返回、提前退出或发生异常时,局部对象析构并自动解锁。
这就是 RAII 在多线程中的应用。
为什么不直接使用 lock() 和 unlock()?
例如:
m_mutex.lock();
if (m_count < 0)
return;
++m_count;
m_mutex.unlock();
如果条件成立,函数会提前返回,导致锁没有释放,其他线程可能永远等待。
改为 QMutexLocker 后,就不容易遗漏解锁操作。
原则:共享数据在哪里被读写,就应该在哪里建立明确的同步规则。
六、使用 std::atomic
对于简单的计数器和停止标志,不一定需要 QMutex。
cpp
#include <atomic>
std::atomic<bool> running(true);
std::atomic<int> frameCount(0);
工作线程:
while (running.load())
{
captureFrame();
frameCount.fetch_add(1);
}
停止线程:
running.store(false);
std::atomic 是 C++11 的功能,你的 C++14 工程可以直接使用。
但需要记住:
- std::atomic 适合简单的线程停止标志。
- std::atomic 适合独立的整数计数。
- 原子变量不能自动保护整个对象。
- 多个变量之间的复合操作,仍可能需要互斥锁。
例如,判断计数是否小于 100,再增加一次,这两个步骤合起来并不是原子事务。
七、线程间如何传递数据?
以红外相机为例: - 采集线程获取图像。
- 处理线程计算温度和清晰度。
- UI 线程显示结果。
一种常见设计是使用线程安全队列:
Camera Thread
|
v
BlockingQueue
|
v
Processing Thread
|
v
Qt Signal
|
v
UI Thread
1. 为什么需要队列?
假设相机每秒产生 30 帧,而图像处理线程每秒只能处理 20 帧。
每秒产生的数据比处理的数据多 10 帧。如果队列无限增长,内存占用和显示延迟会持续增加。
因此,必须明确队列策略:
队列策略 适合场景
只保留最新帧 实时画面显示
FIFO 队列 不能丢失每一帧的测量任务
有界阻塞队列 队列满时让生产者等待
有界丢弃队列 允许丢帧,但限制延迟和内存
2. 使用 QWaitCondition
QWaitCondition 可以让线程在条件不满足时等待,而不是一直循环检查。
例如,消费者等待队列中出现数据:
cpp
QMutexLocker locker(&m_mutex);
while (m_queue.isEmpty() && !m_stopping)
{
m_condition.wait(&m_mutex);
}
生产者添加数据后:
{
QMutexLocker locker(&m_mutex);
m_queue.enqueue(data);
}
m_condition.wakeOne();
wait() 会在等待期间释放互斥锁,被唤醒后重新获取锁。使用 while 是为了在继续处理前重新验证条件。
真实的 BlockingQueue 还应处理停止状态、队列容量、生产者与消费者同时退出等问题。
八、Qt 跨线程信号与槽
Qt 支持跨线程通信。对于 UI 更新,通常使用排队连接:
cpp
connect(worker, &Worker::sigImageReady,
this, &MainWindow::updateImage,
Qt::QueuedConnection);
这样,槽函数会在接收对象所属线程的事件循环中执行。
如果 MainWindow 属于 UI 线程,那么 updateImage() 就可以在 UI 线程中更新界面。
为什么不能直接从工作线程更新 UI?
Qt Widgets 控件通常不是线程安全的。直接从采集线程调用:
ui->labelImage->setPixmap(pixmap);
可能导致界面异常、崩溃或难以复现的问题。
正确的思路是:工作线程处理数据,通过信号把结果发送给 UI 线程,再由 UI 线程更新控件。
此外,如果信号携带的是 cv::Mat,普通赋值通常只复制矩阵头,像素数据仍然共享。跨线程传递时要约定数据所有权,避免一边显示、一边修改同一块像素内存。
九、死锁:两个线程互相等待
假设有两把锁:
- 线程 A 先锁住 mutexA,再等待 mutexB。
- 线程 B 先锁住 mutexB,再等待 mutexA。
两边都无法继续执行,就会产生死锁。
避免方法:
- 统一锁的获取顺序。
- 尽量缩短持锁时间。
- 不要在持锁期间进行长时间网络等待或图像计算。
- 使用 QMutexLocker 管理锁的生命周期。
- 设计线程退出时的唤醒机制,避免消费者永久等待队列数据。
例如,复制共享图像的矩阵头时可以缩短锁的持有时间:
cpp
cv::Mat localImage;
{
QMutexLocker locker(&m_mutex);
localImage = m_sharedImage;
}
processImage(localImage);
但是,cv::Mat 的浅拷贝会共享像素内存。如果其他线程可能修改像素数据,仍需要额外的所有权约束,或在适当情况下使用 clone() 创建独立副本。
十、线程安全退出
线程安全不只是保证数据不冲突,还包括程序关闭时能正常结束线程。
例如:
cpp
class CameraThread : public QThread
{
Q_OBJECT
public:
void stop()
{
requestInterruption();
}
protected:
void run() override
{
while (!isInterruptionRequested())
{
captureFrame();
}
}
private:
void captureFrame()
{
// Capture one frame
}
};
关闭时:
cameraThread.stop();
cameraThread.wait();
需要特别注意:如果 captureFrame() 一直阻塞,线程就无法及时检查停止请求。对于实际相机 SDK,应设计超时、取消采集或安全释放设备的机制。
如果消费者线程阻塞在 QWaitCondition::wait(),仅设置停止标志还不够,还需要唤醒它:
cpp
{
QMutexLocker locker(&m_mutex);
m_stopping = true;
m_condition.wakeAll();
}
消费者醒来后,检查 m_stopping 并退出循环。
不要在线程仍然访问设备或共享资源时就销毁这些资源;退出顺序必须保证线程先停止使用资源,再释放资源。
十一、本课知识总结
工具 作用
QThread 管理工作线程
QMutex 保护共享数据
QMutexLocker 自动管理加锁与解锁
QWaitCondition 协调生产者与消费者
std::atomic 安全访问简单共享变量
Qt::QueuedConnection 跨线程排队调用槽函数
requestInterruption() 请求线程停止
wait() 等待线程结束
学习多线程时,最重要的不是记住 API,而是建立四个习惯:
- 明确每份数据由谁拥有。
- 明确哪些线程可以读写数据。
- 明确线程之间如何同步和传递消息。
- 明确线程在程序关闭时如何退出。
十二、课后练习
假设你的红外相机每秒采集 30 帧,而处理线程每秒只能处理 20 帧。
问题 1:如果主要目的是实时显示画面,应该采用哪种缓冲策略?
A. 只保留最新一帧,允许丢弃旧帧
B. 无限 FIFO 队列,保留所有帧
C. 给整个图像处理过程一直加锁
问题 2:为什么不能在工作线程中直接修改 QLabel?
问题 3:如果线程阻塞在 QWaitCondition::wait(),停止时应该怎么处理?
提交答案
完成这课后,下一步可以学习 第三阶段 · 第 1 课:生产者---消费者模型、线程池与任务调度,进一步把这些概念应用到你的相机采集和设备控制架构中。