Wednesday, December 1, 2021

Python GUIs: Simpler multithreading PyQt/PySide apps with QThreadPool.start() — Easily run Python functions and methods in another thread

In PyQt5 version 5.15.0 the .start() method of QThreadPool got a new signature that accepts either a Python function, a Python method, or a PyQt/PySide slot. This greatly simplifies the process of running Python code in a separate thread, avoiding the need to create a QRunnable first.

The .start() method schedules the execution of the given function/method/slot on a new thread using the QThreadPool and so avoids blocking your app's main GUI thread. If you have a simple task that you want to execute on another thread, you can now just pass it to .start() and be done with it.

For more information on using a QRunnable for threading, see the multithreading tutorial.

PySide was a little slower to add that signature, but it finally happened in version 6.2.0

To demonstrate how to use .start() to run user-defined Python functions/methods, we'll build a simple GUI with a long-running task.

A bad GUI design

Our demo application is a simple sheep counter which continuously counts upwards from 1. While the sheep are being counted, you can press a button to pick the currently counted sheep. Picking sheep is hard, so this task takes a long time.

Demo GUI app This is how the demo GUI app looks like.

If you use PyQt5, make sure that it is of version 5.15.0 or greater, otherwise the demo GUI app won’t work for you.

python
import time

from PySide6.QtCore import Slot, QTimer
from PySide6.QtWidgets import QApplication, QLabel, QMainWindow, QPushButton, QVBoxLayout, QWidget


class MainWindow(QMainWindow):

    def __init__(self, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)

        self.setFixedSize(250, 100)
        self.setWindowTitle("Sheep Picker")

        self.sheep_number = 1
        self.timer = QTimer()
        self.picked_sheep_label = QLabel()
        self.counted_sheep_label = QLabel()

        self.layout = QVBoxLayout()
        self.main_widget = QWidget()
        self.pick_sheep_button = QPushButton("Pick a sheep!")

        self.layout.addWidget(self.counted_sheep_label)
        self.layout.addWidget(self.pick_sheep_button)
        self.layout.addWidget(self.picked_sheep_label)

        self.main_widget.setLayout(self.layout)
        self.setCentralWidget(self.main_widget)

        self.timer.timeout.connect(self.count_sheep)
        self.pick_sheep_button.pressed.connect(self.pick_sheep)

        self.timer.start()

    @Slot()
    def count_sheep(self):
        self.sheep_number += 1
        self.counted_sheep_label.setText(f"Counted {self.sheep_number} sheep.")

    @Slot()
    def pick_sheep(self):
        self.picked_sheep_label.setText(f"Sheep {self.sheep_number} picked!")
        time.sleep(5)  # This function represents a long-running task!


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

    main_window = MainWindow()
    main_window.show()

    app.exec()
python
import time

from PyQt6.QtCore import pyqtSlot, QTimer
from PyQt6.QtWidgets import QApplication, QLabel, QMainWindow, QPushButton, QVBoxLayout, QWidget


class MainWindow(QMainWindow):

    def __init__(self, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)

        self.setFixedSize(250, 100)
        self.setWindowTitle("Sheep Picker")

        self.sheep_number = 1
        self.timer = QTimer()
        self.picked_sheep_label = QLabel()
        self.counted_sheep_label = QLabel()

        self.layout = QVBoxLayout()
        self.main_widget = QWidget()
        self.pick_sheep_button = QPushButton("Pick a sheep!")

        self.layout.addWidget(self.counted_sheep_label)
        self.layout.addWidget(self.pick_sheep_button)
        self.layout.addWidget(self.picked_sheep_label)

        self.main_widget.setLayout(self.layout)
        self.setCentralWidget(self.main_widget)

        self.timer.timeout.connect(self.count_sheep)
        self.pick_sheep_button.pressed.connect(self.pick_sheep)

        self.timer.start()

    @pyqtSlot()
    def count_sheep(self):
        self.sheep_number += 1
        self.counted_sheep_label.setText(f"Counted {self.sheep_number} sheep.")

    @pyqtSlot()
    def pick_sheep(self):
        self.picked_sheep_label.setText(f"Sheep {self.sheep_number} picked!")
        time.sleep(5)  # This function represents a long-running task!


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

    main_window = MainWindow()
    main_window.show()

    app.exec()
python
import time

from PyQt5.QtCore import pyqtSlot, QTimer
from PyQt5.QtWidgets import QApplication, QLabel, QMainWindow, QPushButton, QVBoxLayout, QWidget


class MainWindow(QMainWindow):

    def __init__(self, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)

        self.setFixedSize(250, 100)
        self.setWindowTitle("Sheep Picker")

        self.sheep_number = 1
        self.timer = QTimer()
        self.picked_sheep_label = QLabel()
        self.counted_sheep_label = QLabel()

        self.layout = QVBoxLayout()
        self.main_widget = QWidget()
        self.pick_sheep_button = QPushButton("Pick a sheep!")

        self.layout.addWidget(self.counted_sheep_label)
        self.layout.addWidget(self.pick_sheep_button)
        self.layout.addWidget(self.picked_sheep_label)

        self.main_widget.setLayout(self.layout)
        self.setCentralWidget(self.main_widget)

        self.timer.timeout.connect(self.count_sheep)
        self.pick_sheep_button.pressed.connect(self.pick_sheep)

        self.timer.start()

    @pyqtSlot()
    def count_sheep(self):
        self.sheep_number += 1
        self.counted_sheep_label.setText(f"Counted {self.sheep_number} sheep.")

    @pyqtSlot()
    def pick_sheep(self):
        self.picked_sheep_label.setText(f"Sheep {self.sheep_number} picked!")
        time.sleep(5)  # This function represents a long-running task!


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

    main_window = MainWindow()
    main_window.show()

    app.exec()

If you run the demo GUI app and you press on the Pick a sheep! button, you’ll notice that for a period of 5 seconds the GUI is completely unresponsive. That's not good.

Have you spotted the problem? The delay comes from the line time.sleep(5) which pauses execution of the Python code for 5 seconds. We've added this to simulate a long-running task – which can be helped by threading! You can experiment by increasing the length of the delay and may notice that your operating system starts complaining about your application not responding.

A good GUI design

So, what can we do to fix our delay issue? Well, this is where the new .start() method of QThreadPool comes in handy!

First, we need to import QThreadPool, so let’s do that.

python
from PySide6.QtCore import QThreadPool
python
from PyQt6.QtCore import QThreadPool
python
from PyQt5.QtCore import QThreadPool

Next, we need to create a QThreadPool instance. Let’s add

python
self.thread_manager = QThreadPool()

to the __init__ block of the MainWindow class.

Now, let’s create a pick_sheep_safely() slot. This new slot will use the .start() method of QThreadPool to call the long-running pick_sheep() slot and move it from the main GUI thread to a completely new thread.

python
@Slot()
def pick_sheep_safely(self):
    self.thread_manager.start(self.pick_sheep)  # This is where the magic happens!
python
@pyqtSlot()
def pick_sheep_safely(self):
    self.thread_manager.start(self.pick_sheep)  # This is where the magic happens!

Also, make sure that you connect the pick_sheep_safely() slot with the pressed signal of self.pick_sheep_button. So, in the __init__ block of the MainWindow class you should have

python
self.pick_sheep_button.pressed.connect(self.pick_sheep_safely)

If you made all those changes, your demo GUI app code should now be:

python
import time

from PySide6.QtCore import Slot, QThreadPool, QTimer
from PySide6.QtWidgets import QApplication, QLabel, QMainWindow, QPushButton, QVBoxLayout, QWidget


class MainWindow(QMainWindow):

    def __init__(self, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)

        self.setFixedSize(250, 100)
        self.setWindowTitle("Sheep Picker")

        self.sheep_number = 1
        self.timer = QTimer()
        self.picked_sheep_label = QLabel()
        self.counted_sheep_label = QLabel()

        self.layout = QVBoxLayout()
        self.main_widget = QWidget()
        self.thread_manager = QThreadPool()
        self.pick_sheep_button = QPushButton("Pick a sheep!")

        self.layout.addWidget(self.counted_sheep_label)
        self.layout.addWidget(self.pick_sheep_button)
        self.layout.addWidget(self.picked_sheep_label)

        self.main_widget.setLayout(self.layout)
        self.setCentralWidget(self.main_widget)

        self.timer.timeout.connect(self.count_sheep)
        self.pick_sheep_button.pressed.connect(self.pick_sheep_safely)

        self.timer.start()

    @Slot()
    def count_sheep(self):
        self.sheep_number += 1
        self.counted_sheep_label.setText(f"Counted {self.sheep_number} sheep.")

    @Slot()
    def pick_sheep(self):
        self.picked_sheep_label.setText(f"Sheep {self.sheep_number} picked!")
        time.sleep(5)  # This function doesn't affect GUI responsiveness anymore...

    @Slot()
    def pick_sheep_safely(self):
        self.thread_manager.start(self.pick_sheep)  # ...since it is called by this start() method


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

    main_window = MainWindow()
    main_window.show()

    app.exec()
python
import time

from PyQt6.QtCore import pyqtSlot, QThreadPool, QTimer
from PyQt6.QtWidgets import QApplication, QLabel, QMainWindow, QPushButton, QVBoxLayout, QWidget


class MainWindow(QMainWindow):

    def __init__(self, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)

        self.setFixedSize(250, 100)
        self.setWindowTitle("Sheep Picker")

        self.sheep_number = 1
        self.timer = QTimer()
        self.picked_sheep_label = QLabel()
        self.counted_sheep_label = QLabel()

        self.layout = QVBoxLayout()
        self.main_widget = QWidget()
        self.thread_manager = QThreadPool()
        self.pick_sheep_button = QPushButton("Pick a sheep!")

        self.layout.addWidget(self.counted_sheep_label)
        self.layout.addWidget(self.pick_sheep_button)
        self.layout.addWidget(self.picked_sheep_label)

        self.main_widget.setLayout(self.layout)
        self.setCentralWidget(self.main_widget)

        self.timer.timeout.connect(self.count_sheep)
        self.pick_sheep_button.pressed.connect(self.pick_sheep_safely)

        self.timer.start()

    @pyqtSlot()
    def count_sheep(self):
        self.sheep_number += 1
        self.counted_sheep_label.setText(f"Counted {self.sheep_number} sheep.")

    @pyqtSlot()
    def pick_sheep(self):
        self.picked_sheep_label.setText(f"Sheep {self.sheep_number} picked!")
        time.sleep(5)  # This function doesn't affect GUI responsiveness anymore...

    @pyqtSlot()
    def pick_sheep_safely(self):
        self.thread_manager.start(self.pick_sheep)  # ...since it is called by this start() method


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

    main_window = MainWindow()
    main_window.show()

    app.exec()
python
import time

from PyQt5.QtCore import pyqtSlot, QThreadPool, QTimer
from PyQt5.QtWidgets import QApplication, QLabel, QMainWindow, QPushButton, QVBoxLayout, QWidget


class MainWindow(QMainWindow):

    def __init__(self, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)

        self.setFixedSize(250, 100)
        self.setWindowTitle("Sheep Picker")

        self.sheep_number = 1
        self.timer = QTimer()
        self.picked_sheep_label = QLabel()
        self.counted_sheep_label = QLabel()

        self.layout = QVBoxLayout()
        self.main_widget = QWidget()
        self.thread_manager = QThreadPool()
        self.pick_sheep_button = QPushButton("Pick a sheep!")

        self.layout.addWidget(self.counted_sheep_label)
        self.layout.addWidget(self.pick_sheep_button)
        self.layout.addWidget(self.picked_sheep_label)

        self.main_widget.setLayout(self.layout)
        self.setCentralWidget(self.main_widget)

        self.timer.timeout.connect(self.count_sheep)
        self.pick_sheep_button.pressed.connect(self.pick_sheep_safely)

        self.timer.start()

    @pyqtSlot()
    def count_sheep(self):
        self.sheep_number += 1
        self.counted_sheep_label.setText(f"Counted {self.sheep_number} sheep.")

    @pyqtSlot()
    def pick_sheep(self):
        self.picked_sheep_label.setText(f"Sheep {self.sheep_number} picked!")
        time.sleep(5)  # This function doesn't affect GUI responsiveness anymore...

    @pyqtSlot()
    def pick_sheep_safely(self):
        self.thread_manager.start(self.pick_sheep)  # ...since it is called by this start() method


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

    main_window = MainWindow()
    main_window.show()

    app.exec()

Now, if you press on the Pick a sheep! button the pick_sheep method is executed in a separate thread and does not block the main GUI thread. Your application will remain responsive, and the sheep counting will continue normally -- even though it still has to complete a long-running task in the background.

Try increasing the length of the delay now – for example, sleep(10) – and you'll notice that it has no effect on the responsiveness of the UI.

Conclusion

And that’s it! I hope you’ll find this new .start() method of QThreadPool useful in any of your PyQt/PySide GUI apps that have some kind of a long-running task to be performed while the app’s main GUI thread is running.

For more, see the complete PyQt5 tutorial.



from Planet Python
via read more

No comments:

Post a Comment

TestDriven.io: Working with Static and Media Files in Django

This article looks at how to work with static and media files in a Django project, locally and in production. from Planet Python via read...