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的临界区雷同:
https://img2023.cnblogs.com/blog/370256/202405/370256-20240527064054965-513936096.png
#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的初始代码中发现了一些棘手的问题,但经过几天的调试和日志记录,它现在听起来很稳定——但这是另一篇文章要讨论的问题了!
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
页:
[1]