Concurrent processes in PySide2/PyQt5 applications

January 03 2020

Background

Qt is a popular GUI toolkit for writing desktop applications. It has bindings for Python using either PySide2 or PyQt5 (which use essentially identical syntax). This means you can code Qt apps without needing to know C++. One of the challenging aspects in all GUI tookits is running processes concurrently while still using your application. This is because the main GUI runs in a thread and if you launch your process in the same thread it will block all user interaction until it’s finished. That’s a problem if your task runs more than a second or two. The solution is to run your jobs in other threads. The recommended solution in Qt is to use classes called QRunnable and QThreadPool. QThreadPool handles queuing and execution of workers. Workers are QRunnable objects containing the process you want to run. You place the code to run in the run() method of the QRunnable class.

This method is mostly taken from the example by Martin Fitzpatrick at learnpyqt.com. It’s also explained in more detail there.

Imports

Imports for PySide2 are somewhat different than the PyQt5 version.

import sys,os,subprocess,time,traceback
import random
from PySide2 import QtCore
from PySide2.QtWidgets import *
from PySide2.QtGui import *

Worker and ThreadPool

We make a Worker by sub-classing QRunnable, then placing the code wewish you execute within the run() method. It also defines Signals that are used to pass data out of the running Worker. So you can emit progress changes to the GUI. You probably shouldn’t use this to pass very large amounts of data back to the GUI though.

class Worker(QtCore.QRunnable):
    """Worker thread for running background tasks."""

    def __init__(self, fn, *args, **kwargs):
        super(Worker, self).__init__()
        # Store constructor arguments (re-used for processing)
        self.fn = fn
        self.args = args
        self.kwargs = kwargs
        self.signals = WorkerSignals()
        self.kwargs['progress_callback'] = self.signals.progress

    @QtCore.Slot()
    def run(self):
        try:
            result = self.fn(
                *self.args, **self.kwargs,
            )
        except:
            traceback.print_exc()
            exctype, value = sys.exc_info()[:2]
            self.signals.error.emit((exctype, value, traceback.format_exc()))
        else:
            self.signals.result.emit(result)
        finally:
            self.signals.finished.emit()

class WorkerSignals(QtCore.QObject):
    """
    Defines the signals available from a running worker thread.
    Supported signals are:
    finished
        No data
    error
        `tuple` (exctype, value, traceback.format_exc() )
    result
        `object` data returned from processing, anything
    """
    finished = QtCore.Signal()
    error = QtCore.Signal(tuple)
    result = QtCore.Signal(object)
    progress = QtCore.Signal(int)

The application

This is a small dialog to illustrate the principle. We make a QDialog window and add start and stop buttons and a progress bar. The run_threaded_process() method defined here can run any process in a thread. It instantiates a Worker and then adds it to the threadpool. progress_fn() and on_complete() are provided as arguments and connected to the signals of the worker. Note that to stop the process I just added a flag that is set when the stop button is pressed. Finally, test() is the actual function we run. It could be anything but in this case it just runs a loop calculating randon numbers. The loop inside test() is then interrupted. This was the only way I knew how to do it but it may not be the best method. Also notice that if you keep pressing the start button new processes will run and more numbers appear. The stop button stops all of them at once.

class App(QDialog):
    """GUI Application using PySide2 widgets"""
    def __init__(self):
        QDialog.__init__(self)
        self.setAttribute(QtCore.Qt.WA_DeleteOnClose)
        self.setGeometry(QtCore.QRect(200, 200, 500, 500))
        self.threadpool = QtCore.QThreadPool()

        layout = QVBoxLayout(self)
        self.setLayout(layout)
        self.startbutton = QPushButton('START')
        self.startbutton.clicked.connect(self.run)
        layout.addWidget(self.startbutton)
        self.stopbutton = QPushButton('STOP')
        self.stopbutton.clicked.connect(self.stop)
        layout.addWidget(self.stopbutton)
        self.progressbar = QProgressBar(self)
        self.progressbar.setRange(0,1)
        layout.addWidget(self.progressbar)
        self.info = QTextEdit(self)
        self.info.append('Hello')
        layout.addWidget(self.info)
        return

    def progress_fn(self, msg):
        """Update progress"""

        self.info.append(str(msg))        
        return

    def run_threaded_process(self, process, progress_fn, on_complete):
        """Execute a function in the background with a worker"""

        worker = Worker(fn=process)
        self.threadpool.start(worker)
        worker.signals.finished.connect(on_complete)
        worker.signals.progress.connect(progress_fn)
        self.progressbar.setRange(0,0)
        return

    def run(self):
        """call process"""

        self.stopped = False
        self.run_threaded_process(self.test, self.progress_fn, self.completed)

    def stop(self):
        self.stopped=True
        return

    def completed(self):
        self.progressbar.setRange(0,1)
        return

    def test(self, progress_callback):
        """Do some process here"""

        total = 500
        for i in range(0,total):
            time.sleep(.2)
            x = random.randint(1,1e4)
            progress_callback.emit(x)
            if self.stopped == True:
                return