本篇目的:讀者可以了解如何建立 QThread、放入工作物件、並安全關閉 QThread 與物件。讓程式可以執行多項工作而不當機
我們使用軟體時最討厭畫面卡著不動,但程式的預設是一次只執行一件事(單執行緒)。因此當我們在執行讀取大量資料等耗時操作時,其他的東西(包括UI 畫面)是完全無法更新的。在這樣的情況下我們無法實作進度條等通知,user 也無法分辨程式是正在處理資料還是當掉了。例如下面的計數 10 秒的計數器,設計是一秒”計數一次與更新畫面一次”,但實際上是完成計數 10 次後才更新畫面。
(幫我撐 10 秒,這張圖是會動的)

也因此我們今天要使用多執行緒 (multithreading) 處理這種 "處理運算時,畫面就完全卡死不能動”的問題。今天我們會介紹 QThread 的建立、啟用、讓運算與畫面更新並行,並且最後在不使用時,安全的關閉 QThread 與對應的工作 class。
當我們執行一程式時,我們就是在運行一個行程 (process)。而 Python 環境中的預設是任務在主執行緒中、依照順序一個一個完成(也稱為同步模式),可以想像所有的人在捷運尖峰時段時,仍堅持只走某個電扶梯。但這在 GUI 程式中會造成程式同一時間只能處理 UI 或是運算功能,也導致運行計算時 UI 毫無反應。
也因此我們可以採用多執行緒的執行方式,多執行緒的示意圖如下,可以想像成在捷運尖峰時段時大家分散到不同的電扶梯去。我們透過將計算任務放到背景執行緒可以避免主執行緒被大量計算塞住導致 UI 不反應。而 UI 元件因為在程式執行期間從頭到尾都存在,因此會放在主執行緒中。

以我們的使用情境來說,使用多執行緒的目的是讓 UI 能夠保持反應並顯示處理進度等資訊,也因此牽涉到大量的 UI 元件狀態修改。因為任務會同時處理,為了避免不同執行緒修改同一 UI 元件導致異常,就好像大家都搶者上同一個馬桶。因此使用多執行緒的守則是不可以直接修改與執行其他執行緒的東西,不同執行緒之間的交互作用應該使用 Signal & Slot。

我們的多執行緒應用多用於耗時的讀檔 or 計算流程,以下的範例會實作兩個一直在計算、很耗時間(一秒與三秒更新一次)的計數器,並且計數時會更新 UI 畫面。這種2個計時器同步運行,且還要更新 UI 的功能一定要在多執行緒中才能達成。

在這個專案中,我們會有3個執行緒:
主執行緒:處理 UI
執行緒 #1:執行一秒計數一次之計數器
執行緒 #2:執行三秒計數一次之計數器
因為是不同執行緒,因此我們無論是從主執行緒通知執行緒 #1/#2 開始或停止計時、或是執行緒 #1/#2 要更動 UI 畫面皆須透過 signal & slot 通知其他執行緒。因此我們先定義訊號與 worker (負責計數之 class)。
class MainController(QObject):
"""管理 UI、Worker 與多個背景執行緒。"""
start_sig_1 = Signal() # 開啟計數之訊號
stop_sig_1 = Signal() # 停止計數之訊號
start_sig_3 = Signal()
stop_sig_3 = Signal()
def __init__(self, ui_window: Ui_MainWindow) -> None:
"""建立兩組計數 Worker 與對應執行緒。"""
super().__init__()
self.ui_window = ui_window
# 建立兩個工作物件,各自負責不同計數間隔。
# 這就是 worker
self.accu_1s = AccumulateCounter(accu_time=1)
self.accu_3s = AccumulateCounter(accu_time=3)
self.setup_thread() #建立 thread
Worker 可以是負責讀檔、計算之物件,以本案例來說是負責計數。
Worker 裡面是甚麼不是本文重點,重點是 worker 需要發射訊號,因此需要繼承 QObject。
from PySide6.QtCore import QObject, QTimer, Signal, Slot
class AccumulateCounter(QObject):
"""依固定間隔累加數值的背景 Worker。"""
numeral_sig = Signal(str)
def __init__(
self,
accu_time: int = 3,
parent: QObject | None = None,
) -> None:
"""初始化計數間隔、數值與計時器。"""
super().__init__(parent)
self.accu_time = accu_time
self.numeral = 0
self.timer: QTimer | None = None
@Slot()
def start_accumulation(self) -> None:
"""啟動背景計時器開始計數。"""
# 第一次啟動時,才建立屬於此 Worker 的 QTimer。
if self.timer is None:
self.timer = QTimer(self)
# QTimer的時間到了之後,發射訊號給計數器
self.timer.timeout.connect(self.accumulate_once)
# 避免重複按開始後,重複啟動計時器。
if self.timer.isActive():
return
print("開始計數")
# QTimer 間隔單位為毫秒,因此秒數需乘以 1000。
self.timer.start(self.accu_time * 1000)
@Slot()
def accumulate_once(self) -> None:
"""執行一次計數並回傳最新數值。"""
self.numeral += 1
# 透過 signal 將結果交回 Controller 更新 UI。
self.numeral_sig.emit(str(self.numeral))
@Slot()
def stop_accumulation(self) -> None:
"""停止計時器並保留目前計數結果。"""
# 尚未開始或已停止時,不需要重複處理。
if self.timer is None or not self.timer.isActive():
return
self.timer.stop()
print("停止計數")
我習慣將 thread 的建立放在獨立的一個 function 中,這個 function 的流程如下:
建立 thread 物件,因為該 thread 物件不可因為 function 執行完畢而被回收,因此前面須加上 self.
使用 moveToThread 把 worker 當引數送進 thread 物件中,注意一個 worker 對一個 thread
若 thread 結束時,裡面的 worker 不可以留著。因此要將 thread.finished 訊號連接到 worker 的刪除行為
啟動 thread 讓 thread 在背景 stand by,這樣裡面的 worker 才能收訊號與做動作
# conrtoller 中
def setup_thread(self) -> None:
"""建立兩個 QThread 並將 Worker 移入其中。"""
self.thread_1s = QThread()
self.thread_3s = QThread()
# 將 Worker 移入背景執行緒
self.accu_1s.moveToThread(self.thread_1s)
self.accu_3s.moveToThread(self.thread_3s)
# 綁定生命週期釋放:執行緒結束時安全銷毀 Worker
self.thread_1s.finished.connect(self.accu_1s.deleteLater)
self.thread_3s.finished.connect(self.accu_3s.deleteLater)
# 啟動背景執行緒的事件迴圈
self.thread_1s.start()
self.thread_3s.start()
在本範例中,setup_thread() 只在 Controller 初始化時執行一次。之後使用者按下開始或停止按鈕時,是透過 signal 通知同一個 worker 工作或暫停,不需要重新建立 worker 與 thread。
如果每次按下開始都重新建立 QThread 與 worker,並直接把 self.thread、self.worker 指向新物件,並不會自動停止舊的背景執行緒。
若舊的 QThread 尚在執行就被銷毀,會引發錯誤導致 GUI 程式閃退;若舊的物件仍被其他參考保留,則可能累積多個 worker、thread 與的資料來佔據我們又貴又小的記憶體。
因此,本範例會在程式啟動時建立 worker 與 thread,並在執行期間重複使用;程式關閉時要按照流程(下面會提到)結束 thread 並清理 Worker。
若採用”每次任務建立一組 worker 與 thread”的設計,則必須等該任務與 thread 確實結束後,再清理 worker 並建立下一組
在一些較早期的文章中會讓 worker 繼承 QThread,然後直接在主執行緒(也就是 controller 所在處)執行該 worker。
但 QT 官方建議寫法是上面的 ”worker 繼承 QObject 並將 worker 移至 QThread”,原因是QThread 的本質是 ”執行緒管理者”、不該乘載工作邏輯,且這樣寫的話不能使用 signal & slot (官方說明)
Worker 若要修改 UI,需透過 signal & slot。可以看到下面 controller 中有一個 slot update_1s_text 對應修改 UI 功能。
當不同執行緒發射訊號要修改同一對象時,QT 底層會依先後順序做排程,避免同一時間修改同一元件造成的異常。
Qtimer 計時 → (時間到了) → 發射單訊號給計數器 → 計數器累計數字 → 發射含數字訊號給 comtroller
# conrtoller 中
def make_connection(self) -> None:
"""連接 UI 按鈕、Controller 與 Worker 之訊號。"""
# 按鈕觸發 Controller 訊號
self.ui_window.bn_1s_start.clicked.connect(self.start_sig_1.emit)
self.ui_window.bn_1s_stop.clicked.connect(self.stop_sig_1.emit)
# Controller 訊號跨執行緒通知 Worker 執行
self.start_sig_1.connect(self.accu_1s.start_accumulation)
self.stop_sig_1.connect(self.accu_1s.stop_accumulation)
# Worker 透過 Signal 回傳結果,由 Controller 在主執行緒更新 UI
self.accu_1s.numeral_sig.connect(self.update_1s_text)
@Slot(str)
def update_1s_text(self, context: str) -> None:
"""更新 UI 標籤文字(位於主執行緒執行)。"""
self.ui_window.lb_1s_step.setText(context)
一般使用者很容易以為「按下右上角的 X,整個程式與所有執行緒就會乖乖關閉」。
實際上按下X後,主執行緒只會關閉 UI 畫面後自行銷毀,而背景執行緒與對應 worker 需要我們開發者主動設定關閉流程。若我們沒有指定背景執行緒的關閉流程,背景執行緒在流程中會被強制關閉,就像直接拔電腦插頭一樣。
不正常關閉背景執行緒與 worker 可能會導致以下結果:
檔案損毀:強制關閉時若 worker 正在寫入檔案,會造成壞檔
程式崩潰:若主執行緒關閉時,背景執行緒的 worker 仍在執行,Qt 會強制 abort 閃退
主執行緒關閉前,要先確保背景執行緒不會運作、且裡面的 worker 要被清理。我們可以在 controller 中先定義背景執行緒的關閉流程,然後在程式關閉前呼叫該 function。
執行緒的清理 function 流程如下:
退出事件迴圈(quit):呼叫 thread.quit() 通知背景執行緒停止事件迴圈
等待收尾(wait):呼叫 thread.wait() 等待背景執行緒徹底終止
另外我們在上面的 setup_thread 中設定背景執行緒終止時,會透過 thread.finished.connect(worker.deleteLater) 通知 worker 刪除自己。透過這樣的流程設定即可正常關閉背景執行緒與其對應的 worker。
# conrtoller 中
@Slot()
def shutdown_threads(self) -> None:
"""安全關閉所有背景執行緒與釋放資源。"""
for thread in (self.thread_1s, self.thread_3s):
if thread.isRunning():
thread.quit() # 告知執行緒退出事件迴圈
for thread in (self.thread_1s, self.thread_3s):
thread.wait() # 等待執行緒完全停止
print("背景執行緒已完全釋放")
上面的 function 在我們的流程設定中是在 GUI 程式關閉前才呼叫,因此我們要想辦法抓到程式程式關閉的時機。QT 內有一訊號 QApplication.aboutToQuit 便是視窗即將關閉的訊號,我們可以把它連接到剛剛設定的 shutdown_threads function 即可設定背景執行緒關閉流程
# main.py 中的整合配置
if __name__ == "__main__":
app = QApplication(sys.argv)
myWin = MyMainWindow()
controller = MainController(myWin)
# 於程式退出前綁定安全清理方法
app.aboutToQuit.connect(controller.shutdown_threads)
myWin.show()
sys.exit(app.exec())

在上面的流程圖中,我們可以看到多執行緒的特點:
主執行緒會管理 UI、背景執行緒的生命期、執行緒內的 worker 行為;而背景執行緒會管理裡面的 worker 生命期
耗時的 worker 在背景執行緒運作
不同執行緒之間靠 signal & slot 傳遞資訊
背景執行緒與 worker 的銷毀流程需另外指定,且由 controller 管理
今天我們完成了多執行緒的建立、跨執行緒的資訊傳遞,以及 QThread 與 worker 的生命週期管理。
明天開始進入主題六「實戰演練與結語」!在 Day 27 綜合實戰 (上) 中,我們將把主題一至五的所有模組(data pipeline + UI )完整串接,打造專屬於半導體工程師的良率報表輸出工具。