Three Locks To Rule Them All(三把锁统治一切)

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的临界区类似:
img

#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很可能不会出现在你的开发电脑上,而是会在实际负载中出现。

一旦你确定了真正的瓶颈,尝试将逻辑代码拆分成小块:

  1. 确保你有针对此方法的多线程回归测试代码,以验证你的修改实际上仍然是正确的,并且...更快;
  2. 代码的部分内容可能本身就是线程安全的(例如错误检查或结果日志记录):无需使用锁来保护它;
  3. 根据共享的资源,将处理代码隔离到一些私有/受保护的方法中,并进行适当的锁定。

越少越好

最终,为了实现最佳性能:

  1. 让你的锁尽可能短。
  2. 更喜欢对小数据使用多个锁,而不是一些巨大的锁;
  3. 对每个列表或队列使用一个锁,而不是对每个进程或业务逻辑方法使用一个锁。

多种锁以统治全局

除了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方法,这些方法不需要任何锁,但期望在锁内被调用。

TRWLightLockTRWLock是支持多个读取/排他写入的锁。
这是常规临界区缺少的一个功能。你的共享资源很有可能会被频繁读取,而很少被修改。由于读取操作在设计上是线程安全的,因此没有必要阻止其他读取线程读取资源。只有写入/更新数据时才应该是排他的,并防止其他线程访问。这就是ReadLock/ReadOnlyLockWriteLock的用途。
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()原子函数,但TRWLightLockTRWLock稍微复杂一些。

在mORMot 2代码库中,我们尝试使用尽可能好的锁。当锁可能在一段时间内(超过微秒)存在争用时,我们使用TRtlCriticalSection/TSynLock,而其他锁(如果可能的话,使用多个读取/排他写入方法)则用于保护非常小的调优代码。
当然,线程安全性在回归测试期间进行了测试,有数十个并发线程试图打破锁的逻辑。我可以告诉你,我们在TAsyncServer的初始代码中发现了一些棘手的问题,但经过几天的调试和日志记录,它现在听起来很稳定——但这是另一篇文章要讨论的问题了!😃

posted @ 2024-05-26 23:33  海利鸟  阅读(22)  评论(0编辑  收藏  举报