第二阶段 · 第 7 课:C++ 多线程与线程安全

第二阶段 · 第 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++ 内存模型下,对同一个普通变量进行无同步的并发读写会导致未定义行为。

解决办法有两种:

  1. 使用互斥锁保护共享变量。
  2. 使用适合该场景的原子变量。
    五、使用 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。
    两边都无法继续执行,就会产生死锁。

避免方法:

  1. 统一锁的获取顺序。
  2. 尽量缩短持锁时间。
  3. 不要在持锁期间进行长时间网络等待或图像计算。
  4. 使用 QMutexLocker 管理锁的生命周期。
  5. 设计线程退出时的唤醒机制,避免消费者永久等待队列数据。
    例如,复制共享图像的矩阵头时可以缩短锁的持有时间:
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 课:生产者---消费者模型、线程池与任务调度,进一步把这些概念应用到你的相机采集和设备控制架构中。

相关推荐
LMT_Lanmantech2 小时前
Synopsys Verdi安全合规:风险、审计与应对
安全·软件许可证
SatanII2 小时前
Kubernetes 认证与授权详解:守护集群API访问安全
安全·贪心算法·kubernetes
安协技术部门2 小时前
安全光栅与红外报警器安全回路设计有什么区别?
安全·制造
爱聊灵光的维C2 小时前
C语言初阶:关于函数设计的详细讲解和注意事项
c语言·开发语言·青少年编程·蓝桥杯·学习方法
熊猫钓鱼>_>2 小时前
上架审核常见驳回与隐私合规清单:过审前先自查这 8 条【鸿蒙心迹】
开发语言·前端·华为·华为云·harmonyos·openharmony·ai制作
Godspeed Zhao2 小时前
具身智能系统概论——系统安全与可靠性
安全·系统安全
SEO_juper2 小时前
Bash 交叉验证 robots.txt 和 sitemap:两个文件自相矛盾,页面永远收录不了
开发语言·python·node.js·json·bash
geovindu2 小时前
rust: Read-Write Lock Pattern
开发语言·后端·设计模式·rust·读写锁模式·同步型模式