How to use QThread correctly (part 2)?
This part is the continuation of the blog How to use QThread correctly (part 1)?. If you haven’t read the first part then please read part 1 before reading this.
What is a QThread and How it works?
3. Modify examples to abort the download
So far so good. Let’s add one more functionality to our small app, a cancel button. This will give users an option to cancel the download in between.
Let’s add a Cancel
button to the app. Update __init__()
method in MainWindow
class (in both examples)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#...
self.cancel_btn = QPushButton(text='Cancel')
# self.cancel_btn.clicked.connect(self.cancel)
self.container_frame = QFrame()
layout_h = QHBoxLayout()
self.container_frame.setLayout(layout_h)
layout_h.addWidget(self.download_btn)
layout_h.addWidget(self.cancel_btn)
layout_v.addWidget(self.progress_bar)
layout_v.addWidget(self.container_frame) # Add container_frame to layout_v
# ...
Make the following changes in WorkerThread
class of demo-subclass.py
{: .filepath}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class WorkerThread(QThread)
progress = Signal(int)
def __init__(self, n):
super().__init__()
self.n = n
self.is_cancel = False
@Slot()
def stop_download(self):
self.is_cancel = True
def do_work(self):
for i in range(1, self.n + 1):
if self.is_cancel:
break
self.progress.emit(i)
time.sleep(1)
# ...
class MainWindow(QMainWindow):
def __init__(self, *args, **kwargs):
# ...
@Slot()
def download(self):
self.cancel_btn.setEnabled(True)
# ...
self.cancel_btn.clicked.connect(self.worker.stop_download)
self.worker_thread.start()
@Slot()
def finished(self):
self.download_btn.setEnabled(True)
self.cancel_btn.setEnabled(False)
self.progress_bar.reset()
While download is in progress and managed by worker thread, when we press Cancel
button then it will emit it’s clicked()
signal in the main thread. And receiver of this signal is main thread itself, therefore the method stop_download()
will execute in the main thread (worker_thread
lives in the main thread and therefore main thread will handle received signal and execute stop_download()
slot). Both of these jobs is done in parallel (not really!).
Note that we are setting worker_thread.is_cancel
from main thread while reading its value in worker thread, is this a problem? Not really. In this case is_cancel
is a boolean and is thread safe as it does not involve any non-atomic operations and is modified by main thread only. (More info here)
But when in doubt, you can use mutex. If you wish, add self.mutex = QMutex()
line to the __init__()
method of WorkerThread
and update the stop_download()
slot as below
1
2
3
4
def stop_download(self):
self.mutex.lock()
self.is_cancel = True
self.mutex.unlock()
Let’s modify demo-worker-object.py
{: .filepath } file as follows
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class Worker(QObject):
progress = Signal(int)
finished = Signal()
def __init__(self, n):
super().__init__()
self.n = n
self.is_cancel = False
@Slot()
def stop_download(self):
print('Stop requested!')
self.is_cancel = True
@Slot()
def do_work(self):
for i in range(1, self.n + 1):
if self.is_cancel:
break
self.progress.emit(i)
time.sleep(1)
self.finished.emit()
class MainWindow(QMainWindow):
def __init__(self, *args, **kwargs):
# ...
@Slot()
def download(self):
self.cancel_btn.setEnabled(True)
# ...
self.cancel_btn.clicked.connect(self.worker.stop_download)
self.worker_thread.start()
@Slot()
def finished(self):
self.download_btn.setEnabled(True)
self.cancel_btn.setEnabled(False)
self.worker_thread.quit()
self.worker_thread.wait()
self.progress_bar.reset()
As you can see, clicking Cancel
is not doing what we expected it to do! But, why?
Unlike subclass approach where only code in run()
method is executed in a separate thread, worker-object approach has different rule:
“Code inside the worker’s slot will be executed in a separate thread.”
Unlike subclass approach, slot stop_download()
will execute in the worker thread because worker
lives in secondary thread (after calling its moveToThread()
method) and this thread will handle its slot.
Since do_work()
is a blocking task, till this job finishes, the local event loop will wait to get back the control and meanwhile all the incoming signals will be queued in the event-queue of the worker thread. The slot stop_download()
is invoked only after control returns to the event loop of the thread worker_thread
is managing.
We can fix this problem by directly invoking stop_download()
from main thread.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
class Worker(QObject):
progress = Signal(int)
finished = Signal()
def __init__(self, n):
super().__init__()
self.n = n
self.is_cancel = False
@Slot()
def stop_download(self):
self.is_cancel = True
@Slot()
def do_work(self):
for i in range(1, self.n + 1):
if self.is_cancel:
break
self.progress.emit(i)
time.sleep(1)
self.finished.emit()
class MainWindow(QMainWindow):
def __init__(self, *args, **kwargs):
# ...
@Slot()
def download(self):
self.cancel_btn.setEnabled(True)
# ...
self.cancel_btn.clicked.connect(self.cancel)
self.worker_thread.start()
@Slot()
def cancel(self):
self.worker.stop_download()
@Slot()
def finished(self):
self.download_btn.setEnabled(True)
self.cancel_btn.setEnabled(False)
self.worker_thread.quit()
self.worker_thread.wait()
self.progress_bar.reset()
Todo: Add a gif
Note that cancel_btn.clicked
signal is not connected to worker.stop_download()
slot rather it is connected to cancel()
slot of MainWindow
. Since cancel()
is invoked in main thread and hence it will invoke worker.stop_download()
as a normal method in main thread instead of worker thread.
4. Using a non-GUI Qt class in secondary thread
As I already mentioned QTimer
is a non-GUI class and it requires an event loop. Suppose we need to do something periodically in a secondary thread. Let’s try QThread
subclass approach.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import sys
from PySide6.QtWidgets import QApplication
from PySide6.QtCore import QThread, qDebug, QTimer, Slot
class WorkerThread(QThread):
@Slot()
def on_timeout(self):
qDebug(f"on_timeout() called from thread: {self.currentThread()}")
def do_work(self):
qDebug(f"Worker thread: {self.currentThread()}")
timer = QTimer()
timer.timeout.connect(self.on_timeout)
timer.start(1000) # Start the timer and emit timeout() signal every second.
self.exec() # Start the event loop. Without this QTimer will never emit timeout() signal
def run(self):
self.do_work()
if __name__ == '__main__':
app = QApplication(sys.argv)
worker_thread = WorkerThread()
worker_thread.start()
qDebug(f"Main thread: {worker_thread.currentThread()}")
app.exec()
This will produce the output like
1
2
3
4
5
6
Main thread: <PySide6.QtCore.QThread(0x6000004cf8c0) at 0x10de67440>
Worker thread: <__main__.WorkerThread(0x6000006c5ea0) at 0x10de67340>
on_timeout() called from thread: <PySide6.QtCore.QThread(0x6000004cf8c0) at 0x10de67680>
on_timeout() called from thread: <PySide6.QtCore.QThread(0x6000004cf8c0) at 0x10de675c0>
on_timeout() called from thread: <PySide6.QtCore.QThread(0x6000004cf8c0) at 0x10de67680>
...
Notice the id of main thread is 0x6000004cf8c0
and on_timeout()
is running in this thread. But, we expected to run on_timeout()
in secondary thread. Why this behavior?
As mentioned earlier, worker_thread
lives in main thread and queued signals are handled in receiver’s thread. So, secondary thread emits timer.timeout
signal and it is received in main thread. Therefore, Slot worker_thread.on_timeout()
is handled/executed by the main thread signal handler, not by secondary thread.
Using worker-object approach we can execute the periodic task in secondary thread.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import sys
from PySide6.QtWidgets import QApplication
from PySide6.QtCore import QThread, qDebug, QTimer, QObject, Slot
class Worker(QObject):
@Slot()
def on_timeout(self):
qDebug(f"on_timeout() called from thread: "
f"{self.thread()}")
@Slot()
def do_work(self):
qDebug(f"Worker thread: {self.thread()}")
self.timer = QTimer()
self.timer.timeout.connect(self.on_timeout)
self.timer.start(1000)
if __name__ == '__main__':
app = QApplication(sys.argv)
worker = Worker()
worker_thread = QThread()
worker.moveToThread(worker_thread)
worker_thread.started.connect(worker.do_work)
worker_thread.start()
qDebug(f"Main thread: {worker_thread.thread()}")
app.exec()
And the output
1
2
3
4
5
6
Main thread: <PySide6.QtCore.QThread(0x600002bd0300) at 0x10de12940>
Worker thread: <PySide6.QtCore.QThread(0x6000029fb560) at 0x10de12740>
on_timeout() called from thread: <PySide6.QtCore.QThread(0x6000029fb560) at 0x10de12740>
on_timeout() called from thread: <PySide6.QtCore.QThread(0x6000029fb560) at 0x10de12740>
on_timeout() called from thread: <PySide6.QtCore.QThread(0x6000029fb560) at 0x10de12740>
...
As you can see worker.on_timeout()
is running in secondary thread.
Conclusion
For this particular app both approach has been used. But as we can see, for this app
- No event loop is needed
- No signals/slots need to be handled inside the secondary thread (we are emitting signals though)
In such case where you want to perform some expensive operation in another thread, where the thread does not need to receive any signals or events, subclass QThread
.
When to use worker-object approach?
When you need an event loop in
QThread
. Certain non-GUI classes (such asQTimer
,QTcpSocket
, andQProcess
) requires the presence of event loop. If you are using instances of these classes in your thread then you will have to use worker-object approach.If you have to handle signals/slots in the secondary thread.