第二十七章 多线程

27.1 QThread类

27.2 简单爬虫实战

27.3 小结



当在执行某些复杂且耗时的操作时,我们不能将该操作放在界面控制线程中(即UI线程,就是app.exec_()所在的线程),否则我们会发现界面停止响应(或卡顿),比如下面这个例子就是:

import sys
import time
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QLabel, QVBoxLayout


class Demo(QWidget):
    def __init__(self):
        super(Demo, self).__init__()
        self.count = 0

        self.button = QPushButton('Count', self)
        self.button.clicked.connect(self.count_func)
        self.label = QLabel('0', self)
        self.label.setAlignment(Qt.AlignCenter)

        self.v_layout = QVBoxLayout()
        self.v_layout.addWidget(self.label)
        self.v_layout.addWidget(self.button)
        self.setLayout(self.v_layout)

    def count_func(self):
        while True:
            self.count += 1
            self.label.setText(str(self.count))
            time.sleep(1)
            
            
if __name__ == '__main__':
    app = QApplication(sys.argv)
    demo = Demo()
    demo.show()
    sys.exit(app.exec_())

理想的情况应该是这样,当我们点击按钮时,程序就会无限计数,然后将文本设置为相应的数字。但是这里的计数操作绝对耗时,而且它处在UI线程中(此时都是主线程),所以程序界面会停止响应,文本不会显示计数,而查看控制台会发现是有在计数的。

运行截图如下,程序界面停止响应,文本不显示计数:

python QT5 多线程 等待 pyqt5多线程崩溃_qt

控制台却是有输出计数的:

python QT5 多线程 等待 pyqt5多线程崩溃_qt_02

为解决这种情况,我们应该采用多线程,将复杂耗时的操作放在与界面控制不同的线程中,让两者独立开来。下面我们就来了解下如何使用多线程(第十章中讲到的QTimer也属于多线程技术,所以同样可以解决上述程序中的界面卡死问题,不过我们本章主要还是讲QThread)。

27.1 QThread类

要实现多线程,我们要先继承QThread类并重新实现其中的run()函数,也就是说把耗时的操作放入run()函数中。比如我们把上面例子的计数操作放在run()函数中:

class MyThread(QThread):
    def __init__(self):
        super(MyThread, self).__init__()
        self.count = 0

    def run(self):
        while True:
            print(self.count)
            self.count += 1
            self.sleep(1)

小伙伴们可能还发现了一点,就是笔者这里把time.sleep(1)用self.sleep(1)来代替了,QThread类有一个sleep()方法可以强制让当前线程睡眠相应的秒数。接下来只需要将该线程在Demo类中实例化并调用start()就可以使用了:

import sys
from PyQt5.QtCore import Qt, QThread
from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QLabel, QVBoxLayout


class Demo(QWidget):
    def __init__(self):
        super(Demo, self).__init__()

        self.button = QPushButton('Count', self)
        self.button.clicked.connect(self.count_func)
        self.label = QLabel('0', self)
        self.label.setAlignment(Qt.AlignCenter)

        self.my_thread = MyThread()    

        self.v_layout = QVBoxLayout()
        self.v_layout.addWidget(self.label)
        self.v_layout.addWidget(self.button)
        self.setLayout(self.v_layout)

    def count_func(self):
        self.my_thread.start()


class MyThread(QThread):
    def __init__(self):
        super(MyThread, self).__init__()
        self.count = 0

    def run(self):
        while True:
            print(self.count)
            self.count += 1
            self.sleep(1)


if __name__ == '__main__':
    app = QApplication(sys.argv)
    demo = Demo()
    demo.show()
    sys.exit(app.exec_())

到目前我们运行起来发现只是在控制台有输出,所以现在还差最后一步,就是让界面上的数字发生变化。但是label变量在UI线程中,而count变量在my_thread线程中,要怎么让label显示count变量的值呢?

答案就是信号。我们要用信号来时刻传递count变量的值给UI线程中的label。我们在第二章中的2.5节了解过了自定义信号的用法,现在再来看下如何在自定义信号中传递值:

import sys
from PyQt5.QtCore import Qt, QThread, pyqtSignal
from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QLabel, QVBoxLayout


class Demo(QWidget):
    def __init__(self):
        super(Demo, self).__init__()

        self.button = QPushButton('Count', self)
        self.button.clicked.connect(self.count_func)
        self.label = QLabel('0', self)
        self.label.setAlignment(Qt.AlignCenter)

        self.my_thread = MyThread()
        self.my_thread.my_signal.connect(self.set_label_func)  # 3

        self.v_layout = QVBoxLayout()
        self.v_layout.addWidget(self.label)
        self.v_layout.addWidget(self.button)
        self.setLayout(self.v_layout)

    def count_func(self):
        self.my_thread.start()

    def set_label_func(self, num):  # 4
        self.label.setText(num)


class MyThread(QThread):
    my_signal = pyqtSignal(str)     # 1

    def __init__(self):
        super(MyThread, self).__init__()
        self.count = 0

    def run(self):
        while True:
            print(self.count)
            self.count += 1
            self.my_signal.emit(str(self.count)) # 2
            self.sleep(1)


if __name__ == '__main__':
    app = QApplication(sys.argv)
    demo = Demo()
    demo.show()
    sys.exit(app.exec_())

1. 首先在MyThread()类中自定义一个信号,pyqtSignal(str)加上str就代表这个信号可以传一个字符串数值;

2. 然后在run()函数中,我们调用信号的emit()函数释放信号,其中传入str(self.count)字符串值(count变量本身是int类型,而信号要传递的是字符串,所以要调用str()方法将count转为字符串)。也就是说每次循环都会释放信号,而该信号会同时传递count变量的字符串值;

3-4. 其次在UI线程中进行信号和槽的连接,在槽函数中我们将label的值设为传递过来的数值。

此时运行起来我们发现界面已经不会再卡死了:

python QT5 多线程 等待 pyqt5多线程崩溃_python QT5 多线程 等待_03

既然有了启动的按钮,那当然也要加个停止的按钮了:

import sys
from PyQt5.QtCore import Qt, QThread, pyqtSignal
from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QLabel, QHBoxLayout, QVBoxLayout


class Demo(QWidget):
    def __init__(self):
        super(Demo, self).__init__()

        self.button = QPushButton('Start', self)
        self.button.clicked.connect(self.count_func)
        self.button_2 = QPushButton('Stop', self)           # 3
        self.button_2.clicked.connect(self.stop_count_func)

        self.label = QLabel('0', self)
        self.label.setAlignment(Qt.AlignCenter)

        self.my_thread = MyThread()
        self.my_thread.my_signal.connect(self.set_label_func)

        self.h_layout = QHBoxLayout()
        self.v_layout = QVBoxLayout()
        self.h_layout.addWidget(self.button)
        self.h_layout.addWidget(self.button_2)
        self.v_layout.addWidget(self.label)
        self.v_layout.addLayout(self.h_layout)
        self.setLayout(self.v_layout)

    def count_func(self):
        self.my_thread.is_on = True         # 5
        self.my_thread.start()

    def set_label_func(self, num):
        self.label.setText(num)

    def stop_count_func(self):              # 4
        self.my_thread.is_on = False
        self.my_thread.count = 0


class MyThread(QThread):
    my_signal = pyqtSignal(str)

    def __init__(self):
        super(MyThread, self).__init__()
        self.count = 0
        self.is_on = True   # 1

    def run(self):
        while self.is_on:   # 2
            print(self.count)
            self.count += 1
            self.my_signal.emit(str(self.count))
            self.sleep(1)


if __name__ == '__main__':
    app = QApplication(sys.argv)
    demo = Demo()
    demo.show()
    sys.exit(app.exec_())

1-2. 要控制my_thread线程的启动和停止,只需要控制run函数中的while循环即可,is_on变量的作用就是如此;

3. 实例化一个停止按钮,并将信号和槽连接起来,该按钮用于停止my_thread线程;

4. 在槽函数中我们将my_thread中的is_on变量设为False,并将count变量重设为0,此时发现界面上的数字已经不会再改变,my_thread线程运行结束;

5. 在count_func()槽函数中,我们需要重新将is_on变量设为True,不然调用start()方法的话run()函数中循环会直接退出。

运行截图如下:

python QT5 多线程 等待 pyqt5多线程崩溃_PyQt5_04

27.2 简单爬虫实战

在爬虫应用中,我们通常会把耗时的爬虫操作放在自定义的多线程中,并将爬取下来的内容保存到文件里。下面我们用python3自带的urllib模块来获取python官网首页的源码并保存到txt文件中(一些读者可能还没学爬虫,不过没关系,这里只是用到非常简单的例子做个示范,重点还是要学会怎么运用PyQt5的多线程)。

获取python官网首页源码的操作如下:

import urllib.request
response = urllib.request.urlopen('https://www.python.org')
print(response.read().decode('utf-8'))

非常简单,导入urllib库中的request的模块,接下来调用urlopen()方法传入需要请求的网址即可。获取到的网页响应就是response,然后调用read()方法读取响应内容并通过decode()方法解码。

输出内容如下:

python QT5 多线程 等待 pyqt5多线程崩溃_qt_05

我们将该操作放入到放入到自定义的CrawlThread类中,同时加上保存到文件的操作:

class CrawlThread(QThread):
    status_signal = pyqtSignal(str)

    def __init__(self):
        super(CrawlThread, self).__init__()

    def run(self):
        self.status_signal.emit('Crawling')
        response = urllib.request.urlopen('https://www.python.org')
        self.status_signal.emit('Saving')
        with open('python.txt', 'w') as f:
            f.write(response.read().decode('utf-8'))
        self.status_signal.emit('Done')

status_signal用来传递状态字符串,这样可以让用户知道进度如何。那接下来就只需要在Demo类中实例化该线程,并且将线程中的信号和槽进行连接即可。完整代码如下:

import sys
import urllib.request
from PyQt5.QtCore import Qt, QThread, pyqtSignal
from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QLabel, QVBoxLayout


class Demo(QWidget):
    def __init__(self):
        super(Demo, self).__init__()
        self.button = QPushButton('Start', self)
        self.button.clicked.connect(self.start_func)
        self.label = QLabel('Ready to do', self)
        self.label.setAlignment(Qt.AlignCenter)

        self.crawl_thread = CrawlThread()
        self.crawl_thread.status_signal.connect(self.status_func)

        self.v_layout = QVBoxLayout()
        self.v_layout.addWidget(self.label)
        self.v_layout.addWidget(self.button)
        self.setLayout(self.v_layout)

    def start_func(self):
        self.crawl_thread.start()

    def status_func(self, status):
        self.label.setText(status)


class CrawlThread(QThread):
    status_signal = pyqtSignal(str)

    def __init__(self):
        super(CrawlThread, self).__init__()

    def run(self):
        self.status_signal.emit('Crawling')
        response = urllib.request.urlopen('https://www.python.org')
        self.status_signal.emit('Saving')
        with open('python.txt', 'w') as f:
            f.write(response.read().decode('utf-8'))
        self.status_signal.emit('Done')


if __name__ == '__main__':
    app = QApplication(sys.argv)
    demo = Demo()
    demo.show()
    sys.exit(app.exec_())

运行截图如下:

python QT5 多线程 等待 pyqt5多线程崩溃_PyQt5_06

python QT5 多线程 等待 pyqt5多线程崩溃_python QT5 多线程 等待_07

python QT5 多线程 等待 pyqt5多线程崩溃_PyQt多线程_08

python QT5 多线程 等待 pyqt5多线程崩溃_python QT5 多线程 等待_09

运行完毕后可以发现项目目录下多了个python.txt文件,里面就是python官网首页的源码了。

27.3 小结

1. 为避免程序界面卡死,我们应该将复杂耗时的操作放入到自定义的线程中,重新定义run函数即可;

2. 若要实时获取线程中的某个值,可以通过信号来传递。