Three Locks To Rule Them All(三把鎖統治一切)
【英文原文】
為了確保執行緒安全,特別是在伺服器端,我們通常使用臨界區(critical sections)或鎖(locks)來保護程式碼。在最近的Delphi版本中,我們引入了TMonitor特性,但我更傾向於信任作業系統提供的鎖機制,這些鎖是透過Windows臨界區或POSIX futex/mutex來實現的。
但需要注意的是,並非所有的鎖在效能和使用上都是相同的。在大多數情況下,我們其實並不需要Windows API的臨界區或pthread庫所帶來的額外開銷。
因此,在mORMot 2中,除了這些作業系統提供的鎖之外,我們還引入了多種原生鎖。這些原生鎖除了具備基本的鎖定功能外,還擁有多讀/單寫能力或重入(re-entrancy)能力。
執行緒安全——一條艱難的路
對於常規的RAD(快速應用開發)/客戶端應用程式而言,通常單個執行緒就足以滿足需求。透過使用訊息和/或TTimer,我們可以在應用程式中實現一些簡單的協作式多工處理,這對於大多數用途而言已經足夠了。
然而,在伺服器端,為了提升可擴充套件性,業務程式碼必須是執行緒安全的。根據我的實驗經驗,實現執行緒安全比實現平行計算要困難得多。
需要注意的是,多執行緒程式設計並不容易,有時甚至非常難以除錯。因為問題往往難以重現——很容易遇到難以捉摸、難以重現的bug(有時被稱為海森堡bug,即HeisenBug)。
因此,在開始多執行緒程式設計之前,請確保你已經閱讀並理解了關於執行緒安全以及現代CPU記憶體和操作執行的一些基本知識。我最近發現了一系列部落格文章,其中詳細介紹了在極端情況下可能出現的一些陷阱……這些陷阱也同樣可能會發生在你的程式設計過程中,就像我曾經遇到過的那樣!
鎖帶來的保障
為了確保執行緒安全,我們所擁有的最便捷的特性就是鎖。鎖可以保護某些程式碼段,使其免受多個執行緒的併發執行影響。
更準確地說,我們實際上保護的是資源而非程式碼本身。程式碼本身是執行緒安全的,但當多個執行緒同時訪問資料時,資料就需要額外的關注。如果我們只是讀取資料,那通常不會有問題。但是,一旦有一個執行緒修改了資料,其他執行緒就很可能會受到影響——比如,你向一個列表中新增了一個專案,然後該列表在記憶體中的儲存位置被重新分配了,那麼由於指標失效,你可能會遇到一些隨機的記憶體保護錯誤(如GPF)。又或者兩個執行緒同時向列表中新增專案,那麼計數器或儲存空間可能會出現錯誤。為了避免這類問題,我們需要鎖定對資料的訪問。
以下是POSIX的libpthread庫提供鎖的方式——這種方式與Windows的臨界區類似:
#include <pthread.h>
pthread_mutex_t mutex;
int main() {
pthread_mutex_init(&mutex, NULL); // 初始化互斥鎖
// ... 在需要保護的程式碼段前後加鎖和解鎖 ...
pthread_mutex_lock(&mutex); // 加鎖
// 臨界區:只有獲得鎖的執行緒才能執行這裡的程式碼
// ... 執行執行緒不安全的操作 ...
pthread_mutex_unlock(&mutex); // 解鎖
// ...
pthread_mutex_destroy(&mutex); // 銷燬互斥鎖
return 0;
}
在上面的程式碼中,pthread_mutex_lock
函式用於在臨界區前加鎖,而pthread_mutex_unlock
函式則用於在臨界區後解鎖。所有在這兩個函式呼叫之間的記憶體操作都被安全地保護起來,防止了任何不希望的記憶體重排序跨越這個邊界。你可以將你的執行緒不安全程式碼放在這個“三明治”的中間,這樣就確保了每次只有一個執行緒能夠執行它。
鎖不貴,競爭才貴
使用鎖的主要規則是,鎖的範圍應該儘可能小。
為什麼?
獲取一個未鎖定的互斥鎖,或釋放一個互斥鎖幾乎是免費的,它通常是一條原子彙編指令。在Intel/AMD上,原子指令具有鎖字首,或者明確指定為這樣,例如cmpxchg操作。在ARM上,你通常需要編寫一個小迴圈,或者至少需要幾個指令。
在mormot.core.base.pas中,我們提供了一些跨平臺和跨編譯器的原子處理函式,這些函式是用最佳化的組合語言編寫的,或者呼叫了RTL(執行時庫):
procedure LockedInc32(int32: PInteger);
procedure LockedDec32(int32: PInteger);
procedure LockedInc64(int64: PInt64);
function InterlockedIncrement(var I: integer): integer;
function InterlockedDecrement(var I: integer): integer;
function RefCntDecFree(var refcnt: TRefCnt): boolean;
function LockedExc(var Target: PtrUInt; NewValue, Comperand: PtrUInt): boolean;
procedure LockedAdd(var Target: PtrUInt; Increment: PtrUInt);
procedure LockedAdd32(var Target: cardinal; Increment: cardinal);
procedure LockedDec(var Target: PtrUInt; Decrement: PtrUInt);
但是,如果兩個(或更多)執行緒爭奪一個鎖,那麼只有一個執行緒會獲得它。因此,其他執行緒將不得不等待。等待通常首先是透過旋轉(即執行一個空迴圈)來完成的,並嘗試獲取鎖。最終,可能會發生一個作業系統核心呼叫,以利用CPU核心,並嘗試執行來自另一個執行緒的掛起程式碼。
這種鎖競爭、旋轉或切換到另一個執行緒才是真正降低整個程序效能的原因。你只是在浪費時間和能源來訪問共享資源。
因此,在實踐中,我建議遵循一些簡單的規則。
先讓它工作,再讓它快速執行
你可能首先會使用一個巨大的臨界區來保護整個方法。大多數情況下,這都沒問題。
不要猜測,在多核CPU上執行實際的基準測試(不是在單核虛擬機器上!),嘗試重現可能發生的最壞情況。
擁有詳細且執行緒感知的日誌,以便正確除錯生產程式碼——海森堡bug很可能不會出現在你的開發電腦上,而是會在實際負載中出現。
一旦你確定了真正的瓶頸,嘗試將邏輯程式碼拆分成小塊:
- 確保你有針對此方法的多執行緒迴歸測試程式碼,以驗證你的修改實際上仍然是正確的,並且...更快;
- 程式碼的部分內容可能本身就是執行緒安全的(例如錯誤檢查或結果日誌記錄):無需使用鎖來保護它;
- 根據共享的資源,將處理程式碼隔離到一些私有/受保護的方法中,並進行適當的鎖定。
越少越好
最終,為了實現最佳效能:
- 讓你的鎖儘可能短。
- 更喜歡對小資料使用多個鎖,而不是一些巨大的鎖;
- 對每個列表或佇列使用一個鎖,而不是對每個程序或業務邏輯方法使用一個鎖。
多種鎖以統治全域性
除了TSynLock包裝器外,mormot.core.os.pas還定義了以下幾種鎖:
一個輕量級的、非重入的排他鎖,儲存在PtrUInt值中。
- 在旋轉一段時間後會呼叫SwitchToThread,但不使用任何讀/寫作業系統API。
- 警告:這些方法是非重入的,即在未解鎖的情況下連續兩次呼叫Lock會導致死鎖。對於需要重入的方法,請使用TRWLock或TSynLocker/TRTLCriticalSection。
- 輕量級鎖預計將只持有非常短的時間:如果鎖可能會阻塞太長時間,請使用TSynLocker或TRTLCriticalSection。
- 使用多個輕量級鎖,每個鎖保護幾個變數(例如一個列表),可能比使用更全域性的TRTLCriticalSection/TRWLock更有效。
- 在32位CPU上僅佔用4個位元組,在64位CPU上佔用8個位元組。
TLightLock = record
procedure Lock;
function TryLock: boolean;
procedure UnLock;
end;
一個輕量級的、支援多個讀取/排他寫入的、非可升級的鎖。
- 在旋轉一段時間後會呼叫SwitchToThread,但不使用任何讀/寫作業系統API。
- 警告:ReadLocks是重入的並允許併發訪問,但在一個ReadLock內或另一個WriteLock內呼叫WriteLock會導致死鎖。
- 如果您需要一個可升級的鎖,請考慮使用TRWLock。
- 輕量級鎖預計將只持有非常短的時間:如果鎖可能會阻塞太長時間,請使用TSynLocker或TRTLCriticalSection。
- 使用多個輕量級鎖,每個鎖保護幾個變數(例如一個列表),可能比使用更全域性的TRTLCriticalSection/TRWLock更有效。
- 在32位CPU上僅佔用4個位元組,在64位CPU上佔用8個位元組。
TRWLightLock = record
procedure ReadLock;
function TryReadLock: boolean;
procedure ReadUnLock;
procedure WriteLock;
function TryWriteLock: boolean;
procedure WriteUnLock;
end;
type
TRWLockContext = (cReadOnly, cReadWrite, cWrite);
一個輕量級的、支援多個讀取/排他寫入的、重入的鎖。
- 在旋轉一段時間後會呼叫SwitchToThread,但不使用任何讀/寫作業系統API。
- 鎖預計將只持有非常短的時間:如果鎖可能會阻塞太長時間,請使用TSynLocker或TRTLCriticalSection。
- 警告:所有方法都是重入的,但如果在ReadOnlyLock之後呼叫WriteLock/ReadWriteLock,則會導致死鎖。
TRWLock = record
procedure ReadOnlyLock;
procedure ReadOnlyUnLock;
procedure ReadWriteLock;
procedure ReadWriteUnLock;
procedure WriteLock;
procedure WriteUnlock;
procedure Lock(context: TRWLockContext {$ifndef PUREMORMOT2} = cWrite {$endif});
procedure UnLock(context: TRWLockContext {$ifndef PUREMORMOT2} = cWrite {$endif});
end;
TLightLock
是最簡單的鎖。
它會獲取一個鎖,然後在爭用時進行旋轉或休眠。但請注意,它是非重入的:如果你從同一個執行緒連續兩次呼叫Lock
,第二次Lock
將會永遠等待。因此,你必須確保你的程式碼在處理過程中不會呼叫其他可能也會呼叫Lock
的方法,否則你的執行緒將會“死鎖”。這種競態條件相對容易識別:無論處於什麼條件,它總是會阻塞並導致死鎖。為了解決這個問題,不要呼叫執行Lock
的其他方法:例如,你可以定義一些私有/受保護的LockedDoSomething
方法,這些方法不需要任何鎖,但期望在鎖內被呼叫。
TRWLightLock
和TRWLock
是支援多個讀取/排他寫入的鎖。
這是常規臨界區缺少的一個功能。你的共享資源很有可能會被頻繁讀取,而很少被修改。由於讀取操作在設計上是執行緒安全的,因此沒有必要阻止其他讀取執行緒讀取資源。只有寫入/更新資料時才應該是排他的,並防止其他執行緒訪問。這就是ReadLock
/ReadOnlyLock
和WriteLock
的用途。
TRWLock
更進一步,允許使用ReadWriteLock
而不是ReadOnlyLock
將讀鎖升級為寫鎖。ReadWriteLock
後面可以跟WriteLock
,而ReadOnlyLock
後面應該總是跟ReadOnlyUnlock
,但絕對不能跟WriteLock
,否則會導致死鎖。
最後但同樣重要的是,ReadOnlyLock
/ReadOnlyUnLock
是重入的(你可以巢狀呼叫它們),因為它們是透過計數器實現的。而TRWLock.WriteLock
是重入的,因為它會跟蹤鎖定的執行緒ID,從而檢測到巢狀呼叫,就像TRtlCriticalSection
所做的那樣。
底層細節
只是為了好玩,看看原始碼:
procedure TLightLock.LockSpin;
var
spin: PtrUInt;
begin
spin := SPIN_COUNT;
repeat
spin := DoSpin(spin);
until LockedExc(Flags, 1, 0);
end;
procedure TLightLock.Lock;
begin
// 我們嘗試了一個專用的asm,但它更慢:內聯是首選
if not LockedExc(Flags, 1, 0) then
LockSpin;
end;
function TLightLock.TryLock: boolean;
begin
result := LockedExc(Flags, 1, 0);
end;
procedure TLightLock.UnLock;
begin
Flags := 0; // 非重入鎖不需要額外的執行緒安全性
end;
TLightLock
相當直接,使用了簡單的CAS(比較並交換)LockedExc()
原子函式,但TRWLightLock
和TRWLock
稍微複雜一些。
在mORMot 2程式碼庫中,我們嘗試使用盡可能好的鎖。當鎖可能在一段時間內(超過微秒)存在爭用時,我們使用TRtlCriticalSection
/TSynLock
,而其他鎖(如果可能的話,使用多個讀取/排他寫入方法)則用於保護非常小的調優程式碼。
當然,執行緒安全性在迴歸測試期間進行了測試,有數十個併發執行緒試圖打破鎖的邏輯。我可以告訴你,我們在TAsyncServer
的初始程式碼中發現了一些棘手的問題,但經過幾天的除錯和日誌記錄,它現在聽起來很穩定——但這是另一篇文章要討論的問題了!😃