VB.NET 執行緒 筆記(精進篇)
Thread 可以先從「畫面為什麼會卡住」開始理解。Windows Forms 的畫面、按鈕點擊、Label 更新與視窗互動,主要都由 UI 執行緒處理。當一段耗時工作也放在 UI 執行緒執行時,畫面就會暫時沒有空回應操作。
執行緒本身是程式執行期間的工作單位。程式碼只是寫好的指令;真正讓指令往下執行的是執行緒。一個應用程式可以只有主要執行緒,也可以另外建立背景執行緒,讓耗時工作不要佔住畫面。
這篇的閱讀重點是先建立操作感:UI 執行緒負責畫面,背景執行緒負責耗時工作,畫面更新必須安全回到 UI 執行緒。 後面再逐步看 Thread、SyncLock、ThreadPool、Task 與 CancellationToken。
先從畫面體驗理解 Thread
第一個感覺:畫面卡住,不一定是程式壞掉
表單程式按下按鈕後,如果畫面暫時不能拖曳、不能點擊、Label 沒有立刻更新,常見原因是 UI 執行緒正在忙著做其他工作。這時程式可能還在跑,只是畫面沒有空處理互動。
- UI 執行緒:處理畫面、按鈕事件、控制項更新。
- 耗時工作:大量計算、檔案處理、設備等待、網路等待、報表產生。
- 卡住原因:耗時工作和畫面反應擠在同一個 UI 執行緒上。
第二個感覺:背景工作讓畫面有空呼吸
背景執行緒的目的,是把耗時工作移出 UI 執行緒。UI 執行緒繼續處理畫面,背景執行緒處理資料。等背景工作完成,再把結果交回 UI 執行緒顯示。
用三句話抓住這篇主軸:
- Thread 是執行單位:負責把程式碼跑起來。
- UI 執行緒管畫面:控制項更新與使用者互動主要靠它處理。
- 背景執行緒管耗時工作:做完後要用
BeginInvoke、Invoke或Await回到 UI 顯示結果。
| 情境 | 比較適合的處理方式 | 理解重點 |
|---|---|---|
| 只改幾個控制項 | 直接在 UI 執行緒處理。 | 工作很短,不需要刻意開背景執行緒。 |
| 大量計算 | Thread 或 Task.Run。 |
避免 CPU 工作長時間占住 UI。 |
| 檔案、設備、網路等待 | Task、Async / Await。 |
等待期間不要凍結畫面。 |
| 多條背景工作改同一份資料 | SyncLock、Interlocked 或並行集合。 |
避免同時改寫造成資料不穩。 |
執行環境與元件配置說明
以下範例使用 Windows Forms
測試前請建立 Windows Forms 專案,並依各場景放置對應主控項。為了讓輸出結果容易閱讀,建議將顯示訊息用的 Label 或 TextBox 預先放大,並將多行文字區的 Multiline 屬性設為 True。
Label:適合顯示短狀態或計數結果。TextBox:適合顯示多行記錄,建議搭配ScrollBars = Vertical。Button:用來啟動、取消或比較不同模式。ProgressBar:適合模擬背景工作進度。ListBox:適合顯示多筆工作完成紀錄。
執行緒建立與 UI 封送
場景一:使用 Thread 模擬產生今日報表
這個場景用「產生今日報表」來示範。按下按鈕後,畫面先顯示開始產生;耗時整理放到背景執行緒;完成後再回到 UI 執行緒更新 Label1。測試時可以在等待期間拖曳表單,感受畫面沒有被整段工作堵住。
需要的主控項
Button1:開始產生報表。Label1:顯示報表產生狀態。
範例程式碼
Imports System.Threading
Public Class Form1
Private reportThread As Thread
Private Sub Button1_Click(sender As Object, e As EventArgs) Handles Button1.Click
If reportThread IsNot Nothing AndAlso reportThread.IsAlive Then
Label1.Text = "今日報表仍在產生中"
Exit Sub
End If
Label1.Text = "開始產生今日報表..."
reportThread = New Thread(AddressOf BuildDailyReport)
reportThread.IsBackground = True
reportThread.Start()
End Sub
Private Sub BuildDailyReport()
Dim reportItemCount As Integer = 0
For pageNo As Integer = 1 To 5
Thread.Sleep(400)
reportItemCount += pageNo * 3
Next
If Me.IsHandleCreated AndAlso Not Me.IsDisposed Then
Me.BeginInvoke(New MethodInvoker(Sub()
Label1.Text = "今日報表完成,共整理 " & reportItemCount.ToString() & " 筆資料"
End Sub))
End If
End Sub
End Class
邏輯解析
- 按下
Button1時,程式仍在 UI 執行緒,適合先更新狀態文字。 New Thread(AddressOf BuildDailyReport)把報表整理工作交給另一個執行緒。BuildDailyReport只負責模擬整理資料,不直接碰畫面控制項。BeginInvoke把更新Label1的動作交回 UI 執行緒,避免跨執行緒更新錯誤。
為什麼不能直接改 Label1.Text?
Windows Forms 控制項不是執行緒安全物件。 若背景執行緒直接修改控制項內容,容易發生跨執行緒存取例外。凡是涉及控制項文字、清單內容、進度條數值等更新,都應透過 Invoke、BeginInvoke 或 Await 回到 UI 執行緒。
Thread 的入口方法
Thread 建立時,需要指定背景執行緒開始後要執行哪一段程式。常見寫法是 AddressOf 方法名稱,代表把方法交給 Thread,等呼叫 Start() 後才執行。
Dim worker As New Thread(AddressOf BuildDailyReport)
worker.Start()
BuildDailyReport() 是立刻呼叫方法;AddressOf BuildDailyReport 是把方法作為執行入口交給 Thread。文章中只需記住這個差異即可。
生命週期與管理方式
場景二:觀察三個檔案依序處理
這個場景用「三個檔案依序處理」來觀察生命週期。重點是看出 Thread 不是建立後就馬上跑;建立、呼叫 Start、背景執行、完成結束,會一行一行顯示在 TextBox1。
需要的主控項
Button1:建立並啟動執行緒。TextBox1:多行顯示狀態記錄。
範例程式碼
Imports System.Threading
Public Class Form1
Private jobThread As Thread
Private Sub Form1_Load(sender As Object, e As EventArgs) Handles MyBase.Load
TextBox1.Multiline = True
TextBox1.ScrollBars = ScrollBars.Vertical
End Sub
Private Sub Button1_Click(sender As Object, e As EventArgs) Handles Button1.Click
TextBox1.Clear()
jobThread = New Thread(AddressOf SimulateFileProcessing)
TextBox1.AppendText("建立完成,尚未啟動" & Environment.NewLine)
jobThread.Start()
TextBox1.AppendText("已呼叫 Start,等待系統排程" & Environment.NewLine)
End Sub
Private Sub SimulateFileProcessing()
For fileNo As Integer = 1 To 3
Thread.Sleep(600)
AppendLogFromWorker("處理中:檔案 " & fileNo.ToString())
Next
AppendLogFromWorker("三個檔案處理完成,執行緒結束")
End Sub
Private Sub AppendLogFromWorker(ByVal message As String)
If Me.IsHandleCreated AndAlso Not Me.IsDisposed Then
Me.BeginInvoke(New MethodInvoker(Sub()
TextBox1.AppendText(message & Environment.NewLine)
End Sub))
End If
End Sub
End Class
邏輯解析
- 執行緒物件被建立後,還不會自動執行,必須呼叫
Start()。 Start()代表進入待排程狀態,實際執行時機由系統決定。- 工作方法自然執行完畢後,該執行緒就會結束。
- 同一個
Thread物件結束後不能再次呼叫Start()。
| 階段 | 實務理解 |
|---|---|
| 建立後未啟動 | 已配置 Thread 物件,但尚未呼叫 Start()。 |
| 等待排程 | 已呼叫 Start(),等待系統安排執行時間。 |
| 執行中 | 工作方法正在進行,可與 UI 執行緒並行存在。 |
| 已結束 | 工作方法執行完成,或透過協作式機制結束。 |
共享資料與同步控制
場景三:使用 SyncLock 保護兩個櫃台的售票數
這個場景用「兩個櫃台同時售票」來示範共享資料。兩條背景執行緒都會增加同一個售票總數,因此需要保護這段加總動作。這裡要理解的不是鎖越多越好,而是「同一份資料被同時修改時,才需要同步控制」。
需要的主控項
Button1:啟動兩個櫃台售票。Label1:顯示最終售票數。
範例程式碼
Imports System.Threading
Public Class Form1
Private soldTicketCount As Integer = 0
Private ReadOnly lockObject As New Object()
Private Sub Button1_Click(sender As Object, e As EventArgs) Handles Button1.Click
soldTicketCount = 0
Label1.Text = "開始統計售票數..."
Dim t1 As New Thread(AddressOf SellTickets)
Dim t2 As New Thread(AddressOf SellTickets)
t1.Start()
t2.Start()
Dim watcher As New Thread(Sub()
t1.Join()
t2.Join()
If Me.IsHandleCreated AndAlso Not Me.IsDisposed Then
Me.BeginInvoke(New MethodInvoker(Sub()
Label1.Text = "最終售票數:" & soldTicketCount.ToString()
End Sub))
End If
End Sub)
watcher.IsBackground = True
watcher.Start()
End Sub
Private Sub SellTickets()
For i As Integer = 1 To 1000
SyncLock lockObject
soldTicketCount += 1
End SyncLock
Next
End Sub
End Class
邏輯解析
soldTicketCount是共享變數,兩條執行緒都會修改。SyncLock lockObject確保同一時間只有一條執行緒能進入臨界區。Join()用來等待指定執行緒完成。- 等待動作放在
watcher,避免 UI 執行緒被Join()卡住。
常見同步工具比較
SyncLock:語法直接,適合保護短小的臨界區。Interlocked:適合單一數值的原子遞增、交換或比較。ReaderWriterLockSlim:適合讀多寫少的共享資料。ConcurrentDictionary等並行集合:適合高頻率的共享集合操作。
ThreadPool、Task 與 Async 的分工
場景四:使用 ThreadPool 處理四張圖片縮圖
這個場景用「四張圖片產生縮圖」來示範 ThreadPool。每張圖片都是短工作,適合排入執行緒池,不需要為每張圖片手動建立一條 Thread。
需要的主控項
Button1:加入工作項。ListBox1:顯示完成記錄。
範例程式碼
Imports System.Threading
Public Class Form1
Private Sub Button1_Click(sender As Object, e As EventArgs) Handles Button1.Click
ListBox1.Items.Clear()
For imageNo As Integer = 1 To 4
Dim capturedImage As Integer = imageNo
ThreadPool.QueueUserWorkItem(Sub(state As Object)
Thread.Sleep(500 + capturedImage * 150)
If Me.IsHandleCreated AndAlso Not Me.IsDisposed Then
Me.BeginInvoke(New MethodInvoker(Sub()
ListBox1.Items.Add("縮圖完成:圖片 " & capturedImage.ToString())
End Sub))
End If
End Sub)
Next
End Sub
End Class
邏輯解析
ThreadPool.QueueUserWorkItem把工作交給系統管理的執行緒池。capturedImage保存每一輪迴圈的圖片編號,避免閉包造成顯示混亂。- 工作時間過長時,會長時間占用執行緒池資源,因此不宜把重型長任務全都丟進去。
場景五:使用 Task 與 Await 檢查五個資料夾
這個場景用「檢查五個資料夾」來示範 Task 與 Await。進度條會一段一段前進,最後背景整理結果再回到畫面顯示。這種範例比較容易感受到非同步流程的節奏。
需要的主控項
Button1:開始檢查資料夾。Label1:顯示檢查結果。ProgressBar1:顯示檢查進度。
範例程式碼
Imports System.Threading
Imports System.Threading.Tasks
Public Class Form1
Private Async Sub Button1_Click(sender As Object, e As EventArgs) Handles Button1.Click
Button1.Enabled = False
Label1.Text = "檢查資料夾中..."
ProgressBar1.Value = 0
Try
For stepNo As Integer = 1 To 5
Await Task.Delay(350)
ProgressBar1.Value = stepNo * 20
Next
Dim summary As String = Await Task.Run(Function() BuildFolderCheckSummary())
Label1.Text = summary
Finally
Button1.Enabled = True
End Try
End Sub
Private Function BuildFolderCheckSummary() As String
Thread.Sleep(800)
Return "檢查完成:5 個資料夾中有 2 個需要備份"
End Function
End Class
邏輯解析
Async Sub常用於 Windows Forms 事件處理程序。Await Task.Delay(...)用來模擬可等待的非同步進度,等待期間不會凍結畫面。Task.Run把資料夾檢查摘要交給背景執行。Await完成後,預設會回到 UI 流程,因此可直接更新控制項。
取消機制與安全結束
場景六:使用 CancellationToken 取消大型檔案整理
這個場景用「大型檔案整理」來示範取消。按下開始後,背景工作分段進行;按下取消時,不是硬切掉執行緒,而是發出取消訊號,讓工作在下一個檢查點安全結束。
需要的主控項
Button1:開始整理大型檔案。Button2:取消整理。Label1:顯示整理狀態。
範例程式碼
Imports System.Threading
Imports System.Threading.Tasks
Public Class Form1
Private cts As CancellationTokenSource
Private Async Sub Button1_Click(sender As Object, e As EventArgs) Handles Button1.Click
If cts IsNot Nothing Then
cts.Dispose()
End If
cts = New CancellationTokenSource()
Button1.Enabled = False
Button2.Enabled = True
Label1.Text = "大型檔案整理中..."
Try
Dim message As String = Await OrganizeLargeFilesAsync(cts.Token)
Label1.Text = message
Catch ex As OperationCanceledException
Label1.Text = "任務已取消"
Finally
Button1.Enabled = True
Button2.Enabled = False
End Try
End Sub
Private Sub Button2_Click(sender As Object, e As EventArgs) Handles Button2.Click
If cts IsNot Nothing Then
cts.Cancel()
End If
End Sub
Private Async Function OrganizeLargeFilesAsync(ByVal token As CancellationToken) As Task(Of String)
For fileGroupNo As Integer = 1 To 8
token.ThrowIfCancellationRequested()
Await Task.Delay(300, token)
Next
Return "整理完成:8 組大型檔案已建立索引"
End Function
End Class
邏輯解析
CancellationTokenSource負責發出取消通知。ThrowIfCancellationRequested()用來在適當節點結束流程。Task.Delay(300, token)讓等待期間也能回應取消。OperationCanceledException是協作式取消的正常結束訊號之一。
Thread、ThreadPool、Task 的選擇比較
| 比較項目 | Thread | ThreadPool | Task / Async |
|---|---|---|---|
| 控制程度 | 高,可自行決定建立方式與背景屬性。 | 中,由系統管理。 | 高,但以工作流程為核心。 |
| 使用成本 | 較高。 | 較低。 | 中等,語法較容易串接流程。 |
| 適合工作 | 需要明確背景執行緒概念或特殊控制的工作。 | 大量短工作項。 | I/O 等待、非同步流程、取消與例外管理。 |
| 表單程式建議 | 可用於理解概念與特定控制情境。 | 適合短工作,不適合長時間占用。 | 多數非同步流程優先考慮。 |
實務補充:完整使用觀念
不是所有工作都需要 Thread
工作很短、只更新畫面、只做少量資料處理時,通常不需要特別建立執行緒。執行緒適合用在會明顯等待、會讓畫面卡住、或需要和 UI 流程分開執行的工作。
- 不太需要:修改 Label 文字、檢查幾個欄位、處理少量資料。
- 可以考慮:大量計算、批次檔案處理、長時間設備等待、報表產生、網路或資料庫等待。
- 表單程式常用方向:理解底層時看
Thread;實務流程多半優先考慮Task / Async / Await。
Invoke、BeginInvoke、InvokeRequired 的位置
背景執行緒要更新 Windows Forms 控制項時,需要回到 UI 執行緒。簡單範例可以直接使用 BeginInvoke;若要寫成共用方法,可以用 InvokeRequired 判斷目前是否已經在 UI 執行緒。
Private Sub SetStatusText(ByVal message As String)
If Label1.InvokeRequired Then
Label1.BeginInvoke(New MethodInvoker(Sub()
Label1.Text = message
End Sub))
Else
Label1.Text = message
End If
End Sub
邏輯解析
InvokeRequired用來判斷目前執行緒是否需要切回 UI 執行緒。BeginInvoke會排入 UI 執行緒執行,不會讓背景執行緒一直等待畫面更新完成。Invoke會等待 UI 執行緒執行完成,使用時需避免互相等待造成卡住。
不建議強制中止執行緒。Thread.Abort、Suspend、Resume 這類做法容易讓資料停在不完整狀態。需要停止長時間工作時,建議使用 CancellationToken 讓工作在安全位置自行結束。
常見錯誤與注意事項
常見問題整理
- 背景執行緒直接操作 UI:容易發生跨執行緒存取例外。
- 鎖定範圍過大:把耗時工作放進
SyncLock,會增加等待時間與死鎖風險。 - 執行緒數量失控:每次按鈕都建立新執行緒,長期執行容易浪費資源。
- 使用過時 API:
Thread.Abort、Suspend、Resume不適合現代實務流程。 - 誤把所有工作都平行化:過度拆分反而會增加同步成本與除錯難度。
實務整理:
- 耗時工作不應直接堵住 UI 執行緒。
- 背景執行緒不能直接更新 Windows Forms 控制項。
- 共享資料需用
SyncLock、Interlocked或並行集合保護。 - 表單程式多數非同步流程可優先考慮
Task與Async / Await。 - 需要中止長時間流程時,優先使用
CancellationToken做協作式取消。
重點整理
Thread是程式執行期間的執行單位,一個應用程式可以有多條執行緒。- Windows Forms 的 UI 控制項必須回到 UI 執行緒更新。
- 背景執行緒適合處理耗時工作,但需要處理 UI 封送與安全結束。
SyncLock適合保護短小的共享資料修改區段。ThreadPool適合短工作項,不適合長時間占用。Task / Async通常更適合表單程式的非同步流程與取消處理。CancellationToken是較安全的取消方式,不應依賴強制中止執行緒。