2024年8月2日 星期五

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

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

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

多執行緒程式中,只要兩個以上的執行緒會同時讀寫同一份資料,就可能出現資料交錯問題。例如同時分配號碼、同時扣庫存、同時寫入清單,若沒有同步保護,最後結果可能和預期不同。

SyncLock 用來保護共享資源。它會讓同一段關鍵程式碼在同一時間只允許一個執行緒進入,其他執行緒必須等待。這篇以 Windows Forms 範例說明共享資料、鎖定物件、臨界區、快照讀取、死結預防與常見替代機制。

先理解 SyncLock 在解決什麼

SyncLock:用來保護共享資源的同步區塊。當某個執行緒進入指定鎖定物件的 SyncLock 區塊後,其他使用同一把鎖的執行緒必須等待,直到鎖被釋放。

SyncLock 的核心觀念

  • 保護共享資料:重點不是鎖住執行緒,而是保護同一份資料。
  • 同一份資料用同一把鎖:不同鎖無法保護同一個資源。
  • 鎖定區塊要短:只包住真正需要一致性的讀寫動作。
  • 不要鎖 UI 操作:UI 更新應在 UI 執行緒處理,不適合放在長時間鎖定區塊中。
  • 避免鎖定公開物件:通常使用 Private ReadOnly ... As New Object() 作為專用鎖。

基本語法

VB.NET
Private ReadOnly stockLock As New Object()
Private stockCount As Integer = 10

SyncLock stockLock
    stockCount -= 1
End SyncLock

stockLock 是專門用來保護 stockCount 的鎖定物件。所有會讀寫 stockCount 的執行緒,都應使用同一把鎖。

名詞 意思 實務理解
共享資源 多個執行緒會共同讀寫的資料。 計數器、庫存、餘額、List 集合。
鎖定物件 讓執行緒排隊的物件。 通常宣告成 Private ReadOnly
臨界區 需要同步保護的程式碼區段。 越短越好,只放必要讀寫。
競爭條件 執行結果受執行緒交錯順序影響。 同時加一、同時扣庫存時常見。

保護共享計數器

場景一:多個櫃台同時發放候位號碼

多個執行緒同時發號時,若沒有鎖定,可能發出重複號碼或跳號。這個範例讓 4 個背景執行緒同時發號,每個執行緒發 25 張票,最後應得到 100 張不同號碼。

需要的主控項
  • ButtonIssueTickets:開始發號。
  • LabelTicketResult:顯示結果。
範例程式碼
VB.NET / Windows Forms
Imports System.Threading

Public Class Form1
    Private ReadOnly ticketLock As New Object()
    Private nextTicketNo As Integer = 0
    Private issuedCount As Integer = 0

    Private Sub ButtonIssueTickets_Click(sender As Object, e As EventArgs) Handles ButtonIssueTickets.Click
        nextTicketNo = 0
        issuedCount = 0
        LabelTicketResult.Text = "發號中..."

        Dim workers As New List(Of Thread)

        For counterNo As Integer = 1 To 4
            Dim worker As New Thread(AddressOf IssueTicketBatch)
            worker.IsBackground = True
            workers.Add(worker)
            worker.Start()
        Next

        Dim waiter As New Thread(Sub()
                                     For Each worker As Thread In workers
                                         worker.Join()
                                     Next

                                     QueueTicketResult("發號完成:" & issuedCount.ToString() & " 張" & vbCrLf &
                                                       "最後號碼:" & nextTicketNo.ToString("000"))
                                 End Sub)
        waiter.IsBackground = True
        waiter.Start()
    End Sub

    Private Sub IssueTicketBatch()
        For index As Integer = 1 To 25
            SyncLock ticketLock
                nextTicketNo += 1
                issuedCount += 1
            End SyncLock
        Next
    End Sub

    Private Sub QueueTicketResult(ByVal message As String)
        If LabelTicketResult.IsDisposed OrElse Not LabelTicketResult.IsHandleCreated Then
            Return
        End If

        LabelTicketResult.BeginInvoke(New MethodInvoker(Sub()
                                                            LabelTicketResult.Text = message
                                                        End Sub))
    End Sub
End Class
畫面輸出結果(LabelTicketResult.Text)
發號完成:100 張 最後號碼:100
邏輯解析
  • nextTicketNo += 1issuedCount += 1 都是共享資料寫入。
  • SyncLock ticketLock 確保同一時間只有一個執行緒能更新號碼。
  • Join 等待所有背景執行緒完成,再把結果排回 UI 顯示。

保護多步驟資料一致性

場景二:活動材料包庫存保留

扣庫存通常不是單一步驟,而是先檢查數量是否足夠,再扣除庫存。這兩個動作必須視為同一段不可被插隊的流程,否則可能兩個執行緒都判斷足夠,最後扣出錯誤庫存。

需要的主控項
  • ButtonReserveKits:開始保留材料包。
  • LabelStockResult:顯示庫存結果。
範例程式碼
VB.NET / Windows Forms
Imports System.Threading

Public Class Form1
    Private ReadOnly stockLock As New Object()
    Private kitStock As Integer = 30
    Private successOrders As Integer = 0

    Private Sub ButtonReserveKits_Click(sender As Object, e As EventArgs) Handles ButtonReserveKits.Click
        kitStock = 30
        successOrders = 0
        LabelStockResult.Text = "保留材料包中..."

        Dim workerA As New Thread(Sub() ReserveKit(12))
        Dim workerB As New Thread(Sub() ReserveKit(15))
        Dim workerC As New Thread(Sub() ReserveKit(10))

        workerA.IsBackground = True
        workerB.IsBackground = True
        workerC.IsBackground = True
        workerA.Start()
        workerB.Start()
        workerC.Start()

        Dim waiter As New Thread(Sub()
                                     workerA.Join()
                                     workerB.Join()
                                     workerC.Join()

                                     QueueStockResult("成功訂單:" & successOrders.ToString() & vbCrLf &
                                                      "剩餘庫存:" & kitStock.ToString())
                                 End Sub)
        waiter.IsBackground = True
        waiter.Start()
    End Sub

    Private Sub ReserveKit(ByVal requestCount As Integer)
        SyncLock stockLock
            If kitStock >= requestCount Then
                kitStock -= requestCount
                successOrders += 1
            End If
        End SyncLock
    End Sub

    Private Sub QueueStockResult(ByVal message As String)
        If LabelStockResult.IsDisposed OrElse Not LabelStockResult.IsHandleCreated Then
            Return
        End If

        LabelStockResult.BeginInvoke(New MethodInvoker(Sub()
                                                           LabelStockResult.Text = message
                                                       End Sub))
    End Sub
End Class
畫面輸出結果
成功訂單:2 剩餘庫存:3
邏輯解析
  • 檢查庫存與扣除庫存必須放在同一個 SyncLock 區塊。
  • 若只鎖扣款、不鎖檢查,仍可能在檢查與扣除之間被其他執行緒插入。
  • 這種「檢查後更新」流程,是 SyncLock 很常見的用途。

保護集合與快照讀取

場景三:背景任務寫入紀錄清單

多個背景執行緒同時寫入同一個 List(Of String) 時,應使用 SyncLock 保護集合。顯示到 UI 前,可先在鎖內建立快照,再離開鎖定區塊後更新畫面。

需要的主控項
  • ButtonBuildLog:開始建立紀錄。
  • ListBoxLog:顯示紀錄。
  • LabelLogCount:顯示紀錄筆數。
範例程式碼
VB.NET / Windows Forms
Imports System.Threading

Public Class Form1
    Private ReadOnly logLock As New Object()
    Private taskLogs As New List(Of String)

    Private Sub ButtonBuildLog_Click(sender As Object, e As EventArgs) Handles ButtonBuildLog.Click
        ListBoxLog.Items.Clear()
        LabelLogCount.Text = "建立紀錄中..."

        SyncLock logLock
            taskLogs.Clear()
        End SyncLock

        Dim workers As New List(Of Thread)

        For taskNo As Integer = 1 To 3
            Dim localTaskNo As Integer = taskNo
            Dim worker As New Thread(Sub()
                                         WriteTaskLog("任務 " & localTaskNo.ToString() & " 完成")
                                     End Sub)
            worker.IsBackground = True
            workers.Add(worker)
            worker.Start()
        Next

        Dim waiter As New Thread(Sub()
                                     For Each worker As Thread In workers
                                         worker.Join()
                                     Next

                                     ShowLogSnapshot()
                                 End Sub)
        waiter.IsBackground = True
        waiter.Start()
    End Sub

    Private Sub WriteTaskLog(ByVal message As String)
        SyncLock logLock
            taskLogs.Add(message)
        End SyncLock
    End Sub

    Private Sub ShowLogSnapshot()
        Dim snapshot As List(Of String)

        SyncLock logLock
            snapshot = New List(Of String)(taskLogs)
        End SyncLock

        If ListBoxLog.IsDisposed OrElse Not ListBoxLog.IsHandleCreated Then
            Return
        End If

        ListBoxLog.BeginInvoke(New MethodInvoker(Sub()
                                                    ListBoxLog.Items.Clear()
                                                    For Each item As String In snapshot
                                                        ListBoxLog.Items.Add(item)
                                                    Next
                                                    LabelLogCount.Text = "紀錄筆數:" & snapshot.Count.ToString()
                                                End Sub))
    End Sub
End Class
畫面輸出結果(ListBoxLog)
任務 1 完成 任務 2 完成 任務 3 完成
邏輯解析
  • taskLogs 是多個背景執行緒共用的集合。
  • 寫入集合與建立快照都使用同一把 logLock
  • UI 更新使用快照資料,避免在畫面更新期間長時間鎖住集合。

快照觀念:鎖內只複製資料,鎖外再做較慢的處理。這樣可以減少其他執行緒等待鎖的時間。

鎖定物件的選擇

場景四:分開保護庫存與紀錄

不同共享資料可以使用不同鎖定物件。庫存與紀錄若沒有必要一起鎖住,就可以分開保護,避免一個資源被處理時,另一個完全不能使用。

需要的主控項
  • ButtonRunSeparateLock:執行分開鎖定範例。
  • LabelSeparateResult:顯示結果。
範例程式碼
VB.NET / Windows Forms
Imports System.Threading

Public Class Form1
    Private ReadOnly badgeStockLock As New Object()
    Private ReadOnly badgeLogLock As New Object()
    Private badgeStock As Integer = 8
    Private badgeLogs As New List(Of String)

    Private Sub ButtonRunSeparateLock_Click(sender As Object, e As EventArgs) Handles ButtonRunSeparateLock.Click
        badgeStock = 8
        badgeLogs.Clear()

        Dim workerA As New Thread(Sub() TakeBadge("A 組"))
        Dim workerB As New Thread(Sub() TakeBadge("B 組"))
        workerA.IsBackground = True
        workerB.IsBackground = True
        workerA.Start()
        workerB.Start()

        Dim waiter As New Thread(Sub()
                                     workerA.Join()
                                     workerB.Join()

                                     QueueSeparateResult("剩餘名牌:" & badgeStock.ToString() & vbCrLf &
                                                         "紀錄筆數:" & badgeLogs.Count.ToString())
                                 End Sub)
        waiter.IsBackground = True
        waiter.Start()
    End Sub

    Private Sub TakeBadge(ByVal groupName As String)
        Dim taken As Boolean = False

        SyncLock badgeStockLock
            If badgeStock > 0 Then
                badgeStock -= 1
                taken = True
            End If
        End SyncLock

        SyncLock badgeLogLock
            If taken Then
                badgeLogs.Add(groupName & " 領取名牌")
            Else
                badgeLogs.Add(groupName & " 領取失敗")
            End If
        End SyncLock
    End Sub

    Private Sub QueueSeparateResult(ByVal message As String)
        If LabelSeparateResult.IsDisposed OrElse Not LabelSeparateResult.IsHandleCreated Then
            Return
        End If

        LabelSeparateResult.BeginInvoke(New MethodInvoker(Sub()
                                                              LabelSeparateResult.Text = message
                                                          End Sub))
    End Sub
End Class
畫面輸出結果
剩餘名牌:6 紀錄筆數:2
邏輯解析
  • badgeStockLock 只保護庫存。
  • badgeLogLock 只保護紀錄清單。
  • 鎖定範圍依共享資料分開,可降低不必要的等待。

不建議鎖定的物件

  • 不要鎖定 Me外部程式碼也可能取得同一個表單物件,鎖定範圍太大。
  • 不要鎖定字串:字串可能被留用,容易造成非預期共用。
  • 不要鎖定公開物件:外部也能鎖同一個物件,容易產生難追蹤的等待。
  • 不要每次 New 一把鎖:每次都不同物件,就無法讓執行緒排隊。

死結預防:多把鎖要有固定順序

死結:兩個以上的執行緒互相等待對方手上的鎖,導致所有相關執行緒都無法繼續。最常見原因是多把鎖的取得順序不一致。

場景五:固定順序搬移資料

當同一段流程必須同時使用兩個共享資源時,所有執行緒都應用相同順序取得鎖。這個範例固定先鎖來源清單,再鎖完成清單,避免互相等待。

需要的主控項
  • ButtonMoveItems:開始搬移資料。
  • LabelMoveResult:顯示結果。
範例程式碼
VB.NET / Windows Forms
Imports System.Threading

Public Class Form1
    Private ReadOnly sourceLock As New Object()
    Private ReadOnly doneLock As New Object()
    Private sourceItems As New List(Of String) From {"A01", "A02", "A03", "A04"}
    Private doneItems As New List(Of String)

    Private Sub ButtonMoveItems_Click(sender As Object, e As EventArgs) Handles ButtonMoveItems.Click
        SyncLock sourceLock
            sourceItems = New List(Of String) From {"A01", "A02", "A03", "A04"}
        End SyncLock

        SyncLock doneLock
            doneItems.Clear()
        End SyncLock

        Dim workerA As New Thread(AddressOf MoveOneItem)
        Dim workerB As New Thread(AddressOf MoveOneItem)
        workerA.IsBackground = True
        workerB.IsBackground = True
        workerA.Start()
        workerB.Start()

        Dim waiter As New Thread(Sub()
                                     workerA.Join()
                                     workerB.Join()
                                     QueueMoveResult("已完成搬移:" & doneItems.Count.ToString() & " 筆")
                                 End Sub)
        waiter.IsBackground = True
        waiter.Start()
    End Sub

    Private Sub MoveOneItem()
        SyncLock sourceLock
            SyncLock doneLock
                If sourceItems.Count > 0 Then
                    Dim item As String = sourceItems(0)
                    sourceItems.RemoveAt(0)
                    doneItems.Add(item)
                End If
            End SyncLock
        End SyncLock
    End Sub

    Private Sub QueueMoveResult(ByVal message As String)
        If LabelMoveResult.IsDisposed OrElse Not LabelMoveResult.IsHandleCreated Then
            Return
        End If

        LabelMoveResult.BeginInvoke(New MethodInvoker(Sub()
                                                          LabelMoveResult.Text = message
                                                      End Sub))
    End Sub
End Class
畫面輸出結果
已完成搬移:2 筆
邏輯解析
  • 需要兩把鎖時,所有流程都使用相同取得順序。
  • 不一致的順序容易造成互相等待。
  • 更好的設計是減少同時需要多把鎖的流程。

Interlocked 與 SyncLock 的選擇

機制 適合情境 限制
SyncLock 多步驟流程、多欄位一致性、集合讀寫。 鎖太久會讓其他執行緒等待。
Interlocked 單一整數遞增、遞減、交換。 不適合包住多步驟商業規則。
Monitor 需要 TryEnter、逾時控制等進階鎖定。 寫法較細,需更注意釋放。
Mutex 跨處理序同步。 比一般同處理序鎖定更重。

場景六:單純完成筆數使用 Interlocked

若需求只是單一整數加一,不需要整段 SyncLockInterlocked.Increment 可直接提供安全的原子遞增。

需要的主控項
  • ButtonCountDone:開始計數。
  • LabelDoneCount:顯示完成筆數。
範例程式碼
VB.NET / Windows Forms
Imports System.Threading

Public Class Form1
    Private doneCount As Integer = 0

    Private Sub ButtonCountDone_Click(sender As Object, e As EventArgs) Handles ButtonCountDone.Click
        doneCount = 0
        LabelDoneCount.Text = "計數中..."

        Dim workers As New List(Of Thread)

        For workerNo As Integer = 1 To 5
            Dim worker As New Thread(AddressOf AddDoneCount)
            worker.IsBackground = True
            workers.Add(worker)
            worker.Start()
        Next

        Dim waiter As New Thread(Sub()
                                     For Each worker As Thread In workers
                                         worker.Join()
                                     Next

                                     If LabelDoneCount.IsHandleCreated AndAlso Not LabelDoneCount.IsDisposed Then
                                         LabelDoneCount.BeginInvoke(New MethodInvoker(Sub()
                                                                                         LabelDoneCount.Text = "完成筆數:" & doneCount.ToString("N0")
                                                                                     End Sub))
                                     End If
                                 End Sub)
        waiter.IsBackground = True
        waiter.Start()
    End Sub

    Private Sub AddDoneCount()
        For index As Integer = 1 To 1000
            Interlocked.Increment(doneCount)
        Next
    End Sub
End Class
畫面輸出結果(LabelDoneCount.Text)
完成筆數:5,000
邏輯解析
  • Interlocked.Increment 適合單一整數的安全遞增。
  • 若同時需要檢查庫存、扣除庫存、寫入紀錄,就不是單一遞增問題,仍應使用 SyncLock

實務判斷與常見誤區

常見問題整理

  • 鎖定物件不一致:同一份資料若使用不同鎖,等於沒有真正同步。
  • 鎖定區塊過大:把網路、檔案、Sleep 或大量計算放進鎖內,會拖慢其他執行緒。
  • 鎖定 UI 控制項:控制項有自己的 UI 執行緒規則,不應用鎖來取代跨執行緒 UI 更新。
  • 在鎖內更新 UI:UI 可能卡住,也會拉長鎖定時間。
  • 鎖定 Me 或字串:鎖定範圍不明確,容易造成非預期等待。
  • 多把鎖順序不一致:容易造成死結。
需求 建議做法 原因
共享集合讀寫 SyncLock 集合操作通常不是單一步驟。
檢查後更新 SyncLock 檢查與更新必須不可被插隊。
單一整數加一 Interlocked.Increment 更直接且輕量。
需要逾時取得鎖 Monitor.TryEnter SyncLock 沒有直接提供逾時語法。
跨程式同步 Mutex 可跨處理序使用。

鎖定範圍建議:先在鎖內取得必要資料或完成必要更新,再離開鎖定區塊。畫面更新、檔案輸出、網路請求與長時間等待,通常不應放在 SyncLock 內。

重點整理

  1. SyncLock 用來保護多執行緒下的共享資料一致性。
  2. 想保護同一份資料,就必須使用同一把鎖。
  3. 鎖定物件建議使用 Private ReadOnly ... As New Object()
  4. 鎖定區塊要短,只包住真正需要同步的讀寫動作。
  5. 檢查後更新、集合寫入、多欄位一致性都適合使用 SyncLock
  6. 單一整數遞增可考慮使用 Interlocked.Increment
  7. 多把鎖必須固定取得順序,避免死結。
  8. 不要用 SyncLock 取代 Windows Forms 的跨執行緒 UI 更新規則。