As you read this post you are probably interested in Machine Learning [ML] and hopefully in Linux systems as a ML-platform as well. This post series wants to guide you over a bridge between the standard tool-set of Python3 notebooks in Jupyterlab for the control of ML-algorithms and graphical Qt-applications on your Linux desktop. The objective is to become more independent of some limitations of the browser based Jupyterlab notebooks.
One aspect is the use of graphical Qt-based control elements (as e.g. buttons, etc.) in desktop windows. On the other hand we want to use background threads to produce (ML) data which we later, e.g. during training runs, display in Qt windows. Background threads will also enable us to run smaller code in other cells of our notebook during long ML-runs. We are also confident that we can keep up the interactivity of both our Qt windows and Jupyterlab during such runs.
We will later use the callback machinery of Keras based ML-runs to produce ML-data and other information about a running ML-algorithm in the background of Jupyterlab. These data will be sent to Matplotlib- and Qt callback-functions in Jupyterlab which then update Qt windows.
Knowledge gained so far …
During the previous posts we have gathered enough information to now build an example PyQt application, which utilizes two background threads.
- Using PyQt with QtAgg in Jupyterlab – I – a first simple example
- Using PyQt with QtAgg in Jupyterlab – II – excursion on threads, signals and events
- Using PyQt with QtAgg in Jupyterlab – III – a simple pattern for background threads
We have seen that QtAgg, a backend bridge for producing Matplotlib [MPL] plots in Qt windows, can be used for full fledged PyQt applications, too. In the first post we became familiar with some useful Qt-widgets and the general structure of Qt-Apps.
In the 2nd and 3rd posts we have learned that both Matplotlib figures and Qt-widgets must be controlled by the main thread associated with our Jupyterlab notebook. A Qt event loop is started in this thread by QtAgg for us. We have also noted that background threads controlled by QThread-objects can send signals which end up serialized in the Qt event queue of the main thread. From there they can be handled asynchronously, but in timely order by callbacks, which in turn update Qt-widgets for MPL-plots and other information. The 3rd post discussed a general pattern to employ both a raw data producing worker thread and a receiver thread to prepare the data for eventual foreground handling.
Objective of this post
In this post I will discuss a simple application that produces data with the help of two background threads according to the pattern discussed in the previous post. All data and information will periodically be sent from the background to callbacks in the main thread. Although we only use one main Qt window the structure of the application includes all key elements to serve as a blueprint for more complex situations. We will in particular discuss how to stop the background jobs and their threads in a regular way. An interesting side topic will be how one captures print output to stdout from background jobs.
Level of this post: Advanced. Some experience with Jupyterlab, QtAgg, Matplotlib and (asynchronous) PyQt is required. The first three posts of this series provide (in my opinion) a quick, though steep learning curve for PyQt newbies.
Application elements
Our PyQt application will contain three major elements in a vertical layout:
- Two buttons to start and stop two background threads. These threads provide data for a sine-curve with steadily growing frequency and some related information text.
- A Qt-widget for a Matplotlib figure to display the changing sine curve.
- A series of QTextEdit widgets to display messages from the background and from callbacks in the foreground.
Our pattern requires the following threads: A “worker thread” periodically creates raw data and puts them into Python queues. A “receiver thread” reads out the queues and refines the data.
In our case the receiver thread will add additional information and data. Then signals are used to communicate with callbacks in the main thread. We send all data for widget and figure updates directly with the signals. This is done for demonstration purposes. We could also have used supplemental data queues for the purpose of inter-thread data exchange. For plotting we use Matplotlib and the related Figure.canvas-widget provided by QtAgg.
So, we have a main thread with a Qt event loop (and of course a loop for Jupyterlab REPL interaction) and two background threads which perform some (simple) asynchronous data production for us.
Our challenge: Qt and Matplotlib control with Python code in a Jupyterlab notebook
The application looks pretty simple. And its structure will indeed be simple. However, as always the devil is an expert for faults in details. In our particular situation with Jupyterlab we need to get control over the following tasks:
- setup and start of two background threads – a worker thread and a receiver thread,
- association of worker and receiver objects to the named threads with a respective affinity,
- asynchronous inter-thread communication and data exchange via signals,
- updates of Qt-widgets and integrated Matplotlib figures,
- spinning the Qt-event-loop in the main thread to ensure quick widget updates,
- a regular stop of thread activities and a removal of thread-related objects,
- checking interactivity of both the Jupyterlab and the Qt-interface,
- stability of the plot-production against potentially conflicting commands from the main thread.
All via code executed in cells of a Python notebook. An additional topic is:
- capturing print-commands in the background and transmission of the text to the foreground.
A walk through the code
For the rest of this post I quickly walk through the code. Please note that the example is for demonstration purposes, only. Experts would, of course, have done many things more elegantly. In the next post we will run the code and test notebook and Qt window interactivity as well as stability (in the sense of thread safety).
Cell 1 – Imports
The following list of modules resembles the respective list for the example in our first post.
import time
#import gc # need some garbage collection
import sys # for PyQt5
import math
import numpy as np
import queue
# a useful module to redirect print-output
from contextlib import redirect_stdout
# For plotting
import matplotlib
import matplotlib.backends
import matplotlib.pyplot as plt
from matplotlib.figure import Figure
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar
# PyQt
from PyQt5 import QtWidgets, QtCore
from PyQt5.QtWidgets import *
from PyQt5.QtGui import *
from PyQt5.QtCore import *
To capture and redirect print output I load the module redirect_stdout. See [1] and [2] for more information.
Cell 2 – Activate QtAgg
matplotlib.use('QtAgg')
This step is absolutely necessary! Without it your notebook kernel will crash when it encounters some PyQt statements later on.
Cell 3 – A Writestream helper-object to redirect the contents of print statements in the background
We need an object which replaces the stdout stream for some print-statements in the background. An instance of the following class will later be used by redirect_stdout. The class predominantly needs a “write()“-method to handle text.
# The Stream Object which replaces the default stream
# associated with sys.stdout
# This object just puts data into a Python queue!
# Any object with write method for a text str is working
class WriteStream(object):
def __init__(self, queue_msgs):
self.queue_msgs = queue_msgs
# When texts come from print statements
# two (!) objects are added to the queue "text" + "\n"
def write(self, text):
self.queue_msgs.put(text)
In our case we just put the text delivered to write() into a Python queue. The queue can later be read out by other threads.
Cell 4 – Helper object to encapsulate sine data
We will later send sine data from the worker thread to the receiver thread and further on to the main thread. We put the generated individual sine data for the changing curve into objects based on the following class:
class SinObj():
def __init__(self, pi_fact=1, col='red'):
self.pi_fact = pi_fact
# col (color) will later be overwritten
self.col = col
self.pi = np.pi
self.make_sins()
def make_sins(self):
self.sinx = np.arange(0, self.pi_fact*self.pi, 0.1)
self.siny = np.sin(self.sinx)
Data production is rather primitive in our case! We use Numpy for sine data creation. But note:
In real world applications extensive data production should use libraries which circumvent the Python GIL – as e.g. Numpy, OpenBLAS or Tensorflow2 on the GPU. Or extensive I/O-operations.
Otherwise we will not be able to run at least parts of the threads’ code in parallel on multiple CPU-cores. For more information see the 3rd post of this series and its literature list.
Cell 5 – Code for a Worker object (affine to a worker thread) – constructor
Now it gets a bit more interesting: We build a class for an object that later does the data creating work for us in a “worker thread”. We will see in another section how this object gets its affinity to a defined background thread. As the code (with comments) is a bit lengthy I split it up.
# Worker Object [derived from QObject]
# (It will later be run in a QThread)
class MyWorker(QObject):
# Static variables
# ~~~~~~~~~~~~~~~~
# Signals MUST be defined as static variables
# Note: Signals could also be defined in the app's MainWindow
# We would then emit them by using a reference to the window
# Signal at start and regular end of the object's action
# (= while loop) => will be send to qMainWin
signal_start_end = pyqtSignal(str)
# Intermdiate msg-signals - will be sent directly to MainWindow
signal_msg = pyqtSignal(str)
# Constructor
# ~~~~~~~~~~~
def __init__(self, qMainWin, thrd
, num_iterations=20, time_sleep=1.0):
# Parameters:
# ~~~~~~~~~~~
# qMainWin: A reference to the App's MainWindow
# derived from QMainWindow
# thrd: A reference to the thread which the object gets affine to
# num_iterations: max num of iterations of the while loop
# time_sleep: sleep time between iterations
# required here for demonstration purposes
# Normally extensive operations consume the time
# Constructor of parent class
QObject.__init__(self)
# Main App window and threadd
self.qMainWin = qMainWin
self.thrd = thrd
# Maximum number of iterations / sleep time
self.num = num_iterations
self.time_sleep = time_sleep
# Number of elements in a "batch"
# Here: Just used to send intermediate msg
# Normally we would operate with real batches
# of data, e.g. in ML scenarios
self.batch_size = qMainWin.batch_size_worker
# print("Worker: Batch size = ", self.batch_size)
# factor for sine period - will be raised
self.pi_fact = 0
self.pi = np.pi
# Queues - will be read by Receiver object
# ~~~~~~~~
# Queue for messages to Receiver - stdout-redirect
self.queue_msgs = qMainWin.queue_worker_msgs
# Queue for sine data
self.queue_sins = qMainWin.queue_sins
# Stream object to capture stdout
self.streamObj = WriteStream(self.queue_msgs)
# Connect signals to callbacks
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# connect start/end signals to callback in main window
self.signal_start_end.connect(qMainWin.callback_worker_start_end)
# connect intermediate msg signal to callback in main window
self.signal_msg.connect(qMainWin.callback_for_worker_msgs)
# get a time-reference + send start time
# ~~~~~~~~~~~~~~~~~~~~
self.time_ref = qMainWin.time_thrds_start
self.start_time = time.perf_counter()
st_w = round( (self.start_time - self.time_ref), 5)
msg = "\nWORKER: Started at " + str(st_w)
self.signal_start_end.emit(msg)
...
...
Some points are interesting here – also see the comments in the code:
- Arguments: The object gets information about the max number of iterations for working steps, a sleep_time-interval for faking a period of work, and two references – one to the main Qt window of the application and one to the thread, which the object is affine to.
- Reference to main window: The nice thing about Python threads is that they share a common memory space. All threads have access to all objects. I use a reference to our Qt applications main window (see a section below) to access some of its internal data (as e.g. queues). In our case we have just one such window. In other applications we could have multiple main windows. We then would have to be careful to provide the right references.
- Reference to affine thread: A QObject can get an affinity to a thread. This affinity must be set explicitly, e.g. by moving an object to a thread. We inform our object about its affine thread (independently of how this information is handled internally in Qt).
- Signals as static variables: We define signals, which later will be emitted by the worker object. Note: Signals require a definition as static variables of the class. (This is just how it works in Qt).
We connect the signals to callback functions of the app’s main window. Due to the affinity of a later instance of MyWorker to a background thread the signals will be turned into events in the main thread’s Qt event queue and handled thereafter in a serialized, thread-safe manner. - Batches: In our simple example “batches” are only “virtually” present, namely as some additional modulo operations during the while loop. For this we define a virtual batch_size. In real world cases batches would have a much stronger impact on the production (and later handling) of defined groups of data objects.
- Queues: Queues provide a tool for serialization by their very nature. The sine (raw) data later produced by a MyWorker instance will be put into a queue, queue_sins, which the app’s main window (qMainWin) hosts. This queue will later be read out, element by element, by a receiver object (affine to the 2nd background thread, namely the receiver thread).
A second queue, queue_msgs, is used to capture print statements of the worker object. The queue’s elements will also be read by the receiver object. - Capturing print statements: I use a module to capture print statements (see the imports). In our simple case capturing of print statements is done for demonstration purposes. The contents of the print string is handed over to our WriteStream-instance, which in turn puts the string into a queue. Other threads can read the captured statements from there and handle them in a serialized way. In our case the receiver thread’s object will read the messages. I will in addition send the information about the number of the produced sine-objects, the related batch number and time via a signal directly to the main thread. This is only for the purpose of comparison.
Cell 5 – methods to stop the worker action in a regular way
We need methods which trigger a halt of the worker object – and which afterward also initiates the end of the related thread. Such methods are absolutely necessary to stop background activities in a controlled way. For examples, we should never stop or leave a PyQt application with still running background threads. So, we need to stop and clean them up somehow.
class MyWorker(QObject):
...
def __init__(...):
...
# Method to stop Worker regularly (by stopping while loop)
# ~~~~~~~~~~~~~~~~~~~~
# Note : This method will be called directly; not via signal
def ende(self):
print("WORKER: stopping ... ")
# this stops the while loop and leads indirectly
# to the emission of more local signals
self.num = 0
# Method to print final msg and send a signal
# ~~~~~~~~~~~~~~~~~~~~~~~
# Will be called directly - not via signal
def end_msg(self):
print("WORKER: finished !")
end_time = round( (time.perf_counter() - self.time_ref), 5)
msg = "\nWORKER: Finished at " + str(end_time)
self.signal_start_end.emit(msg)
...
The first method “ende()” just changes the number of iterations to zero. This will change the condition for the central while loop of the worker object (see below) and the while loop ends. The second method, “end_msg()” emits a signal regarding the end of the worker object’s activities. This signal will be handled asynchronously by a method of the main window (in the main thread).
Cell 5 – central method of worker object with a while-loop
A method “worker_run()” organizes the work of the worker object. This method will be triggered by a signal coming from the starting worker thread. See below. This means that this method actually is a slot for a signal. We therefore mark the function by a slot decorator. This will induce an effective low-level realization. See [4] for more information on slot decoration in Python 3. The creation of data and related messages is done during iterations of a central while-loop.
class MyWorker(QObject):
...
def __init__(...):
...
# Worker's main function. Gets started via signal from thread
# Note: This method will be connected to a start signal from the
# (affine) thread => Should be marked as a SLOT in Python
@QtCore.pyqtSlot()
def worker_run(self):
i = 0
n_worker_batch = 0
# Need a while loop as self.num will be changed dynamically
while i < self.num:
# Print option to a notebook cell
# print("Worker: i=", i)
# Create new sine-data with growing period number
self.pi_fact += 1
# We create a full object for data transmission
# (recommended; but in real world apps we may
# need to trigger garbage collection sometimes)
sin_obj = SinObj(pi_fact=self.pi_fact)
# put obj into queue for receiver
self.queue_sins.put(sin_obj)
# Capture print() -> put text into queue for receiver
# In parallel: After each "batch" send a msg to qMainWin
if i%self.batch_size == 0:
n_worker_batch += 1
print_text = "Worker i = " + str(i) + \
" :: w-batch = " + n_worker_batch
print_text2 = "Worker To Rec.: " + print_text
# Print something uncaptured to stdout
# print(print_text)
# ! Note: Capturing always prints additional "\n"
# ! This leads to 2 entries in the queue:
# "\n" and print-"text"
with redirect_stdout(self.streamObj):
print(print_text2)
# Send signal to main window with msg-text
time_pt = round( (time.perf_counter() - self.time_ref), 5)
msg = "\nWorker: Sine obj " + str(i) + \
" to queue (at " + str(time_pt) + ", batch: " + \
str(n_worker_batch) + ")"
self.signal_msg.emit(msg)
# Pause during which other threads can work.
# In real life cases we have ongoing data production
# operations, which should be done by libs/operations
# bypassing the GIL (NUMPY, OpenBLAS, TF2, I/O)
time.sleep(self.time_sleep)
i += 1
# Regular end of Worker
# ~~~~~~~~~~~~~~~~~~~~~
# We directly set the status variable for a running worker
# to False. This is harmless as fully controlled and no
# conflicting events can occur
self.qMainWin.worker_is_running = False
# Sequence of required steps to shutdown object AND thread
self.end_msg()
self.deleteLater()
self.thrd.quit()
We create the sine-data by creating an instance of class SinObj. The parameter for the sine-frequency is raised with each iteration.
Regarding related messages: We capture print-statements when a group of sine-objects – a batch – has been created:
The WriteStream puts “print_text” into a queue. An important point is that our capture module “redirect_stdout” always creates a trailing string “\n” which is put as a separate element into the queue. This means that we get 2 entries in the queue “queue_msgs” per print-text.
We send the same information in parallel as a string “print_text2” together with a signal “signal_msg“. This signal will be asynchronously handled by a callback function of the main window.
Stopping the worker object and the related thread
Interesting are the four statements after the loop. When we regularly break the loop by changing the loop condition these statements will initiate the end of the worker object and of the related thread. We first inform the main Qt window (in the main thread) that the worker object has finished its while loop. For this purpose we call the worker object’s “end_msg()“-function. Eliminating the thread and its affine objects requires a bit more. Note:
Stopping the thread and eliminating affine objects before terminating the application or starting the threads again is important! The thread controlled by a QThread-object must have “finalized” before the QThread-object is eliminated or the whole PyQt app is stopped (e.g. by closing the main window). Background threads which still run at a stop of a PyQt app may crash the Jupyterlab notebook kernel.
PyQt encapsulates C++-objects. The elimination of these objects must happen in a controlled way. We get help from a standard method “deleteLater()” which organizes the release of the C++-object – as soon as the thread ends or in the next iteration of a local event-loop of the thread. Note further:
Any thread can get/have a running event loop! We would need such loops for example to handle signals (intended for the thread) by methods of the thread itself or of affine objects. A loop would explicitly be started by a method exec() of the threads control-object (of type QThread).
In our example case we will not explicitly start event loops for our background threads (see below). However, the default implementation of a QThread’s run()-function does start an event loop of the worker thread for us, nevertheless – although we will not overwrite such a function by some own statements. We will therefore not see a function “run()” explicitly in our code. But, such a method always exists and it is automatically executed when the thread starts. As a consequence we must manually stop the worker-thread and its event-loop at the end of the worker object’s activities. We do this by calling the “quit()“-function of the thread as a final statement in the worker-object.
Hint: Be careful too distinguish between the real thread (some kind of process to be scheduled by the OS) and the QThread-object which controls it. I know its confusing, but the control object QThread in our case lives in the main thread while its thread (with the related event-loop) exists as an entity separate from the main thread.
Cell 6 – a receiver object (affine to a receiver thread)
According to the scheme discussed in the last post we set up a simple receiver thread which reads out data produced by the worker, supplements them by some information and sends them to the main thread. In a real world application the activity of a receiver thread for data refinement would be much more extensive. Here we fake a processing time by a sleep interval. Otherwise, the remark I made for real world worker activities to bypass the GIL is relevant for a receiver, too.
The structure is very similar to the worker object – so I give only some comments.
# Receiver Object [derived from QObject]
# to be run in a QThread
class MyReceiver(QObject):
# Signals at start and regular end of the receiver object
# will be send to qMainWin
signal_start_end = pyqtSignal(str)
signal_finished = pyqtSignal()
# Intermediate signals to emit - with sine data in object form
signal_data = QtCore.pyqtSignal(object)
signal_msg = QtCore.pyqtSignal(str)
# Constructor
def __init__(self, qMainWin, thrd
, num_iterations=50, time_sleep=0.05):
# Parameters:
# ~~~~~~~~~~
# qMainWin: a reference to the Main Application Window
# thrd: a reference to the thread which the object is affine to
# num_iterations: max num of iterations of while loop
# time_sleep: sleep time between iterations
QObject.__init__(self)
# Main App window and thrd
self.qMainWin = qMainWin
self.thrd = thrd
# Color list
self.li_col = ['blue', 'red', 'orange', 'green', 'darkgreen'
, 'darkred', 'magenta', 'black']
# Queues
# ~~~~~~~~
# Queue for messages from Worker - stdout-redirect
self.queue_msgs = qMainWin.queue_worker_msgs
# Queue for sine data
self.queue_sins = qMainWin.queue_sins
# maximum number of iterations and sleep time
self.num = num_iterations
self.time_sleep = time_sleep
# Worker batch size
self.worker_batch_size = qMainWin.batch_size_worker
# Receiever batch size
self.receiver_batch_size = qMainWin.batch_size_receiver
# Connect signals to callbacks
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# connect msg signal to callback in the main window
self.signal_msg.connect(qMainWin.callback_for_receiver_msgs)
# connect data signal to callback in the main window
self.signal_data.connect(qMainWin.callback_for_receiver_data)
# connect signal for regular end of Receiver object
self.signal_start_end.connect(qMainWin.callback_receiver_start_end)
# Special signal at end of the Receiver to stop Worker, too
self.signal_finished.connect(qMainWin.callback_receiver_finish)
# get a time-reference
self.time_ref = qMainWin.time_thrds_start
self.start_time = time.perf_counter()
st_r = round( (self.start_time - self.time_ref), 5)
msg = "\nRECEIVER: Started at " + str(st_r)
self.signal_start_end.emit(msg)
# Method to stop Receiver (by stopping while loop)
# ~~~~~~~~~~~~~~~~~~~~~~
def ende(self):
print("RECEIVER: Stopping ...")
# this stops the while loop
self.num = 0
# Method to print final msg + send signal to qMainWin
# ~~~~~~~~~~~~~~~~~~~~~~~
def end_msg(self):
print("RECEIVER: finished !")
end_time = round( (time.perf_counter() - self.time_ref), 5)
msg = "\nRECEIVER: Finished at " + str(end_time)
self.signal_start_end.emit(msg)
self.signal_finished.emit()
@QtCore.pyqtSlot() # gets started signal from thread
def receiver_run(self):
i = 0
n_worker_batches = 0
n_receiver_batches = 0
n_sine_objects = 0
col_rand = 1
while i < self.num:
#if i%10 == 0:
#print("Receiver: loop i = ", i)
# Receiver works faster than Worker
# gets data from 2 queues
# Data from Worker msg queue
text_worker = ''
if self.queue_msgs.qsize() > 0:
text_worker = self.queue_msgs.get()
slash_n = self.queue_msgs.get()
n_worker_batches += 1
# print("Receiver: i = ", i, " :: n_w_batch = ", n_worker_batches)
# print("Receiver: i = ", i, " :: text_worker = ", text_worker)
msg = "\nRECEIVER: Worker msg = " + \
text_worker
self.signal_msg.emit(msg)
# print(self.queue.qsize())
if self.queue_sins.qsize() > 0:
n_sine_objects += 1
# print("From Receiver: n_sine = ", n_sine_objects)
sine_obj = self.queue_sins.get()
# add color
sine_obj.col = self.li_col[col_rand]
# send signal with dtaa obj to qMainWin
self.signal_data.emit(sine_obj)
if n_sine_objects%self.receiver_batch_size == 0:
col_rand = np.random.randint(0, len(self.li_col))
n_receiver_batches += 1
msg_batch = "\nRECEIVER: Rec-batch Nr " + \
str(n_receiver_batches) + "\n"
self.signal_msg.emit(msg_batch)
time.sleep(self.time_sleep)
i += 1
# Regular end of Receiver
# ~~~~~~~~~~~~~~~~~~~~~~~
# We directly set the status of the running Receiver to False
self.qMainWin.receiver_is_running = False
# Sequence of steps to shutdown object and thread
self.end_msg()
self.deleteLater()
self.thrd.quit()
Again we define some static variables for the signals and indicate the data type to be transported. We connect to a callback to handle the sine data transmitted with signals of type “signal_data(object)” to the main thread. We send intermediate messages and messages regarding the start and the end of the receiver object. At the end of the receiver’s while loop an additional signal, “signal_finished“, triggers a stop of the worker object if it should still be running.
We in general assume
- that the receiver’s while loop runs at a higher frequency than the worker’s loop;
- that it makes no sense to further produce raw data by the worker if the receiver object stops its work.
The receiver object has a while-loop, too. During each iteration an element, i.e. a “SinObj”, is picked from the queue “queue_sins“. The only “refinement” the receiver object contributes is setting a color for the plot-curve:
The color is added to SinObj. Then the object with all sine data is added to a signal “signal_data” which is emitted. Note that for each new receiver-“batch” the color of the sine-data is changed randomly.
The receiver object also reads out the information the worker put in the “queue_msgs“. Note that we have to read out 2 elements of the queue due to capturing print text in the worker (see above). In our case the information from the worker is just forwarded to the main thread.
At the end of the while loop we have analogous statements as in the worker object – this time, of course, to signal the end of the Receiver’s actions, delete(Later) the object itself and stop the running event-loop of the receiver-thread.
All in all the Receiver picks up data and information from the Worker, modifies and supplements them and forwards the information (to the main thread) with signals. Note that aside of signals we could again have used a queue to provide data to callbacks in the main thread.
Cell 7 – the main Qt window – constructor
Let us turn to the main thread and a class for its main Qt Window (derived from QtWidgets.QMainWindow). The constructor prepares its layout and its central elements. A comparison with the example application of the 1st post in this series may be helpful.
# A Man Window for our example application
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# An instance will produce a Qt-window on the screen
class MyApp(QtWidgets.QMainWindow):
# Constructor
def __init__(self, max_worker_iters=20, max_receiver_iters=50):
# initialization of parent class
QtWidgets.QMainWindow.__init__(self)
self.setWindowTitle("PyQt My-Threader")
# Initial size of Qt window
#self.setMinimumSize(QSize(300, 300))
self.resize(960, 800)
# some useful colors
self.col_red = QColor('red')
self.col_darkred = QColor(125, 0, 0)
self.col_darkblue = QColor(0, 0, 125)
self.col_darkgreen = QColor(0, 125, 0)
self.col_black = QColor(3, 3, 3)
# Queues for data and msgs
# ~~~~~~~~~~~~~~~~~~~~~~~~
# Both queues will be read by the Receiver object
# in the receiver thread
# Queue for msgs from the worker thread
self.queue_worker_msgs = queue.Queue()
# Queue for sinx/siny data from worker
self.queue_sins = queue.Queue()
# Design of the Main Window
# ~~~~~~~~~~~~~~~~~~~~~~~~~~
# Central widget
self.mainWidget = QtWidgets.QWidget()
self.setCentralWidget(self.mainWidget)
# VBOX-Layout
self.mainLayout = QVBoxLayout(self)
self.mainWidget.setLayout(self.mainLayout)
self.mainLayout.setContentsMargins(0, 0, 0, 0)
self.mainLayout.setSpacing(12)
# Groupbox1 = Multiple Buttons / fixed height
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
self.groupbox1 = QGroupBox(" Buttons for Thread Control")
self.groupbox1.setStyleSheet('font-weight:bold;')
self.groupbox1.setFixedHeight(100)
self.mainLayout.addWidget(self.groupbox1)
self.vbox1 = QHBoxLayout()
self.groupbox1.setLayout(self.vbox1)
# 1st button: start threads
self.but_start_threads = QPushButton('start\nthreads', self)
self.but_start_threads.setMinimumSize(QSize(150, 50))
sizePolicy_but = QSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
self.but_start_threads.setSizePolicy(sizePolicy_but)
self.vbox1.addWidget(self.but_start_threads)
# Stretch element
self.vbox1.insertStretch(1)
# 2nd button: stop threads
# 1st button: start threads
self.but_stop_threads = QPushButton('stop\nthreads', self)
self.but_stop_threads.setMinimumSize(QSize(150, 50))
sizePolicy_but = QSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
self.but_stop_threads.setSizePolicy(sizePolicy_but)
self.vbox1.addWidget(self.but_stop_threads)
# Groupbox 2 = Figure Canvas for Matplotlib Figure
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# WARNING: Create matplotlib figure inside this class.
# ~~~~~~
# Otherwise you MUST destroy figure separatly
# after closing the window via x on GUI-window
# or win.close in Jupyterlab
# Alternative: catch the close event and close then
self.groupbox2 = QGroupBox(" Qt-Canvas for MPL-plot")
self.groupbox2.setStyleSheet('font-weight:bold;')
self.mainLayout.addWidget(self.groupbox2)
self.vbox2 = QVBoxLayout()
self.groupbox2.setLayout(self.vbox2)
# Create Matplotlib figure
self.fig1 = Figure(figsize=[5., 3.], dpi=96)
# Create ax inside
self.ax11 = self.fig1.add_subplot(111)
# Assign Qt FigureCanvas widget to fig1-variable
self.canvas1 = FigureCanvas(self.fig1) # !important
# Create interactive navigation toolbar widget
self.nav1 = NavigationToolbar(self.canvas1
, self.mainWidget)
# Add figure and toolbar widgets to vbox_fig1
self.vbox2.addWidget(self.nav1)
self.vbox2.addWidget(self.canvas1)
# !!! Important Otherwise the nav-bar will crash
# It needs a drawn ax => Else mismatch with
# figure.canvas and navi bar (x-position)
self.canvas1.draw()
# Set vertical Stretchfactor
self.mainLayout.setStretchFactor(self.groupbox2, 1)
# Groupbox 3 = Multiple QTextEdits
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
self.groupbox3 = QGroupBox(" Messages from thread-affine objects")
self.groupbox3.setStyleSheet('font-weight:bold;')
#self.groupbox3.setFixedHeight(250)
self.mainLayout.addWidget(self.groupbox3)
self.hbox3 = QHBoxLayout()
self.groupbox3.setLayout(self.hbox3)
# Display Ctrl messages
self.groupbox3_1 = QGroupBox(" Ctrl-Msgs")
self.groupbox3_1.setStyleSheet('font-weight:bold;')
self.vbox3_1 = QVBoxLayout()
self.groupbox3_1.setLayout(self.vbox3_1)
self.qTextEdit_1 = QTextEdit() # For control msgs
self.qTextEdit_1.setReadOnly(True)
self.hbox3.addWidget(self.groupbox3_1)
self.vbox3_1.addWidget(self.qTextEdit_1)
# Display Worker messages
self.groupbox3_2 = QGroupBox(" Worker-Msgs")
self.groupbox3_2.setStyleSheet('font-weight:bold;')
self.vbox3_2 = QVBoxLayout()
self.groupbox3_2.setLayout(self.vbox3_2)
self.qTextEdit_2 = QTextEdit() # For Worker msgs
self.qTextEdit_2.setReadOnly(True)
self.hbox3.addWidget(self.groupbox3_2)
self.vbox3_2.addWidget(self.qTextEdit_2)
# Display Receiver messages
self.groupbox3_3 = QGroupBox(" Receiver-Msgs")
self.groupbox3_3.setStyleSheet('font-weight:bold;')
self.vbox3_3 = QVBoxLayout()
self.groupbox3_3.setLayout(self.vbox3_3)
self.qTextEdit_3 = QTextEdit() # For Reveiver msgs
self.qTextEdit_3.setReadOnly(True)
self.hbox3.addWidget(self.groupbox3_3)
self.vbox3_3.addWidget(self.qTextEdit_3)
# equal horizontal stretch factor
self.hbox3.setStretchFactor(self.groupbox3_1, 1)
self.hbox3.setStretchFactor(self.groupbox3_2, 1)
self.hbox3.setStretchFactor(self.groupbox3_3, 1)
# Set vertical Stretchfactor - realtive to
# previous plot figure
self.mainLayout.setStretchFactor(self.groupbox3, 1)
# Connect buttons to callbacks
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
self.but_start_threads.clicked.connect(self.start_threads)
self.but_stop_threads.clicked.connect(self.stop_threads)
# Setting thread and worker parameters
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
self.threads = []
# Max nums of iterations for Worker / Receiver
self.max_num_worker_iters = max_worker_iters
self.max_num_receiver_iters = max_receiver_iters
# Batch sizes Worker / Receiever (here just for intermediate msgs)
self.batch_size_worker = 5
self.batch_size_receiver = 5
# Sleep times for Worker / Receiever [secs]
self.time_sleep_worker = 0.1
self.time_sleep_receiver = 0.05
# Status of threads
self.worker_is_running = False
self.receiver_is_running = False
# display Qt-window on screen
self.show()
...
...
We first set the title and size of the main window. (Note that in more complex applications you will have multiple such main windows on your desktop). Then we define some useful standard colors.
We set up two Python queues. They are used by the worker/receiver-objects in the background threads; see above.
The layout for a central widget follows a vertical layout. Each row collects widgets in a QGroupBox. The first row contains 2 buttons separated by a dynamic stretcher. Events like clicks on these buttons are later assigned to callback functions.
The second row is more interesting. Its GroupBox receives a Qt “Figure“-widget, which contains our MPL figure with just one axis sub-plot. Two key statements which bridge MPL to Qt are
- self.canvas1 = FigureCanvas(self.fig1)
- self.nav1 = NavigationToolbar(self.canvas1, self.mainWidget)
The first statement sets the canvas-widget which will be updated with new axes tick marks and of course the changing sine curve. We provide a standard MPL toolbar for user interaction with the MPL plot. It allows e.g. to zoom into the plot or move it within the canvas region. Note that we must execute self.canvas1.draw() to prevent a later problem with the toolbar location.
The third row gets a QGroupBox with a horizontal layout to host 3 passive (ro) QTextEdit-elements. The first QTextEdit displays general control messages, the 2nd messages from the Worker, the 3rd messages from the Receiver.
After having created all graphical elements we just define some parameters as the maximum number of while-loop-iterations for the Worker and the Receiver, batch sizes and sleep-times (to fake periods of worker/receiver actions).
Cell 7 – the main Qt window – a method to start the background threads
Now we start our two background threads, a worker and receiver thread, and assign objects to them. We use QThread control objects for this purpose. Note again that these control objects live in the main thread, while the threads themselves exist as separate entities after their start. Threads and their QThread control objects are not the same!
class MyApp(QtWidgets.QMainWindow):
# Constructor
def __init__(self, max_worker_iters=20, max_receiver_iters=50):
....
# Function to start the two background threads
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def start_threads(self):
# Clear some objects
self.qTextEdit_1.clear() # For control msgs
self.qTextEdit_1.setFontWeight(QFont.Normal)
self.qTextEdit_1.setTextColor(self.col_black)
self.qTextEdit_2.clear() # For msgsfrom Worker Thread
self.qTextEdit_2.setFontWeight(QFont.Normal)
self.qTextEdit_2.setTextColor(self.col_black)
self.qTextEdit_3.clear() # For msgs from Receiver Thread
self.qTextEdit_3.setFontWeight(QFont.Normal)
self.qTextEdit_3.setTextColor(self.col_black)
self.queue_worker_msgs.queue.clear()
self.queue_sins.queue.clear()
# A list for the opened threads
self.threads = []
# Time reference
self.time_thrds_start = time.perf_counter()
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Prepare Worker thread and start it
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
self.worker_thread = QThread()
# Set up a worker object - we submit also the ref. to the main win
self.worker_obj = MyWorker(self
, self.worker_thread
, num_iterations=self.max_num_worker_iters
, time_sleep=self.time_sleep_worker)
# Change thread affinity of worker object
self.worker_obj.moveToThread(self.worker_thread)
# Start the objects function "worker_run"
# We use an automatic start signal from the thread for this purpose
self.worker_thread.started.connect(self.worker_obj.worker_run)
# End worker object and worker thread
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Situation 1: Regular stop via the worker object
# ~~~~~~~~~~~ The while loop in the worker obj is forced to stop =>
# The thread should stop, too
# All the required action is done in the worker object.
# Situation 2: Stop of thread by external command
# ~~~~~~~~~~~ e.g. by some brutal intervention
# E.g. the user closes the main window =>
# Relevant is a close event which can be captured.
# We always turn such situations into regular stops.
# But we also need to delete the thread control objects
# after the threads are stopped.
# We also display a final message.
# We use an automatic signal at the end of the threads operations to
# trigger these actions.
self.worker_thread.finished.connect(self.worker_thrd_finished)
# last direct print related to worker
# self.worker_thread.finished.connect(
# lambda: print("Finished Worker Thread")
# )
# Start the worker thread
# ~~~~~~~~~~~~~~~~~~~~~~~~~
# # triggers the thread's run function, if existent
self.threads.append(self.worker_thread)
self.worker_thread.start()
self.worker_is_running = True
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Prepare Receiver thread and start it
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
self.receiver_thread = QThread()
# Set up a receiver object
self.receiver_obj = MyReceiver(self
, self.receiver_thread
, num_iterations=self.max_num_receiver_iters
, time_sleep= self.time_sleep_receiver)
self.receiver_obj.moveToThread(self.receiver_thread)
# Start the objects function "receiver_run" when thread starts
self.receiver_thread.started.connect(self.receiver_obj.receiver_run)
# finished
self.receiver_thread.finished.connect(self.receiver_thrd_finished)
self.receiver_thread.finished.connect(
lambda: print("Finished Thread Receiver")
)
# Start the receiver thread
# ~~~~~~~~~~~~~~~~~~~~~~~~~
# # triggers the thread's run function, if existent
self.threads.append(self.receiver_thread)
self.receiver_thread.start()
self.receiver_is_running = True
...
...
Before we start the threads we clean up the QTextEdit-output windows. We also request the time as an anchor for measuring time-differences during the actions of the thread worker and receiver objects.
We first define the control object for the worker thread. Note that this does not yet start the real worker thread. Then we create the worker object as a MyWorker-instance. The affinity of the object to the worker to the later thread is set by
- self.worker_obj.moveToThread(self.worker_thread)
This statement sets the affinity of our object to the thread via our control object (even before the real thread is started).
How do we get the worker object to start its activities together with the thread? At the start and at the end of the real threads two signals are automatically emitted. We connect the start-signal to the “worker_run()“-method of the worker object. This ensures that the central method of the worker object starts its while loop with the start of the worker thread. When the worker thread finalizes we perform some additional cleaning actions. For this we use a method of the main window.
After all these preparations we eventually start the (real) worker thread through the start()-method of its control object.
The receiver thread and the receiver object are handled in an analogous way.
Cell 7 – the main Qt window – callbacks
The following methods of the main window respond to signals coming from the background threads.
class MyApp(QtWidgets.QMainWindow):
# Constructor
def __init__(self, max_worker_iters=20, max_receiver_iters=50):
....
# Callbacks for worker and receiver threads
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Method for regular start/end signal of Worker
# msg will be written to 1st QTextEdit
@QtCore.pyqtSlot(str)
def callback_worker_start_end(self, text):
# We write a msg to the Ctrl QTExtEdit_1
self.qTextEdit_1.moveCursor(QTextCursor.End)
self.qTextEdit_1.setFontWeight(QFont.Normal)
self.qTextEdit_1.setTextColor(self.col_black)
self.qTextEdit_1.insertPlainText(text)
QtWidgets.QApplication.processEvents() #update gui for pyqt
# Method to handle Worker signals with msgs
# will be written to the 2nd QTextEdit
@QtCore.pyqtSlot(str)
def callback_for_worker_msgs(self, text):
self.qTextEdit_2.moveCursor(QTextCursor.End)
self.qTextEdit_2.setFontWeight(QFont.Normal)
self.qTextEdit_2.setTextColor(self.col_black)
self.qTextEdit_2.insertPlainText(text)
QtWidgets.QApplication.processEvents() #update gui for pyqt
# Method for regular start/end signal of Receiver
# msg will be written to 1st QTextEdit
@QtCore.pyqtSlot(str)
def callback_receiver_start_end(self, text):
# We write a msg to the Ctrl QTExtEdit_1
self.qTextEdit_1.moveCursor(QTextCursor.End)
self.qTextEdit_1.setFontWeight(QFont.Normal)
self.qTextEdit_1.setTextColor(self.col_black)
self.qTextEdit_1.insertPlainText(text)
QtWidgets.QApplication.processEvents() #update gui for pyqt
# Method for regular end signal of Reciever
# => Stop the Worker, too
@QtCore.pyqtSlot()
def callback_receiver_finish(self):
# Stop worker - if still running
if self.worker_is_running:
self.worker_obj.ende()
# We write a msg to the Ctrl QTExtEdit_1
msg = "\nStopping Worker due to end of Receiver"
self.qTextEdit_1.moveCursor(QTextCursor.End)
self.qTextEdit_1.insertPlainText(msg)
QtWidgets.QApplication.processEvents() #update gui for pyqt
# Method to handle Receiver signals with msgs
# will be written to the 3rd QTextEdit
@QtCore.pyqtSlot(str)
def callback_for_receiver_msgs(self, text):
self.qTextEdit_3.moveCursor(QTextCursor.End)
self.qTextEdit_3.setFontWeight(QFont.Normal)
self.qTextEdit_3.setTextColor(self.col_black)
self.qTextEdit_3.insertPlainText(text)
QtWidgets.QApplication.processEvents() #update gui for pyqt
# Method for Receiver signals with sine data
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@QtCore.pyqtSlot(object)
def callback_for_receiver_data(self, sine_obj):
sin_x = sine_obj.sinx
sin_y = sine_obj.siny
sine_col = sine_obj.col
# Hier weitermachen XXXX
self.ax11.clear()
self.ax11.plot(sin_x, sin_y, color=sine_col)
self.fig1.canvas.draw()
self.fig1.canvas.flush_events()
# Stop threads - and related msgs
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Reaction to finished Worker thread
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@QtCore.pyqtSlot()
def worker_thrd_finished(self):
if self.worker_is_running:
print("STRANGE END of WORKER THREAD!")
text = "\nWORKER: Thread finalized"
print(text)
self.qTextEdit_1.moveCursor(QTextCursor.End)
self.qTextEdit_1.insertPlainText( text )
QtWidgets.QApplication.processEvents() #update gui for pyqt
self.worker_thread.deleteLater()
self.threads.pop(0)
# Reaction to finished Receiver thread
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@QtCore.pyqtSlot()
def receiver_thrd_finished(self):
if self.worker_is_running:
print("STRANGE END of RECEIVER THREAD!")
text = "\nRECEIVER: Thread finalized"
print(text)
self.qTextEdit_1.moveCursor(QTextCursor.End)
self.qTextEdit_1.insertPlainText( text )
QtWidgets.QApplication.processEvents() #update gui for pyqt
self.threads.pop(0)
# Actively stop worker obj and thread
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def stop_worker(self):
if self.worker_is_running:
self.worker_obj.ende()
# Actively stop receiver obj and thread
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def stop_receiver(self):
if self.receiver_is_running:
self.receiver_obj.ende()
# Actively stop threads and worker/receiver objects
# ~~~~~~~~~~~~~~~~~~~~~
@QtCore.pyqtSlot()
def stop_threads(self, b_how=0):
print("Start Finishing Threads")
# An end of the Receiver will stop the Worker, too
self.stop_receiver()
# The threads should come to an automatic end, too
if b_how == 0:
text = "\nInitialized end of threads (via button)"
else:
text = "\nInitialized end of threads"
self.qTextEdit_1.moveCursor(QTextCursor.End)
self.qTextEdit_1.insertPlainText( text )
QtWidgets.QApplication.processEvents() #update gui for pyqt
# Closing main window by pressing X
# ~~~~~~~~~~~~~~~~~~~~~~
# This event must lead to a controlled end of
# both the threads and their affine objects
@QtCore.pyqtSlot()
def closeEvent(self, event):
# An end of the Receiver will stop the Worker, too
text = "\nUser is closing Main Win "
print(text)
self.qTextEdit_1.moveCursor(QTextCursor.End)
self.qTextEdit_1.insertPlainText( text )
QtWidgets.QApplication.processEvents() #update gui for pyqt
self.stop_threads(b_how=1)
# close figure
self.canvas1.deleteLater()
# Wait a bit
time_sleep = 1.0
time.sleep(time_sleep)
# accept event
event.accept()
Most of these methods put some forwarded messages onto one of the QTextEdit-frames. Note that we relatively often call
QtWidgets.QApplication.processEvents()
to enforce a spinning of the Qt-loop in the main thread for updating the QTextEdit-widgets. Almost all of the methods are slots. We again mark them by a slot decorator which ensures an effective internal realization.
The method “callback_receiver_finish()” reacts to an end of the actions of the receiver object (which deletes itself via deleteLater()). When the receiver stops working it makes no sense in our primitive application to produce more sine-data by the worker. We therefore initiate the end of the worker-object if its while-loop should still be running.
Handling of signals for the sine data in the main thread
As we have learned we must control graphical widgets and MPL-related figures from the main thread.
In our example the main thread receives all data as a payload of signals from the receiver thread. The method callback_for_receiver_data(self, sine_obj) handles the plotting of the sine curves for us. The sine shape changes around every 0.1 secs.
Our method extracts the x- and y-data for the sine curve from the transmitted objects and puts them into the figure’s canavas widget. Note that we can use the classical MPL commands “figure.canvas.draw()” and “figure.canvas.flush_events()” to request plot updates by spinning the Qt event loop. The 2nd command is by the way, directly translated by QtAgg into “QtWidgets.QApplication.processEvents()“.
Arriving message strings are instead directly written to the QTextEdit-frames. The writing process automatically triggers an update.
Stopping the threads and the application
Our application got a button to start the coworking threads and their objects (via method start_threads()). In addition, we have a button and a method to stop both threads, too. The method is “stop_threads()“.
We can use the stop-button at any time. It will then initiate a controlled shutdown of the worker and receiver objects’ activities and their threads. The end of the threads will in turn trigger a deleteLater() of the Qthread objects. So, we are free to afterward start the threads again from scratch.
Note that it is sufficient to stop the receiver object. This will lead through a cascade of signals and event to a stop of the worker object and its thread in a regular way, too.
However, the user may may also choose to close the main window. This is a critical operation as we would destroy control objects and signal handling methods for still running threads. This would lead to a kernel crash of the Jupyterlab notebook. Theefore we must capture and handle this user event – and shutdown all our background threads in a controlled way. We use stop_threads() to clear and clean up all objects related to the beackground threads. To be on the safe side it execute also deleLater() for the “Figure.canvas”-widget – as I do not know what it internally does. I give the whole application enough time to finalize all stopping and deleting. Only afterward I accept the user-intended window closure.
Print statements ?
The reader may have noticed that only a few print statements have survived. The mark the end of background thread activities. I kept them just for test control purposes. They are not really necessary. Note that our goal should be to get free of direct print statements to notebook cells. stdout in Jupyterlab notebooks moves to the output region of an active cell. This means that in particular print statements from the background will end up at the cell to which you have moved your mouse and editing activities. This would be disturbing and is to be avoided.
Full program code as PDF
The following PDF is only intended as experimental learning and test material for the reader.
PyQt_Primitive_3_Interacting_Threads_V03.pdf
Conclusion
Bringing PyQt, Matplotlib, Python, (background) threads and Jupyter notebooks together requires some effort and an overview about what happens in which thread and what object communicates with what other object. QtAgg helps us only with the general integration and the central Qt event loop.
However, structuring a PyQt-application to run background threads and establish a safe communication with the main thread via its event loop is something we must set up and control by ourselves. The code in this post shows how we can work with two background threads to produce data which shall be shown in Qt-widgets and Matplotlib figures in the foreground in a thread safe way. In our case serialization is enforced by the main event queue.
We needed to work with queues, signals and callbacks. We have also seen that we can even redirect print()-statements of a background job to be captured and forwarded to widgets in the main thread. And we have learned that we should end the business of running threads and affine objects always in a controlled way, before we close some application.
In the next post of this series we will run our test application and also test the stability of the Matplotlib figure canvas against intermediate and potentially conflicting requests from the main thread.
Links and Literature
[1] “Redirecting stdout and stderr to a PyQt4 QTextEdit from a secondary thread” at stackoverflow
[2] Capturing stdout in a QThread and update GUI at stackexchange
[3] Use PyQt’s QThread to Prevent Freezing GUIs at Real Python
[4] About a decoration of callback functions as slots see: https://stackoverflow.com/ questions/ 45841843/ function-of-pyqtslot