15. 线程和进程¶
这章节我们将讲解下在Qt中使用QThread类和多线程,并使用例程进行说明。
15.1. 进程和线程概念¶
进程是一个具有一定独立功能的程序在一个数据集上的一次动态执行的过程,是操作系统进行资源分配和调度的一个独立单位,是应用程序运行的载体。 每个进程拥有独立的内存地址控件和运行状态,操作系统会根据调度算法(比如参考进程状态)对任务进行调度切换, 控制CPU加载不同的内存地址最终去执行不同的任务。
早期单核CPU时,我们可以在系统上一边听音乐一些写文档,实际CPU通过时间片轮转调度来切换进程,由于CPU速度很快,会让我们感觉两个任务在”同时执行”。 但是,因为多核CPU和进程切换开销很大等原因,就出现了线程。
线程是程序执行中一个单一的顺序控制流程,是程序执行流的最小单元,是处理器调度和分派的基本单位。 线程就可以粗暴的理解为进程的细分,多个线程共用一个进程的内存地址,线程更为轻量上下文切换比进程消耗资源更少。 每个线程都有自己的堆栈,这意味着每个线程都有自己的调用历史记录和局部变量,与进程不同,线程共享相同的地址空间。
我在举个简单的例子来理解一下这两个概念: 假设现在系统上运行着两个程序,VSCODE和酷狗音乐播放器。 我正在用VSCODE写文档,也就你看到的这些文字;然后音乐播放器真播放着我喜欢的音乐。 简单的就能把一个应用理解成一个进程,它们同时工作,这就是所谓的多任务。
再看单个音乐播放进程中,播放器将mp3格式的文件解码,并二进制数据送入音频驱动器, 同时桌面上的歌词,并且进度条也在不断的更新,这就是线程并发性。
实际上线程的用途也是单个进程的并行,在多核CPU中,拥有多个线程的程序会将线程分配给多个内核实现真的的并行。
15.2. Qt中的线程¶
每个程序在启动时都有一个线程,这个线程称为“主线程”(在Qt应用程序中也称为 GUI线程 )。一般在主线程中创建“工作线程”,会调用start()函数开始执行线程的任务。
线程好处
使用线程基本上有下面两个好处:
通过使用多核处理器来加快处理速度。
在开发存在界面交互的程序中,为了使一些耗时操作不造成卡顿,通过减轻长时间处理的负担或阻止对其他线程的调用,使GUI线程或其他时间紧迫的线程保持响应。
另外,使用线程时也需要非常小心,启动线程很容易,但是很难确保所有共享数据保持一致, 而且这种问题通常很难发现,因为可能仅偶尔出现一次或者仅在特定的硬件配置上出现。
QThread和QObject
QThread 类是Qt中线程控制的基础,提供了不依赖平台的线程管理方法,一般创建线程,通过继承QThread定义一个线程类。
QThread类的主要接口:
类型 |
函数 |
描述 |
---|---|---|
公有函数 |
bool wait(unsigned long time) |
线程将会被阻塞,等待time毫秒 |
公有函数 |
bool isFinished() const |
线程状态,线程是否结束 |
公有函数 |
bool isRunning() const |
线程状态,线程是否在运行 |
信号 |
void finished() |
终止线程实例运行,发送信号 |
信号 |
void started() |
启动线程,发送信号 |
槽函数 |
void quit() |
线程终止 |
槽函数 |
void start(QThread::Priority priority = InheritPriority) |
线程启动 |
槽函数 |
terminate() |
线程结束 |
QThread继承自QObject,可以使用信号与槽功能开始或者结束线程。
QObject接收到队列中的信号或发布的事件时,该对象的槽函数或事件处理程序将在该对象所在的线程中运行, 线程之外将无法接收排队的信号或已发布的事件。
基于非图形用户界面的子类可以无线程操作,单一类运行某功能时,可以不需要线程。但是,运行单一类的目标程序的上级功能时,则必须通过线程实现。
如果在线程A和线程B没有结束的情况下,应设计使主线程时间循环不结束;而若线程A迟迟不结束而导致主线程循环也迟迟不能结束,故也要防止线程A没有在一定时间内结束。
15.3. 线程创建和使用¶
Qt 中使用线程,一般通过QThread类,在 Qt QThread文档 中演示了两种使用方法创建使用线程。 一种是通过重写run()来实现,另外一种是通过 moveToThread() 将Object对象移到到新线程中,我们下面通过例程(参考配套例程)说明下。
重写run()函数:
1 2 3 4 5 6 7 8 9 10 11 12 | class MyRunThread : public QThread
{
Q_OBJECT
public:
explicit MyRunThread(QObject *parent = nullptr);
protected:
void run(); //线程的事件循环
signals:
void value(int);
};
|
1 2 3 4 5 6 7 8 9 10 11 12 13 | MyRunThread::MyRunThread(QObject *parent)
: QThread{parent}
{
}
void MyRunThread::run()
{
int m_value = QRandomGenerator::global()->bounded(1, 100); //产生一个随机整数,在[1,100]
msleep(100); //线程休眠100ms
emit value(m_value); // 信号
quit(); // 退出线程
}
|
1 2 3 4 5 6 7 8 9 10 | //....
thread1 = new MyRunThread();
connect(thread1, SIGNAL(value(int)), this, SLOT(get_value(int)));
//...
//按键点击,开启线程1
void MainWindow::on_btn_thread1_clicked()
{
thread1->start();
}
|
QThread父类是QObject,所以可以使用信号与槽机制,QThread有finished()和started()信号,started()信号在run()函数被调用之前发射,finished()信号在run()函数结束时发射。
run()未调用exec()开启事件循环,在run()执行结束时,会自动退出。例程的run()中,使用quit()退出线程。
重写run()来开启一个线程,只有run()在子线程中执行,MyRunThread实例化是在主线程中。
通过 moveToThread():
1 2 3 4 5 6 7 8 9 10 11 | class GenerateNumber : public QObject
{
Q_OBJECT
private slots:
void slot_MyThread();
signals:
void get_value(int);
};
|
1 2 3 4 5 | void GenerateNumber::slot_MyThread()
{
int m_value = QRandomGenerator::global()->bounded(1, 100); //产生一个随机整数,在[1,100]
emit get_value(m_value);
}
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | thread2 = new GenerateNumber();
thread2->moveToThread(&workerThread);
connect(thread2, SIGNAL(get_value(int)), this, SLOT(thread2Value(int)));
connect(&workerThread, &QThread::finished, thread2, &QObject::deleteLater);
connect(ui->btn_thread2, SIGNAL(clicked()), thread2, SLOT(slot_MyThread()));
void MainWindow::thread2Value(int val)
{
ui->lineEdit_thread2->setText(QString::number(val));
}
void MainWindow::on_btn_thread2_clicked()
{
workerThread.start();
}
|
GenerateNumber类继承自顶层QObject。
new的thread2对象没有父对象指针,这里使线程QThread::finished()关联了QObject对象的一个函数deleteLater(),线程结束后,销毁对象。
movetoThread()将GenerateNumber的槽函数在指定的线程中调用,仅槽函数在子线程中调用,其GenerateNumber实例化等仍然在主线程中调用。
异步执行,获得工作线程结果的一种方法是等待线程终止。但是在许多情况下,一直阻塞等待是不可接受的,可以通过发布事件或排队的信号和时隙异步传递结果。
我们还可以使用QThread::create()静态函数,创建简单线程,执行程序函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | void MainWindow::on_btn_MyThread_clicked()
{
//线程执行,使用lambda函数
myThread=QThread::create([=]()
{
for (int i=0;i<10 ;i++ )
{
qDebug()<<" myThread : "<<QThread::currentThread()<<" id :"<<QThread::currentThreadId();
}
});
// 关联started和finished信号,输出debug信息
connect(myThread,&QThread::started,[=]()
{
qDebug()<<" myThread started.";
});
connect(myThread,&QThread::finished,[=]()
{
qDebug()<<" myThread finished.";
});
// 删除
connect(myThread,&QThread::finished,myThread,&QThread::deleteLater);
// 启动线程
myThread->start();
}
|
更多内容可以参考下Qt帮助手册。
线程事件循环
在Qt中每个线程都有自己的事件循环,Qt程序启动的主线程事件循环一般是使用 QCoreApplication::exec(),或者 QDialog::exec()。 其他子线程开始事件循环是使用 QThread::exec()。简单示意图看下(参考 Qt文档):
15.4. 线程同步和通讯¶
多线程之间的通讯和同步是一个重要的问题,例如多个线程之间可能访问同一个变量或者这个线程需要另外一个线程完成之后执行相应动作等。 线程之间的简单同步,可以使用信号与槽,更多的Qt也提供了QMutex、QReadWriteLock、QSemaphore和QWaitCondition等许多类用于同步线程。
互斥锁 (QMutex) 是基于互斥量(mutex)的线程同步类,通过互斥锁锁定对共享资源的访问。 例如第一个线程对一个变量锁定,第二线程也访问这个变量时,将进入睡眠状态,直到第一个线程完成其任务并解锁该互斥锁。
QMutex主要的公共函数:
void lock() #锁定一个互斥量
bool tryLock(int timeout = 0) #尝试锁定一个互斥量,等待timeout
bool try_lock() #尝试锁定一个互斥量,不等待
void unlock() #解锁一个互斥量
QMutex使用示例:
1 2 3 4 5 6 7 8 9 10 | QMutex mutex;
int number = 6;
void method()
{
mutex.lock();
number *= 3;
number /= 2;
mutex.unlock();
}
|
QMutexLocker 类和QMutex一样,它简化了QMutex互斥锁的锁定和解锁:
例如下面是对一个文件访问的锁定:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | ...
class Config
{
public:
static QSharedPointer<Config>& instance()
{
if (self.isNull())
{
QMutexLocker mutexLocker(&m_Mutex); //当QMutexLocker对象删除时,解锁
if (self.isNull())
self = QSharedPointer<Config>(new Config());
}
return self;
}
...
private:
static QMutex m_Mutex;
static QSharedPointer<Config> self;
...
};
|
当然使用互斥锁也有可能会存在如下问题:
如果一个线程锁定了一个资源但没有将其解锁,则该应用程序可能会冻结,因为该资源将永久无法供其他线程使用。
另一个类似的情况是僵局。例如,假设线程A正在等待线程B解锁资源。如果线程B也正在等待线程A解锁其他资源,则两个线程最终将永远等待,因此应用程序将冻结。
读写锁 (QReadWriteLock) 类似于QMutex,不同的是它对“读”和“写”操作进行了区分。 当不写入数据时,可以安全地同时读取多个线程,从而提高了并行性,适合许多并发读取并且写入不频繁的场景。
例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | QReadWriteLock lock;
void ReaderThread::run()
{
...
lock.lockForRead();
read_file();
lock.unlock();
...
}
void WriterThread::run()
{
...
lock.lockForWrite();
write_file();
lock.unlock();
...
}
|
数据没有被写入时,多线程去读取该数据是安全的。
信号量(QSemaphore) 是的一般化QMutex保护一定数目的相同的资源,QMutex只保护一种资源, QSemaphore的典型应用就是同步访问生产者和消费者之间的环形缓冲器(生产者-消费者模型)。
条件变量(QWaitCondition) 不通过强制互斥而是通过提供条件变量来同步线程。当其他原语使线程等待直到资源被解锁时,QWaitCondition使线程等待直到满足特定条件。 要允许等待的线程继续进行,请调用wakeOne()来唤醒一个随机选择的线程,或者调用wakeAll()来同时唤醒它们。
线程间的通讯
线程间的通讯可以通过:共享内存,线程属于一个进程,与进程内的其他线程一起共享这片地址空间, 或者使用Qt的信号槽和事件循环机制。
Qt的事件系统对于线程间通信非常有用,每个线程可能都有其自己的事件循环。 要在另一个线程中调用槽(或任何可调用的方法),需要将该调用置于目标线程的事件循环中。 这样,目标线程就可以在槽函数开始运行之前完成其当前任务,而原始线程则可以继续并行运行。
要将调用置于事件循环中,需要建立排队的信号槽连接,每当发出信号时,事件系统都会记录其自变量,信号接收器所在的线程将运行该槽函数; 或者,调用QMetaObject::invokeMethod()以达到没有信号的相同效果。
在这两种情况下,connect函数必须使用QueuedConnection,因为DirectConnection会绕过事件系统并立即在当前线程中运行该方法。
关于connect函数的连接方式,需要看下connect函数原型:
1 2 3 4 5 6 7 8 | static QMetaObject::Connection connect(const QObject *sender, const char *signal,
const QObject *receiver, const char *member, Qt::ConnectionType = Qt::AutoConnection);
static QMetaObject::Connection connect(const QObject *sender, const QMetaMethod &signal,const QObject *receiver,
const QMetaMethod &method, Qt::ConnectionType type = Qt::AutoConnection);
inline QMetaObject::Connection connect(const QObject *sender, const char *signal,
const char *member, Qt::ConnectionType type = Qt::AutoConnection) const;
|
connect有一个参数 Qt::ConnectionType,该参数描述了可以在信号和槽之间使用的连接类型,确定了特定信号是立即传送到还是排队等待稍后传送。 参数值参考下 这里 。
如果信号接收方与发送方在同一个线程,则使用Qt::DirectConnection,否则使用 Qt::QueuedConnection;连接类型在信号发射时决定。
15.5. 进程间通信¶
前面我们有提到都是Qt线程之间,比如可以线程间通讯,那进程呢? 当然可以,比如Qt D-Bus,QLocalSocket、QLocalServer以及QSharedMemory等。
这里举常用的例子,应用程序只运行一个实例
QSharedMemory实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | int main(int argc, char *argv[])
{
QApplication a(argc, argv);
// 临界区
QSystemSemaphore semaphore("semaphore",1,QSystemSemaphore::Open);
semaphore.acquire();
// QSharedMemory
QSharedMemory memory("memory");
if (!memory.create(1))
{
QString dlgTitle="information";
QString str="实例已运行,该APP只允许运行一个实例";
QMessageBox::information(nullptr, dlgTitle,str,QMessageBox::Yes);
semaphore.release();
return 0;
}
semaphore.release();
MainWindow w;
w.show();
return a.exec();
}
|
这种方式有弊端,在程序发生崩溃时,未及时清除共享区数据,可能会导致程序不能正常启动。
15.5.1. QProcess¶
进程间通信,可能我们现在这阶段用得更多是,如何去启动一个进程。
要用QProcess启动进程,请将要运行的程序的名称和命令行参数作为参数传递给start(),参数作为单独的字符串提供在QStringList中。 或者,您可以将程序设置为与setProgram()和setArguments()一起运行,然后调用start()或open()。 然后QProcess进入启动状态,并且在程序启动后,QProcess进入运行状态并发出started()信号。
QProcess允许您将进程视为顺序I/O设备,您可以像使用QTcpSocket访问网络连接一样来写入和读取该过程。 然后,您可以通过调用write()来写入流程的标准输入,并通过调用read(),readLine()和getChar()来读取标准输出。 由于QProcess继承了QIODevice,因此它还可以用作QXmlReader的输入源,或生成要使用QNetworkAccessManager上载的数据。
当QProcess退出,QProcess中重新进入notrunning状态的状态(初始状态),并发出finished()信号。 finished()信号提供工艺参数的退出代码和退出状态,你也可以调用exitCode() 来获得最后的进程的退出代码完成,并通过exitStatus()来获得它的退出状态。 如果进程在任何时间点发生错误,QProcess将发出errorOccurred()信号。您还可以调用error()来查找上次发生的错误的类型,并调用state()来查找当前进程的状态。
比如在语言设置,之后需要重启App,就是通过QProcess去重新执行run.sh脚本。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | void LanguagePage::SltCurrentIndexClicked(QtListWidgetItem *item)
{
AppConfig::SaveSetting("System", "language", item->m_strText);
#if 0
int nRet = QtMessageBox::ShowAskMessage(tr("语言设置重启生效,是否立即重启?"), tr("语言设置"));
if (nRet == QDialog::Accepted)
{
#ifdef __arm__
QString strCmd = qApp->applicationDirPath() + "/run.sh";
QProcess::startDetached(strCmd);
qApp->quit();
#endif
}
#endif
}
|
15.6. 例程测试¶
15.6.1. 例程说明¶
例程使用QThread类,创建和使用线程,简单的测试我们使用QThread::create()静态函数,创建线程打印子线程的id等; 一种是通过继承QThread类,重写run()来实现,在run函数中生成一个随机数,通过信号与槽传递; 另外一种是通过moveToThread()将Object对象移到到新线程中。
完整程序参考下配套例程。
15.6.2. 编译构建¶
Ubuntu 选择 Desktop Qt 5.15.2 GCC 64bit 套件,编译运行:
LubanCat 选择 lubancat_rk,只编译。
提示
当两种构建套件进行切换时,请重新构建项目或清除之前项目。