在具有计算量大的后台进程的 Qt 中显示进度条

Show progressbar in Qt with computationally heavy background process

我正在构建一个让用户导出 his/her 工作的应用程序。这是一个计算量很大的过程,持续一分钟左右,在此期间我想显示一个进度条(并使 UI 的其余部分无响应)。

我已经尝试了下面的实现,它适用于非计算昂贵的后台进程(例如等待 0.1 秒)。但是,对于 CPU 繁重的进程,UI 变得非常迟钝且没有响应(但并非完全没有响应)。

知道如何解决这个问题吗?

import sys
import time

from PySide2 import QtCore
from PySide2.QtCore import Qt
import PySide2.QtWidgets as QtWidgets


class MainWindow(QtWidgets.QMainWindow):
    """Main window, with one button for exporting stuff"""

    def __init__(self, parent=None):
        super().__init__(parent)
        central_widget = QtWidgets.QWidget(self)
        layout = QtWidgets.QHBoxLayout(self)
        button = QtWidgets.QPushButton("Press me...")
        button.clicked.connect(self.export_stuff)
        layout.addWidget(button)
        central_widget.setLayout(layout)
        self.setCentralWidget(central_widget)

    def export_stuff(self):
        """Opens dialog and starts exporting"""
        some_window = MyExportDialog(self)
        some_window.exec_()


class MyAbstractExportThread(QtCore.QThread):
    """Base export thread"""
    change_value = QtCore.Signal(int)

    def run(self):
        cnt = 0
        while cnt < 100:
            cnt += 1
            self.operation()
            self.change_value.emit(cnt)

    def operation(self):
        pass


class MyExpensiveExportThread(MyAbstractExportThread):

    def operation(self):
        """Something that takes a lot of CPU power"""
        some_val = 0
        for i in range(1000000):
            some_val += 1


class MyInexpensiveExportThread(MyAbstractExportThread):

    def operation(self):
        """Something that doesn't take a lot of CPU power"""
        time.sleep(.1)


class MyExportDialog(QtWidgets.QDialog):
    """Dialog which does some stuff, and shows its progress"""

    def __init__(self, parent=None):
        super().__init__(parent, Qt.WindowCloseButtonHint)
        self.setWindowTitle("Exporting...")
        layout = QtWidgets.QHBoxLayout()
        self.progress_bar = self._create_progress_bar()
        layout.addWidget(self.progress_bar)
        self.setLayout(layout)
        self.worker = MyInexpensiveExportThread()  # Works fine
        # self.worker = MyExpensiveExportThread()  # Super laggy
        self.worker.change_value.connect(self.progress_bar.setValue)
        self.worker.start()
        self.worker.finished.connect(self.close)

    def _create_progress_bar(self):
        progress_bar = QtWidgets.QProgressBar(self)
        progress_bar.setMinimum(0)
        progress_bar.setMaximum(100)
        return progress_bar


if __name__ == "__main__":
    app = QtWidgets.QApplication()
    window = MainWindow()
    window.show()
    sys.exit(app.exec_())

您应该使用 asyncqt,它是 PySide2 的 quamash 衍生产品。我在您的代码中保留了 QThread 实现,并使用 QEventLoop 进行了修改。作为最终解决方案,您应该考虑像在 asyncqt github 页面中那样使用 run_in_executor 更改 QThread 实现。

import sys
import time
import asyncio
from PySide2.QtCore import (Qt, Signal, Slot, QObject, QThread)
from PySide2.QtWidgets import (QApplication, QProgressBar, QWidget, QHBoxLayout, QPushButton, QMainWindow, QDialog)
from asyncqt import (QEventLoop, QThreadExecutor)


class MainWindow(QMainWindow):
    """Main window, with one button for exporting stuff"""

    def __init__(self, parent=None):
        super().__init__(parent)
        central_widget = QWidget(self)
        layout = QHBoxLayout(self)
        button = QPushButton("Press me...")
        button.clicked.connect(self.export_stuff)
        layout.addWidget(button)
        central_widget.setLayout(layout)
        self.setCentralWidget(central_widget)

    def export_stuff(self):
        """Opens dialog and starts exporting"""
        some_window = MyExportDialog(self)
        some_window.exec_()


class MyAbstractExportThread(QThread):
    """Base export thread"""
    change_value = Signal(int)
    loop = asyncio.get_event_loop()

    def run(self):
        cnt = 0
        while cnt < 100:
            cnt += 1
            self.loop.run_until_complete(self.operation())
            self.change_value.emit(cnt)

    @asyncio.coroutine
    def operation(self):
        pass


class MyExpensiveExportThread(MyAbstractExportThread):

    @asyncio.coroutine
    def operation(self):
        """Something that takes a lot of CPU power"""
        some_val = 0
        for i in range(10000000):
            some_val += 1


class MyInexpensiveExportThread(MyAbstractExportThread):

    def operation(self):
        """Something that doesn't take a lot of CPU power"""
        time.sleep(.1)


class MyExportDialog(QDialog):
    """Dialog which does some stuff, and shows its progress"""

    def __init__(self, parent=None):
        super().__init__(parent, Qt.WindowCloseButtonHint)
        self.loop = asyncio.get_event_loop()
        self.setWindowTitle("Exporting...")
        layout = QHBoxLayout()
        self.progress_bar = self._create_progress_bar()
        layout.addWidget(self.progress_bar)
        self.setLayout(layout)
        # self.worker = MyInexpensiveExportThread()  # Works fine
        self.worker = MyExpensiveExportThread()  # Super laggy
        self.worker.change_value.connect(self.set_progressbar)
        self.worker.finished.connect(self.close)

        with QThreadExecutor(1) as qt_thread_executor:
            loop.run_in_executor(qt_thread_executor, self.worker.start)

    def _create_progress_bar(self):
        progress_bar = QProgressBar(self)
        progress_bar.setMinimum(0)
        progress_bar.setMaximum(100)
        return progress_bar

    @Slot(int)
    def set_progressbar(self, value):
        self.loop.call_soon_threadsafe(self.progress_bar.setValue, value)


if __name__ == "__main__":
    app = QApplication()
    loop = QEventLoop(app)
    asyncio.set_event_loop(loop)
    window = MainWindow()
    window.show()
    loop.run_forever(sys.exit(app.exec_()))

谢谢 oetzi。这样效果更好,但仍然会稍微拖累 UI。我做了一些研究,并为那些感兴趣的人找到了以下内容。

在 运行 使用线程处理计算量大的进程时显示响应式用户界面的困难源于这样一个事实,在这种情况下结合了所谓的 IO 绑定线程(即 GUI),带有 CPU 绑定线程(即计算)。对于 IO-bound 进程,完成所需的时间取决于线程必须等待输入或输出(例如,用户单击事物或计时器)这一事实。相比之下,完成 CPU 绑定过程所需的时间受执行该过程的处理单元的功率限制。

原则上,在Python中混合这些类型的线程应该没有问题。尽管 GIL 强制在单个实例中只有一个线程 运行ning,但操作系统实际上将进程拆分为更小的指令,并在它们之间切换。如果一个线程是 运行ning,它有 GIL 并执行它的一些指令。在固定的时间后,它需要释放 GIL。一旦释放,GIL 可以安排激活任何其他 'runnable' 线程——包括刚刚释放的线程。

然而,问题在于这些线程的调度。这里的事情对我来说变得有点模糊,但基本上发生的是 CPU 绑定线程似乎主导了这个选择,从我可以收集到的由于称为 "convey effect" 的进程。因此,当 运行 在后台绑定 CPU 线程时,Qt GUI 的不稳定和不可预测的行为。

我发现了一些有趣的阅读 material:

所以...这非常好,我们如何解决这个问题?

最后,我设法使用多处理得到了我想要的东西。这允许您实际上 运行 一个与 GUI 并行的进程,而不是以顺序方式。这确保 GUI 保持响应,就像在后台没有 CPU 绑定进程一样。

多处理有很多自身的困难,例如,在进程之间来回发送信息是通过在管道中发送腌制对象来完成的。但是,就我而言,最终结果确实更好。

下面我放了一段代码,展示了我的解决方案。它包含一个名为 ProgressDialog 的 class,它提供了一个简单的 API 用于使用您自己的 CPU 绑定进程进行设置。

"""Contains class for executing a long running process (LRP) in a separate
process, while showing a progress bar"""

import multiprocessing as mp

from PySide2 import QtCore
from PySide2.QtCore import Qt
import PySide2.QtWidgets as QtWidgets


class ProgressDialog(QtWidgets.QDialog):
    """Dialog which performs a operation in a separate process, shows a
    progress bar, and returns the result of the operation

    Parameters
    ----
    title: str
        Title of the dialog
    operation: callable
        Function of the form f(conn, *args) that will be run
    args: tuple
        Additional arguments for operation
    parent: QWidget
        Parent widget

    Returns
    ----
    result: int
        The result is an integer. A 0 represents successful completion, or
        cancellation by the user. Negative numbers represent errors. -999
        is reserved for any unforeseen uncaught error in the operation.

    Examples
    ----
    The function passed as the operation parameter should be of the form
    ``f(conn, *args)``. The conn argument is a Connection object, used to
    communicate the progress of the operation to the GUI process. The
    operation can pass its progress with a number between 0 and 100, using
    ``conn.send(i)``. Once the process is finished, it should send 101.
    Error handling is done by passing negative numbers.

    >>> def some_function(conn, *args):
    >>>     conn.send(0)
    >>>     a = 0
    >>>     try:
    >>>         for i in range(100):
    >>>                 a += 1
    >>>                 conn.send(i + 1)  # Send progress
    >>>     except Exception:
    >>>         conn.send(-1)  # Send error code
    >>>     else:
    >>>         conn.send(101)  # Send successful completion code

    Now we can use an instance of the ProgressDialog class within any 
    QtWidget to execute the operation in a separate process, show a progress 
    bar, and print the error code:

    >>> progress_dialog = ProgressDialog("Running...", some_function, self)
    >>> progress_dialog.finished.connect(lambda err_code: print(err_code))
    >>> progress_dialog.open()
    """

    def __init__(self, title, operation, args=(), parent=None):
        super().__init__(parent, Qt.WindowCloseButtonHint)
        self.setWindowTitle(title)
        self.progress_bar = QtWidgets.QProgressBar(self)
        self.progress_bar.setValue(0)
        layout = QtWidgets.QHBoxLayout()
        layout.addWidget(self.progress_bar)
        self.setLayout(layout)

        # Create connection pipeline
        self.parent_conn, self.child_conn = mp.Pipe()

        # Create process
        args = (self.child_conn, *args)
        self.process = mp.Process(target=operation, args=args)

        # Create status emitter
        self.progress_emitter = ProgressEmitter(self.parent_conn, self.process)
        self.progress_emitter.signals.progress.connect(self.slot_update_progress)
        self.thread_pool = QtCore.QThreadPool()

    def slot_update_progress(self, i):
        if i < 0:
            self.done(i)
        elif i == 101:
            self.done(0)
        else:
            self.progress_bar.setValue(i)

    def open(self):
        super().open()
        self.process.start()
        self.thread_pool.start(self.progress_emitter)

    def closeEvent(self, *args):
        self.progress_emitter.running = False
        self.process.terminate()
        super().closeEvent(*args)


class ProgressEmitter(QtCore.QRunnable):
    """Listens to status of process"""

    class ProgressSignals(QtCore.QObject):
        progress = QtCore.Signal(int)

    def __init__(self, conn, process):
        super().__init__()
        self.conn = conn
        self.process = process
        self.signals = ProgressEmitter.ProgressSignals()
        self.running = True

    def run(self):
        while self.running:
            if self.conn.poll():
                progress = self.conn.recv()
                self.signals.progress.emit(progress)
                if progress < 0 or progress == 101:
                    self.running = False
            elif not self.process.is_alive():
                self.signals.progress.emit(-999)
                self.running = False