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
的初始代码中发现了一些棘手的问题,但经过几天的调试和日志记录,它现在听起来很稳定——但这是另一篇文章要讨论的问题了!