2024年8月2日 星期五

24.VB.NET 筆記 進階篇 - SyncLock 同步鎖定筆記

VB.NET SyncLock 同步鎖定筆記 (進階篇) - 多執行緒同步機制完整教學

VB.NET SyncLock 同步鎖定 筆記 (進階篇)

在 VB.NET 的多執行緒程式設計中,共享資源就像一間「單人洗手間」共享資源就像洗手間一樣,同一時間只能讓一個人使用,其他人必須排隊等候。。而 SyncLock (同步鎖定) 就像是這間洗手間門上的那把「鎖」SyncLock 就像門上的鎖,確保同一時間只有一個執行緒能進入並使用共享資源。。在沒有鎖的情況下,多個執行緒 (人) 可能會同時試圖進入並使用資源 (洗手間),造成混亂與衝突。SyncLock 確保在任何時候,只有一個執行緒能取得鑰匙、鎖上門,並在裡面安全地完成工作。其他人則必須在門外排隊等候,直到使用者出來並交還鑰匙。這種「一次只允許一人進入」的機制,是確保執行緒安全、防止資料損毀的核心概念。

認識 SyncLock 同步鎖定

SyncLock (同步鎖定): SyncLock 陳述式可確保在同一時間,只有一個執行緒可以執行被鎖定的程式碼區塊。它透過為指定的「鎖定物件」鎖定物件是作為執行緒之間溝通用的「鑰匙」,所有想進入受保護區域的執行緒,都必須先試圖取得這個物件的鎖。取得互斥鎖互斥鎖確保同一時間只有一個執行緒能持有,其他執行緒必須等待。來達成此目的。當一個執行緒離開 SyncLock 區塊時,鎖定就會被釋放。

SyncLock 主要由兩個部分組成:

  • 鎖定物件 (The Lock Object): 這是一個參考型別的物件,作為執行緒之間溝通用的「鑰匙」就像真實世界的鑰匙,只有持有它的人才能進入被保護的區域。。所有想進入受保護區域的執行緒,都必須先試圖取得這個物件的鎖。
  • 程式碼區塊 (The Code Block): 這是被 SyncLock ... End SyncLock 包圍的區域,其中包含存取共享資源的程式碼。執行緒必須先取得鎖定物件的鑰匙,才能執行此區塊的程式碼。

透過這個機制,SyncLock 成為了共享資源的「管理員」管理員確保所有存取都井然有序,防止衝突和資料損毀。,確保所有存取都井然有序。

使用 SyncLock

根據需求的不同,SyncLock 可以應用在從簡單到複雜的各種場景。

功能一:保護共享變數 (基礎同步)

這是 SyncLock 最核心的用途。當多個執行緒需要同時讀寫同一個變數時(例如計數器),若不加以保護,就會發生「競爭條件」競爭條件是指多個執行緒同時存取共享資源時,由於執行順序的不確定性,導致結果不可預期。,導致最終結果不可預期且通常是錯誤的。

基本語法
' 宣告一個私有的鎖定物件
Private ReadOnly lockObject As New Object()

SyncLock lockObject
    ' 此處是受保護的程式碼區塊,
    ' 同一時間只有一個執行緒能進入。
End SyncLock
使用的控制項:
  • Button1:用於啟動測試未使用 SyncLock 的情況。
  • Button2:用於啟動測試使用 SyncLock 的情況。
  • Label1:用於顯示未使用 SyncLock 的計數結果。
  • Label2:用於顯示使用 SyncLock 的計數結果。
  • Label3:用於顯示測試狀態訊息。
範例程式碼
' 引入必要的命名空間以使用執行緒功能
Imports System.Threading

' 定義主要的表單類別
Public Class Form1
    ' 宣告一個鎖定物件,作為執行緒同步的依據
    Private ReadOnly lockObject As New Object()
    ' 宣告一個共享資源 (計數器)
    Private sharedCounter As Integer = 0
    
    ' 按下 Button1 時觸發的事件,測試未使用 SyncLock 的情況
    Private Sub Button1_Click(sender As Object, e As EventArgs) Handles Button1.Click
        ' 重設計數器為 0
        sharedCounter = 0
        ' 更新狀態標籤
        Label3.Text = "測試中 (未使用 SyncLock)..."
        ' 建立 10 個執行緒,每個執行緒將計數器遞增 10000 次
        For i As Integer = 1 To 10
            ' 建立新執行緒,執行遞增動作但不加鎖
            Dim t As New Thread(Sub()
                                    ' 執行 10000 次遞增操作
                                    For j As Integer = 1 To 10000
                                        ' 直接遞增,沒有鎖定保護
                                        sharedCounter += 1
                                    Next
                                End Sub)
            ' 設定為背景執行緒,隨主程式關閉
            t.IsBackground = True
            ' 啟動執行緒
            t.Start()
        Next
        ' 等待一段時間讓執行緒完成
        Threading.Thread.Sleep(1000)
        ' 在 UI 執行緒上更新顯示結果
        Me.Invoke(Sub()
                      ' 理想結果應為 10 * 10000 = 100000
                      Label1.Text = "未使用 SyncLock 結果: " & sharedCounter.ToString("N0")
                      Label3.Text = "測試完成"
                  End Sub)
    End Sub
    
    ' 按下 Button2 時觸發的事件,測試使用 SyncLock 的情況
    Private Sub Button2_Click(sender As Object, e As EventArgs) Handles Button2.Click
        ' 重設計數器為 0
        sharedCounter = 0
        ' 更新狀態標籤
        Label3.Text = "測試中 (使用 SyncLock)..."
        ' 建立 10 個執行緒,每個執行緒將計數器遞增 10000 次
        For i As Integer = 1 To 10
            ' 建立新執行緒,執行遞增動作並使用鎖定
            Dim t As New Thread(Sub()
                                    ' 執行 10000 次遞增操作
                                    For j As Integer = 1 To 10000
                                        ' 使用 SyncLock 保護共享變數的存取
                                        SyncLock lockObject
                                            ' 在鎖定保護下遞增計數器
                                            sharedCounter += 1
                                        End SyncLock
                                    Next
                                End Sub)
            ' 設定為背景執行緒,隨主程式關閉
            t.IsBackground = True
            ' 啟動執行緒
            t.Start()
        Next
        ' 等待一段時間讓執行緒完成
        Threading.Thread.Sleep(1000)
        ' 在 UI 執行緒上更新顯示結果
        Me.Invoke(Sub()
                      ' 理想結果應為 10 * 10000 = 100000
                      Label2.Text = "使用 SyncLock 結果: " & sharedCounter.ToString("N0")
                      Label3.Text = "測試完成"
                  End Sub)
    End Sub
End Class
詳細講解

此範例展示了 SyncLock 的核心功能。當按下 Button1 時,10 個執行緒同時對 sharedCounter 進行遞增操作,但沒有任何保護機制。由於競爭條件多個執行緒同時讀取相同的值,遞增後寫回,導致部分遞增操作被覆蓋。的存在,最終結果通常會小於期望的 100,000。

當按下 Button2 時,同樣是 10 個執行緒進行相同的操作,但這次使用了 SyncLock lockObject 保護遞增操作。此時,每個執行緒在遞增前必須先取得鎖,確保在同一時間只有一個執行緒能修改 sharedCounter。因此,最終結果會精確地達到 100,000,展現了 SyncLock 確保執行緒安全執行緒安全是指程式碼能在多執行緒環境下正確執行,不會產生資料競爭或不一致的結果。的能力。

需要注意的是,Me.Invoke 用於確保 UI 更新在主執行緒上執行,這是 Windows Forms 的要求。

功能二:協調多執行緒更新 UI (進階應用)

在圖形化介面 (UI) 應用中,背景執行緒通常不能直接更新 UI 元件。更常見的是,背景執行緒處理資料,並將結果安全地寫入共享變數,再由 Timer 或主執行緒定期讀取並更新畫面。

使用的控制項:
  • TextBox1:用於輸入要啟動的執行緒數量。
  • Button1:用於啟動背景執行緒。
  • Label1:用於顯示共享計數器的即時值。
  • Timer1:用於定期更新 UI 顯示。
範例程式碼
' 引入必要的命名空間
Imports System.Threading

' 定義主要的表單類別
Public Class Form1
    ' 宣告共享計數器
    Private counter As Integer = 0
    ' 宣告鎖定物件
    Private ReadOnly lockObject As New Object()
    
    ' 表單載入時的初始化
    Private Sub Form1_Load(sender As Object, e As EventArgs) Handles MyBase.Load
        ' 設定 Timer 的間隔為 100 毫秒
        Timer1.Interval = 100
        ' 設定 Label1 的初始文字
        Label1.Text = "計數器: 0"
    End Sub
    
    ' 按下按鈕時觸發的事件
    Private Sub Button1_Click(sender As Object, e As EventArgs) Handles Button1.Click
        ' 宣告變數存放執行緒數量
        Dim threadCount As Integer = 0
        ' 嘗試解析 TextBox1 的文字為整數
        Integer.TryParse(TextBox1.Text, threadCount)
        
        ' 檢查輸入的數量是否有效
        If threadCount <= 0 Then
            ' 輸入無效時顯示預設訊息
            Label1.Text = "請輸入有效的執行緒數量"
            ' 結束方法執行
            Return
        End If
        
        ' 重設計數器為 0
        counter = 0
        ' 根據輸入的數量,建立並啟動執行緒
        For i As Integer = 1 To threadCount
            ' 建立新的執行緒
            Dim t As New Thread(AddressOf IncrementCounter)
            ' 設定為背景執行緒,隨主程式關閉
            t.IsBackground = True
            ' 啟動執行緒
            t.Start()
        Next
        
        ' 啟動 Timer 來更新 UI
        Timer1.Start()
    End Sub
    
    ' 執行緒要執行的工作
    Private Sub IncrementCounter()
        ' 使用 SyncLock 保護對 counter 的存取
        SyncLock lockObject
            ' 在鎖定保護下遞增計數器
            counter += 1
        End SyncLock
    End Sub
    
    ' Timer 每隔一段時間觸發,用以更新 UI
    Private Sub Timer1_Tick(sender As Object, e As EventArgs) Handles Timer1.Tick
        ' 宣告區域變數存放當前計數
        Dim currentCount As Integer = 0
        ' 同樣需要鎖定,以確保讀取到的是一個完整的數值
        SyncLock lockObject
            ' 在鎖定保護下讀取計數器值
            currentCount = counter
        End SyncLock
        ' 更新 Label1 顯示當前計數
        Label1.Text = "計數器: " & currentCount.ToString("N0")
    End Sub
End Class
詳細講解

此範例展示了如何在多執行緒環境中安全地更新 UI。當按下 Button1 時,程式會根據使用者在 TextBox1 輸入的數量建立相應的執行緒。每個執行緒執行 IncrementCounter 方法,該方法使用 SyncLock 保護對共享變數 counter 的遞增操作。

Timer1 定期觸發 Timer1_Tick 事件,在這個事件處理程序中,同樣使用 SyncLock 來讀取 counter 的當前值。這確保了讀取操作的原子性原子性是指操作要麼完全執行,要麼完全不執行,不會被其他執行緒中斷。,避免讀取到正在被其他執行緒修改的不完整值。讀取後,將值顯示在 Label1 上,使用者可以看到計數器的即時變化。

這個模式在實際應用中非常常見,特別是在需要背景處理大量資料並即時更新 UI 的場景中,例如檔案下載進度、資料庫查詢結果統計等。

同步機制比較與效能考量

為了在不同情境下做出最佳選擇,了解 SyncLock 與其替代方案的差異至關重要。

同步機制 優點 適用場景與限制
SyncLock 語法最簡潔,直觀易懂,由編譯器確保鎖定會被釋放。 適用: 大多數應用程式內的執行緒同步需求。
限制: 功能單純,無法跨應用程式,且鎖定期間執行緒會被阻塞。
Monitor 提供比 SyncLock 更精細的控制,例如 TryEnter 可設定等待逾時,避免無限期等待。 適用: 需要處理鎖定逾時、避免死結的複雜場景。
限制: 語法較 SyncLock 繁瑣,需要手動使用 Try...Finally 確保 Monitor.Exit 被呼叫。
Mutex 可命名,並用於跨應用程式處理序 (Process) 的同步。 適用: 確保系統中只有一個應用程式實例在執行,或多個應用程式需同步存取共享資源 (如檔案) 的情境。
限制: 效能開銷比 SyncLock 大非常多。
Semaphore 允許多個執行緒同時進入受保護區域,但數量不能超過設定的上限。 適用: 限制資源池 (如資料庫連線、網路API呼叫) 的併發存取數量,而非完全互斥。
限制: 設計較複雜,主要用於流量控制而非資料保護。

效能最佳實務

SyncLock 區塊的程式碼應盡可能簡短快速。避免在鎖定區域內進行耗時操作,例如檔案 I/O、網路請求或複雜的資料庫查詢。這些操作會導致鎖定被長時間持有,使得其他等待的執行緒被嚴重阻塞,從而大幅降低應用程式的整體效能與回應速度。

進階應用與技巧

致命陷阱:死結 (Deadlock)

當兩個或多個執行緒互相持有對方需要的鎖,並等待對方釋放時,就會形成「死結」死結就像兩個人在狹窄走廊相遇,都不願意讓步,結果誰都走不了。,所有相關執行緒都會永久停擺。例如,執行緒 A 鎖定了 lock1 並等待 lock2,而執行緒 B 鎖定了 lock2 並等待 lock1

要避免死結,最重要的原則是:當需要取得多個鎖時,必須確保所有執行緒都以相同的順序來取得它們

實例展示:避免死結的正確做法

使用的控制項:
  • Button1:用於執行可能產生死結的錯誤範例。
  • Button2:用於執行正確避免死結的範例。
  • Label1:用於顯示執行狀態。
  • Label2:用於顯示執行結果。
範例程式碼
' 引入必要的命名空間
Imports System.Threading

' 定義主要的表單類別
Public Class Form1
    ' 宣告兩個鎖定物件
    Private ReadOnly lock1 As New Object()
    Private ReadOnly lock2 As New Object()
    ' 宣告計數器變數
    Private result As Integer = 0
    
    ' 按下 Button1 時執行錯誤的鎖定順序 (可能死結)
    Private Sub Button1_Click(sender As Object, e As EventArgs) Handles Button1.Click
        ' 重設結果為 0
        result = 0
        ' 更新狀態標籤
        Label1.Text = "執行中 (錯誤示範)..."
        
        ' 建立第一個執行緒
        Dim t1 As New Thread(Sub()
                                 ' 先鎖定 lock1
                                 SyncLock lock1
                                     ' 等待一小段時間,增加死結發生機率
                                     Thread.Sleep(10)
                                     ' 嘗試鎖定 lock2
                                     SyncLock lock2
                                         ' 遞增結果
                                         result += 1
                                     End SyncLock
                                 End SyncLock
                             End Sub)
        
        ' 建立第二個執行緒
        Dim t2 As New Thread(Sub()
                                 ' 先鎖定 lock2 (與 t1 的順序相反)
                                 SyncLock lock2
                                     ' 等待一小段時間,增加死結發生機率
                                     Thread.Sleep(10)
                                     ' 嘗試鎖定 lock1
                                     SyncLock lock1
                                         ' 遞增結果
                                         result += 1
                                     End SyncLock
                                 End SyncLock
                             End Sub)
        
        ' 設定兩個執行緒為背景執行緒
        t1.IsBackground = True
        t2.IsBackground = True
        ' 啟動兩個執行緒
        t1.Start()
        t2.Start()
        
        ' 等待兩個執行緒完成 (設定逾時避免無限等待)
        Dim completed As Boolean = t1.Join(2000) AndAlso t2.Join(2000)
        
        ' 在 UI 執行緒上更新顯示結果
        Me.Invoke(Sub()
                      ' 檢查是否在時間內完成
                      If completed Then
                          ' 執行緒正常完成
                          Label1.Text = "執行完成"
                          Label2.Text = "結果: " & result
                      Else
                          ' 可能發生死結
                          Label1.Text = "執行逾時 (可能死結)"
                          Label2.Text = "未能完成"
                      End If
                  End Sub)
    End Sub
    
    ' 按下 Button2 時執行正確的鎖定順序 (避免死結)
    Private Sub Button2_Click(sender As Object, e As EventArgs) Handles Button2.Click
        ' 重設結果為 0
        result = 0
        ' 更新狀態標籤
        Label1.Text = "執行中 (正確示範)..."
        
        ' 建立第一個執行緒
        Dim t1 As New Thread(Sub()
                                 ' 先鎖定 lock1
                                 SyncLock lock1
                                     ' 等待一小段時間
                                     Thread.Sleep(10)
                                     ' 再鎖定 lock2
                                     SyncLock lock2
                                         ' 遞增結果
                                         result += 1
                                     End SyncLock
                                 End SyncLock
                             End Sub)
        
        ' 建立第二個執行緒
        Dim t2 As New Thread(Sub()
                                 ' 同樣先鎖定 lock1 (與 t1 順序相同)
                                 SyncLock lock1
                                     ' 等待一小段時間
                                     Thread.Sleep(10)
                                     ' 再鎖定 lock2
                                     SyncLock lock2
                                         ' 遞增結果
                                         result += 1
                                     End SyncLock
                                 End SyncLock
                             End Sub)
        
        ' 設定兩個執行緒為背景執行緒
        t1.IsBackground = True
        t2.IsBackground = True
        ' 啟動兩個執行緒
        t1.Start()
        t2.Start()
        
        ' 等待兩個執行緒完成
        t1.Join()
        t2.Join()
        
        ' 在 UI 執行緒上更新顯示結果
        Me.Invoke(Sub()
                      ' 顯示執行完成
                      Label1.Text = "執行完成"
                      Label2.Text = "結果: " & result
                  End Sub)
    End Sub
End Class
詳細講解

此範例展示了死結的成因與避免方法。在 Button1 的錯誤示範中,執行緒 t1 先鎖定 lock1 再嘗試鎖定 lock2,而執行緒 t2 則先鎖定 lock2 再嘗試鎖定 lock1。這種相反的鎖定順序很容易造成死結:t1 持有 lock1 等待 lock2,而 t2 持有 lock2 等待 lock1,兩者都無法繼續執行。

為了防止無限期等待,程式使用 Join(2000) 設定 2 秒逾時。如果執行緒在時間內未完成,就可能發生了死結,此時會顯示「執行逾時」訊息。

在 Button2 的正確示範中,兩個執行緒都遵循相同的鎖定順序:先鎖定 lock1,再鎖定 lock2。這樣可以確保不會發生循環等待循環等待是死結的必要條件之一,指多個執行緒形成一個等待環,每個都在等下一個釋放資源。,執行緒總能順利完成工作。

這個原則可以總結為:在整個應用程式中,建立一個全域的鎖定順序,所有需要多個鎖的程式碼都必須遵循這個順序

實用技巧:選擇正確的鎖定物件

一個好的鎖定物件應是 PrivateReadOnly 的。絕對不要鎖定以下物件:

  • Me (this):鎖定公開物件會讓外部程式碼也能鎖定它,可能無意中造成死結。
  • 字串 (String):因為字串留用 (String Interning) 機制,相同內容的字串常數在整個應用程式中可能是同一個物件,鎖定它會造成意想不到的跨模組影響。
  • GetType():鎖定型別物件會影響到所有使用該類別的執行緒,範圍過大。

最安全的方式就是專門為鎖定宣告一個 Private ReadOnly _lock As New Object()

SyncLock 最佳實務建議

建議一:保持鎖定區域簡短

鎖定區域內的程式碼應該盡可能短小精悍。只在絕對必要時才使用鎖定,並在完成關鍵操作後立即釋放。長時間持有鎖會導致其他執行緒長時間等待,嚴重影響程式的併發性併發性是指系統同時處理多個任務的能力,鎖定時間越短,併發性越好。與效能。

建議二:避免在鎖內呼叫外部方法

在鎖定區域內呼叫不受控制的外部方法(特別是來自第三方程式庫的方法)是危險的。這些方法內部可能也會使用鎖定,從而增加死結的風險。如果外部方法執行時間不可預期,也會導致鎖被長時間持有。

建議三:考慮使用讀寫鎖 (ReaderWriterLockSlim)

如果共享資源的讀取操作遠多於寫入操作,考慮使用 ReaderWriterLockSlim。它允許多個執行緒同時讀取,但寫入時會獨佔鎖定。這可以大幅提升讀取密集型應用的效能。

注意事項:不要依賴鎖定順序來協調業務邏輯

鎖定機制應該只用於保護共享資料的一致性,而不是用來協調複雜的業務流程順序。如果需要協調執行緒的執行順序,應該使用更高階的同步原語,例如 ManualResetEventAutoResetEventBarrier

總結

SyncLock (同步鎖定) 是 VB.NET 中非常重要的多執行緒同步機制,正確理解和使用 SyncLock 可以讓程式碼在多執行緒環境下更加安全和可靠。在實際開發中,SyncLock 常用於保護共享資源、協調執行緒操作和實現執行緒安全的設計。

記住以下關鍵要點:

  • 始終使用專用的私有鎖定物件
  • 保持鎖定區域簡短快速
  • 多個鎖時必須統一順序避免死結
  • 根據場景選擇適當的同步機制
  • 避免在鎖內執行耗時操作

透過本文的學習,您應該能夠熟練掌握 SyncLock 的使用方法,並在實際專案中靈活應用,構建出高效且安全的多執行緒應用程式。