在 Delphi 多執行緒應用程式中,確保執行緒間安全地交換資料至關重要。本文將剖析幾種常用的執行緒通訊技術,包含同步與非同步方法,並探討它們的效能差異和適用場景。同步方法 TThread.Synchronize 能確保資料完整性,但可能造成效能瓶頸。非同步方法 TThread.Queue 則提升了效率,但需要額外處理資料一致性。訊息傳遞機制提供更靈活的通訊方式,適用於複雜的互動場景。而使用 TThreadedQueue<T> 結合定時器的輪詢機制則在效能和複雜度之間取得平衡。同時,文章也提供效能測試資料,並簡要介紹 Spring4D 和 OmniThreadLibrary 等第三方函式庫,為 Delphi 開發者在多執行緒程式設計中提供更全面的參考。
平行世界初探:同步與通訊的藝術
在平行程式設計的世界中,分享資料的管理是一項挑戰。當多個平行任務同時存取分享物件時,建立或銷毀該物件可能會引發問題。銷毀物件尤其危險,因為它可能導致其他執行緒存取已釋放的記憶體,從而引發程式當機。
銷毀分享物件的風險
在非ARC(自動參考計數)環境中,銷毀正在被其他執行緒使用的物件顯然是危險的。即使在ARC環境中,將分享介面設為nil也可能導致問題,因為一個執行緒可能會嘗試清除物件,而另一個執行緒卻試圖複製它。
FShared := nil; // 在一個執行緒中
tmp := FShared; // 在另一個執行緒中
tmp.DoSomething; // 可能會當機
這種情況可能會導致難以預料的行為,因為 _IntfClear 和 _IntfCopy 的呼叫可能會相互幹擾。
建立分享物件的方法
與銷毀物件相比,建立物件則是可行的。透過使用鎖定機制,可以確保物件只被建立一次。
簡單鎖定法
一種方法是使用 TCriticalSection 來同步存取物件建立的過程。
// 在主執行緒中
FInitializationLock := TCriticalSection.Create;
// 在工作執行緒中
FInitializationLock.Acquire;
if not assigned(FSharedObject) then
FSharedObject := TSharedObject.Create;
FInitializationLock.Release;
雙重檢查鎖定法
為了提高效率,可以使用雙重檢查鎖定模式。
// 在主執行緒中
FInitializationLock := TCriticalSection.Create;
// 在工作執行緒中
if not assigned(FSharedObject) then
begin
FInitializationLock.Acquire;
if not assigned(FSharedObject) then
FSharedObject := TSharedObject.Create;
FInitializationLock.Release;
end;
無鎖初始化法
甚至可以使用無鎖的技術,例如透過 TInterlocked.CompareExchange 來建立物件。
procedure CreateSharedObject;
var
value: TSharedObject;
begin
if not assigned(FSharedObject) then
begin
value := TSharedObject.Create;
if TInterlocked.CompareExchange(pointer(FSharedObject), pointer(value), nil) <> nil then
value.Free;
end;
end;
內容解密:
此程式碼首先檢查 FSharedObject 是否已經被建立。如果沒有,它會建立一個新的 TSharedObject 例項並將其儲存在臨時變數 value 中。然後,它使用 CompareExchange 將 value 儲存在分享變數 FSharedObject 中,但前提是 FSharedObject 仍然是 nil。如果另一個執行緒在此期間建立了物件並將其儲存在 FSharedObject 中,則 CompareExchange 將傳回非 nil 值,表示操作失敗,此時當前執行緒會釋放 value 以避免記憶體洩漏。
從分享到通訊
儘管資料分享很困難,但可以透過改變演算法來避免它。與其分享資料,不如複製資料並在各個執行緒中獨立處理,最後再將結果聚合起來。
資料複製與聚合的優勢
- 避免鎖定:減少了因鎖定而導致的效能瓶頸。
- 提高可擴充套件性:隨著CPU核心數量的增加,程式的效能可以更好地擴充套件。
這種方法的關鍵在於各個執行緒之間的通訊機制,使得主執行緒能夠收集並聚合各個執行緒的結果。
執行緒間的通訊:從工作執行緒傳送資料到主執行緒
在多執行緒程式設計中,將資料從一個執行緒傳送到另一個執行緒是一項基本且重要的任務。當我們不需要考慮物件所有權時,這個過程會變得更加簡單。最佳實踐是使用簡單的資料型別(例如整數)和受管理的資料型別(例如字串和介面)。由於這些資料不會同時在兩個執行緒中使用,因此物件生命週期的規則不適用。我們可以以正常的方式建立和銷毀這些物件。
傳送資料到主執行緒的方法
從工作執行緒向主執行緒傳送資料取決於主執行緒的實作方式。以下是四種與主執行緒通訊的技術:傳送 Windows 訊息、使用 Synchronize、Queue 和輪詢。
Windows 訊息
第一種方法是透過傳送 Windows 訊息給主執行緒。這個方法僅限於 Windows 作業系統。下面介紹兩種實作方式:第一種僅適用於 VCL 應用程式,而第二種也適用於 FireMonkey。
在 IncDecComm 程式中,所有的背景任務都以相同的方式啟動。當點選按鈕時,會呼叫 RunInParallel 方法,並傳遞兩個方法的名稱作為引數,一個用於遞增,另一個用於遞減初始值。
procedure TfrmIncDecComm.RunInParallel(task1, task2: TProc<integer>);
begin
FNumDone := 0;
FTasks[0] := TTask.Run(procedure begin task1(0); end);
FTasks[1] := TTask.Run(procedure begin task2(0); end);
end;
使用 Windows 訊息的實作
當點選按鈕時,以下程式碼會被執行。它初始化結果 FValue 為零,以便正確聚合部分結果。同時,將 FNumDone 變數設為零,該變數用於計算完成任務的數量。
procedure TfrmIncDecComm.btnMessageClick(Sender: TObject);
begin
FValue := 0;
FNumDone := 0;
RunInParallel(IncMessage, DecMessage);
end;
兩個任務的實作方式相同,下面只展示一個方法。IncMessage 方法將起始值遞增 CNumRepeat 次。最後,它會將 MSG_TASK_DONE 訊息回傳給主表單,並將結果值作為引數傳遞。
procedure TfrmIncDecComm.IncMessage(startValue: integer);
var
i: integer;
value: integer;
begin
value := startValue;
for i := 1 to CNumRepeat do
value := value + 1;
PostMessage(Handle, MSG_TASK_DONE, value, 0);
end;
處理 Windows 訊息
為了在主表單中處理這個訊息,我們需要宣告 MSG_TASK_DONE 變數,並為表單新增一個訊息處理方法 MsgTaskDone。
const
MSG_TASK_DONE = WM_USER;
procedure MsgTaskDone(var msg: TMessage);
message MSG_TASK_DONE;
這個方法會在每次主表單接收到 MSG_TASK_DONE 訊息時被呼叫。在內部,我們首先透過 msg.WParam 欄位傳遞的部分結果來遞增 FValue 值(聚合步驟)。之後,我們遞增 FNumDone 計數器。當 FNumDone 等於 2 時,我們呼叫清理方法 Done。
procedure TfrmIncDecComm.MsgTaskDone(var msg: TMessage);
begin
Inc(FValue, msg.WParam);
Inc(FNumDone);
if FNumDone = 2 then
Done('Windows message');
end;
使用隱藏視窗處理 Windows 訊息
在 FireMonkey 中,我們需要建立一個隱藏視窗來接收和處理訊息。當點選 Message + AllocateHwnd 按鈕時,以下程式碼會被執行。它初始化 FValue 和 FNumDone,然後透過呼叫 AllocateHwnd 建立一個隱藏視窗。
procedure TfrmIncDecComm.btnAllocateHwndClick(Sender: TObject);
begin
FValue := 0;
FNumDone := 0;
FMsgWnd := AllocateHwnd(MsgWndProc);
Assert(FMsgWnd <> 0);
RunInParallel(IncMsgHwnd, DecMsgHwnd);
end;
清理隱藏視窗
為了清理這個隱藏視窗,我們需要呼叫 DeallocateHwnd 方法。
if FMsgWnd <> 0 then
begin
DeallocateHwnd(FMsgWnd);
FMsgWnd := 0;
end;
Synchronize 和 Queue
第二和第三種方法是相似的,因此將它們合併在一個章節中討論。
設定與第一種方法相同,我們只需要初始化 FValue 和 FNumTasks。遞增和遞減任務中的程式碼也以相同的 for 迴圈開始,但隨後使用不同的方法將值傳回主執行緒。
使用 TThread.Synchronize
以下程式碼使用 TThread.Synchronize 在目標執行緒中執行匿名方法。Synchronize 方法接受一個 TThread 物件作為第一個引數,匿名方法作為第二個引數。它將在主執行緒中執行第二個引數。
procedure TfrmIncDecComm.IncSynchronize(startValue: integer);
var
i: integer;
value: integer;
begin
value := startValue;
for i := 1 to CNumRepeat do
value := value + 1;
TThread.Synchronize(nil,
procedure
begin
// 在主執行緒中執行的程式碼
end);
end;
圖表翻譯:
此圖示展示了使用Windows訊息與TThread.Synchronize進行執行緒間通訊的流程。
@startuml
skinparam backgroundColor #FEFEFE
skinparam defaultTextAlignment center
skinparam rectangleBackgroundColor #F5F5F5
skinparam rectangleBorderColor #333333
skinparam arrowColor #333333
title 圖表翻譯:
rectangle "傳送Windows訊息" as node1
rectangle "使用TThread.Synchronize" as node2
node1 --> node2
@enduml圖表翻譯: 此圖示顯示了兩種不同的執行緒間通訊方法,分別是透過傳送Windows訊息和使用TThread.Synchronize,兩者都實作了從工作執行緒到主執行緒的資料傳輸。
平行世界入門:執行緒通訊技術詳解
在多執行緒程式設計中,如何有效地進行執行緒間的通訊是一個至關重要的課題。本文將探討 Delphi 中多種執行緒通訊的方法,包括 TThread.Synchronize、TThread.Queue、訊息傳遞機制以及使用 TThreadedQueue<T> 進行輪詢等技術。
使用 TThread.Synchronize 進行執行緒同步
TThread.Synchronize 是一種在 Delphi 中常用的執行緒同步方法。它允許在工作執行緒中暫停執行,並將指定的匿名方法傳遞到主執行緒中執行,然後等待主執行緒完成該方法的執行後再繼續工作執行緒。
程式碼範例
procedure TfrmIncDecComm.btnThSyncClick(Sender: TObject);
begin
FValue := 0;
FNumDone := 0;
RunInParallel(IncThSync, DecThSync);
end;
procedure TfrmIncDecComm.IncThSync(startValue: integer);
var
i: integer;
value: integer;
begin
value := startValue;
for i := 1 to CNumRepeat do
value := value + 1;
TThread.Synchronize(nil,
procedure
begin
PartialResult(value);
end);
end;
procedure TfrmIncDecComm.PartialResult(value: integer);
begin
Inc(FValue, value);
Inc(FNumDone);
if FNumDone = 2 then
Done('Synchronize');
end;
內容解密:
TThread.Synchronize方法的使用:在工作執行緒中呼叫TThread.Synchronize,將PartialResult方法傳遞到主執行緒中執行。這確保了 UI 更新和分享變數的存取是在主執行緒中進行的,避免了多執行緒競爭條件。PartialResult方法的作用:更新分享變數FValue和任務完成計數器FNumDone。當兩個任務都完成時,呼叫Done方法結束操作。
使用 TThread.Queue 進行非同步執行
與 TThread.Synchronize 不同,TThread.Queue 不會阻塞工作執行緒,而是將匿名方法排入主執行緒的佇列中並立即傳回,讓工作執行緒繼續執行後續任務。
程式碼範例
procedure TfrmIncDecComm.IncThQueue(startValue: integer);
var
i: integer;
value: integer;
begin
value := startValue;
for i := 1 to CNumRepeat do
value := value + 1;
TThread.Queue(nil,
procedure
begin
PartialResult(value);
end);
end;
內容解密:
TThread.Queue的非同步特性:與Synchronize不同,Queue不會等待主執行緒完成匿名方法的執行,而是立即傳回,使工作執行緒能夠繼續處理其他任務,提高了整體效率。- 適用場景:當工作執行緒不需要等待主執行緒處理結果時,使用
TThread.Queue可以減少執行緒阻塞,提升系統效能。
輪詢機制:使用 TThreadedQueue<T> 和 TTimer
另一種執行緒通訊的方式是透過輪詢機制,即主執行緒定期檢查佇列中是否有新的資料。
程式碼範例
procedure TfrmIncDecComm.btnThQueueAndTImerClick(Sender: TObject);
begin
FValue := 0;
FNumDone := 0;
FResultQueue := TThreadedQueue<integer>.Create(2, 0, 0);
RunInParallel(IncThQueue, DecThQueue);
TimerCheckQueue.Enabled := true;
end;
procedure TfrmIncDecComm.IncThQueue(startValue: integer);
var
i: integer;
value: integer;
begin
value := startValue;
for i := 1 to CNumRepeat do
value := value + 1;
Assert(FResultQueue.PushItem(value) = wrSignaled);
end;
procedure TfrmIncDecComm.TimerCheckQueueTimer(Sender: TObject);
var
qsize: integer;
value: integer;
begin
while FResultQueue.PopItem(qsize, value) = wrSignaled do
begin
FValue := FValue + value;
Inc(FNumDone);
if FNumDone = 2 then
begin
Done('TThreadedQueue + TTimer');
break;
end;
end;
end;
圖表翻譯:
此圖示展示了使用 TThreadedQueue<T> 和 TTimer 的輪詢機制。工作執行緒將計算結果推播到佇列中,而主執行緒透過定時器定期檢查佇列並處理資料。
- 主執行緒啟動定時器並定期檢查佇列狀態。
- 工作執行緒完成計算後將結果推播到佇列中。
- 主執行緒從佇列中取出資料並進行匯總處理。
各種通訊方式的效能比較
| 方法 | 時間 (毫秒) |
|---|---|
| 原始單執行緒 | 75 |
| Critical Section | 1,017 |
| TMonitor | 700 |
| Spinlock | 493 |
| TInterlocked | 407 |
| Message-based | 16-23 |
| Synchronize/Queue | 13-14 |
| Timer-based | 24 |
圖表翻譯:
此圖示比較了不同執行緒通訊方法的效能。可以看出,根據訊息傳遞和佇列的方法明顯優於傳統的鎖定機制,其中 Synchronize 和 Queue 的效能最佳。
第三方函式庫支援
除了 Delphi 原生的功能外,還有多個第三方函式庫可以簡化平行程式設計,如 Spring4D 和 OmniThreadLibrary。這些函式庫提供了更豐富的功能和更簡潔的 API,能夠進一步提升開發效率。