2024年5月28日 星期二

8.VB.NET 精進篇 筆記 - 執行緒(Thread)

VB.NET 執行緒(Thread)筆記(精進篇)

VB.NET 執行緒 筆記(精進篇)

Thread 可以先從「畫面為什麼會卡住」開始理解。Windows Forms 的畫面、按鈕點擊、Label 更新與視窗互動,主要都由 UI 執行緒處理。當一段耗時工作也放在 UI 執行緒執行時,畫面就會暫時沒有空回應操作。

執行緒本身是程式執行期間的工作單位。程式碼只是寫好的指令;真正讓指令往下執行的是執行緒。一個應用程式可以只有主要執行緒,也可以另外建立背景執行緒,讓耗時工作不要佔住畫面。

這篇的閱讀重點是先建立操作感:UI 執行緒負責畫面,背景執行緒負責耗時工作,畫面更新必須安全回到 UI 執行緒。 後面再逐步看 ThreadSyncLockThreadPoolTaskCancellationToken

先從畫面體驗理解 Thread

第一個感覺:畫面卡住,不一定是程式壞掉

表單程式按下按鈕後,如果畫面暫時不能拖曳、不能點擊、Label 沒有立刻更新,常見原因是 UI 執行緒正在忙著做其他工作。這時程式可能還在跑,只是畫面沒有空處理互動。

  • UI 執行緒:處理畫面、按鈕事件、控制項更新。
  • 耗時工作:大量計算、檔案處理、設備等待、網路等待、報表產生。
  • 卡住原因:耗時工作和畫面反應擠在同一個 UI 執行緒上。

第二個感覺:背景工作讓畫面有空呼吸

背景執行緒的目的,是把耗時工作移出 UI 執行緒。UI 執行緒繼續處理畫面,背景執行緒處理資料。等背景工作完成,再把結果交回 UI 執行緒顯示。

流程感受
按下按鈕 → UI 顯示「開始處理」 → 背景執行緒整理資料 → UI 仍可拖曳與回應 → 背景完成後回到 UI 顯示結果

用三句話抓住這篇主軸:

  1. Thread 是執行單位:負責把程式碼跑起來。
  2. UI 執行緒管畫面:控制項更新與使用者互動主要靠它處理。
  3. 背景執行緒管耗時工作:做完後要用 BeginInvokeInvokeAwait 回到 UI 顯示結果。
情境 比較適合的處理方式 理解重點
只改幾個控制項 直接在 UI 執行緒處理。 工作很短,不需要刻意開背景執行緒。
大量計算 ThreadTask.Run 避免 CPU 工作長時間占住 UI。
檔案、設備、網路等待 TaskAsync / Await 等待期間不要凍結畫面。
多條背景工作改同一份資料 SyncLockInterlocked 或並行集合。 避免同時改寫造成資料不穩。

執行環境與元件配置說明

以下範例使用 Windows Forms

測試前請建立 Windows Forms 專案,並依各場景放置對應主控項。為了讓輸出結果容易閱讀,建議將顯示訊息用的 LabelTextBox 預先放大,並將多行文字區的 Multiline 屬性設為 True

  • Label:適合顯示短狀態或計數結果。
  • TextBox:適合顯示多行記錄,建議搭配 ScrollBars = Vertical
  • Button:用來啟動、取消或比較不同模式。
  • ProgressBar:適合模擬背景工作進度。
  • ListBox:適合顯示多筆工作完成紀錄。

執行緒建立與 UI 封送

場景一:使用 Thread 模擬產生今日報表

這個場景用「產生今日報表」來示範。按下按鈕後,畫面先顯示開始產生;耗時整理放到背景執行緒;完成後再回到 UI 執行緒更新 Label1。測試時可以在等待期間拖曳表單,感受畫面沒有被整段工作堵住。

需要的主控項
  • Button1:開始產生報表。
  • Label1:顯示報表產生狀態。
範例程式碼
VB.NET / Windows Forms
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
畫面輸出結果(Label1.Text)
開始產生今日報表... 今日報表完成,共整理 45 筆資料
邏輯解析
  • 按下 Button1 時,程式仍在 UI 執行緒,適合先更新狀態文字。
  • New Thread(AddressOf BuildDailyReport) 把報表整理工作交給另一個執行緒。
  • BuildDailyReport 只負責模擬整理資料,不直接碰畫面控制項。
  • BeginInvoke 把更新 Label1 的動作交回 UI 執行緒,避免跨執行緒更新錯誤。

為什麼不能直接改 Label1.Text?

Windows Forms 控制項不是執行緒安全物件。 若背景執行緒直接修改控制項內容,容易發生跨執行緒存取例外。凡是涉及控制項文字、清單內容、進度條數值等更新,都應透過 InvokeBeginInvokeAwait 回到 UI 執行緒。

Thread 的入口方法

Thread 建立時,需要指定背景執行緒開始後要執行哪一段程式。常見寫法是 AddressOf 方法名稱,代表把方法交給 Thread,等呼叫 Start() 後才執行。

VB.NET
Dim worker As New Thread(AddressOf BuildDailyReport)
worker.Start()

BuildDailyReport() 是立刻呼叫方法;AddressOf BuildDailyReport 是把方法作為執行入口交給 Thread。文章中只需記住這個差異即可。

生命週期與管理方式

場景二:觀察三個檔案依序處理

這個場景用「三個檔案依序處理」來觀察生命週期。重點是看出 Thread 不是建立後就馬上跑;建立、呼叫 Start、背景執行、完成結束,會一行一行顯示在 TextBox1

需要的主控項
  • Button1:建立並啟動執行緒。
  • TextBox1:多行顯示狀態記錄。
範例程式碼
VB.NET / Windows Forms
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
畫面輸出結果(TextBox1)
建立完成,尚未啟動 已呼叫 Start,等待系統排程 處理中:檔案 1 處理中:檔案 2 處理中:檔案 3 三個檔案處理完成,執行緒結束
邏輯解析
  • 執行緒物件被建立後,還不會自動執行,必須呼叫 Start()
  • Start() 代表進入待排程狀態,實際執行時機由系統決定。
  • 工作方法自然執行完畢後,該執行緒就會結束。
  • 同一個 Thread 物件結束後不能再次呼叫 Start()
階段 實務理解
建立後未啟動 已配置 Thread 物件,但尚未呼叫 Start()
等待排程 已呼叫 Start(),等待系統安排執行時間。
執行中 工作方法正在進行,可與 UI 執行緒並行存在。
已結束 工作方法執行完成,或透過協作式機制結束。

共享資料與同步控制

場景三:使用 SyncLock 保護兩個櫃台的售票數

這個場景用「兩個櫃台同時售票」來示範共享資料。兩條背景執行緒都會增加同一個售票總數,因此需要保護這段加總動作。這裡要理解的不是鎖越多越好,而是「同一份資料被同時修改時,才需要同步控制」。

需要的主控項
  • Button1:啟動兩個櫃台售票。
  • Label1:顯示最終售票數。
範例程式碼
VB.NET / Windows Forms
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
畫面輸出結果(Label1.Text)
開始統計售票數... 最終售票數:2000
邏輯解析
  • soldTicketCount 是共享變數,兩條執行緒都會修改。
  • SyncLock lockObject 確保同一時間只有一條執行緒能進入臨界區。
  • Join() 用來等待指定執行緒完成。
  • 等待動作放在 watcher,避免 UI 執行緒被 Join() 卡住。

常見同步工具比較

  • SyncLock:語法直接,適合保護短小的臨界區。
  • Interlocked:適合單一數值的原子遞增、交換或比較。
  • ReaderWriterLockSlim:適合讀多寫少的共享資料。
  • ConcurrentDictionary 等並行集合:適合高頻率的共享集合操作。

ThreadPool、Task 與 Async 的分工

場景四:使用 ThreadPool 處理四張圖片縮圖

這個場景用「四張圖片產生縮圖」來示範 ThreadPool。每張圖片都是短工作,適合排入執行緒池,不需要為每張圖片手動建立一條 Thread

需要的主控項
  • Button1:加入工作項。
  • ListBox1:顯示完成記錄。
範例程式碼
VB.NET / Windows Forms
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
畫面輸出結果(ListBox1)
縮圖完成:圖片 1 縮圖完成:圖片 2 縮圖完成:圖片 3 縮圖完成:圖片 4
邏輯解析
  • ThreadPool.QueueUserWorkItem 把工作交給系統管理的執行緒池。
  • capturedImage 保存每一輪迴圈的圖片編號,避免閉包造成顯示混亂。
  • 工作時間過長時,會長時間占用執行緒池資源,因此不宜把重型長任務全都丟進去。

場景五:使用 Task 與 Await 檢查五個資料夾

這個場景用「檢查五個資料夾」來示範 TaskAwait。進度條會一段一段前進,最後背景整理結果再回到畫面顯示。這種範例比較容易感受到非同步流程的節奏。

需要的主控項
  • Button1:開始檢查資料夾。
  • Label1:顯示檢查結果。
  • ProgressBar1:顯示檢查進度。
範例程式碼
VB.NET / Windows Forms
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
畫面輸出結果
檢查資料夾中... 檢查完成:5 個資料夾中有 2 個需要備份
邏輯解析
  • Async Sub 常用於 Windows Forms 事件處理程序。
  • Await Task.Delay(...) 用來模擬可等待的非同步進度,等待期間不會凍結畫面。
  • Task.Run 把資料夾檢查摘要交給背景執行。
  • Await 完成後,預設會回到 UI 流程,因此可直接更新控制項。

取消機制與安全結束

場景六:使用 CancellationToken 取消大型檔案整理

這個場景用「大型檔案整理」來示範取消。按下開始後,背景工作分段進行;按下取消時,不是硬切掉執行緒,而是發出取消訊號,讓工作在下一個檢查點安全結束。

需要的主控項
  • Button1:開始整理大型檔案。
  • Button2:取消整理。
  • Label1:顯示整理狀態。
範例程式碼
VB.NET / Windows Forms
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
畫面輸出結果(Label1.Text)
大型檔案整理中... 任務已取消
邏輯解析
  • 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 執行緒。

VB.NET / Windows Forms
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.AbortSuspendResume 這類做法容易讓資料停在不完整狀態。需要停止長時間工作時,建議使用 CancellationToken 讓工作在安全位置自行結束。

常見錯誤與注意事項

常見問題整理

  • 背景執行緒直接操作 UI:容易發生跨執行緒存取例外。
  • 鎖定範圍過大:把耗時工作放進 SyncLock,會增加等待時間與死鎖風險。
  • 執行緒數量失控:每次按鈕都建立新執行緒,長期執行容易浪費資源。
  • 使用過時 API:Thread.AbortSuspendResume 不適合現代實務流程。
  • 誤把所有工作都平行化:過度拆分反而會增加同步成本與除錯難度。

實務整理:

  1. 耗時工作不應直接堵住 UI 執行緒。
  2. 背景執行緒不能直接更新 Windows Forms 控制項。
  3. 共享資料需用 SyncLockInterlocked 或並行集合保護。
  4. 表單程式多數非同步流程可優先考慮 TaskAsync / Await
  5. 需要中止長時間流程時,優先使用 CancellationToken 做協作式取消。

重點整理

  1. Thread 是程式執行期間的執行單位,一個應用程式可以有多條執行緒。
  2. Windows Forms 的 UI 控制項必須回到 UI 執行緒更新。
  3. 背景執行緒適合處理耗時工作,但需要處理 UI 封送與安全結束。
  4. SyncLock 適合保護短小的共享資料修改區段。
  5. ThreadPool 適合短工作項,不適合長時間占用。
  6. Task / Async 通常更適合表單程式的非同步流程與取消處理。
  7. CancellationToken 是較安全的取消方式,不應依賴強制中止執行緒。