Qt多线程编程中的同步机制及线程里的QTimer使用误区

1、Qt多线程编程中的同步机制

在多线程编程中,多个线程可能会同时访问共享资源,这时就可能出现数据竞争的问题,从而导致程序的行为不确定。为了避免这种情况,我们需要使用同步机制来保证线程安全。今天,我们来一起看看在 Qt/C++ 中常见的同步机制,了解它们如何帮助我们确保线程之间的协作不会出错。

1. 1 互斥锁(QMutex / std::mutex)

互斥锁是最常见的同步工具之一,它保证在同一时刻只有一个线程可以访问共享资源。简单来说,它就像是一个"门",只有持有锁的线程才能进入修改资源,而其他线程则必须等到门开了才能进去。

代码示例(QMutex):

cpp 复制代码
#include <QMutex>
#include <QThread>
#include <iostream>

QMutex mutex;  // 互斥锁
int sharedResource = 0;

class Worker : public QThread {
public:
    void run() override {
        mutex.lock();  // 加锁,进入临界区
        std::cout << "Thread " << QThread::currentThreadId() << " is entering the critical section." << std::endl;
        sharedResource++;  // 修改共享资源
        std::cout << "Shared Resource: " << sharedResource << std::endl;
        mutex.unlock();  // 解锁,其他线程可以进入
    }
};

int main() {
    Worker worker1, worker2;
    worker1.start();
    worker2.start();
    worker1.wait();
    worker2.wait();
    return 0;
}

关键点:

使用 mutex.lock() 锁定互斥锁,只有一个线程可以进入临界区。

执行完临界区的代码后,通过 mutex.unlock() 解锁,允许其他线程进入。

1. 2 读写锁(QReadWriteLock)

读写锁允许多个线程同时读取共享资源,但在写操作时,只允许一个线程进行写入,其他线程必须等待。这种方式特别适合多读少写的场景,可以显著提高程序性能。

代码示例(QReadWriteLock):

cpp 复制代码
#include <QReadWriteLock>
#include <QThread>
#include <iostream>

QReadWriteLock lock;
int sharedResource = 0;

class Reader : public QThread {
public:
    void run() override {
        lock.lockForRead();  // 获取读锁
        std::cout << "Reader thread " << QThread::currentThreadId() << " reading: " << sharedResource << std::endl;
        lock.unlock();  // 释放读锁
    }
};

class Writer : public QThread {
public:
    void run() override {
        lock.lockForWrite();  // 获取写锁
        sharedResource++;
        std::cout << "Writer thread " << QThread::currentThreadId() << " writing: " << sharedResource << std::endl;
        lock.unlock();  // 释放写锁
    }
};

int main() {
    Reader reader1, reader2;
    Writer writer1;
    reader1.start();
    reader2.start();
    writer1.start();
    reader1.wait();
    reader2.wait();
    writer1.wait();
    return 0;
}

关键点:

lock.lockForRead() 允许多个线程同时获取读锁并读取数据。

lock.lockForWrite() 写操作时,只有一个线程可以获得锁,其他线程需要等待。

在有大量读操作,写操作较少的情况下,读写锁的性能优势非常明显。

1. 3 原子操作(QAtomicInt / std::atomic)

原子操作是一种轻量级的同步方式。它保证了对变量的操作是原子的,多个线程访问时不需要加锁,操作本身是安全的,适合用于计数器等简单的共享数据操作。

代码示例(QAtomicInt):

cpp 复制代码
#include <QAtomicInt>
#include <QThread>
#include <iostream>

QAtomicInt atomicCounter = 0;

class Worker : public QThread {
public:
    void run() override {
        for (int i = 0; i < 1000; ++i) {
            atomicCounter.ref();  // 原子增操作
        }
    }
};

int main() {
    Worker worker1, worker2;
    worker1.start();
    worker2.start();
    worker1.wait();
    worker2.wait();
    std::cout << "Final counter: " << atomicCounter.load() << std::endl;
    return 0;

}

关键点:

atomicCounter.ref() 是一个原子操作,不需要加锁,硬件保证它在多个线程之间的安全性。

原子操作适合简单的增减计数操作,避免了锁带来的性能开销。

2、线程里创建 QTimer 为什么会偶发性不触发

这个问题在 Qt 项目里见过太多次了。设备轮询、心跳检测、串口重连、后台采集,很多地方都要用定时器。于是代码很自然地写成这样:

cpp 复制代码
void Worker::start()
{
    QTimer *timer = new QTimer(this);
    connect(timer, &QTimer::timeout,
            this, &Worker::pollDevice);
    timer->start(1000);
}

看起来没啥问题,但问题是,真实项目里它就是不触发。没崩溃,没报错,程序还在跑,就是定时任务像失踪了一样。

QTimer 不是自己偷偷跑的

很多人以为 QTimer::start() 之后,它就像系统闹钟一样,到点自动回调。

其实不是。

QTimer 依赖 Qt 的事件循环。说白了,它需要有一个线程在跑事件分发,到了时间点,事件循环才会把 timeout 信号发出来。

所以在线程里用 QTimer,要看两个关键点:定时器到底属于哪个线程? 那个线程有没有事件循环?少一个,定时器都可能不触发。

项目里最常见的坑,是在构造函数里创建定时器:

cpp 复制代码
Worker::Worker(QObject *parent)
    : QObject(parent)
{
    timer = new QTimer(this);
}

然后后面再来一句:

cpp 复制代码
worker->moveToThread(thread);

看起来像是把 Worker 搬到子线程了,但很多问题就从这里开始。

因为 QObject 有线程亲和性。对象在哪个线程创建,默认就属于哪个线程。你后面再 move,创建时机、父子对象、启动位置一混,问题就开始变得很难查。

我的经验是:别太相信"看起来已经移过去了"。

更稳的写法,是在线程真正启动后,再创建和启动定时器:

cpp 复制代码
connect(thread, &QThread::started,
        worker, &Worker::start);

然后在 Worker::start() 里创建 QTimer:

cpp 复制代码
void Worker::start()
{
    timer = new QTimer(this);

    connect(timer, &QTimer::timeout,
            this, &Worker::pollDevice);

    timer->start(1000);
}

这样做的好处很直接: 定时器创建、启动、timeout 执行,都在同一个线程上下文里。后面出问题,也好定位。

别把 QThread 写成死循环工具,还有一种写法更容易出事:继承 QThread,然后重写 run():

cpp 复制代码
void MyThread::run()
{
    while (running) {
        doWork();
        QThread::msleep(10);
    }
}

这种代码在项目里很常见,尤其是做设备通信、采集线程的时候。

问题是,你这样写,线程里通常没有 Qt 事件循环。没有事件循环,QTimer、socket、串口这些依赖事件分发的对象,就容易出各种奇怪问题。

不是不能这么写,而是别一边死循环,一边又指望 QTimer 像正常事件驱动那样工作。

两套模型混着用,短期能跑,后期一定难维护。

3、推荐 QObject + moveToThread

如果线程里要用 QTimer、QTcpSocket、QSerialPort 这类对象,我一般更推荐:

业务逻辑写在 QObject 里。 线程只负责承载事件循环。 不要把业务逻辑硬塞进 QThread::run()。

典型结构是这样:

cpp 复制代码
QThread *thread = new QThread;
Worker *worker = new Worker;

worker->moveToThread(thread);

connect(thread, &QThread::started,
        worker, &Worker::start);

connect(worker, &Worker::finished,
        thread, &QThread::quit);

connect(worker, &Worker::finished,
        worker, &QObject::deleteLater);

connect(thread, &QThread::finished,
        thread, &QObject::deleteLater);

thread->start();

这套写法不花哨,但项目里很稳。

它把几个事情分清楚了: 线程什么时候启动,业务什么时候初始化,资源什么时候释放。

Qt 项目后期最怕的不是代码多,而是生命周期乱。尤其是通信类项目,退出流程一乱,轻则偶现崩溃,重则下次设备连不上。

排查时别只盯着 connect

很多人遇到 QTimer 不触发,第一反应是查:

是不是没 connect? 槽函数是不是写错? interval 是不是太短? 对象是不是被释放了?

这些当然要看,但在线程场景里,我会优先查这三个问题:

QTimer 是在哪个线程创建的? QTimer 是在哪个线程 start 的? 当前线程有没有事件循环?

这三个问题,比盯着 API 参数有用多了。

这个经验特别适合这些场景:设备状态轮询 串口定时读取 网络心跳检测 断线重连 后台采集 缓存定时清理 工业软件里的周期任务

这些任务看起来都不复杂,但一旦放进线程,问题就不再只是"定时执行一段代码"这么简单了。

4、killTimer 警告,无法退出线程

做 Qt/C++ 项目时,我见过不少人在线程里用 QTimer。

这事本身没问题。比如串口轮询、TCP 心跳、设备状态刷新、Modbus 采集、定时重连,这些场景用 QTimer 都挺合适。它比 while + sleep 看着舒服,也更符合 Qt 的事件模型。但问题通常不是出在"运行时",而是出在"退出时"。项目跑着好好的,窗口一关,设备一断,线程一停,控制台突然开始刷:

cpp 复制代码
QObject::killTimer: Timers cannot be stopped from another thread
QObject::~QObject: Timers cannot be stopped from another thread

很多人看到这两行,第一反应是:程序又没崩,先不管。

我以前也这么干过。说实话,开发阶段看到这种 warning,很容易把它当成"Qt 有点矫情"。但后面项目维护久了就会发现,这类警告不是废话,它是在提醒你:你的对象生命周期已经开始乱了。

真正的问题,不是 QTimer 难用

QTimer 本身不难,它的规则也很简单:它依赖线程的事件循环。

也就是说,定时器在哪个线程里工作,就应该在哪个线程里创建、启动、停止和销毁。你不能让它在子线程里跑着,然后主线程突然冲进来把它停掉、删掉。

很多项目里的坑,是这样埋下的:

cpp 复制代码
Worker *worker = new Worker;
QThread *thread = new QThread;

worker->moveToThread(thread);
thread->start();

然后在 Worker 构造函数里创建定时器:

cpp 复制代码
Worker::Worker()
{
    timer = new QTimer(this);
}

这段代码看起来很正常,对吧?

但坑就在这里。Worker 是在主线程 new 出来的,构造函数也是在主线程执行的。你在构造函数里创建 QTimer,它一开始就跟主线程有关系。后面虽然 worker->moveToThread(thread) 了,但定时器的创建时机、父子对象关系、事件循环状态,很容易变得不干净。

小 Demo 里可能一点事没有,因为 Demo 太简单了:启动一次,关闭一次,中间没有设备掉线,没有线程重启,也没有复杂的析构链路。

真实项目就不是这么回事了。

主窗口关闭时,通信线程可能还在跑;线程准备退出时,timer 可能还没停;worker 准备析构时,事件循环可能已经没了。这个时候 Qt 不报警才怪。

警告刷屏,其实是在提醒你"退出流程不干净"

很多 Qt 多线程问题,最恶心的地方就在这里:运行时看不出来,退出时才暴露。

运行的时候,timer 正常触发,数据正常采集,界面正常刷新,大家都觉得代码没问题。可一到退出,谁先停、谁后删、谁还挂着事件,问题一下就出来了。

短期看,可能只是日志里多几行 warning。长期看,它可能变成退出卡死、偶发崩溃、线程释放不干净,甚至对象已经销毁了,队列里还有事件没处理。

这种问题最烦的是不稳定。开发机上偶尔出现,客户现场更容易出现;你越想复现,它越不给你面子。最后翻日志才发现,killTimer 早就提醒过你,只是你没当回事。

我一般会这样写

我的习惯是:不要在 Worker 构造函数里急着创建 QTimer。

先把 worker 移到线程里,等线程真正启动后,再让 worker 自己在所属线程里创建和启动定时器。

cpp 复制代码
connect(thread, &QThread::started,
        worker, &Worker::start);

connect(this, &Controller::stopWorker,
        worker, &Worker::stop,
        Qt::QueuedConnection);

connect(worker, &Worker::finished,
        thread, &QThread::quit);

connect(worker, &Worker::finished,
        worker, &QObject::deleteLater);

connect(thread, &QThread::finished,
        thread, &QObject::deleteLater);

Worker 里面可以这样处理:

cpp 复制代码
void Worker::start()
{
    timer = new QTimer(this);

    connect(timer, &QTimer::timeout,
            this, &Worker::pollDevice);

    timer->start(100);
}

void Worker::stop()
{
    if (timer) {
        timer->stop();
    }

    emit finished(
);
}

这段代码的重点不是炫技,而是把生命周期理顺。

timer 在 worker 所在线程里创建,也在 worker 所在线程里启动。停止的时候,通过 Qt::QueuedConnection 把 stop 操作投递回 worker 线程执行,而不是主线程直接硬停。

说白了就是一句话:谁的线程,谁收尾。

别把 moveToThread 当万能药

很多人对 moveToThread() 有个误解,以为对象一 move,里面所有问题都解决了。

不是的。

moveToThread() 只是改变对象的线程归属,它不会帮你重新设计生命周期,也不会帮你处理已经创建好的定时器,更不会帮你安排退出顺序。

还有一种常见写法也很危险:

cpp 复制代码
thread->quit();
thread->wait();
delete worker;

看起来干净利落,实际上很可能把 worker 在错误的线程里 delete 掉。尤其 worker 里有 QTimer、socket、串口对象这类依赖事件循环的东西时,问题就更容易冒出来。

Qt 项目里很多坑不是 API 难,而是代码"能跑"以后,大家就懒得管它怎么退出。

哪些场景尤其要小心

只要你在线程里用了定时器,就应该对这个问题敏感。

尤其是设备通信、串口采集、网络心跳、状态轮询、自动重连、日志定时刷新、后台任务调度这些场景。它们有一个共同点:平时存在感很低,但生命周期很长。

这种代码最怕"差不多能跑"。因为它可能一跑就是几个小时、几天,甚至客户现场一年不关机。你今天忽略的 warning,后面很可能变成现场偶发问题。

而且这种问题一旦进了老项目,维护起来特别痛。新人不敢改,老人记不清,大家只能在一堆 connect 和 deleteLater 里猜当初的意图。

我的建议很简单

QTimer 放线程里不是不能用,我反而建议该用就用。比起自己写死循环、sleep、各种标志位,QTimer 更清爽,也更符合 Qt 的思路。

但前提是你得尊重它的线程规则。

定时器在哪个线程工作,就在哪个线程创建、启动、停止、销毁。线程退出前,先停业务,再退事件循环,最后释放对象。别让主线程像项目经理一样,隔空指挥所有对象"马上下班"。

QObject::killTimer 这个警告,看起来只是退出时刷几行日志,但它暴露的往往是对象生命周期混乱。

项目里真正靠谱的代码,不只是能跑,还得能优雅退出。