Qt 多线程编程之敲开 QThread 类的大门

Qt 多线程编程之敲开 QThread 类的大门

作者:南理汉子

链接:https://zhuanlan.zhihu.com/p/53270619

来源:知乎

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

 

本文结构如下:

  1. 概述
  2. 优雅的开始我们的多线程编程之旅
    1. 我们该把耗时代码放在哪里?
    2. 再谈 moveToThread()
  3. 启动线程前的准备工作
    1. 开多少个线程比较合适?
    2. 设置栈大小
  4. 启动线程/退出线程
    1. 启动线程
    2. 优雅的退出线程
  5. 操作运行中的线程
    1. 获取状态
      1. 运行状态
      2. 线程标识
      3. 更为精细的事件处理
    2. 操作线程
      1. 安全退出线程必备函数:wait()
      2. 线程间的礼让行为
      3. 线程的中断标志位
  6. 为每个线程提供独立数据
  7. 附:所有函数

1. 概述

在阅读本文之前,你需要了解进程和线程相关的知识,详情参考《Qt 中的多线程技术》。

在很多文章中,人们倾向于把 QThread 当成线程的实体,区区创建一个 QThread 类对象就被认为是开了一个新线程。当然这种讨巧的看法似乎能快速的让我们入门,但是只要深入多线程编程领域后就会发现这种看法越来越站不住脚,甚至编写的代码脱离我们的控制,代码越写越复杂。最典型的问题就是“明明把耗时操作代码放入了新线程,可实际仍在旧线程中运行”。造成这种情况的根源在于继承 QThread 类,并在 run() 函数中塞入耗时操作代码

追溯历史,在 Qt 4.4 版本以前的 QThread 类是个抽象类,要想编写多线程代码唯一的做法就是继承 QThread 类。但是之后的版本中,Qt 库完善了线程的亲和性以及信号槽机制,我们有了更为优雅的使用线程的方式,即 QObject::moveToThread()。这也是官方推荐的做法,遗憾的是网上大部分教程没有跟上技术的进步,依然采用 run() 这种腐朽的方式来编写多线程程序。

2. 优雅的开始我们的多线程编程之旅

在 Qt 4.4 版本后,之所以 Qt 官方对 QThread 类进行了大刀阔斧地改革,我认为这是想让多线程编程更加符合 C++ 语言的「面向对象」特性。继承的本意是扩展基类的功能,所以继承 QThread 并把耗时操作代码塞入 run() 函数中的做法怎么看都感觉不伦不类。

2.1 我们该把耗时代码放在哪里?

暂时不考虑多线程,先思考这样一个问题:想想我们平时会把耗时操作代码放在哪里?一个类中。那么有了多线程后,难道我们要把这段代码从类中剥离出来单独放到某个地方吗?显然这是很糟糕的做法。QObject 中的 moveToThread() 函数可以在不破坏类结构的前提下依然可以在新线程中运行

假设现在我们有个 QObject 的子类 Worker,这个类有个成员函数 doSomething(),该函数中运行的代码非常耗时。此时我要做的就是将这个类对象“移动”到新线程里,这样 Worker 的所有成员函数就可以在新线程中运行了。那么如何触发这些函数的运行呢?信号槽。在主线程里需要有个 signal 信号来关联并触发 Worker 的成员函数,与此同时 Worker 类中也应该有个 signal 信号用于向外界发送运行的结果。这样思路就清晰了,Worker 类需要有个槽函数用于执行外界的命令,还需要有个信号来向外界发送结果。如下列代码:

// Worker.h
#ifndef WORKER_H
#define WORKER_H
#include <QObject>

class Worker : public QObject
{
  Q_OBJECT
public:
  explicit Worker(QObject *parent = nullptr);

signals:
  void resultReady(const QString &str); // 向外界发送结果

public slots:
  void on_doSomething(); // 耗时操作
};
#endif // WORKER_H

// Worker.cpp
#include "worker.h"
#include <QDebug>
#include <QThread>

Worker::Worker(QObject *parent) : QObject(parent)
{
}

void Worker::on_doSomething()
{
  qDebug() << "I"m working in thread:" << QThread::currentThreadId();
  emit resultReady("Hello");
}

在 Worker 类中,总共也就两个成员:on_doSomething() 槽函数被外界触发来执行耗时操作,操作的结果用 resultReady() 信号发射出去,我们这里将“Hello”字符串作为结果发射出去。为了体现是在不同线程中执行的,我们在 on_doSomething() 槽函数中打印当前线程 ID。

// Controller.h
#ifndef CONTROLLER_H
#define CONTROLLER_H
#include <QObject>
#include <QThread>
#include "worker.h"

class Controller : public QObject
{
  Q_OBJECT
public:
  explicit Controller(QObject *parent = nullptr);
  ~Controller();

  void start();

signals:
  void startRunning(); // 用于触发新线程中的耗时操作函数

public slots:
  void on_receivResult(const QString &str); // 接收新线程中的结果

private:
  QThread m_workThread;
  Worker *m_worker;
};
#endif // CONTROLLER_H

在作为“外界”的 Controller 类中,由于要发送命令与接收结果,因此同样是有两个成员:startRunning() 信号用于启动 Worker 类的耗时函数运行,on_receivResult() 槽函数用于接收新线程的运行结果。注意别和 Worker 类的两个成员搞混了,在本例中信号对应着槽,即“外界”的信号触发“新线程”的槽,“外界”的槽接收“新线程”的信号结果。

// Controller.cpp
#include "controller.h"
#include <QThread>
#include <QDebug>

Controller::Controller(QObject *parent) : QObject(parent)
{
  qDebug() << "Controller"s thread is :" << QThread::currentThreadId();

  m_worker = new Worker();
  m_worker->moveToThread(&m_workThread);

  connect(this, &Controller::startRunning, m_worker, &Worker::on_doSomething);
  connect(&m_workThread, &QThread::finished, m_worker, &QObject::deleteLater);
  connect(m_worker, &Worker::resultReady, this, &Controller::on_receivResult);

  m_workThread.start();
}

Controller::~Controller()
{
  m_workThread.quit();
  m_workThread.wait();
}

void Controller::start()
{
  emit startRunning();
}

void Controller::on_receivResult(const QString &str)
{
  qDebug() << str;
}

在 Controller 类的实现里,首先实例化一个 Worker 对象并把它“移动”到新线程中,然后就是在新线程启动前将双方的信号槽连接起来。同 Worker 类一样,为了体现是在不同线程中执行的,我们在构造函数中打印当前线程 ID。

// main.cpp
#include <QCoreApplication>
#include <QThread>
#include <QDebug>
#include "controller.h"

int main(int argc, char *argv[])
{
  QCoreApplication a(argc, argv);

  qDebug() << "The main threadID is :" << QThread::currentThreadId();
  Controller controller;
  controller.start();

  return a.exec();
}

在 main.cpp 中我们实例化一个 Controller 对象,并运行 start() 成员函数发射出信号来触发 Worker 类的耗时操作函数。来看看运行结果:

从结果可以看出,Worker 类对象的成员函数是在新线程中运行的。而 Controller 对象是在主线程中被创建,因此它就隶属于主线程。

2.2 再谈 moveToThread()

“移动到新线程”是一个很形象的描述,作为入门的认知是可以的,但是它的本质是改变线程亲和性(也叫关联性)。为什么要强调这一点?这是因为如果你天真的认为 Worker 类对象整体都移动到新线程中去了,那么你就会本能的认为 Worker 类对象的控制权是由新线程所属,然而事实并不是如此。「在哪创建就属于哪」这句话放在任何地方都是适用的。比如上一节的例子中,Worker 类对象是在 Controller 类中创建并初始化,因此该对象是属于主线程的。而 moveToThread() 函数的作用是将槽函数在指定的线程中被调用。当然,在新线程中调用函数的前提是该线程已经启动处于就绪状态,所以在上一节的 Controller 构造函数中,我们把各种信号槽连接起来后就可以启动新线程了。

使用 moveToThread() 有一些需要注意的地方,首先就是类对象不能有父对象,否则无法将该对象“移动”到新线程。如果类对象保存在栈上,自然销毁由操作系统自动完成;如果是保存在堆上,没有父对象的指针要想正常销毁,需要将线程的 finished() 信号关联到 QObject 的 deleteLater() 让其在正确的时机被销毁。其次是该对象一旦“移动”到新线程,那么该对象中的计时器(如果有 QTimer 等计时器成员变量)将重新启动。不是所有的场景都会遇到这两种情况,但是记住这两个行为特征可以避免踩坑。

3. 启动线程前的准备工作

3.1 开多少个线程比较合适?

说“开线程”其实是不准确的,这种事儿只有操作系统才能做,我们所能做的是管理其中一个线程。无论是 QThread thread 还是 QThread *thread,创建出来的对象仅仅是作为操作系统线程的接口,用这个接口可以对线程进行一些操作。虽然这样说不准确,但下文我们仍以“开线程”的说法,只是为了表述方便。作为入门教程,能在主线程之外“开”一个线程就已经够了,那么讲解“开”多个线程的内容实在没有必要。本节的目的是想在叩开多线程大门的同时能向里望一望多线程领域的世界,就当是抛砖引玉吧。

我们来思考这样一个问题:“线程数是不是越大越好”?显然不是,“开”一千个线程是没有意义的。根据《Qt 中的多线程技术》中所讲的,线程的切换是要消耗系统资源的,频繁的切换线程会使性能降低。线程太少的话又不能完全发挥 CPU 的性能。一般后端服务器都会设置最大工作线程数,不同的架构师有着不同的经验,有些业务设置为 CPU 逻辑核心数的4倍,有的甚至达到32倍。如上图所示,Chrome 浏览器运行时就开了36个线程。

So, the minimum number of threads is equal to the number of available cores. If all tasks are computation intensive, then this is all we need. Having more threads will actually hurt in this case because cores would be context switching between threads when there is still work to do. If tasks are IO intensive, then we should have more threads.

When a task performs an IO operation, its thread gets blocked. The processor immediately context switches to run other eligible threads. If we had only as many threads as the number of available cores, even though we have tasks to perform, they can”t run because we haven”t scheduled them on threads for the processors to pick up.

If tasks spend 50 percent of the time being blocked, then the number of threads should be twice the number of available cores. If they spend less time being blocked—that is, they”re computation intensive—then we should have fewer threads but no less than the number of cores. If they spend more time being blocked—that is, they”re IO intensive—then we should have more threads, specifically, several multiples of the number of cores.

So, we can compute the total number of threads we”d need as follows:


Number of threads = Number of Available Cores / (1 – Blocking Coefficient)

Venkat Subramaniam 博士的《Programming Concurrency on the JVM》这本书中提到关于最优线程数的计算,即线程数量 = 可用核心数/(1 – 阻塞系数)。可用核心数就是所有逻辑 CPU 的总数,这可以用 QThread::idealThreadCount() 静态函数获取,比如双核四线程的 CPU 的返回值就是4。但是阻塞系数比较难计算,这需要用一些性能分析工具来辅助计算。如果只是粗浅的计算下线程数,最简单的办法就是 CPU 核心数 * 2 + 2。更为精细的找到最优线程数需要不断的调整线程数量来观察系统的负载情况。

3.2 设置栈大小

根据《Qt 中的多线程技术》所述,线程“与进程内的其他线程一起共享这片地址空间,基本上就可以利用进程所拥有的资源而无需调用新的资源”,这里所指的资源之一就是堆栈空间。每个线程都有自己的栈,彼此独立,由编译器分配。一般在 Windows 的栈大小为2M,在 Linux 下是8M。

Qt 提供了获取以及设置栈空间大小的函数:stackSize()、setStackSize(uint stackSize)。其中 stackSize() 函数不是返回当前所在线程的栈大小,而是获取用 stackSize() 函数手动设置的栈大小。如果是用编译器默认的栈大小,该函数返回0,这一点需要注意。为什么要设置栈的大小?这是因为有时候我们的局部变量很大(常见于数组),当超过编译器默认大小时程序就会因为栈溢出而报错,这时候就需要手动设置栈大小了。

以上文「2.1 我们该把耗时代码放在哪里?」中的代码为例,在 Linux 操作系统环境下,假如我们在 on_doSomething() 函数中添加一个9M大小的数组 array,可以看出在程序运行时会由于栈溢出而导致异常退出,因为 Linux 默认的栈空间仅为8M。

如果我们设置了栈大小为10m,那么程序会正常运行,不会出现栈溢出的问题。

4. 启动线程/退出线程

4.1 启动线程

调用 start() 函数就可以启动函数在新线程中运行,运行后会发出 started() 信号。

在「1.概述」中我们知道将耗时函数放入新线程有 moveToThread() 和继承 QThread 且重新实现 run() 函数两种方式。有这么一种情况:此时我有 fun1() fun2() 两个耗时函数,将 fun1() 中的代码放入 run() 函数,而将 fun2() 以 moveToThread() 的方式也放到这个线程中。那新线程该运行哪个函数呢?其实调用 start() 函数后,新线程会优先执行 run() 中的代码,即先执行 fun1() 函数,其次才会运行 fun2() 函数。这种情况不常见,但了解这种先后顺序有助于我们理解 start() 函数。

说到 run() 函数就不得不提 exec() 函数。这是个 protected 函数,因此只能在类内使用。默认 run() 函数会调用 exec() 函数,即启用一个局部的不占 CPU 的事件循环。为什么要默认启动个事件循环呢?这是因为没有事件循环的话,耗时代码只要执行完线程就会退出,频繁的开销线程显然很浪费资源。因此,如果使用上述第二种“开线程”的方式,别忘了在 run() 函数中调用 exec() 函数。

4.2 优雅的退出线程

退出线程可是个技术活,不是随随便便就可以退出。比如我们关闭主进程的同时,里面的线程可能还处在运行状态,尤其线程上跑着耗时操作。这时候你可以用 terminate() 函数强制终止线程,调用该函数后所有处于等待状态的线程都会被唤醒。该函数是异步的,也就是说调用该函数后虽然获得了返回值,但此时线程依然可能在运行。因此,一般是在后面跟上 wait() 函数来保证线程已退出。当然强制是很暴力的行为,有可能会造成局部变量得不到清理,或者无法解锁互斥关系,种种行为都是很危险的,除非必要时才会使用该函数。

上文「4.1 启动线程」结尾提到“默认 run() 函数会调用 exec() 函数”,耗时操作代码执行完后,线程由于启动了事件循环是不退出的。所以,正常的退出线程其实质是退出事件循环,即执行 exit(int returnCode = 0) 函数。返回0代表成功,其他非零值代表异常。quit() 函数等价于 exit(0)。线程退出后会发出 finished() 信号。

5. 操作运行中的线程

5.1 获取状态

(1)运行状态

根据《Qt 中的多线程技术》中的「1.3 线程的生命周期」所述,线程的状态有很多种,而往往我们只关心一个线程是运行中还是已经结束。QThread 提供了 isRunning()、isFinished() 两个函数来判断当前线程的运行状态。

(2)线程标识

Returns the thread handle of the currently executing thread.

Warning: The handle returned by this function is used for internal purposes and should not be used in any application code.

Note: On Windows, this function returns the DWORD (Windows-Thread ID) returned by the Win32 function GetCurrentThreadId(), not the pseudo-HANDLE (Windows-Thread HANDLE) returned by the Win32 function GetCurrentThread().

关于 currentThreadId() 函数,很多人将该函数用于输出线程ID,这是错误的用法。该函数主要用于 Qt 内部,不应该出现在我们的代码中。那为什么还要开放这个接口?这是因为我们有时候想和系统线程进行交互,而不同平台下的线程 ID 表示方式不同。因此调用该函数返回的 Qt::HANDLE 类型数据并转化成对应平台的线程 ID 号数据类型(例如 Windows 下是 DWORD 类型),利用这个转化后的 ID 号就可以与系统开放出来的线程进行交互了。当然,这就破坏了移植性了。

需要注意的是,这个 Qt::HANDLE 是 ID 号而不是句柄。句柄相当于对象指针,一个线程可以被多个对象所操控,而每个线程只有一个全局线程 ID 号。正确的获取线程 ID 做法是:调用操作系统的线程接口来获取。以下是不同平台下获取线程 ID 的代码:

#include <QCoreApplication>
#include <QDebug>

#ifdef Q_OS_LINUX
#include <pthread.h>
#endif

#ifdef Q_OS_WIN
#include <windows.h>
#endif

int main(int argc, char *argv[])
{
    QCoreApplication a(argc, argv);

#ifdef Q_OS_LINUX
    qDebug() << pthread_self();
#endif

#ifdef Q_OS_WIN
    qDebug() << GetCurrentThreadId();
#endif

    return a.exec();
}

我们自己的程序内部可以调用 currentThread() 函数来获取 QThread 指针,有了线程指针就可以对线程进行一些操作了。

3)更为精细的事件处理

在《Qt 中的事件系统 – 有什么难的呢?》一文中我们提到事件的整个运行流程,文中所提及的 QCoreApplication::processEvents() 等传递事件方法其实是很简单的,但如果再深入下去就无能为力了。Qt 提供了 QAbstractEventDispatcher 类用于更为精细的事件处理,该类精细到可以管理 Qt 事件队列,即接收到事件(来自操作系统或者 Qt 写的程序)后负责发送到 QCoreApplication 或者 QApplication 实例以进行处理。而文中讲的是从 QCoreApplication 接收到事件开始,再往后的事情了。

线程既然可以开启事件循环,那么就可以调用 eventDispatcher()、setEventDispatcher() 函数来设置和获取事件调度对象,然后对事件进行更为精细的操作。

除此以外,loopLevel() 函数可以获取有多少个事件循环在线程中运行。正如下文所说,这个函数本来在 Qt 4 中被删除了,但是对于那些想知道有多少事件循环的人来说该函数还是有用的。所以在 Qt 5 中又加了进来。

This function used to reside in QEventLoop in Qt 3 and was deprecated in Qt 4. However this is useful for those who want to know how many event loops are running within the thread so we just make it possible to get at the already available variable.

5.2 操作线程

(1)安全退出线程必备函数:wait(unsigned long time = ULONG_MAX)

在本文「4.2 优雅的退出线程」中已经提到“一般是在后面跟上 wait() 函数来保证线程已退出”,线程退出的时候不要那么暴力,告诉操作系统要退出的线程后,给点时间(即阻塞)让线程处理完。也可以设置超时时间 time,时间一到就强制退出线程。一般在类的析构函数中调用,正如本文开头「2.1 我们该把耗时代码放在哪里?」的示例代码那样:

Controller::~Controller()
{
    m_workThread.quit();
    m_workThread.wait();
}

(2)线程间的礼让行为

这是个很有意思的话题,一般我们都希望每个线程都能最大限度的榨干系统资源,何来礼让之说呢?有时候我们采用多线程并不只是运行耗时代码,而是和主 GUI 线程分开,避免主界面卡死的情况发生。那么有些线程上跑的任务可能对实时性要求不高,这时候适当的缩短被 CPU 选中的机会可以节约出系统资源

除了调用 setPriority()、priority() 优先级相关的函数以外,QThread 类还提供了 yieldCurrentThread() 静态函数,该函数是在通知操作系统“我这个线程不重要,优先处理其他线程吧”。当然,调用该函数后不会立马将 CPU 计算资源交出去,而是由操作系统决定。

QThread 类还提供了 sleep()、msleep()、usleep() 这三个函数,这三个函数也是在通知操作系统“在未来 time 时间内我不参与 CPU 计算”。从我们直观的角度看,就好像当前线程“沉睡”了一段时间。

(3)线程的中断标志位

Qt 为每一个线程都设置了一个布尔变量用来标记当前线程的终端状态,用 isInterruptionRequested() 函数来获取,用 requestInterruption() 函数来设置中断标记。这个标记不是给操作系统看的,而是给用户写的代码中进行判断。也就是说调用 requestInterruption() 函数并不能中断线程,需要我们自己的代码去判断。这有什么用处呢?

while (ture) {
    if (!isInterruptionRequested()) {
        // 耗时操作
        ......
    }
}

这种设计可以让我们自助的中断线程,而不是由操作系统强制中断。经常我们会在新线程上运行无限循环的代码,在代码中加上判断中断标志位可以让我们随时跳出循环。好处就是给了我们程序更大的灵活性

6. 为每个线程提供独立数据

思考这样一个问题,如果线程本身存在全局变量,那么修改一处后另一个线程会不会受影响?我们以一段代码为例:

// main.cpp
#include <QCoreApplication>
#include "workthread.h"

int main(int argc, char *argv[])
{
    QCoreApplication a(argc, argv);

    WorkThread thread1;
    WorkThread thread2;

    thread1.start();
    thread2.start();

    return a.exec();
}

// WorkThread.h
#ifndef WORKTHREAD_H
#define WORKTHREAD_H
#include <QThread>

class WorkThread : public QThread
{
public:
    WorkThread();

protected:
    virtual void run() override;
};
#endif

// WorkThread.cpp
#include "workthread.h"
#include <QDebug>
#include <QThreadStorage>

WorkThread::WorkThread()
{
}

quint64 g_value1 = 0;
void WorkThread::run()
{
    g_value1 = quint64(currentThreadId());
    qDebug() << g_value1;
}

我们继承 QThread 类并重写 run() 函数,函数中的全局变量 g_value1 由线程 ID 赋值。实例化出两个线程对象并均启动。其结果输出如下:

可以看到两个输出的结果是一样的,这并非是同一个线程输出两次,而是线程 thread1 对全局变量的修改影响了线程 thread2。造成这个现象的原因也很好理解,根据《Qt 中的多线程技术》中的「1.2 多核CPU」所述,“线程隶属于某一个进程,与进程内的其他线程一起共享这片地址空间”。也就是说全局变量属于公共资源,被所有线程所共享,只要一个线程修改了这个全局变量自然就会影响其他线程对该全局变量的访问。

而 QThreadStorage 类为每个线程提供了独立的数据存储功能,即使在线程中用到全局变量,只要存在 QThreadStorage 中,也不会影响到其他线程。我们对上面的 workthread.cpp 进行稍加修改,从结果来看,每个线程都有属于各自的全局变量,而互不影响。如下图所示:

需要注意的是,QThreadStorage 的析构函数并不会删除所储存的数据,只有线程退出才会被删除。

附:所有函数

  • 启动前的准备工作
    • 构造函数:QThread(QObject *parent = nullptr)
    • 系统理想的线程数量:[static]int idealThreadCount()
    • 堆栈大小
      • 获取:uint stackSize() const
      • 设置:void setStackSize(uint stackSize)
  • 启动/退出
    • 启动
      • 执行:[slots]void start(QThread::Priority priority = InheritPriority)
      • 信号:void started()
      • 事件循环:[protected]int exec()
    • 退出
      • 执行
        • void exit(int returnCode = 0)
        • [slots]void quit()
      • 信号:void finished()
      • 强制中止[不常用]
        • [slots]void terminate()
        • [static protected]void setTerminationEnabled(bool enabled = true)
  • 运行中
    • 状态获取
      • 是否运行
        • bool isRunning() const
        • bool isFinished() const
      • 标识:
        • 当前线程:[static]QThread * currentThread()
        • 线程ID:[static]Qt::HANDLE currentThreadId()
      • 更为精细的事件管理
        • 获取:QAbstractEventDispatcher * eventDispatcher() const
        • 设置:void setEventDispatcher(QAbstractEventDispatcher *eventDispatcher)
        • 循环级别:int loopLevel() const
    • 行为
      • 阻塞:bool wait(unsigned long time = ULONG_MAX)
      • 线程之间的礼让
        • 优先级
          • 获取:QThread::Priority priority() const
          • 设置:void setPriority(QThread::Priority priority)
        • 切换线程:[static]void yieldCurrentThread()
        • 睡眠
          • [static]void sleep(unsigned long secs) const
          • [static]void msleep(unsigned long msecs)
          • [static]void usleep(unsigned long usecs)
      • 请求中断
        • 判断:bool isInterruptionRequested() const
        • 执行:void requestInterruption()
  • 其他(仅支持C++17)
    • [static]QThread * create(Function &&f, Args &&… args)
    • [static]QThread * create(Function &&f)

以上就是本文的全部内容,多线程的内容丰富多彩,受限于笔者的知识程度,有些内容无法深入讨论,比如 QThreadStorage 和 C++11 关键字 thread_local 的区别。在未来的学习生活中,如果对多线程有了更深的感悟,笔者会回来更新本文的。

hmoban主题是根据ripro二开的主题,极致后台体验,无插件,集成会员系统
自学咖网 » Qt 多线程编程之敲开 QThread 类的大门