多线程编程炙手可热,尤其是当代多核处理器的硬件条件下,多线程可以更好的利用系统硬件资源。Qt对多线程的支持也非常好,本篇笔记总结下Qt的多线程技术。
Qt对多线程的支持优点在于:跨平台的多线程类,线程安全的事件投递以及跨线程的信号-槽连接。Qt中涉及的主要线程类有:
类名 |
描述 |
QAtomicInteger |
独立于平台的整数类型 |
QAtomicPointer |
模板类,提供与平台无关的指针类型 |
QFuture |
表示异步计算的结果 |
QFutureSynchronizer |
简化QFuture同步的辅助类 |
QFutureWatcher |
允许使用信号和插槽监控QFuture |
QMutex |
提供相互排斥的锁,或互斥量 |
QMutexLocker |
是一个辅助类,自动对 QMutex 加锁与解锁 |
QReadLocker |
QReadWriteLock的辅助类 读操作 |
QReadWriteLock |
提供了一个可以同时读写操作的锁 |
QWriteLocker |
QReadWriteLock的辅助类 写操作 |
QRunnable |
所有可运行对象的基类 |
QSemaphore |
提供了一个整型信号量 |
QThread |
独立于平台的线程管理方式 |
QThreadPool |
管理QThreads的线程池 |
QThreadStorage |
线程数据存储 |
QWaitCondition |
用于同步线程的条件变量 |
QtConcurrent |
高级API,无需使用低级线程原语即可编写多线程程序 |
QThread是Qt中所有线程控制的基础。每个QThread实例代表并控制一个线程。
QThread可以直接实例化也可以子类化。实例化QThread提供了一个并行事件循环,从而允许在辅助线程中使用信号和槽。子类化QThread允许应用程序在开始其事件循环之前初始化新线程,或者在没有事件循环的情况下运行并行代码。
简单讲,QThreads在其成员函数run()中执行。默认情况下,通过调用exec()或start()函数来启动QThread,也即运行run()函数里的循环。注意,start启动线程后会在run运行完后自动退出,而exec启动的线程必须调用exit才可以退出。当线程开始和完成时,QThread会通过信号started() 和finished()通知您,或者可以使用isFinished()和isRunning()查询线程的状态。
通过调用exit()和quit()函数来正确的退出线程,在某种情况下,你可能需要强制退出,那么可以调用terminate()函数,但这不保证数据完整性和资源释放。
QThread还提供了与平台无关的静态睡眠功能:sleep(),msleep()和usleep()分别设置睡眠秒,毫秒和微秒单位的时间。但是要注意的是由于Qt是事件驱动的框架,因此一般来说,没有必要使用wait()和sleep()函数。当你需要使用wait()时,请考虑监听isFinished()信号;需要使用sleep时,请考虑使用QTimer定时器类。
静态函数currentThreadId()和currentThread()返回当前正在执行的线程的标识符。前者返回线程的特定于平台的ID。后者返回一个QThread指针。要给您的线程指定的名称,可以在启动线程之前调用setObjectName()。如果不调用setObjectName(),则为线程提供的名称将是线程对象的运行时类型的类名称。
下面来看两种典型的应用QThread的方式:
1.子类化QThread
class WorkerThread : public QThread
{
Q_OBJECT
void run() override {
QString result;
/* ... here is the expensive or blocking operation ... */
emit resultReady(result);
}
signals:
void resultReady(const QString &s);
};
void MyObject::startWorkInAThread()
{
WorkerThread *workerThread = new WorkerThread(this);
connect(workerThread, &WorkerThread::resultReady, this, &MyObject::handleResults);
connect(workerThread, &WorkerThread::finished, workerThread, &QObject::deleteLater);
workerThread->start();
}
这个架构中,WorkerThread 是派生于QThread的子类,通过重写它的run函数来实现要在线程中运行的内容,并通过信号和槽进行数据交互。在主线程中的startWorkInAThread()中创建这个子类的对象,并调用start函数以启动线程。一旦启动线程后,程序会有单独的线程运行至run函数内,如果你的run函数内没有循环时间,如while(1)等,那么它将会在run函数执行完后自动退出该线程,除非你调用了exec()函数。另外需要注意的是,QThread实例workerThread存在于实例化它的旧线程(MyObject)中,而不是存在于调用run()的新线程(WorkerThread)中。这意味着所有槽函数都将在旧线程中执行。因此,希望在新线程中调用槽函数的开发人员必须使用另一种多线程的方式:实例化的方法。还有,WorkerThread的构造函数在旧线程中执行,而run()在新线程中执行。如果从两个函数访问同一成员变量,则将从两个不同的线程访问该变量,可能要检查这样做是否安全。
2.实例化QThread
简单讲就是您可以通过使用QObject :: moveToThread()将工作对象移动到线程中来使用它们。这听起来比较抽象,但实际上非常简单实用。
class Worker : public QObject
{
Q_OBJECT
public slots:
void doWork(const QString ¶meter) {
QString result;
/* ... here is the expensive or blocking operation ... */
emit resultReady(result);
}
signals:
void resultReady(const QString &result);
};
class Controller : public QObject
{
Q_OBJECT
QThread workerThread;
public:
Controller() {
Worker *worker = new Worker;
worker->moveToThread(&workerThread);
connect(&workerThread, &QThread::finished, worker, &QObject::deleteLater);
connect(this, &Controller::operate, worker, &Worker::doWork);
connect(worker, &Worker::resultReady, this, &Controller::handleResults);
workerThread.start();
}
~Controller() {
workerThread.quit();
workerThread.wait();
}
public slots:
void handleResults(const QString &);
signals:
void operate(const QString &);
};
这里,Controller是我们的主线程类,Worker是子线程类,它直接继承于QObject。在Controller的构造函数里实例化Worker,新建一个QThread对象workerThread,通过QObject :: moveToThread()将wokker放进workerThread。这时,您可以自由地将Worker的槽函数连接到任何线程中来自任何对象的任何信号。借助称为queued connections的机制,可以安全地跨不同线程连接信号和插槽。workerThread.start();表示启动线程,当我们在主线程中发送一个operate信号后,worker则会进入doWork函数,并且是在单独的线程中执行。这种实例化的结果在使用Qt的一些IO操作类时非常方便,比如前面几篇笔记中涉及的串口和网口编程。如果你使用了子类化的方式派生一个网络收发类,程序将报错不能在另外一个线程创建QTcpSocket之类的警告或错误,还有人遇到了不是收不到就是发不出数据的问题。这里强烈推荐这种moveToThread的方式来实现,且接口(如QTcpSocket)的实例化一定放在你的doWork函数里。
尽管多线程的目的是允许代码并行运行,但有时线程必须停止并等待其他线程。例如,如果两个线程尝试同时写入同一变量,则结果是不确定的。强制线程互相等待的原理称为互斥。这是保护共享资源(如数据)的常用技术。Qt提供了低级语法以及用于同步线程的高级机制。
QMutex是强制执行互斥的基本类。线程锁定互斥锁以获取对共享资源的访问。如果第二个线程试图在已锁定互斥锁的同时锁定它,则第二个线程将进入睡眠状态,直到第一个线程完成其任务并解锁该互斥锁。
QMutex mutex;
int complexFunction(int flag)
{
mutex.lock();
int retVal = 0;
switch (flag) {
case 0:
case 1:
mutex.unlock();
return moreComplexFunction(flag);
case 2:
{
int status = anotherFunction();
if (status < 0) {
mutex.unlock();
return -2;
}
retVal = status + flag;
}
break;
default:
if (flag > 10) {
mutex.unlock();
return -1;
}
break;
}
mutex.unlock();
return retVal;
}
QReadWriteLock类似于QMutex,它对“读”和“写”操作进行了区分。当不写入数据时,可以安全地同时读取多个线程。QMutex强制多个reader轮流读共享数据,但QReadWriteLock允许同时读取,从而提高了并行性。
QReadWriteLock lock;
void ReaderThread::run()
{
lock.lockForRead();
read_file();
lock.unlock();
}
void WriterThread::run()
{
lock.lockForWrite();
write_file();
lock.unlock();
}
QSemaphore是的一般化的QMutex,是特殊的线程锁,允许多个线程同时访问临界资源,而一个QMutex只保护一个临界资源。经典的生产者-消费者模型如下:某工厂只有固定仓位,生产人员每天生产的产品数量不一,销售人员每天销售的产品数量也不一致。当生产人员生产P个产品时,就一次需要P个仓位,当销售人员销售C个产品时,就要求仓库中有足够多的产品才能销售。如果剩余仓位没有P个时,该批次的产品都不存入,当当前已有的产品没有C个时,就不能销售C个以上的产品,直到新产品加入后方可销售。QSemaphore来控制对环状缓冲的访问,此缓冲区被生产者线程和消费者线程共享。生产者不断向缓冲区写入数据直到缓冲末端,再从头开始。消费者从缓冲不断读取数据。信号量比互斥量有更好的并发性,假如我们用互斥量来控制对缓冲的访问,那么生产者、消费者不能同时访问缓冲区。然而,我们知道在同一时刻,不同线程访问缓冲的不同部分并没有什么危害。
const int DataSize = 100000;
const int BufferSize = 8192;
char buffer[BufferSize];
QSemaphore freeBytes(BufferSize);
QSemaphore usedBytes;
class Producer : public QThread
{
public:
void run() override
{
qsrand(QTime(0,0,0).secsTo(QTime::currentTime()));
for (int i = 0; i < DataSize; ++i) {
freeBytes.acquire();
buffer[i % BufferSize] = "ACGT"[(int)qrand() % 4];
usedBytes.release();
}
}
};
class Consumer : public QThread
{
Q_OBJECT
public:
void run() override
{
for (int i = 0; i < DataSize; ++i) {
usedBytes.acquire();
fprintf(stderr, "%c", buffer[i % BufferSize]);
freeBytes.release();
}
fprintf(stderr, "\n");
}
signals:
void stringConsumed(const QString &text);
protected:
bool finish;
};
int main(int argc, char *argv[])
{
QCoreApplication app(argc, argv);
Producer producer;
Consumer consumer;
producer.start();
consumer.start();
producer.wait();
consumer.wait();
return 0;
}
QWaitCondition不通过强制互斥而是通过提供条件变量来同步线程。当其他方法使线程等待直到资源被解锁时,QWaitCondition使线程等待直到满足特定条件。要允许等待的线程继续进行,须调用wakeOne()唤醒一个随机选择的线程,或者调用wakeAll()同时唤醒所有线程。也可以用QWaitCondition代替QSemaphore解决上述生产者-消费者问题。
#include
#include
#include
const int DataSize = 100000;
const int BufferSize = 8192;
char buffer[BufferSize];
QWaitCondition bufferNotEmpty;
QWaitCondition bufferNotFull;
QMutex mutex;
int numUsedBytes = 0;
class Producer : public QThread
{
public:
Producer(QObject *parent = NULL) : QThread(parent)
{
}
void run() override
{
qsrand(QTime(0,0,0).secsTo(QTime::currentTime()));
for (int i = 0; i < DataSize; ++i) {
mutex.lock();
if (numUsedBytes == BufferSize)
bufferNotFull.wait(&mutex);
mutex.unlock();
buffer[i % BufferSize] = "ACGT"[(int)qrand() % 4];
mutex.lock();
++numUsedBytes;
bufferNotEmpty.wakeAll();
mutex.unlock();
}
}
};
class Consumer : public QThread
{
Q_OBJECT
public:
Consumer(QObject *parent = NULL) : QThread(parent)
{
}
void run() override
{
for (int i = 0; i < DataSize; ++i) {
mutex.lock();
if (numUsedBytes == 0)
bufferNotEmpty.wait(&mutex);
mutex.unlock();
fprintf(stderr, "%c", buffer[i % BufferSize]);
mutex.lock();
--numUsedBytes;
bufferNotFull.wakeAll();
mutex.unlock();
}
fprintf(stderr, "\n");
}
signals:
void stringConsumed(const QString &text);
};
int main(int argc, char *argv[])
{
QCoreApplication app(argc, argv);
Producer producer;
Consumer consumer;
producer.start();
consumer.start();
producer.wait();
consumer.wait();
return 0;
}
QMutexLocker,QReadLocker和QWriteLocker是更加方便使用的类,它们使使用QMutex和QReadWriteLock更加容易。它们在构造时锁定资源,并在销毁时自动解锁资源。它们旨在简化使用QMutex和QReadWriteLock的代码,从而减少资源因意外而永久锁定的机会。
QMutex mutex;
int complexFunction(int flag)
{
QMutexLocker locker(&mutex);
int retVal = 0;
switch (flag) {
case 0:
case 1:
return moreComplexFunction(flag);
case 2:
{
int status = anotherFunction();
if (status < 0)
return -2;
retVal = status + flag;
}
break;
default:
if (flag > 10)
return -1;
break;
}
return retVal;
}
这些同步类可用于使方法线程安全。但是,这样做会导致性能下降,这就是为什么大多数Qt方法没有使线程安全的原因。线程锁可能也会带来风险,如果线程锁定了资源但未解锁,则应用程序可能会冻结,因为该资源将永久无法供其他线程使用。例如,如果抛出异常并迫使当前函数返回而不释放其锁,则可能发生这种情况。另一个类似的情况是僵局。例如,假设线程A正在等待线程B解锁资源。如果线程B也正在等待线程A解锁另一个资源,则两个线程最终将永远等待,因此应用程序将冻结。
Qt的事件系统对于线程间通信非常有用。每个线程可能都有自己的事件循环。要在另一个线程中调用槽函数(或任何可调用的方法),请将该调用置于目标线程的事件循环中。这样,目标线程可以在槽函数开始运行之前完成其当前任务,而原始线程则可以继续并行运行。
要将某个调用置于事件循环中,请建立queued信号与槽连接。每当发出信号时,事件系统都会记录其这个动作。信号接收器所在的线程将运行对应的槽函数。另外,不使用信号,调用QMetaObject::invokeMethod()也可以达到相同的效果。在这两种情况下,必须使用queued连接,因为direct连接绕过了事件系统,并且立即在当前线程中运行此方法。
使用事件系统进行线程同步时,与使用低级同步不同,没有死锁的风险。但是,事件系统不强制执行互斥。如果可调用方法访问共享数据,则仍必须使用低级同步对其进行保护。
话虽如此,Qt的事件系统以及隐式共享的数据结构为传统线程锁定提供了一种替代方法。如果仅使用信号和插槽,并且线程之间没有共享变量,那么多线程程序可以完全不使用低级同步。
什么叫可重入和线程安全?
一个线程安全的函数可以同时被多个线程调用,甚至调用者会使用共享数据也没有问题,因为对共享数据的访问是串行的。一个可重入函数也可以同时被多个线程调用,但是每个调用者只能使用自己的数据。因此,一个线程安全的函数总是可重入的,但一个可重入的函数并不一定是线程安全的。一个可重入的类,指的是类的成员函数可以被多个线程安全地调用,只要每个线程使用类的不同的对象。而一个线程安全的类,指的是类的成员函数能够被多线程安全地调用,即使所有的线程都使用类的同一个实例。
举个栗子:
大多数C++类是可重入的,因为它们典型地仅仅引用成员数据。任何线程可以访问可重入类实例的成员函数,只要同一时间没有其他线程调用这个实例的成员函数。
class Counter
{
public:
Counter() {n=0;}
void increment() {++n;}
void decrement() {--n;}
int value() const {return n;}
private:
int n;
};
上述Counter类是可重入的,但却不是线程安全的。假如多个线程都试图修改数据成员n,结果是不确定的。这是因为++和--运算符并不总是atomic。实际上,它们通常扩展为三个机器指令:
将变量的值加载到寄存器中。
递增或递减寄存器的值。
将寄存器的值存储回主存储器。
如果线程A和线程B同时加载变量的旧值,增加它们的寄存器值并存储回去,它们最终将互相覆盖,并且变量仅增加一次!
显然,在这种情况下访问必须被序列化:线程A必须不中断执行第1、2、3步后,线程B才能执行相同的步骤。或相反亦然。使类具有线程安全性的一种简单方法是使用QMutex保护对数据成员的所有访问。
class Counter
{
public:
Counter() { n = 0; }
void increment() { QMutexLocker locker(&mutex); ++n; }
void decrement() { QMutexLocker locker(&mutex); --n; }
int value() const { QMutexLocker locker(&mutex); return n; }
private:
mutable QMutex mutex;
int n;
};
QMutexLocker类在函数的末尾自动将互斥锁锁定在其构造函数中,并在调用析构函数时将其解锁。锁定互斥锁可确保对来自不同线程的访问进行序列化。(Mutex声明的时候用了mutable关键字,是因为我们需要锁定和解锁的互斥体value()是一个const函数。)
许多Qt类都是可重入的,但是它们不是线程安全的,因为使它们成为线程安全的会导致反复锁定和解锁QMutex的额外开销。例如,QString是可重入的,但不是线程安全的。您可以安全地同时从多个线程访问QString的不同实例,但是不能安全地同时从多个线程访问QString的相同实例(除非您使用QMutex保护自己的访问)。
一些Qt类和函数是线程安全的。这些主要是与线程相关的类(例如QMutex)和基本函数(例如QCoreApplication :: postEvent())。
QThread继承QObject。上面也介绍了QThread可以发出信号以指示线程已开始执行或完成执行,并且还提供了一些槽函数。QObject可以在多个线程中使用,发出调用其他线程中的槽函数的信号,并且向“存活”于其它线程中的对象发送事件。
QObject是可重入的。它的大多数非GUI子类(例如QTimer,QTcpSocket,QUdpSocket和QProcess)也是可重入的,从而可以同时在多个线程中使用这些类。注意,这些类旨在从单个线程中创建和使用。不能保证在一个线程中创建对象并从另一个线程调用其功能。有三个约束要注意:
a. QObject的子类必须始终在创建父类的线程中创建。这意味着,除其他事项外,您永远不要将QThread对象(this)作为在该线程中创建一个对象的父级传递(因为QThread对象本身是在另一个线程中创建的)。如在worker中不能有QTimer *timer = new QTimer(this)的写法。
b.事件驱动的对象只能在单个线程中使用。具体而言,这适用于计时器机制和网络模块。就像上面所说的,您不能在不属于这个对象的线程中连接套接字,或者启动定时器。在删除QThread之前,必须确保删除在线程中创建的所有对象。通过在run()实现中的堆栈上创建对象,可以轻松完成此操作。
c.尽管QObject是可重入的,但GUI类(尤其是QWidget及其所有子类)不是可重入的。它们只能在主线程中使用。如前所述,还必须从该线程中调用QCoreApplication :: exec()。
实际上,可以通过将耗时的操作放在单独的工作线程中,并在工作线程完成后在主线程的屏幕上显示结果,来轻松解决在主线程以外的其他线程中使用GUI类的可能性。
通常,不支持在QApplication之前创建QObject,并且可能导致退出时发生奇怪的崩溃,具体取决于平台。结构正确的单线程或多线程应用程序应使QApplication成为第一个创建的,最后一个销毁的QObject。所以main函数里会首先创建QApplication a(argc, argv)最后return a.exec()。
每个线程可以有自己的事件循环。初始线程使用QCoreApplication :: exec()或对于对话框GUI应用程序(有时是QDialog :: exec())启动其事件循环。其他线程可以使用QThread :: exec()启动事件循环。与QCoreApplication一样,QThread提供了一个exit(int)函数和一个quit()槽函数。
线程中的事件循环使线程可以使用某些需要事件循环的非GUI Qt类(例如QTimer,QTcpSocket和QProcess)。它还可以将来自任何线程的信号连接到特定线程的插槽。这在下面的“ 跨线程的信号和插槽”部分中进行了详细说明。
一个QObject实例被称为“存活”于它所被创建的线程中。该对象的事件由该线程的事件循环调度。可以用QObject::thread()方法获取一个QObject所处的线程。
QObject::moveToThread()函数改变一个对象和及其子对象的线程所属性。(如果对象有父对象的话,对象不能被移动到其它线程中)。
从另一个线程(不是QObject对象所属的线程)对该QObject对象调用delete方法是不安全的,除非能保证该对象在那个时刻不处理事件,使用QObejct::deleteLater()更好。一个DeferredDelete类型的事件将被提交(posted),而该对象的线程的件循环最终会处理这个事件。默认情况下,拥有一个QObject的线程就是创建QObject的线程,而不是 QObject::moveToThread()被调用后的。
如果没有事件循环运行,事件将不会传递给对象。例如:在一个线程中创建了一个QTimer对象,但从没有调用exec(),那么,QTimer就永远不会发射timeout()信号,即使调用deleteLater()也不行。(这些限制也同样适用于主线程)。
利用线程安全的方法QCoreApplication::postEvent(),可以在任何时刻给任何线程中的任何对象发送事件,事件将自动被分发到该对象所被创建的线程事件循环中。
所有的线程都支持事件过滤器,而限制是监控对象必须和被监控对象存在于相同的线程中。QCoreApplication::sendEvent()(不同于postEvent())只能将事件分发到和该函数调用者相同的线程中的对象。
QObject及其所有子类都不是线程安全的。这包括整个事件传递系统。重要的是要记住,事件循环可能是在您从另一个线程访问对象时将事件传递给QObject子类。
如果您正在QObject子类上调用的函数不存在于当前线程中,并且该对象可能接收事件,则必须使用互斥对象保护对QObject子类的内部数据的所有访问;否则,您可能会遇到崩溃或其他不良行为。
与其他对象一样,QThread对象位于创建对象的线程中,而不是在调用QThread :: run()时创建的线程中。在QThread子类中提供槽函数通常是不安全的,除非您用互斥锁保护成员变量。
另一方面,您可以从QThread :: run()实现中安全地发出信号,因为信号发出是线程安全的。
Qt支持以下信号插槽连接类型:
Auto Connection(默认):如果信号在接收对象具有亲和力的线程中发出,则其行为与Direct Connection相同。否则,该行为与Queued Connection相同。
Direct Connection:发出信号后立即调用槽函数。无论槽函数所属对象在哪个线程,槽函数都在发射信号的线程内执行。
Queued Connection:当控制权返回到接收者线程的事件循环时,将调用该槽函数。该槽函数在接收者的线程中执行。
Blocking Queued Connection:插槽将像队列连接一样被调用,除了当前线程阻塞,直到该槽函数返回为止。注意:使用此类型连接同一线程中的对象将导致死锁。
Unique Connection:该行为与Auto Connection相同,但是仅在不重复现有连接的情况下才建立连接。也就是说,如果相同的信号已经连接到同一对对象的相同插槽,则不会建立连接,并且connect()返回false。
可以通过将附加参数传递给connect()来指定连接类型。请注意,如果在接收者的线程中运行事件循环,则在发送者和接收者位于不同线程中时使用Direct Connection是不安全的。
频繁创建和销毁线程可能会很昂贵。为了减少这种开销,可以将现有线程重用于新任务。QThreadPool是可重用的QThreads的集合。
要在QThreadPool的线程之一中运行代码,请重新实现QRunnable::run()并实例化子类QRunnable。使用QThreadPool::start()将QRunnable放入QThreadPool的运行队列中。当线程可用时,QRunnable::run()中的代码将在该线程中执行。
每个Qt应用程序都有一个全局线程池,可通过QThreadPool::globalInstance()访问该线程池。该全局线程池根据CPU中的内核数自动维护最佳线程数。但是,可以显式创建和管理单独的QThreadPool。
class HelloWorldTask : public QRunnable
{
void run()
{
qDebug() << "Hello world from thread" << QThread::currentThread();
}
};
HelloWorldTask *hello = new HelloWorldTask();
// QThreadPool takes ownership and deletes 'hello' automatically
QThreadPool::globalInstance()->start(hello);
更高级的线程池模块为Qt Concurrent。Qt Concurrent提供高层次的函数,处理一些常见并行计算模式:map,filter和reduce。与使用QThread和QRunnable不同,这些函数从不需要使用低级线程语法(例如互斥量或信号量)。相反,它们返回一个QFuture对象,当准备就绪时,该对象可用于检索函数的结果。QFuture还可以用于查询计算进度以及暂停/恢复/取消计算。为了方便起见,QFutureWatcher使得能够与交互QFuture经由信号和槽连接。
Qt Concurrent的map,filter和reduce算法会自动将计算分布在所有可用的处理器内核上,因此,应用程序当在具有更多内核的系统上部署时将继续扩展。
该模块还提供了QtConcurrent::run()函数,该函数可以在另一个线程中运行任何函数。但是,QtConcurrent::run()仅支持map,filter和reduce功能可用的功能子集。该QFuture可用于获取函数的返回值和检查。但是,对QtConcurrent::run()的调用仅使用一个线程,不能暂停/继续/取消,也不能查询进度。