问题 使用关键部分避免Delphi中的缓存一致性问题?


我刚读了一篇MSDN文章, “同步和多处理器问题”,解决多处理器计算机上的内存缓存一致性问题。这真的让我大开眼界,因为我不会想到他们提供的例子中会有竞争条件。本文解释了对内存的写入可能实际上并不是按照我的代码中编写的顺序发生的(从其他cpu的角度来看)。这对我来说是个新概念!

本文提供了2种解决方案:

  1. 在需要跨多个cpus的缓存一致性的变量上使用“volatile”关键字。这是一个C / C ++关键字,在Delphi中不可用。
  2. 使用InterlockExchange()和InterlockCompareExchange()。如果必须的话,我可以在德尔福做这件事。它看起来有点乱。

文章还提到“以下同步函数使用适当的障碍来确保内存排序:•进入或离开关键部分的函数”。

这是我不明白的部分。这是否意味着对内存的任何写入限于使用关键部分的函数都不受缓存一致性和内存排序问题的影响?我没有任何反对Interlock *()函数,但我的工具带中的另一个工具将是很好的!


10030
2017-08-28 19:16


起源



答案:


首先,根据语言标准,volatile不会像文章所说的那样做。 volatile的获取和释放语义是MSVC特定的。如果您使用其他编译器或其他平台进行编译,则可能会出现问题。 C ++ 11引入了语言支持的原子变量,希望在适当的时候最终终止(错误地)使用volatile作为线程构造。

确实实现了关键部分和互斥锁,以便从所有线程中正确地看到受保护变量的读写。

我认为考虑关键部分和互斥锁(锁)的最佳方式是实现序列化的设备。也就是说,由这种锁保护的代码块是一个接一个地连续执行而没有重叠。序列化也适用于内存访问。由于高速缓存一致性或读/写重新排序,不存在任何问题。

使用存储器总线上的基于硬件的锁实现互锁功能。这些函数由无锁算法使用。这意味着他们不使用像重要部分那样的重型锁,而是使用这些重量轻的硬件锁。

无锁算法比基于锁的算法更有效,但无锁算法可能非常难以正确编写。除非性能影响是可辨别的,否则优先选择关键部分而不是锁定。

另一篇值得一读的文章是 “双重检查锁定”声明


7
2017-08-28 20:24



大卫,经常引用布尔值,整数等作为原子(如果正确对齐),因此线程安全。我认为这里接受的答案 stackoverflow.com/questions/510031/... 从正确的角度来看。引用“读取是线程安全的。写入不是线程安全的。” - LU RD
@LU RD你需要准确。线程安全是什么意思?确切地说,“读取是线程安全的,写入不是线程安全的”是什么意思? - David Heffernan
这只是一个普遍的警告,不要假设人们可以考虑对所谓的原子变量进行操作,以便在所有线程上下文中使用。 - LU RD
感谢David,关于非MS C上“volatile”的信息,以及关于轻量级无锁算法的信息。但是,只是为了澄清,如果我通过一个关键部分序列化对变量的所有访问...当一个cpu上的一个线程写入一个变量时,当另一个cpu从另一个内存缓存中读取下一个线程时,它肯定会读取从原始线程写的值?一旦第一个线程写入并离开临界区,所有cpus的所有缓存都将保持一致? - Troy
是的,只要您通过相同的锁保护对读取和写入的所有内存访问。否则,锁定的意义何在? - David Heffernan


答案:


首先,根据语言标准,volatile不会像文章所说的那样做。 volatile的获取和释放语义是MSVC特定的。如果您使用其他编译器或其他平台进行编译,则可能会出现问题。 C ++ 11引入了语言支持的原子变量,希望在适当的时候最终终止(错误地)使用volatile作为线程构造。

确实实现了关键部分和互斥锁,以便从所有线程中正确地看到受保护变量的读写。

我认为考虑关键部分和互斥锁(锁)的最佳方式是实现序列化的设备。也就是说,由这种锁保护的代码块是一个接一个地连续执行而没有重叠。序列化也适用于内存访问。由于高速缓存一致性或读/写重新排序,不存在任何问题。

使用存储器总线上的基于硬件的锁实现互锁功能。这些函数由无锁算法使用。这意味着他们不使用像重要部分那样的重型锁,而是使用这些重量轻的硬件锁。

无锁算法比基于锁的算法更有效,但无锁算法可能非常难以正确编写。除非性能影响是可辨别的,否则优先选择关键部分而不是锁定。

另一篇值得一读的文章是 “双重检查锁定”声明


7
2017-08-28 20:24



大卫,经常引用布尔值,整数等作为原子(如果正确对齐),因此线程安全。我认为这里接受的答案 stackoverflow.com/questions/510031/... 从正确的角度来看。引用“读取是线程安全的。写入不是线程安全的。” - LU RD
@LU RD你需要准确。线程安全是什么意思?确切地说,“读取是线程安全的,写入不是线程安全的”是什么意思? - David Heffernan
这只是一个普遍的警告,不要假设人们可以考虑对所谓的原子变量进行操作,以便在所有线程上下文中使用。 - LU RD
感谢David,关于非MS C上“volatile”的信息,以及关于轻量级无锁算法的信息。但是,只是为了澄清,如果我通过一个关键部分序列化对变量的所有访问...当一个cpu上的一个线程写入一个变量时,当另一个cpu从另一个内存缓存中读取下一个线程时,它肯定会读取从原始线程写的值?一旦第一个线程写入并离开临界区,所有cpus的所有缓存都将保持一致? - Troy
是的,只要您通过相同的锁保护对读取和写入的所有内存访问。否则,锁定的意义何在? - David Heffernan


这MSDN文章仅仅是多线程应用程序开发的第一步:简而言之,它的意思是“保护您的共享变量与锁(又名关键部分),因为你不知道,你读/写数据都是一样的线程”。

CPU每核心缓存只是可能的问题之一,这将导致读取错误的值。可能导致竞争条件的另一个问题是两个线程同时写入资源:之后无法知道将存储哪个值。

由于代码期望数据一致,因此某些多线程程序可能表现错误。对于多线程,当您处理共享变量时,您不确定通过单个指令编写的代码是否按预期执行。

InterlockedExchange/InterlockedIncrement 函数是具有LOCK前缀的低级asm操作码(或者由设计锁定,如 XCHG EDX,[EAX] 操作码),这确实会强制所有CPU内核的高速缓存一致性,因此使asm操作码执行线程安全。

例如,下面是分配字符串值时如何实现字符串引用计数(请参阅 _LStrAsg 在System.pas中 - 这是来自 我们针对Delphi 7/2002的RTL优化版本  - 由于Delphi原始代码受版权保护):

            MOV     ECX,[EDX-skew].StrRec.refCnt
            INC     ECX   { thread-unsafe increment ECX = reference count }
            JG      @@1   { ECX=-1 -> literal string -> jump not taken }
            .....
       @@1: LOCK INC [EDX-skew].StrRec.refCnt { ATOMIC increment of reference count }
            MOV     ECX,[EAX]   
            ...

第一个有区别 INC ECX 和 LOCK INC [EDX-skew].StrRec.refCnt  - 不仅第一个增量ECX而不是引用计数变量,但第一个不是线程安全的,而第二个以LOCK为前缀因此将是线程安全的。

顺便说一句,这个LOCK前缀是问题之一 RTL中的多线程缩放  - 使用较新的CPU会更好,但仍然不完美。

因此,使用关键部分是使代码线程安全的最简单方法:

var GlobalVariable: string;
    GlobalSection: TRTLCriticalSection;

procedure TThreadOne.Execute;
var LocalVariable: string;
begin
   ...
   EnterCriticalSection(GlobalSection);
   LocalVariable := GlobalVariable+'a'; { modify GlobalVariable }
   GlobalVariable := LocalVariable;
   LeaveCriticalSection(GlobalSection);
   ....
end;

procedure TThreadTwp.Execute;
var LocalVariable: string;
begin
   ...
   EnterCriticalSection(GlobalSection);
   LocalVariable := GlobalVariable; { thread-safe read GlobalVariable }
   LeaveCriticalSection(GlobalSection);
   ....
end;

使用局部变量可使关键部分更短,因此您的应用程序将更好地扩展并充分利用CPU内核的全部功能。之间 EnterCriticalSection 和 LeaveCriticalSection,只有一个线程将运行:其他线程将等待 EnterCriticalSection 打电话......所以关键部分越短,你的申请就越快。一些错误设计的多线程应用程序实际上可能比单线程应用程序慢!

不要忘记,如果你的关键部分内的代码可能引发异常,你应该总是写一个明确的 try ... finally LeaveCriticalSection() end; 阻止保护锁定释放,并防止应用程序的任何死锁。

如果用锁(即关键部分)保护共享数据,Delphi是完全线程安全的。请注意,即使在其RTL函数中存在LOCK,即使引用计数变量(如字符串)也应受到保护:此LOCK用于假设正确的引用计数并避免内存泄漏,但它不是线程安全的。为了尽可能快, 看到这个问题

的目的 InterlockExchange 和 InterlockCompareExchange 是更改共享指针变量值。您可以将其视为访问指针值的关键部分的“轻型”版本。

在所有情况下,编写工作多线程代码并不容易 - 甚至是这样 正如Delphi专家刚刚在他的博客中写道的那样

您应该编写简单的线程而根本没有共享数据(在线程启动之前创建数据的私有副本,或者使用只读共享数据 - 本质上是线程安全的),或者调用一些设计良好且经过验证的库- 喜欢 http://otl.17slon.com  - 这将节省大量的调试时间。


7
2017-08-29 06:04



谢谢你的提示。避免共享内存和只是制作副本的非常好的提示。另外,我可以看到InterlockExchange如何帮助提高性能。我只是不想过早地优化。如果关键部分使我的代码更具可读性,那么我应该从那里开始。使用Interlock *()的MSDN文章中的示例有效,但令人困惑。我现在就去看看你的链接吧。 - Troy