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 这个警告,看起来只是退出时刷几行日志,但它暴露的往往是对象生命周期混乱。
项目里真正靠谱的代码,不只是能跑,还得能优雅退出。