Delphi 的多執行緒程式設計能力讓開發者得以提升應用程式效能,但也引入了同步和資料一致性等挑戰。在 Windows 平台上,主執行緒負責管理使用者介面,長時間任務應移至背景執行緒執行,避免 UI 阻塞。同樣地,Android 應用程式中的 UI 執行緒也需要避免執行長時間操作。處理多個客戶端的伺服器應用程式可以為每個客戶端建立獨立執行緒,但需注意資料分享和執行緒間通訊。常見的多執行緒問題包括從背景執行緒存取 UI 元素以及分享資料的同時讀寫。使用 TThread.Synchronize 或 TThread.Queue 可以確保 UI 操作在主執行緒執行,避免衝突。對於分享資料,則需使用同步機制,例如關鍵段(Critical Section),來保護資料的一致性,避免資料競爭和不可預期的結果。然而,使用鎖定機制時需注意避免死鎖,確保程式碼以相同的順序取得和釋放鎖定。
進入平行世界的第一步
何時平行化程式碼
在開始平行化程式碼之前,您應該瞭解特定的程式碼是否適合平行化。有些情況下,平行化特別簡單,而有些情況下則很難實作。
最常見的例子之一是在主執行緒中執行長時間執行的程式碼。在 Delphi 中,主執行緒是唯一負責管理使用者介面的執行緒。如果它正在執行長時間的任務而沒有處理使用者介面事件,那麼使用者介面就會被阻塞。我們可以透過將長時間任務移到背景執行緒來解決這個問題,這樣主執行緒就可以管理使用者介面。
注意
Android 應用程式有一個專門的執行緒來執行使用者介面——UI 執行緒。這個執行緒與 Windows 上的主執行緒類別似,您不應該在其中執行任何長時間執行的操作。
另一個通常相對容易解決的問題是讓伺服器應用程式一次處理多個客戶端。我們可以為每個客戶端建立一個單獨的執行緒(在合理的限制內),並在該執行緒中處理來自該客戶端的請求。由於來自不同客戶端的請求通常不應該直接相互互動,因此我們不需要關心資料共用或執行緒間的通訊,這是多執行緒程式設計中最大的問題來源。
最常見的問題
在開始編寫多執行緒程式碼之前,我想指出一些典型的情況,它們代表了多執行緒程式中最常見的問題來源。然後,我將探討解決這些情況的可能方法。
這些情況的最大問題是,如果您正在編寫單執行緒程式碼,那麼它們都是完全有效的程式設計方法。正因為如此,它們有時甚至會出現在由最好的程式設計師編寫的(多執行緒)程式碼中。
絕對禁止從背景執行緒存取 UI
讓我們從最大的隱藏問題來源開始——從背景執行緒操作使用者介面。令人驚訝的是,這是一個相當常見的問題——即使所有的 Delphi 多執行緒程式設計資源都只是說永遠不要這樣做。儘管如此,它似乎仍然無法讓一些程式設計師明白,他們總是會試圖找到藉口從背景執行緒操作使用者介面。
的確,在某些情況下,可以從背景執行緒操作 VCL 或 FireMonkey,但如果您這樣做,您將會走在薄冰上。即使您的程式碼在當前的 Delphi 中工作,也沒有人可以保證未來 Delphi 中引入的圖形框架變更不會破壞您的程式碼。始終最好乾淨地將背景處理與使用者介面分離。
注意
在除 Android 之外的所有 Delphi 支援的作業系統上,您應該只從主執行緒存取使用者介面功能。然而,在 Android 上,使用者介面功能只能從 UI 執行緒使用。
讓我們看一個很好的例子來演示這個問題。ParallelPaint 演示有一個簡單的表單,包含八個 TPaintBox 元件和八個執行緒。每個執行緒執行相同的繪圖程式碼,並將圖案繪製到自己的 TPaintBox 中。由於每個執行緒只存取自己的 Canvas,而不存取其他使用者介面元件,因此一個天真的程式設計師可能會認為直接從背景執行緒繪製到畫布不會引起問題。但這個天真的程式設計師將會大錯特錯。
ParallelPaint 演示的問題
如果您執行該程式,您將會注意到,儘管程式碼不斷地繪製到某些畫布,但其他畫布在一段時間後停止更新。您甚至可能會收到「Canvas does not allow drawing」異常。無法提前預測哪些執行緒將繼續繪製,哪些不會。
以下截圖顯示了一個輸出範例。當我擷取截圖時,第一行的前兩個畫布和最後一行的最後一個畫布不再更新: 此圖示 圖表翻譯: 圖中顯示了 ParallelPaint 演示輸出的範例,其中一些畫布停止更新。
線條是在 DrawLine 方法中繪製的。它並沒有做任何特別的事情,只是為下一條線設定顏色並繪製它。儘管如此,當從多個執行緒同時呼叫時,這仍然足以破壞使用者介面,即使每個執行緒都使用自己的 Canvas:
procedure TfrmParallelPaint.DrawLine(canvas: TCanvas; p1, p2: TPoint; color: TColor);
begin
Canvas.Pen.Color := color;
Canvas.MoveTo(p1.X, p1.Y);
Canvas.LineTo(p2.X, p2.Y);
end;
內容解密:
此段程式碼展示瞭如何在指定的 Canvas 上使用指定的顏色繪製一條線。首先,將畫筆顏色設定為指定的顏色,然後將畫筆移動到起點,最後繪製到終點。在多執行緒環境中,如果多個執行緒同時存取同一個 Canvas,可能會導致不可預期的結果,因此需要謹慎處理。
正確的做法
為了避免上述問題,我們應該將背景處理與使用者介面分離。在 Delphi 中,可以使用 TThread 或 TTask 等多執行緒元件來實作背景處理,並使用同步機制(如 TThread.Synchronize)來更新使用者介面。
示範正確的多執行緒繪圖方法
procedure TfrmParallelPaint.DrawLineSynchronized(canvas: TCanvas; p1, p2: TPoint; color: TColor);
begin
TThread.Synchronize(nil,
procedure
begin
canvas.Pen.Color := color;
canvas.MoveTo(p1.X, p1.Y);
canvas.LineTo(p2.X, p2.Y);
end);
end;
內容解密:
此段程式碼展示瞭如何使用 TThread.Synchronize 方法在主執行緒中同步更新 Canvas。這樣可以避免多個執行緒同時存取 Canvas 導致的問題。首先,使用 TThread.Synchronize 方法將繪圖操作同步到主執行緒,然後在匿名方法中進行實際的繪圖操作。這種方法確保了 Canvas 的存取是執行緒安全的。
總之,在多執行緒環境中,正確地處理使用者介面和背景處理之間的互動至關重要。透過使用適當的同步機制,可以避免常見的多執行緒問題,確保程式的穩定性和可靠性。
平行程式設計中的常見問題
在平行程式設計的世界中,多執行緒的引入為程式帶來了更高的效能和更複雜的挑戰。本章節將探討在多執行緒環境下常見的一些問題,並提供相應的解決方案。
從背景執行緒存取 UI 元素
在多執行緒程式設計中,一個常見的錯誤是從背景執行緒直接存取使用者介面(UI)元素。這種行為可能導致不可預期的結果,包括程式當機或 UI 無法正確更新。Delphi 的 TThread 類別提供了 Queue 方法,允許開發者在主執行緒中執行程式碼,從而避免直接從背景執行緒存取 UI 元素。
使用 TThread.Queue 方法
procedure TfrmParallelPaint.QueueDrawLine(canvas: TCanvas; p1, p2: TPoint; color: TColor);
begin
TThread.Queue(nil,
procedure
begin
Canvas.Pen.Color := color;
Canvas.MoveTo(p1.X, p1.Y);
Canvas.LineTo(p2.X, p2.Y);
end);
end;
內容解密:
TThread.Queue方法用於將程式碼送到主執行緒執行,確保 UI 操作的安全性。- 使用匿名方法,可以捕捉變數,使得在主執行緒中執行的程式碼能夠存取所需的變數。
- 這種方法避免了直接從背景執行緒存取 UI 元素可能引起的問題。
同時讀寫分享資料結構
在多執行緒環境中,分享資料結構(如列表)可能導致同時讀寫的問題。SharedList 程式演示了這種情況:一個背景執行緒寫入列表,而多個背景執行緒讀取列表。
SharedList 程式範例
procedure TfrmSharedList.ListReader;
var
i, j, a: Integer;
begin
for i := 1 to CNumReads do
for j := 0 to FList.Count - 1 do
a := FList[j];
end;
procedure TfrmSharedList.ListWriter;
var
i: Integer;
begin
for i := 1 to CNumWrites do
begin
if FList.Count > 10 then
FList.Delete(Random(10))
else
FList.Add(Random(100));
end;
end;
內容解密:
ListReader方法不斷讀取列表中的元素。ListWriter方法則隨機新增或刪除列表中的元素。- 同時讀寫分享列表可能導致
EArgumentOutOfRangeException例外。
分享變數的問題
分享簡單變數看似安全,但實際上仍可能出現問題。IncDec 程式演示了對分享變數 FValue 進行遞增和遞減操作可能導致的結果。
IncDec 程式範例
procedure TfrmIncDec.IncValue;
var
i: integer;
value: integer;
begin
for i := 1 to CNumRepeat do begin
value := FValue;
FValue := value + 1;
end;
end;
procedure TfrmIncDec.DecValue;
var
i: integer;
value: integer;
begin
for i := 1 to CNumRepeat do begin
value := FValue;
FValue := value - 1;
end;
end;
內容解密:
IncValue和DecValue方法分別對FValue進行遞增和遞減操作。- 在多執行緒環境下,這些操作可能導致不可預期的結果,因為讀取和寫入
FValue不是原子操作。
多執行緒程式設計中的常見問題
在多執行緒程式設計的世界中,資料分享與同步存取是開發者必須面對的挑戰。當多個執行緒同時存取分享變數時,可能會導致不可預期的結果。以下將探討這些問題的根源與影響。
分享變數的存取問題
考慮以下程式碼範例:
procedure TfrmIncDec.btnIncDec2Click(Sender: TObject);
begin
FValue := 0;
RunInParallel(IncValue, DecValue);
LogValue;
end;
在這個例子中,IncValue 和 DecValue 兩個程式被平行執行,分別對 FValue 進行遞增和遞減操作。理論上,最終 FValue 應該為零,但實際執行結果卻可能大不相同。
程式碼解析
procedure IncValue;
var
i: integer;
value: Int64;
begin
for i := 1 to CNumRepeat do begin
value := FValue;
Inc(value);
FValue := value;
end;
end;
procedure DecValue;
var
i: integer;
value: Int64;
begin
for i := 1 to CNumRepeat do begin
value := FValue;
Dec(value);
FValue := value;
end;
end;
內容解密:
IncValue和DecValue分別對FValue進行讀取、修改和寫回操作。- 當兩個執行緒平行執行時,可能會發生資料競爭(Data Race),導致最終結果不可預期。
- 即使用
Inc(FValue)和Dec(FValue)直接修改FValue,仍然無法保證操作的原子性。
同時讀寫變數的問題
另一個例子展示了同時讀寫變數可能帶來的問題:
procedure TfrmReadWrite.Reader;
var
i: integer;
begin
for i := 1 to CNumRepeat do
FValueList.Add(FPValue^);
end;
procedure TfrmReadWrite.Writer;
var
i: integer;
begin
for i := 1 to CNumRepeat do begin
FPValue^ := $7777777700000000;
FPValue^ := $0000000077777777;
end;
end;
內容解密:
Reader不斷讀取FPValue^的值並存入列表。Writer不斷寫入兩個不同的值到FPValue^。- 由於對 64 位元變數的操作在 32 位元模式下不是原子的,讀取操作可能會捕捉到寫入過程中的中間狀態,導致讀取到錯誤的值。
原子操作的重要性
在多執行緒環境中,原子操作是確保資料一致性的關鍵。所謂原子操作,是指一個操作要麼完全執行,要麼完全不執行,不會被其他執行緒看到中間狀態。
平台相關性
同時讀寫變數的安全性取決於處理器架構。例如,在 32 位元 Intel 處理器上,對 64 位元變數的操作不是原子的,但在 64 位元處理器上則是原子的。
深入平行世界:同步化技術的必要性
在多執行緒程式設計中,正確地存取分享資料是至關重要的。雖然對位元組大小的資料進行存取是原子的,但對於更大的資料量,如字(word)、整數(integer)等,只有在正確對齊的情況下才是原子的。
資料對齊的重要性
- 當資料正確對齊時,可以確保原子性存取。例如,字大小的資料需要字對齊,整數資料需要雙字對齊。
- 在64位元模式下編譯的程式碼中,如果 int64 資料是四字對齊的,也可以進行原子存取。
- 編譯器通常會處理資料對齊問題,但在某些情況下仍需手動檢查對齊,以避免程式設計錯誤。
同步化的必要性
當多個執行緒需要存取或修改分享資料時,僅僅依靠正確的資料對齊是不夠的。為了防止多個執行緒同時修改分享資料導致的問題,必須引入同步化機制。
同步化的例子:TMemoryStream.Size 屬性
function TStream.GetSize: Int64;
var
Pos: Int64;
begin
Pos := Seek(0, soCurrent);
Result := Seek(0, soEnd);
Seek(Pos, soBeginning);
end;
內容解密:
TStream.GetSize方法並非直接存取內部屬性,而是透過一系列的Seek操作來取得流的大小。- 這種實作方式在多執行緒環境下可能導致問題,因為
Seek方法會修改FPosition欄位。 - 當多個執行緒同時讀取
Size屬性時,FPosition成為分享變數,並在多個執行緒中被修改,這最終會導致問題。
自定義 TMemoryStream 解決方案
function TGpMemoryStream.GetSize: int64;
begin
Result := FSize;
end;
內容解密:
- 自定義的
TGpMemoryStream類別直接傳回FSize欄位,避免了TStream.GetSize中的複雜操作。 - 這種實作方式在多執行緒環境下更為安全,因為它避免了對分享變數的修改。
同步化機制
為了防止多個執行緒同時修改分享資料,可以使用鎖定(locking)機制。鎖定可以保護程式的一部分,確保同一時間只有一個執行緒可以存取它。
鎖定的工作原理
- 當一個執行緒成功取得鎖定(lock)但尚未釋放時,其他執行緒無法取得該鎖定。
- 如果其他執行緒嘗試取得鎖定,它將被暫停,直到鎖定可用(即原始鎖定擁有者釋放鎖定)。
圖表翻譯:
- 此圖示展示了兩個執行緒之間的鎖定機制運作過程。
- 執行緒1首先取得鎖定,進入鎖定狀態,此時執行緒2嘗試取得鎖定但被迫等待。
- 當執行緒1釋放鎖定後,鎖定狀態轉變為解鎖狀態,執行緒2隨後取得鎖定。
同步機制
在撰寫多平台應用程式時,存取作業系統同步機制可能會是一件棘手的事。幸運的是,Delphi 的執行時期函式庫提供了非常好的平台無關方式來處理它們。
關鍵段(Critical Section)
實作鎖定的最簡單方式是使用關鍵段。在 Delphi 中,您應該使用 System.SyncObjs 單元中實作的 TCriticalSection 包裝器,而不是直接存取作業系統。
使用範例
讓我們透過範例來說明關鍵段的使用。ReadWrite 範例程式除了實作不安全的讀寫方法外,還實作了使用關鍵段鎖定來保護讀寫資料的程式碼。這個關鍵段在表單建立時建立,在表單銷毀時銷毀。只有一個關鍵段物件被兩個執行緒共用:
procedure TfrmReadWrite.FormCreate(Sender: TObject);
begin
FLock := TCriticalSection.Create;
end;
procedure TfrmReadWrite.FormDestroy(Sender: TObject);
begin
FreeAndNil(FLock);
end;
當讀取執行緒想要從 FPValue^ 讀取資料時,它首先會呼叫 FLock.Acquire 來取得關鍵段。在此時,執行緒要麼成功取得鎖定並繼續執行,要麼會被阻塞直到鎖定變為未被佔用。
procedure TfrmReadWrite.LockedReader;
var
i: integer;
value: int64;
begin
for i := 1 to CNumRepeat do begin
FLock.Acquire;
value := FPValue^;
FLock.Release;
FValueList.Add(value);
end;
end;
注意事項
TCriticalSection也實作了Enter和Leave別名,它們的功能與Acquire和Release完全相同。使用哪一個完全取決於您。- 鎖定機制是合作式的。只有所有執行緒都使用鎖定機制時,它們才會生效。如果一個執行緒使用鎖定,而另一個執行緒忽略它並直接存取資料,則鎖定機制將會失效。
鎖定機制的影響
引入鎖定機制有兩個重要的影響:一是輸出的正確性得到保證,二是程式的執行速度變慢。
@startuml
skinparam backgroundColor #FEFEFE
skinparam componentStyle rectangle
title Delphi多執行緒程式設計與同步機制
package "安全架構" {
package "網路安全" {
component [防火牆] as firewall
component [WAF] as waf
component [DDoS 防護] as ddos
}
package "身份認證" {
component [OAuth 2.0] as oauth
component [JWT Token] as jwt
component [MFA] as mfa
}
package "資料安全" {
component [加密傳輸 TLS] as tls
component [資料加密] as encrypt
component [金鑰管理] as kms
}
package "監控審計" {
component [日誌收集] as log
component [威脅偵測] as threat
component [合規審計] as audit
}
}
firewall --> waf : 過濾流量
waf --> oauth : 驗證身份
oauth --> jwt : 簽發憑證
jwt --> tls : 加密傳輸
tls --> encrypt : 資料保護
log --> threat : 異常分析
threat --> audit : 報告生成
@enduml圖表翻譯: 此圖示展示了使用鎖定機制的一般流程。首先,執行緒嘗試取得鎖定;成功後,它可以安全地存取共用資源;完成後,它釋放鎖定,讓其他執行緒可以存取該資源。
鎖定的特性
- 如果一個執行緒已經佔有鎖定,它可以再次呼叫
Acquire而不會被阻塞。這種行為稱為可重入(re-entrancy)。 - 鎖定機制可能導致死鎖(deadlock)。當兩個或多個執行緒互相等待對方釋放鎖定時,就會發生死鎖。
死鎖問題
死鎖發生在多個執行緒以不同的順序取得多個鎖定時。例如,在 Deadlocking 範例程式中,兩個執行緒以不同的順序取得 LockCounter 和 LockOther 兩個鎖定,從而導致死鎖。
procedure TaskProc1(const task: ITask; const shared: TSharedData);
begin
while frmDeadlock.FTask1.Status <> TTaskStatus.Canceled do begin
shared.LockCounter.Acquire;
shared.LockOther.Acquire;
shared.Counter := shared.Counter + 1;
shared.LockOther.Release;
shared.LockCounter.Release;
end;
end;
procedure TaskProc2(const task: ITask; const shared: TSharedData);
begin
while frmDeadlock.FTask1.Status <> TTaskStatus.Canceled do begin
shared.LockOther.Acquire;
shared.LockCounter.Acquire;
shared.Counter := shared.Counter + 1;
shared.LockCounter.Release;
shared.LockOther.Release;
end;
end;
內容解密:
此段落主要講解了同步機制的概念和實作方法,包括使用關鍵段來保護共用資源,以及鎖定機制的特性和潛在風險。透過範例程式和圖表說明,讀者可以更好地理解同步機制的重要性和實作細節。