Delphi中的執行緒類
猛禽[Mental Studio]
http://mental.mentsu.com
之四
臨界區(CriticalSection)則是共享資料存取保護的技術。它其實也是相當於一個全域的布林變數。但對它的操作有所不同,它只有兩個操作:Enter和Leave,同樣可以把它的兩個狀態當作True和False,分別表示現在是否處於臨界區中。這兩個操作也是原語,所以它可以用於在多線程應用中保護共享數據,防止訪問衝突。
用臨界區保護共享資料的方法很簡單:在每次要存取共享資料之前呼叫Enter設定進入臨界區標誌,然後再操作數據,最後呼叫Leave離開臨界區。它的保護原理是這樣的:當一個執行緒進入臨界區後,如果此時另一個執行緒也要存取這個數據,則它會在呼叫Enter時,發現已經有執行緒進入臨界區,然後此執行緒就會被掛起,等待當前在臨界區的執行緒呼叫Leave離開臨界區,當另一個執行緒完成操作,呼叫Leave離開後,此執行緒就會被喚醒,並設定臨界區標誌,開始操作數據,這樣就防止了訪問衝突。
以前面那個InterlockedIncrement為例,我們就用CriticalSection(Windows API)來實作它:
Var
InterlockedCrit : TRTLCriticalSection;
PRocedure InterlockedIncrement( var aValue : Integer );
Begin
EnterCriticalSection( InterlockedCrit );
Inc( aValue );
LeaveCriticalSection( InterlockedCrit );
End;
現在再來看前面那個例子:
1. 線程A進入臨界區(假設資料為3)
2. 線程B進入臨界區,因為A已經在臨界區中,所以B被掛起
3. 線程A對資料加一(現在是4)
4. 執行緒A離開臨界區,喚醒執行緒B(現在記憶體中的資料是4)
5. 執行緒B被喚醒,對資料加一(現在就是5了)
6. 線程B離開臨界區,現在的資料就是正確的了。
臨界區就是這樣保護共享資料的存取。
關於臨界區的使用,有一點要注意:即資料存取時的異常情況處理。因為如果在資料操作時發生異常,將導致Leave操作沒有被執行,結果將使本應被喚醒的執行緒未被喚醒,可能造成程式的沒有回應。所以一般來說,如下面這樣使用臨界區才是正確的做法:
EnterCriticalSection
Try
// 操作臨界區數據
Finally
LeaveCriticalSection
End;
最後要說明的是,Event和CriticalSection都是作業系統資源,使用前都需要創建,使用完後也需要釋放。如TThread類別用到的一個全域Event:SyncEvent和全域CriticalSection:TheadLock,都是在InitThreadSynchronization和DoneThreadSynchronization中進行創建和釋放的,而它們則是在Classes單元的Initialization和Finalization中被調用的。
由於在TThread中都是用API來操作Event和CriticalSection的,所以前面都是以API為例,其實Delphi已經提供了它們的封裝,在SyncObjs單元中,分別是TEvent類別和TCriticalSection類別。用法也與前面用API的方法相差無幾。因為TEvent的建構函式參數太多,為了簡單起見,Delphi也提供了一個用預設參數初始化的Event類別:TSimpleEvent。
順便再介紹另一個用於執行緒同步的類別:TMultiReadExclusiveWriteSynchronizer,它是在SysUtils單元中定義的。據我所知,這是Delphi RTL中定義的最長的一個類別名,還好它有一個短的別名:TMREWSync。至於它的用處,我想光看名字就可以知道了,我也就不多說了。
有了前面對Event和CriticalSection的準備知識,可以正式開始討論Synchronize和WaitFor了。
我們知道,Synchronize是透過將部分程式碼放到主執行緒中執行來實現執行緒同步的,因為在一個行程中,只有一個主執行緒。先來看看Synchronize的實作:
procedure TThread.Synchronize(Method: TThreadMethod);
begin
FSynchronize.FThread := Self;
FSynchronize.FSynchronizeException := nil;
FSynchronize.FMethod := Method;
Synchronize(@FSynchronize);
end;
其中FSynchronize是一個記錄類型:
PSynchronizeRecord = ^TSynchronizeRecord;
TSynchronizeRecord = record
FThread: TObject;
FMethod: TThreadMethod;
FSynchronizeException: TObject;
end;
用於進行線程和主線程之間進行資料交換,包括傳入線程類對象,同步方法及發生的異常。
在Synchronize中呼叫了它的一個重載版本,而且這個重載版本比較特別,它是一個「類別方法」。所謂類別方法,是一種特殊的類別成員方法,它的呼叫並不需要建立類別實例,而是像建構函數那樣,透過類別名稱呼叫。之所以會用類別方法來實現它,是因為為了可以在線程物件沒有創建時也能呼叫它。不過實際上是用它的另一個重載版本(也是類別方法)和另一個類別方法StaticSynchronize。下面是這個Synchronize的程式碼:
class procedure TThread.Synchronize(ASyncRec: PSynchronizeRecord);
var
SyncProc: TSyncProc;
begin
if GetCurrentThreadID = MainThreadID then
ASyncRec.FMethod
else
begin
SyncProc.Signal := CreateEvent(nil, True, False, nil);
try
EnterCriticalSection(ThreadLock);
try
if SyncList = nil then
SyncList := TList.Create;
SyncProc.SyncRec := ASyncRec;
SyncList.Add(@SyncProc);
SignalSyncEvent;
if Assigned(WakeMainThread) then
WakeMainThread(SyncProc.SyncRec.FThread);
LeaveCriticalSection(ThreadLock);
try
WaitForSingleObject(SyncProc.Signal, INFINITE);
finally
EnterCriticalSection(ThreadLock);
end;
finally
LeaveCriticalSection(ThreadLock);
end;
finally
CloseHandle(SyncProc.Signal);
end;
if Assigned(ASyncRec.FSynchronizeException) then raise ASyncRec.FSynchronizeException;
end;
end;
這段程式碼略多一些,不過也不太複雜。
首先是判斷當前線程是否是主線程,如果是,則簡單地執行同步方法後返回。
如果不是主線程,則準備開始同步過程。
透過局部變數SyncProc記錄執行緒交換資料(參數)和一個Event Handle,其記錄結構如下:
TSyncProc = record
SyncRec: PSynchronizeRecord;
Signal: THandle;
end;
然後建立一個Event,接著進入臨界區(透過全域變數ThreadLock進行,因為同時只能有一個執行緒進入Synchronize狀態,所以可以用全域變數記錄),然後就是把這個記錄資料存入SyncList這個清單中(如果這個列表不存在的話,則創建它)。可見ThreadLock這個臨界區就是為了保護對SyncList的訪問,這點在後面介紹CheckSynchronize時會再看到。
再接著就是呼叫SignalSyncEvent,其程式碼在前面介紹TThread的建構子時已經介紹過了,它的功能就是簡單地將SyncEvent作一個Set的操作。關於這個SyncEvent的用途,後面介紹WaitFor時再詳述。
接下來就是最主要的部分了:呼叫WakeMainThread事件進行同步操作。 WakeMainThread是一個TNotifyEvent類型的全域事件。這裡之所以要用事件進行處理,是因為Synchronize方法本質上是透過訊息,將需要同步的過程放到主執行緒中執行,如果在一些沒有訊息循環的應用中(如Console或DLL)是無法使用的,所以要使用這個事件來處理。
而回應這個事件的是application對象,以下兩個方法分別用來設定和清空WakeMainThread事件的回應(來自Forms單元):
procedure TApplication.HookSynchronizeWakeup;
begin
Classes.WakeMainThread := WakeMainThread;
end;
procedure TApplication.UnhookSynchronizeWakeup;
begin
Classes.WakeMainThread := nil;
end;
上面兩個方法分別是在TApplication類別的建構子和析構函式中被呼叫。
這就是在Application物件中WakeMainThread事件回應的程式碼,訊息就是在這裡被發出的,它利用了一個空訊息來實現:
procedure TApplication.WakeMainThread(Sender: TObject);
begin
PostMessage(Handle, WM_NULL, 0, 0);
end;
而這個訊息的回應也是在Application物件中,請參閱下面的程式碼(刪除無關的部分):
procedure TApplication.WndProc(var Message: TMessage);
…
begin
try
…
with Message do
case Msg of
…
WM_NULL:
CheckSynchronize;
…
except
HandleException(Self);
end;
end;
其中的CheckSynchronize也是定義在Classes單元中的,由於它比較複雜,暫時不詳細說明,只要知道它是具體處理Synchronize功能的部分就好,現在繼續分析Synchronize的程式碼。
執行完WakeMainThread事件後,就退出臨界區,然後呼叫WaitForSingleObject開始等待在進入臨界區前所建立的那個Event。這個Event的功能是等待這個同步方法的執行結束,關於這一點,在後面分析CheckSynchronize時會再說明。
注意在WaitForSingleObject之後又重新進入臨界區,但沒有做任何事就退出了,似乎沒有意義,但這是必須的!
因為臨界區的Enter和Leave必須嚴格的一一對應。那麼是否可以改成這樣呢:
if Assigned(WakeMainThread) then
WakeMainThread(SyncProc.SyncRec.FThread);
WaitForSingleObject(SyncProc.Signal, INFINITE);
finally
LeaveCriticalSection(ThreadLock);
end;
上面的程式碼和原來的程式碼最大的差別在於把WaitForSingleObject也納入臨界區的限制中了。看起來沒什麼影響,還使程式碼大大簡化了,但真的可以嗎?
事實上是不行!
因為我們知道,在Enter臨界區後,如果別的執行緒要再進入,就會被掛起。而WaitFor方法則會掛起目前線程,直到等待別的執行緒SetEvent後才會被喚醒。如果改成上面那樣的程式碼的話,如果那個SetEvent的執行緒也需要進入臨界區的話,死鎖(Deadlock)就發生了(關於死鎖的理論,請自行參考作業系統原理方面的資料)。
死鎖是線程同步中最需要注意的方面之一!
最後釋放開始時所建立的Event,如果被同步的方法傳回異常的話,也會在這裡再次拋出異常。
(待續)