PyQt5中的QThread + Signal不起作用 + GUI冻结问题

15

我试图用imap库创建一个邮箱检查器,在没有GUI的情况下,使用Python、队列和多线程运行得很好。

但是当我尝试加入GUI时,我所做的每个函数都会使GUI冻结直到完成为止。

我尝试了许多来自各种文档(添加QThread,信号,光标等)和教程,但都对我无效。

有人能帮助我理解如何在运行函数时设置或附加文本到QTextEdit,因为它只能在完成后工作。

这是我的代码:

class Checker(QtCore.QThread):
    signal = QtCore.pyqtSignal(object)

    def __init__(self, lignesmailtocheck):
        QtCore.QThread.__init__(self)
        self.lignesmailtocheck = lignesmailtocheck

    def run(self):
            lignemailtocheck = self.lignesmailtocheck.strip()                        
            maillo, passo = lignemailtocheck.split(":",1)
            debmail, finmail = maillo.split("@",1)
            setimap =["oultook.com:imap-mail.outlook.com", "gmail.com:imap.gmail.com"]
            for lignesimaptocheck in sorted(setimap):
                    ligneimaptocheck = lignesimaptocheck.strip()
                    fai, imap = ligneimaptocheck.split(":",1)                                
                    if finmail == fai:
                            passo0 = passo.rstrip()
                            try :
                                    mail = imaplib.IMAP4_SSL(imap)
                                    mail.login(maillo, passo)
                                    mailboxok = open("MailBoxOk.txt", "a+", encoding='utf-8', errors='ignore')
                                    mailboxok.write(maillo+":"+passo+"\n")
                                    mailboxok.close()
                                    totaly = maillo+":"+passo0+":"+imap                                
                                    print(maillo+":"+passo+"\n")

                                    self.send_text.emit(totaly)
                                    time.sleep(1)
                            except imaplib.IMAP4.error:                          
                                           print ("LOGIN FAILED!!! ")
class Ui_Form(object):
    def setupUi(self, Form):
        Form.setObjectName("Form")
        Form.resize(400, 300)

        self.pushButton = QtWidgets.QPushButton(Form)
        self.pushButton.setGeometry(QtCore.QRect(150, 210, 75, 23))
        self.pushButton.setObjectName("pushButton")
        self.pushButton.clicked.connect(self.gogogo)

        self.openliste = QtWidgets.QToolButton(Form)
        self.openliste.setGeometry(QtCore.QRect(40, 110, 71, 21))
        self.openliste.setObjectName("openliste")

        self.textEdit = QtWidgets.QTextEdit(Form)
        self.textEdit.setGeometry(QtCore.QRect(170, 50, 201, 121))
        self.textEdit.setObjectName("textEdit")

        self.progressBar = QtWidgets.QProgressBar(Form)
        self.progressBar.setGeometry(QtCore.QRect(10, 260, 381, 23))
        self.progressBar.setValue(0)
        self.progressBar.setObjectName("progressBar")

        self.retranslateUi(Form)
        QtCore.QMetaObject.connectSlotsByName(Form)

    def retranslateUi(self, Form):
        _translate = QtCore.QCoreApplication.translate
        Form.setWindowTitle(_translate("Form", "Form"))
        self.pushButton.setText(_translate("Form", "PushButton"))
        self.openliste.setText(_translate("Form", "..."))

    def gogogo(self):

        mailtocheck = open('File/toCheck.txt', 'r', encoding='utf-8', errors='ignore').readlines()        
        setmailtocheck = set(mailtocheck)
        for lignesmailtocheck in sorted(setmailtocheck):
            checker = Checker(lignesmailtocheck)

            thread = QThread()
            checker.moveToThread(thread)
            # connections after move so cross-thread:
            thread.started.connect(checker.run)
            checker.signal.connect(self.checkedok)
            thread.start()

    def checkedok(self, data):
        print(data)
        self.textEdit.append(data)
if __name__ == "__main__":
    app = QtWidgets.QApplication(sys.argv)
    Form = QtWidgets.QWidget()
    ui = Ui_Form()
    ui.setupUi(Form)
    Form.show()
    sys.exit(app.exec_())

您必须描述重现问题的步骤。当我运行此代码(删除了几行导入语句后),并单击PushButton时,我看到YY被打印到控制台,但没有冻结。请发布导致问题的代码,并提供导致冻结的步骤,我们可以帮助您解决该问题。 - Oliver
抱歉,我尝试了很多不同的方法来使它工作...步骤如下:点击按钮后,程序会在文件“File/toCheck.txt”中获取登录信息,格式为email:pass,并将登录信息发送到qtreah。在线程中,使用imap尝试登录,如果登录成功,则QtextEdit打印有效的登录信息。如果您在文件中尝试2-3个email:pass,则没有时间看到它是否正确工作,但是如果您尝试很多,则GUI会冻结,并且只有在完成后才会打印到QtextEdit。 - kenjii himura
你在那个循环中创建了多少个线程?Python GIL 防止线程并发执行,如果有大量线程,可能会减少在主线程(重新绘制 GUI)中花费的时间,导致看起来程序被冻结而没有更新。 - three_pineapples
在这段代码中,我按行发送1个线程到.txt文件。但是它可以有2、10、100或1000个,GUI仍然会冻结。我首先尝试使用Python线程和队列来实现相同的功能,但我遇到了与QThread相同的问题。即使发出信号并更新GUI,GUI仍然会冻结,只有在完成后才会更新。请告诉我我做错了什么。 - kenjii himura
我建议从QObject派生Checker线程并创建一个QThread对象,然后将checker.moveTo(thread)。 - Oliver
我在这个链接中找到了关于移动线程的内容,但是我无法在我的GUI中使其正常工作,它仍然会一直冻结直到结束...你能告诉我更多关于这个的信息吗? - kenjii himura
3个回答

60

由于在PyQt中使用QThread经常会有问题,与您类似的问题,这里有一个示例展示了如何在PyQt中正确地使用线程。希望这个示例可以成为类似问题的答案,因此我花费了比平时更多的时间来准备它。

该示例创建了许多工作对象,在非主线程中执行,并通过Qt的异步信号与主(即GUI)线程通信。

import time
import sys

from PyQt5.QtCore import QObject, QThread, pyqtSignal, pyqtSlot
from PyQt5.QtWidgets import QApplication, QPushButton, QTextEdit, QVBoxLayout, QWidget


def trap_exc_during_debug(*args):
    # when app raises uncaught exception, print info
    print(args)


# install exception hook: without this, uncaught exception would cause application to exit
sys.excepthook = trap_exc_during_debug


class Worker(QObject):
    """
    Must derive from QObject in order to emit signals, connect slots to other signals, and operate in a QThread.
    """

    sig_step = pyqtSignal(int, str)  # worker id, step description: emitted every step through work() loop
    sig_done = pyqtSignal(int)  # worker id: emitted at end of work()
    sig_msg = pyqtSignal(str)  # message to be shown to user

    def __init__(self, id: int):
        super().__init__()
        self.__id = id
        self.__abort = False

    @pyqtSlot()
    def work(self):
        """
        Pretend this worker method does work that takes a long time. During this time, the thread's
        event loop is blocked, except if the application's processEvents() is called: this gives every
        thread (incl. main) a chance to process events, which in this sample means processing signals
        received from GUI (such as abort).
        """
        thread_name = QThread.currentThread().objectName()
        thread_id = int(QThread.currentThreadId())  # cast to int() is necessary
        self.sig_msg.emit('Running worker #{} from thread "{}" (#{})'.format(self.__id, thread_name, thread_id))

        for step in range(100):
            time.sleep(0.1)
            self.sig_step.emit(self.__id, 'step ' + str(step))

            # check if we need to abort the loop; need to process events to receive signals;
            app.processEvents()  # this could cause change to self.__abort
            if self.__abort:
                # note that "step" value will not necessarily be same for every thread
                self.sig_msg.emit('Worker #{} aborting work at step {}'.format(self.__id, step))
                break

        self.sig_done.emit(self.__id)

    def abort(self):
        self.sig_msg.emit('Worker #{} notified to abort'.format(self.__id))
        self.__abort = True


class MyWidget(QWidget):
    NUM_THREADS = 5

    # sig_start = pyqtSignal()  # needed only due to PyCharm debugger bug (!)
    sig_abort_workers = pyqtSignal()

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

        self.setWindowTitle("Thread Example")
        form_layout = QVBoxLayout()
        self.setLayout(form_layout)
        self.resize(400, 800)

        self.button_start_threads = QPushButton()
        self.button_start_threads.clicked.connect(self.start_threads)
        self.button_start_threads.setText("Start {} threads".format(self.NUM_THREADS))
        form_layout.addWidget(self.button_start_threads)

        self.button_stop_threads = QPushButton()
        self.button_stop_threads.clicked.connect(self.abort_workers)
        self.button_stop_threads.setText("Stop threads")
        self.button_stop_threads.setDisabled(True)
        form_layout.addWidget(self.button_stop_threads)

        self.log = QTextEdit()
        form_layout.addWidget(self.log)

        self.progress = QTextEdit()
        form_layout.addWidget(self.progress)

        QThread.currentThread().setObjectName('main')  # threads can be named, useful for log output
        self.__workers_done = None
        self.__threads = None

    def start_threads(self):
        self.log.append('starting {} threads'.format(self.NUM_THREADS))
        self.button_start_threads.setDisabled(True)
        self.button_stop_threads.setEnabled(True)

        self.__workers_done = 0
        self.__threads = []
        for idx in range(self.NUM_THREADS):
            worker = Worker(idx)
            thread = QThread()
            thread.setObjectName('thread_' + str(idx))
            self.__threads.append((thread, worker))  # need to store worker too otherwise will be gc'd
            worker.moveToThread(thread)

            # get progress messages from worker:
            worker.sig_step.connect(self.on_worker_step)
            worker.sig_done.connect(self.on_worker_done)
            worker.sig_msg.connect(self.log.append)

            # control worker:
            self.sig_abort_workers.connect(worker.abort)

            # get read to start worker:
            # self.sig_start.connect(worker.work)  # needed due to PyCharm debugger bug (!); comment out next line
            thread.started.connect(worker.work)
            thread.start()  # this will emit 'started' and start thread's event loop

        # self.sig_start.emit()  # needed due to PyCharm debugger bug (!)

    @pyqtSlot(int, str)
    def on_worker_step(self, worker_id: int, data: str):
        self.log.append('Worker #{}: {}'.format(worker_id, data))
        self.progress.append('{}: {}'.format(worker_id, data))

    @pyqtSlot(int)
    def on_worker_done(self, worker_id):
        self.log.append('worker #{} done'.format(worker_id))
        self.progress.append('-- Worker {} DONE'.format(worker_id))
        self.__workers_done += 1
        if self.__workers_done == self.NUM_THREADS:
            self.log.append('No more workers active')
            self.button_start_threads.setEnabled(True)
            self.button_stop_threads.setDisabled(True)
            # self.__threads = None

    @pyqtSlot()
    def abort_workers(self):
        self.sig_abort_workers.emit()
        self.log.append('Asking each worker to abort')
        for thread, worker in self.__threads:  # note nice unpacking by Python, avoids indexing
            thread.quit()  # this will quit **as soon as thread event loop unblocks**
            thread.wait()  # <- so you need to wait for it to *actually* quit

        # even though threads have exited, there may still be messages on the main thread's
        # queue (messages that threads emitted before the abort):
        self.log.append('All threads exited')


if __name__ == "__main__":
    app = QApplication([])

    form = MyWidget()
    form.show()

    sys.exit(app.exec_())

了解 PyQt 中的多线程编程必须理解以下主要概念:

  • Qt 线程有其自己的事件循环(每个线程具有特定的事件循环)。主线程,也称为 GUI 线程,也是一个 QThread,其事件循环由该线程管理。
  • 线程之间的信号是通过接收线程的事件循环异步传输的。因此,GUI 或任何线程的响应能力 = 处理事件的能力。例如,如果线程在一个函数循环中繁忙,则无法处理事件,因此它不会响应来自 GUI 的信号,直到函数返回。
  • 如果线程中的工作对象(方法)可能根据 GUI 发出的信号改变其行动方向(例如,中断循环或等待),则必须在 QApplication 实例上调用 processEvents()。这将允许 QThread 处理事件,从而响应来自 GUI 的异步信号。请注意,QApplication.instance().processEvents() 似乎会在每个线程上调用 processEvents(),如果不希望如此,则 QThread.currentThread().processEvents() 是一个有效的替代方法。
  • 调用 QThread.quit() 不会立即退出其事件循环:它必须等待当前正在执行的槽(如果有的话)返回。因此,一旦告诉线程退出,您必须在其上调用 wait()。因此,中止工作线程通常涉及向其发出信号(通过自定义信号)以停止正在进行的任何操作:这需要在 GUI 对象上使用自定义信号,并将该信号连接到工作线程的槽,工作方法必须调用线程的 processEvents(),以允许发出的信号在执行工作时到达槽。

2
这正是我需要理解的内容,谢谢你。 - kenjii himura
1
有人弄清楚了为什么这个例子在第一次完成后再次运行线程会导致崩溃吗? - Zach Schulze
2
我命名你为PyQt线程之神Freddy McThread!说真的,感谢你提供如此详尽的示例,这正是我所需要的。 - Valentin B.
2
Zach,问题在于第二次运行时,start_threads方法在调用self.__threads = []时会失去对先前线程列表的引用。解决方法是仅在没有存储先前线程列表时才创建空列表:如果没有self.__threads,则self.__threads = []。 - Sergio
3
在线程执行完100个步骤后,这些线程的事件循环仍在运行。如Sergio所说,self.__threads = []会使这些线程被垃圾回收,如果这些线程仍在运行,则会出现错误。如果你想结束这些线程以进行垃圾回收,你必须在它们上面调用thread.quit()thread.wait();例如,在on_worker_done()的末尾可以这样做。如果你只想在现有线程旁边添加5个线程,那么一个解决方案就是像Sergio建议的那样去做。 - S. Kirby
显示剩余7条评论

2

我无法进行测试,因为我的系统上没有setimap。由于CheckerThread不再是一个线程(它只是“存在”于一个线程中),所以我将其重命名为Checker

class Checker(QtCore.QObject):

然后只需使用以下内容替换gogogo(self)中循环的内容:

for lignesmailtocheck in sorted(setmailtocheck):
    checker = Checker(lignesmailtocheck)

    thread = QThread()
    checker.moveToThread(thread)
    # connections after move so cross-thread:
    thread.started.connect(checker.run)
    checker.signal.connect(self.checkedok)
    thread.start()

    self.threads.append(thread)

在使用pyqtSlot时,将runcheckedok都装饰起来通常是一个好主意。

关于Qt线程的SO答案非常方便,可以提醒您注意细节(请注意,它使用旧式连接--您必须将C++ connect(sender,SIGNAL(sig),receiver,SLOT(slot))翻译为PyQt5 sender.sig.connect(receiver.slot))。


谢谢您提供的所有信息,它们帮助我理解了。 - kenjii himura
它似乎可以工作,但我仍然收到一个无法处理的错误,现在错误是:“pyqt qthread在线程仍在运行时被销毁”。 - kenjii himura
在文件fonc中,有一个带有imap的集合:setimap = ["oultook.com:imap-mail.outlook.com", "gmail.com:imap.gmail.com"] - kenjii himura
我只是不想给所有读者我的邮箱的登录信息,就像我在帖子的第一行所说的那样:“我正在尝试制作一个邮箱检查器”,所以检查器意味着要知道密码是否正确,如果正确,代码将添加到文本编辑器中,但我的任何尝试都失败了,使用我的初始代码仅当函数完成时才添加,而使用您的代码时我得到了这个错误:QThread在线程仍在运行时被销毁。 - kenjii himura
很高兴听到这个消息。您应该发布一个答案,以造福所有人。 - Oliver
显示剩余4条评论

0

抱歉回复晚了,但这是一种可以解决类似问题的技术。

问题很明显。GUI会因为其线程需要执行其他任务而冻结。 从PyQt的角度来看,下面给出了一个抽象化的解决方案:

  1. 创建一个继承自threading.Thread的类,它将成为工作线程。
  2. 将队列(queue.Queue)作为通信手段传递给构造函数。
  3. 您可以从GUI线程启动工作线程,并使用队列传递消息。
  4. 为了使GUI线程读取消息,创建一个定时器(QTimer),设置您选择的间隔并注册回调函数。在回调函数中读取队列。

示例代码:

class Worker(threading.Thread):

    def __init__(self, queue):
        super().init()
        self.queue = queue

    def run(self):
         # Your code that uses self.queue.put(object)

class Gui:

    def __init__(self):
        self.timer = Qtimer()
        self.timer.setInterval(milliseconds)
        self.timer.timeout.connect(self.read_data)


    def start_worker(self):
        self.queue = queue.Queue()

        thr = Worker(self.queue)

        thr.start()


    def read_data(self):
        data = self.queue.get()

self.timer.timeout.connect注册回调函数。


无法使QTimer工作,但这个解决方案绝对是最简单且运行得很好的。 - Loïc
虽然简单,但这种临时解决方案在一般情况下绝对不会像预期的那样运行。它能够轻微地工作是由于线程安全的 queue.Queue 类的证明。大多数标准 Python 类和对象都不是线程安全的;这包括原始标量(例如 boolintstr)。利用低级 Python 原语而不是高级 Qt 抽象(例如 QThreadQConcurrent)的多线程 Qt 应用程序绝对是错误的做法。相反,您应该始终在移动到 QThread 中的基于 QObject 的 worker 上使用信号槽连接。 - Cecil Curry

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接