当前位置: 代码网 > it编程>编程语言>Delphi > Three Locks To Rule Them All(三把锁统治一切)

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

2024年05月27日 Delphi 我要评论
Three Locks To Rule Them All(三把锁统治一切) 【英文原文】 为了确保线程安全,特别是在服务器端,我们通常使用临界区(critical sections)或锁(locks)来保护代码。在最近的Delphi版本中,我们引入了TMonitor特性,但我更倾向于信任操作系统提供 ...

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

(0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2026  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com