VB.NET SyncLock 同步鎖定 筆記(進階篇)
多執行緒程式中,只要兩個以上的執行緒會同時讀寫同一份資料,就可能出現資料交錯問題。例如同時分配號碼、同時扣庫存、同時寫入清單,若沒有同步保護,最後結果可能和預期不同。
SyncLock 用來保護共享資源。它會讓同一段關鍵程式碼在同一時間只允許一個執行緒進入,其他執行緒必須等待。這篇以 Windows Forms 範例說明共享資料、鎖定物件、臨界區、快照讀取、死結預防與常見替代機制。
先理解 SyncLock 在解決什麼
SyncLock:用來保護共享資源的同步區塊。當某個執行緒進入指定鎖定物件的 SyncLock 區塊後,其他使用同一把鎖的執行緒必須等待,直到鎖被釋放。
SyncLock 的核心觀念
- 保護共享資料:重點不是鎖住執行緒,而是保護同一份資料。
- 同一份資料用同一把鎖:不同鎖無法保護同一個資源。
- 鎖定區塊要短:只包住真正需要一致性的讀寫動作。
- 不要鎖 UI 操作:UI 更新應在 UI 執行緒處理,不適合放在長時間鎖定區塊中。
- 避免鎖定公開物件:通常使用
Private ReadOnly ... As New Object()作為專用鎖。
基本語法
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:顯示結果。
範例程式碼
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
邏輯解析
nextTicketNo += 1與issuedCount += 1都是共享資料寫入。SyncLock ticketLock確保同一時間只有一個執行緒能更新號碼。Join等待所有背景執行緒完成,再把結果排回 UI 顯示。
保護多步驟資料一致性
場景二:活動材料包庫存保留
扣庫存通常不是單一步驟,而是先檢查數量是否足夠,再扣除庫存。這兩個動作必須視為同一段不可被插隊的流程,否則可能兩個執行緒都判斷足夠,最後扣出錯誤庫存。
需要的主控項
ButtonReserveKits:開始保留材料包。LabelStockResult:顯示庫存結果。
範例程式碼
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
邏輯解析
- 檢查庫存與扣除庫存必須放在同一個
SyncLock區塊。 - 若只鎖扣款、不鎖檢查,仍可能在檢查與扣除之間被其他執行緒插入。
- 這種「檢查後更新」流程,是
SyncLock很常見的用途。
保護集合與快照讀取
場景三:背景任務寫入紀錄清單
多個背景執行緒同時寫入同一個 List(Of String) 時,應使用 SyncLock 保護集合。顯示到 UI 前,可先在鎖內建立快照,再離開鎖定區塊後更新畫面。
需要的主控項
ButtonBuildLog:開始建立紀錄。ListBoxLog:顯示紀錄。LabelLogCount:顯示紀錄筆數。
範例程式碼
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
邏輯解析
taskLogs是多個背景執行緒共用的集合。- 寫入集合與建立快照都使用同一把
logLock。 - UI 更新使用快照資料,避免在畫面更新期間長時間鎖住集合。
快照觀念:鎖內只複製資料,鎖外再做較慢的處理。這樣可以減少其他執行緒等待鎖的時間。
鎖定物件的選擇
場景四:分開保護庫存與紀錄
不同共享資料可以使用不同鎖定物件。庫存與紀錄若沒有必要一起鎖住,就可以分開保護,避免一個資源被處理時,另一個完全不能使用。
需要的主控項
ButtonRunSeparateLock:執行分開鎖定範例。LabelSeparateResult:顯示結果。
範例程式碼
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
邏輯解析
badgeStockLock只保護庫存。badgeLogLock只保護紀錄清單。- 鎖定範圍依共享資料分開,可降低不必要的等待。
不建議鎖定的物件
- 不要鎖定
Me:外部程式碼也可能取得同一個表單物件,鎖定範圍太大。 - 不要鎖定字串:字串可能被留用,容易造成非預期共用。
- 不要鎖定公開物件:外部也能鎖同一個物件,容易產生難追蹤的等待。
- 不要每次 New 一把鎖:每次都不同物件,就無法讓執行緒排隊。
死結預防:多把鎖要有固定順序
死結:兩個以上的執行緒互相等待對方手上的鎖,導致所有相關執行緒都無法繼續。最常見原因是多把鎖的取得順序不一致。
場景五:固定順序搬移資料
當同一段流程必須同時使用兩個共享資源時,所有執行緒都應用相同順序取得鎖。這個範例固定先鎖來源清單,再鎖完成清單,避免互相等待。
需要的主控項
ButtonMoveItems:開始搬移資料。LabelMoveResult:顯示結果。
範例程式碼
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
邏輯解析
- 需要兩把鎖時,所有流程都使用相同取得順序。
- 不一致的順序容易造成互相等待。
- 更好的設計是減少同時需要多把鎖的流程。
Interlocked 與 SyncLock 的選擇
| 機制 | 適合情境 | 限制 |
|---|---|---|
| SyncLock | 多步驟流程、多欄位一致性、集合讀寫。 | 鎖太久會讓其他執行緒等待。 |
| Interlocked | 單一整數遞增、遞減、交換。 | 不適合包住多步驟商業規則。 |
| Monitor | 需要 TryEnter、逾時控制等進階鎖定。 | 寫法較細,需更注意釋放。 |
| Mutex | 跨處理序同步。 | 比一般同處理序鎖定更重。 |
場景六:單純完成筆數使用 Interlocked
若需求只是單一整數加一,不需要整段 SyncLock。Interlocked.Increment 可直接提供安全的原子遞增。
需要的主控項
ButtonCountDone:開始計數。LabelDoneCount:顯示完成筆數。
範例程式碼
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
邏輯解析
Interlocked.Increment適合單一整數的安全遞增。- 若同時需要檢查庫存、扣除庫存、寫入紀錄,就不是單一遞增問題,仍應使用
SyncLock。
實務判斷與常見誤區
常見問題整理
- 鎖定物件不一致:同一份資料若使用不同鎖,等於沒有真正同步。
- 鎖定區塊過大:把網路、檔案、Sleep 或大量計算放進鎖內,會拖慢其他執行緒。
- 鎖定 UI 控制項:控制項有自己的 UI 執行緒規則,不應用鎖來取代跨執行緒 UI 更新。
- 在鎖內更新 UI:UI 可能卡住,也會拉長鎖定時間。
- 鎖定
Me或字串:鎖定範圍不明確,容易造成非預期等待。 - 多把鎖順序不一致:容易造成死結。
| 需求 | 建議做法 | 原因 |
|---|---|---|
| 共享集合讀寫 | SyncLock |
集合操作通常不是單一步驟。 |
| 檢查後更新 | SyncLock |
檢查與更新必須不可被插隊。 |
| 單一整數加一 | Interlocked.Increment |
更直接且輕量。 |
| 需要逾時取得鎖 | Monitor.TryEnter |
SyncLock 沒有直接提供逾時語法。 |
| 跨程式同步 | Mutex |
可跨處理序使用。 |
鎖定範圍建議:先在鎖內取得必要資料或完成必要更新,再離開鎖定區塊。畫面更新、檔案輸出、網路請求與長時間等待,通常不應放在 SyncLock 內。
重點整理
SyncLock用來保護多執行緒下的共享資料一致性。- 想保護同一份資料,就必須使用同一把鎖。
- 鎖定物件建議使用
Private ReadOnly ... As New Object()。 - 鎖定區塊要短,只包住真正需要同步的讀寫動作。
- 檢查後更新、集合寫入、多欄位一致性都適合使用
SyncLock。 - 單一整數遞增可考慮使用
Interlocked.Increment。 - 多把鎖必須固定取得順序,避免死結。
- 不要用
SyncLock取代 Windows Forms 的跨執行緒 UI 更新規則。