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