首页 > 代码库 > QT开发(三十四)——QT多线程编程

QT开发(三十四)——QT多线程编程

QT开发(三十四)——QT多线程编程

一、QT多线程简介

    QT通过三种形式提供了对线程的支持,分别是平台无关的线程类、线程安全的事件投递、跨线程的信号-槽连接。

    QT中线程类包含如下:

 QThread 提供了开始一个新线程的方法
    QThreadStorage 提供逐线程数据存储
    QMutex 提供相互排斥的锁,或互斥量
    QMutexLocker 是一个辅助类,自动对 QMutex 加锁与解锁
    QReadWriterLock 提供了一个可以同时读操作的锁
    QReadLocker与QWriteLocker
 自动对QReadWriteLock 加锁与解锁
    QSemaphore 提供了一个整型信号量,是互斥量的泛化
    QWaitCondition 提供了一种方法,使得线程可以在被另外线程唤醒之前一直休眠。

QThread的两种使用方法:

   A、不使用事件循环。

 a. 子类化 QThread

    b. 重载 run 函数,run函数内有一个 while 或 for 的死循环

    c. 设置一个标记为来控制死循环的退出。

   B、使用事件循环。

    a. 子类化 QThread,

    b. 重载 run 使其调用 QThread::exec() 

    c. 为子类定义信号和槽,由于槽函数并不会在新开的 thread 运行,很多人为了解决这个问题在构造函数中调用 moveToThread(this)
Bradley T. Hughes 给出说明是: QThread 应该被看做是操作系统线程的接口或控制点,而不应该包含需要在新线程中运行的代码。需要运行的代码应该放到一个QObject的子类中,然后将该子类的对象moveToThread到新线程中。

    在Qt4.4之前,run 是虚函数,必须子类化QThread来实现run函数。
而从Qt4.4开始,QThread不再支持抽象类,run 默认调用 QThread::exec() ,不需要子类化 QThread 了,只需要子类化一个 QObject

二、QThread线程

    QThreadQt线程中有一个公共的抽象类,所有的线程都是从QThread抽象类中派生的,要实现QThread中的纯虚函数run(),run()函数是通过start()函数来实现调用的。

创建线程对象的实例,调用QThread::start(),在子线程类run()里出现的代码将会在新建线程中被执行。
    QCoreApplication::exec()总是在主线程(执行main()的那个线程)中被调用,不能从一个QThread中调用。在GUI程序中,主线程也被称为GUI线程,是唯一允许执行GUI相关操作的线程。另外,必须在创建一个QThread之前创建QApplication(or QCoreApplication)对象。

1、线程的优先级

QThread线程总共有8个优先级

QThread::IdlePriority0scheduled only when no other threads are running.

QThread::LowestPriority1scheduled less often than LowPriority.

QThread::LowPriority2scheduled less often than NormalPriority.

QThread::NormalPriority3the default priority of the operating system.

QThread::HighPriority4scheduled more often than NormalPriority.

QThread::HighestPriority5scheduled more often than HighPriority.

QThread::TimeCriticalPriority6scheduled as often as possible.

QThread::InheritPriority7use the same priority as the creating thread. This is the default.

2、线程的创建

void start ( Priority priority = InheritPriority )

启动线程执行,启动后会发出started ()信号

3、线程的执行

int exec();

进入线程时间循环

virtual void run();

线程入口

4、线程的退出

void quit();

相当于exit(0);

void exit ( int returnCode = 0 );

调用exit后,thread将退出event loop,并从exec返回,exec的返回值就是returnCode。通常returnCode=0表示成功,其他值表示失败。

void terminate ();

结束线程,线程是否立即终止取决于操作系统。

线程被终止时,所有等待该线程Finished的线程都将被唤醒。

terminate是否调用取决于setTerminationEnabled ( bool enabled = true )开关。

5、线程的等待

void msleep ( unsigned long msecs )

void sleep ( unsigned long secs )

void usleep ( unsigned long usecs )

bool wait ( unsigned long time = ULONG_MAX )

线程将会被阻塞,等待time毫秒,如果线程退出,则wait会返回。

6、线程的状态

bool isFinished () const线程是否已经退出

bool isRunning () const线程是否处于运行状态

7、线程的属性

Priority priority () const

void setPriority ( Priority priority )

uint stackSize () const

void setStackSize ( uint stackSize )

void setTerminationEnabled ( bool enabled = true )

设置是否响应terminate()函数

8、线程与事件循环

    QThread中对run()的默认实现调用了exec(),从而创建一个QEventLoop对象,由QEventLoop对象处理线程中事件队列(每一个线程都有一个属于自己的事件队列)中的事件。exec()在其内部不断做着循环遍历事件队列的工作,调用QThreadquit()exit()方法使退出线程,尽量不要使用terminate()退出线程,terminate()退出线程过于粗暴,造成资源不能释放,甚至互斥锁还处于加锁状态。

    线程中的事件循环,使得线程可以使用那些需要事件循环的非GUI 类(如,QTimer,QTcpSocket,QProcess)。

    在QApplication之前创建的对象,QObject::thread()返回0,这意味着主线程仅为这些对象处理投递事件,不会为没有所属线程的对象处理另外的事件。可以用QObject::moveToThread()来改变它和它孩子们的线程亲缘关系,假如对象有父亲,它不能移动这种关系。在另一个线程(而不是创建它的那个线程)中delete QObject对象是不安全的。除非你可以保证在同一时刻对象不在处理事件。可以用QObject::deleteLater(),它会投递一个DeferredDelete事件,这会被对象线程的事件循环最终选取到。假如没有事件循环运行,事件不会分发给对象。举例来说,假如你在一个线程中创建了一个QTimer对象,但从没有调用过exec(),那么QTimer就不会发射它的timeout()信号.对deleteLater()也不会工作。(这同样适用于主线程)。你可以手工使用线程安全的函数QCoreApplication::postEvent(),在任何时候,给任何线程中的任何对象投递一个事件,事件会在那个创建了对象的线程中通过事件循环派发。事件过滤器在所有线程中也被支持,不过它限定被监视对象与监视对象生存在同一线程中。类似地,QCoreApplication::sendEvent(不是postEvent()),仅用于在调用此函数的线程中向目标对象投递事件。

三、线程的同步

    QMutex, QReadWriteLock, QSemaphore, QWaitCondition 提供了线程同步的手段。使用线程的主要想法是希望它们可以尽可能并发执行,而一些关键点上线程之间需要停止或等待。例如,假如两个线程试图同时访问同一个 全局变量,结果可能不如所愿。

1、互斥量QMutex

    QMutex 提供相互排斥的锁,或互斥量。在一个时刻至多一个线程拥有mutex,假如一个线程试图访问已经被锁定的mutex,那么它将休眠,直到拥有mutex的线程对此mutex解锁。QMutex常用来保护共享数据访问。QMutex类所以成员函数是线程安全的。

    头文件声明:    #include <QMutex>

    互斥量声明:    QMutex m_Mutex;

 互斥量加锁:    m_Mutex.lock();

    互斥量解锁:    m_Mutex.unlock();

    QMutex ( RecursionMode mode = NonRecursive )

    QMutex有两种模式:Recursive, NonRecursive

ARecursive

    一个线程可以对mutex多次lock,直到相应次数的unlock调用后,mutex才真正被解锁。

BNonRecursive

    默认模式,mutex只能被lock一次。

    如果使用了Mutex.lock()而没有对应的使用Mutex.unlcok()的话就会造成死锁,其他的线程将永远也得不到接触Mutex锁住的共享资源的机会。尽管可以不使用lock()而使用tryLock(timeout)来避免因为死等而造成的死锁( tryLock(负值)==lock()),但是还是很有可能造成错误。

bool tryLock();

如果当前其他线程已对该mutex加锁,则该调用会立即返回,而不被阻塞。

bool tryLock(int timeout);

如果当前其他线程已对该mutex加锁,则该调用会等待一段时间,直到超时

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;
 }

2、互斥锁QMutexLocker

    在较复杂的函数和异常处理中对QMutex类mutex对象进行lock()和unlock()操作将会很复杂,进入点要lock(),在所有跳出点都要unlock(),很容易出现在某些跳出点未调用unlock(),所以Qt引进了QMutex的辅助类QMutexLocker来避免lock()和unlock()操作。在函数需要的地方建立QMutexLocker对象,并把mutex指针传QMutexLocker对象,此时mutex已经加锁,等到退出函数后,QMutexLocker对象局部变量会自己销毁,此时mutex解锁。

头文件声明:    #include<QMutexLocker>

互斥锁声明:    QMutexLocker mutexLocker(&m_Mutex);

互斥锁加锁:    从声明处开始(在构造函数中加锁)

互斥锁解锁:    出了作用域自动解锁(在析构函数中解锁)

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;
 }

3QReadWriteLock

    QReadWriterLock 与QMutex相似,但对读写操作访问进行区别对待,可以允许多个读者同时读数据,但只能有一个写,并且写读操作不同同时进行。使用QReadWriteLock而不是QMutex,可以使得多线程程序更具有并发性。QReadWriterLock默认模式是NonRecursive

QReadWriterLock类成员函数如下:

QReadWriteLock ( )

QReadWriteLock ( RecursionMode recursionMode )

void lockForRead ()

void lockForWrite ()

bool tryLockForRead ()

bool tryLockForRead ( int timeout )

bool tryLockForWrite ()

bool tryLockForWrite ( int timeout )

boid unlock ()

使用实例:

 

QReadWriteLock lock;
 void ReaderThread::run()
 {
     lock.lockForRead();
     read_file();
     lock.unlock();
 }
 
 void WriterThread::run()
 {
     lock.lockForWrite();
     write_file();
     lock.unlock();
 }

4QReadLockerQWriteLocker

    在较复杂的函数和异常处理中对QReadWriterLocklock对象进行lockForRead()/lockForWrite()和unlock()操作将会很复杂,进入点要lockForRead()/lockForWrite(),在所有跳出点都要unlock(),很容易出现在某些跳出点未调用unlock(),所以Qt引进了QReadLocker和QWriteLocker类来简化解锁操作。在函数需要的地方建立QReadLockerQWriteLocker对象,并把lock指针传给QReadLockerQWriteLocker对象,此时lock已经加锁,等到退出函数后,QReadLockerQWriteLocker对象局部变量会自己销毁,此时lock解锁。

  QReadWriteLock lock;

 QByteArray readData()

 {

     lock.lockForRead();

     ...

     lock.unlock();

     return data;

 }

使用QReadLocker

 QReadWriteLock lock;

 QByteArray readData()

 {

     QReadLocker locker(&lock);

     ...

     return data;

 }

5、信号量QSemaphore

    QSemaphore 是QMutex的一般化,可以保护一定数量的相同资源,而一个mutex只保护一个资源。QSemaphore 类的所有成员函数是线程安全的。

    经典的生产者-消费者模型如下:某工厂只有固定仓位,生产人员每天生产的产品数量不一,销售人员每天销售的产品数量也不一致。当生产人员生产P个产品时,就一次需要P个仓位,当销售人员销售C个产品时,就要求仓库中有足够多的产品才能销售。如果剩余仓位没有P个时,该批次的产品都不存入,当当前已有的产品没有C个时,就不能销售C个以上的产品,直到新产品加入后方可销售。

    QSemaphore来控制对环状缓冲的访问,此缓冲区被生产者线程和消费者线程共享。生产者不断向缓冲区写入数据直到缓冲末端,再从头开始。消费者从缓冲不断读取数据。信号量比互斥量有更好的并发性,假如我们用互斥量来控制对缓冲的访问,那么生产者、消费者不能同时访问缓冲区。然而,我们知道在同一时刻,不同线程访问缓冲的不同部分并没有什么危害。

QSemaphore 类成员函数:

QSemaphore ( int n = 0 )

void acquire ( int n = 1 )

int available () const

void release ( int n = 1 )

bool tryAcquire ( int n = 1 )

bool tryAcquire ( int n, int timeout )

实例代码:

 QSemaphore sem(5);      // sem.available() == 5

 sem.acquire(3);         // sem.available() == 2

 sem.acquire(2);         // sem.available() == 0

 sem.release(5);         // sem.available() == 5

 sem.release(5);         // sem.available() == 10

 sem.tryAcquire(1);      // sem.available() == 9, returns true

 sem.tryAcquire(250);    // sem.available() == 9, returns false

生产者-消费者实例:

#include <QtCore/QCoreApplication>
#include <QSemaphore>
#include <QThread>
#include <cstdlib>
#include <cstdio>
const int DataSize = 100000;
const int BufferSize = 8192;
char buffer[BufferSize];
QSemaphore  production(BufferSize);
QSemaphore  consumption;
class Producor:public QThread
{
public:
    void run();
};
void Producor::run()
{
    for(int i = 0; i < DataSize; i++)
    {
        production.acquire();
        buffer[i%BufferSize] = "ACGT"[(int)qrand()%4];
        consumption.release();
    }
}
class Consumer:public QThread
{
public:
    void run();
};
void Consumer::run()
{
    for(int i = 0; i < DataSize; i++)
    {
        consumption.acquire();
        fprintf(stderr, "%c", buffer[i%BufferSize]);
        production.release();
    }
    fprintf(stderr, "%c", "\n");
}
int main(int argc, char *argv[])
{
    QCoreApplication a(argc, argv);
    Producor productor;
    Consumer consumer;
    productor.start();
    consumer.start();
    productor.wait();
    consumer.wait();
    return a.exec();
}

Producer::run函数:

   当producer线程执行run函数,如果buffer中已满,而consumer线程没有读,producer不能再往buffer中写字符, productor.acquire 处阻塞直到 consumer线程读(consume)数据。一旦producer获取到一个字节(资源)就写入一个随机的字符,并调用 consumer.release 使consumer线程可以获取一个资源(读一个字节的数据)。

    Consumer::run函数:

   当consumer线程执行run函数,如果buffer中没有数据,则consumer线程在consumer.acquire处阻塞,直到producer线程执行写操作写入一个字节,并执行consumer.release 使consumer线程的可用资源数=1时,consumer线程从阻塞状态中退出, 并将consumer 资源数-1,consumer当前资源数=0。

6、等待条件QWaitCondition

    QWaitCondition 允许线程在某些情况发生时唤醒另外的线程。一个或多个线程可以阻塞等待QWaitCondition ,用wakeOne()或wakeAll()设置一个条件。wakeOne()随机唤醒一个,wakeAll()唤醒所有。

QWaitCondition ()

bool wait ( QMutex * mutex, unsigned long time = ULONG_MAX )

bool wait ( QReadWriteLock * readWriteLock, unsigned long time = ULONG_MAX )

void wakeOne ()

void wakeAll ()

头文件声明:    #include <QWaitCondition>

等待条件声明:    QWaitCondtion m_WaitCondition;

等待条件等待:    m_WaitConditon.wait(&m_muxtex, time);

等待条件唤醒:    m_WaitCondition.wakeAll();

    在经典的生产者-消费者场合中,生产者首先必须检查缓冲是否已满(numUsedBytes==BufferSize),如果缓冲区已满,线程停下来等待 bufferNotFull条件。如果没有满,在缓冲中生产数据,增加numUsedBytes,激活条件 bufferNotEmpty。使用mutex来保护对numUsedBytes的访问。QWaitCondition::wait() 接收一个mutex作为参数,mutex被调用线程初始化为锁定状态。在线程进入休眠状态之前,mutex会被解锁。而当线程被唤醒时,mutex会处于锁定状态,从锁定状态到等待状态的转换是原子操作。当程序开始运行时,只有生产者可以工作,消费者被阻塞等待bufferNotEmpty条件,一旦生产者在缓冲中放入一个字节,bufferNotEmpty条件被激发,消费者线程于是被唤醒。

#include <QtCore/QCoreApplication>
#include <QSemaphore>
#include <QThread>
#include <cstdlib>
#include <cstdio>
#include <QWaitCondition>
#include <QMutex>
#include <QTime>
const int DataSize = 32;
const int BufferSize = 16;
char buffer[BufferSize];
QWaitCondition bufferNotEmpty;
QWaitCondition bufferNotFull;
QMutex mutex;
int used = 0;
class Producor:public QThread
{
public:
    void run();
};
void Producor::run()
{
    qsrand(QTime(0,0,0).secsTo(QTime::currentTime()));
    for(int i = 0; i < DataSize; i++)
    {
        mutex.lock();
        if(used == BufferSize)
            bufferNotFull.wait(&mutex);
        mutex.unlock();
        buffer[i%BufferSize] = used;
        mutex.lock();
        used++;
        bufferNotEmpty.wakeAll();
        mutex.unlock();
    }
}
class Consumer:public QThread
{
public:
    void run();
};
void Consumer::run()
{
    for(int i = 0; i < DataSize; i++)
    {
        mutex.lock();
        if(used == 0)
            bufferNotEmpty.wait(&mutex);
        mutex.unlock();
        fprintf(stderr, "%d\n", buffer[i%BufferSize]);
        mutex.lock();
        used--;
        bufferNotFull.wakeAll();
        mutex.unlock();
    }
    fprintf(stderr, "%c", "\n");
}
int main(int argc, char *argv[])
{
    QCoreApplication a(argc, argv);
    Producor productor;
    Consumer consumer;
    productor.start();
    consumer.start();
    productor.wait();
    consumer.wait();
    return a.exec();
}

四、可重入与线程安全

可重入reentrant与线程安全thread-safe被用来说明一个函数如何用于多线程程序。假如一个类的任何函数在此类的多个不同的实例上,可以被多个线程同时调用,那么这个类被称为是可重入的。假如不同的线程作用在同一个实例上仍可以正常工作,那么称之为“线程安全”的。

1、可重入

    大多数c++类天生就是可重入的,因为它们典型地仅仅引用成员数据。任何线程可以在类的一个实例上调用这样的成员函数,只要没有别的线程在同一个实例上调用这个成员函数。

class Counter
{
  public:
      Counter() {n=0;}
      void increment() {++n;}
      void decrement() {--n;}
      int value() const {return n;}
 private:
      int n;
};

    Counter类是可重入的,但却不是线程安全的。假如多个线程都试图修改数据成员n,结果未定义。

 

    大多数Qt类是可重入,非线程安全的。有一些类与函数是线程安全的,它们主要是线程相关的类,如QMutex,QCoreApplication::postEvent()。

2、线程安全

    所有的GUI类(比如,QWidget和它的子类),操作系统核心类(比如,QProcess)和网络类都不是线程安全的。

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;
 };

 Counter类是可重入和线程安全的。QMutexLocker类在构造函数中自动对mutex进行加锁,在析构函数中进行解锁。mutex使用了mutable关键字来修饰,因为在value()函数中对mutex进行加锁与解锁操作,而value()是一个const函数。

QObject与线程

    QThread 继承自QObject,它发射信号以指示线程执行开始与结束,而且也提供了许多slots。更有趣的是,QObjects可以用于多线程,这是因为每个线程被允许有它自己的事件循环。
QObject 可重入性

    QObject是可重入的。它的大多数非GUI子类,像 QTimer,QTcpSocket,QUdpSocket,

QHttp,QFtp,QProcess也是可重入的,在多个线程中同时使用这些类是可能 的。需要注意的是,这些类被设计成在一个单线程中创建与使用,因此,在一个线程中创建一个对象,而在另外的线程中调用它的函数,这样的行为不能保证工作良好。有三种约束需要注意:

1,QObject的孩子总是应该在它父亲被创建的那个线程中创建。这意味着,你绝不应该传递QThread对象作为另一个对象的父亲(因为QThread对象本身会在另一个线程中被创建)

2,事件驱动对象仅仅在单线程中使用。明确地说,这个规则适用于"定时器机制“与”网格模块“,举例来讲,你不应该在一个线程中开始一个定时器或是连接一个套接字,当这个线程不是这些对象所在的线程。

3,你必须保证在线程中创建的所有对象在你删除QThread前被删除。这很容易做到:你可以run()函数运行的栈上创建对象。

    尽管QObject是可重入的,但GUI类,特别是QWidget与它的所有子类都是不可重入的。 它们仅用于主线程。正如前面提到过的,QCoreApplication::exec()也必须从那个线程中被调用。实践上,不会在别的线程中使用GUI 类,它们工作在主线程上,把一些耗时的操作放入独立的工作线程中,当工作线程运行完成,把结果在主线程所拥有的屏幕上显示。

 

五、线程与信号和槽

    run 是线程的入口,run的开始和结束意味着线程的开始和结束run函数中的代码在新建线程中执行。

    可以把任何线程的signals连接到特定线程的slots,也就是说信号-槽机制是可以跨线程使用的。

bool QObject::connect ( const QObject * sender, const char * signal, const QObject * receiver, const char *method, Qt::ConnectionType type = Qt::AutoConnection ) 

Qt::AutoConnection类型:Qt支持6种连接方式

A、Qt::DirectConnection(直连方式)(信号与槽函数关系类似于函数调用,同步执行)

    当信号发出后,相应的槽函数将立即被调用。emit语句后的代码将在所有槽函数执行完毕后被执行。

    当信号发射时,槽函数将直接被调用。

    无论槽函数所属对象在哪个线程,槽函数都在发射信号的线程内执行。

B、Qt::QueuedConnection(队列方式)(此时信号被塞到信号队列里了,信号与槽函数关系类似于消息通信,异步执行)

     当信号发出后,排队到信号队列中,需等到接收对象所属线程的事件循环取得控制权时才取得该信号,调用相应的槽函数。emit语句后的代码将在发出信号后立即被执行,无需等待槽函数执行完毕。

    当控制权回到接受者所依附线程的事件循环时,槽函数被调用。

    槽函数在接收者所依附线程执行。

C、Qt::AutoConnection(自动方式)

     Qt的默认连接方式,如果信号的发出和接收这个信号的对象同属一个线程,那个工作方式与直连方式相同;否则工作方式与排队方式相同。

    如果信号在接收者所依附的线程内发射,则等同于直接连接

    如果发射信号的线程和接受者所依附的线程不同,则等同于队列连接

D、Qt::BlockingQueuedConnection(信号和槽必须在不同的线程中,否则就产生死锁)

     这个是完全同步队列只有槽线程执行完成才会返回,否则发送线程也会一直等待,相当于是不同的线程可以同步起来执行。

 

E、Qt::UniqueConnection

    与默认工作方式相同,只是不能重复连接相同的信号和槽,因为如果重复连接就会导致一个信号发出,对应槽函数就会执行多次。

F、Qt::AutoCompatConnection

    是为了连接Qt4与Qt3的信号槽机制兼容方式,工作方式与Qt::AutoConnection一样。

    如果这个参数不设置的话,默认表示的是那种方式呢?

    没加的话与直连方式相同:当信号发出后,相应的槽函数将立即被调用。emit语句后的代码将在所有槽函数执行完毕后被执行。在这个线程内是顺序执行、同步的,但是与其它线程之间肯定是异步的了。如果使用多线程,仍然需要手动同步。

    slot 函数属于我们在main中创建的对象 thread,即thread依附于主线程

    队列连接告诉我们:槽函数在接受者所依附线程执行。即 slot 将在主线程执行

    直接连接告诉我们:槽函数在发送信号的线程执行。

    自动连接告诉我们:二者不同,等同于队列连接。即 slot 在主线程执行

    QThread是用来管理线程的,QThread对象所依附的线程和所管理的线程并不是同一个概念。QThread所依附的线程,就是创建QThread对象的线程,QThread 所管理的线程,就是run启动的线程,也就是新建线程。QThread对象依附在主线程中,QThread对象slot函数会在主线程中执行,而不是次线程。除非:

    QThread对象依附到次线程中(通过movetoThread)

    slot 和信号是直接连接,且信号在次线程中发射


本文出自 “生命不息,奋斗不止” 博客,谢绝转载!

QT开发(三十四)——QT多线程编程