Multithreading in PyQT for a responsive GUI
Today I will go through a simple example of how to use threading in PyQT. Many times I needed my GUI to be responsive while heavy lines of code were executed. What usually happens, is that you have your main application which is hosting your GUI, you are then starting a function that might loop through a huge dataset, or maybe that is continuously recording values from a device, and your GUI will freeze while waiting for this chunky function to end.
Further, you might want to display the results of these functions on your GUI, but your GUI is frozen; you cannot move it, you cannot resize it, you cannot display your information until the heavy process ends.
In the example, we will use something simple as a button that, when pressed, will display on the GUI a first message ("waiting...") and after 5 seconds, a second message ("finished waiting!"). So: button press → message1 → 5 sec → message2.
Just to illustrate better the issue with not having a multithread GUI, I will also add another button that when pressed will display increasing numbers. Below, the simple interface:
Here is the first piece of code that implements the scenario in a non-optimal way:
class MyApp(QMainWindow): def __init__(self): super(MyApp, self).__init__() self.ui = Ui_MainWindow() self.ui.setupUi(self) # connect the two buttons to their functions self.ui.b_waitdisplay.clicked.connect(self.wait_and_display) self.ui.b_numbers.clicked.connect(self.increasing_numbers) self.counter = 0 # this will be my counter; # its value will be the displayed number # function that waits 5 seconds and then display a message def wait_and_display(self): self.ui.b_waitdisplay.setEnabled(False) # disable button self.ui.l_waitdisplay.setText("waiting...") time.sleep(5) # wait five seconds self.ui.l_waitdisplay.setText("FINISHED WAITING!") self.ui.b_waitdisplay.setEnabled(True) # re-enable button # function that display an increasing number def increasing_numbers(self): self.ui.l_numbers.setText(str(self.counter)) # display the # current counter value self.counter+=1 # increase the counter
Something really easy. We just need to connect the event of the button pressed, called the time.sleep() function and then update the label on our GUI. The problem is that when you are waiting 5 seconds before displaying the message, by using time.sleep() you are freezing your application until this time passed. So while waiting, you won't be able to interact with your GUI in any way. For instance, the second button, won't work during those 5 seconds.
Note: even if it was impossible to interact with the GUI during those 5 seconds sleep, our actions were recorded by the program and executed as soon as the heavy process finished (note how the numbers jump from 3 to 7). Also, note how message1 ("waiting...") doesn't have the time to be displayed (we could probably solve it by using "QtGui.QGuiApplication.processEvents()" but not the point). Finally, we are not able to see how the first button was disabled and re-enabled after those 5 seconds.
HOW DO WE SOLVE IT?
Let's threading! Basically, our PyQT program has an event queue to manage all the events related to our program. All the thing we ask from the GUI are added to the queue waiting for their turn to be executed (even when we move our program window around, or we try to minimise it, etc.). The more the processes the longer the queue, and the heavier the processes the longer the process next in the queue will have to wait to be processed.
The issue starts when we have a heavy process that takes "forever" to be completed; all the other processes have to wait, and our GUI is frozen! What we want to do is to move the heavy function onto a secondary process that runs in parallel.
Thus, we will use a so-called Worker class, move the heavy function in it, from the main application (that contains our GUI) we will move the heavy function of the worker inside a parallel thread, and let our GUI be free and responsive while checking on the status (and results) of the thread.
1 - Create the Worker object
In practice: let's start by creating a new object in PyQT using the Worker class. The worker will contain a function that contains the heavy process (in our case, the waiting time function) and it will send some signal out (we will catch it later) when executed.
class Worker(QObject): # here initialise variables for output message = pyqtSignal(str) finished = pyqtSignal() def heavy_function(self): self.message.emit("waiting...") time.sleep(5) self.message.emit("FINISHED WAITING!") self.finished.emit()
OK, now we have two signal variables in our Worker class:
message: this is the message we want to display - it is a string
finished: this will be the signal that indicates the thread finished computing
We also have a function "heavy_function" that send message1 ("waiting...") → waits 5 seconds → and finally, sends message2 ("FINISHED WAITING"). To send the messages, we emit two signals. Finally, we send one last signal to communicate that the thread finished.
And that's it for our Worker class.
2 - Create a function to manage the Worker object (and move it to a secondary thread)
Now let's connect it to that main application. To do that, we will create a new function "start_thread" which will...start the thread. We will connect the first button to this function that looks like this:
def start_thread(self): self.ui.b_waitdisplay.setEnabled(False) self.thread = QThread() # create a QThread object self.worker = Worker() # create a Worker object self.worker.moveToThread(self.thread) # move the worker into the thread self.thread.start() # start the thread # connect signals and slots self.thread.started.connect(self.worker.heavy_function) self.thread.finished.connect( lambda: self.ui.b_waitdisplay.setEnabled(True)) self.worker.finished.connect(self.thread.quit) self.worker.finished.connect(self.worker.deleteLater) self.thread.finished.connect(self.thread.deleteLater) self.worker.message.connect(self.wait_and_display)
Basically, when we will click on the first button, we will disable the button itself, create an instance of the Worker object, an instance of the Thread object, move the Worker (with our heavy_function function) into this thread. Then we start it.
As our "heavy_function" inside the Worker was sending a signal at completion (through the "finished" signal variable), we are now saying to our program to do something when this thread finishes. In particular:
we re-enable the disabled button
we quit and delete the worker and the thread
and finally, we connect the signal emitted through the "message" signal variable from the Worker to a function "wait_and_display" that, as we will see later, will update the text of the label.
3 - Finally use the output from the Worker
OK, we just need to have a look at the simple "wait_and_display" function now:
def wait_and_display(self, message_from_thread): self.ui.l_waitdisplay.setText(message_from_thread)
The "wait_and_display" function is taking a parameter "message_from_thread". Actually, you could have chosen whatever name you wanted; this variable will be populated with the value got from the thread thanks to the line of code: we saw before
Then, we will pass the value to the text of our label (last line).
Let's see what happens now!
Now we are able to maintain a fully working GUI while the waiting function is being elaborated! Note how now we can see the first button being disabled on click, we can continue to output numbers, and move around our program GUI!
Both the examples are available here