11

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

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

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

文章还提到“以下同步函数使用适当的屏障来确保内存排序: • 进入或离开临界区的函数”。

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

4

2 回答 2

8

这篇 MSDN 文章只是多线程应用程序开发的第一步:简而言之,它的意思是“用锁(也称为临界区)保护您的共享变量,因为您不确定您读/写的数据是否对所有人都相同线程”。

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

由于代码期望数据是连贯的,因此某些多线程程序可能会出现错误的行为。使用多线程,当处理共享变量时,您不确定通过单独指令编写的代码是否按预期执行。

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

例如,当您分配字符串值时,这是如何实现字符串引用计数的(参见_LStrAsgSystem.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 ECXLOCK 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 是用来假设正确的引用计数并避免内存泄漏,但它不是线程安全的. 为了使其尽可能快,请参阅这个 SO question

InterlockExchangeand的目的InterlockCompareExchange是改变一个共享指针变量的值。您可以将其视为访问指针值的关键部分的“轻量级”版本。

在所有情况下,编写可工作的多线程代码都不容易——甚至更难正如 Delphi 专家刚刚在他的博客中所写的那样

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

于 2011-08-29T06:04:37.327 回答
7

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

确实实现了临界区和互斥锁,以便从所有线程中正确地看到受保护变量的读取和写入。

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

互锁功能是使用内存总线上基于硬件的锁来实现的。这些函数由无锁算法使用。这意味着他们不像临界区那样使用重量级的锁,而是使用这些轻量级的硬件锁。

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

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

于 2011-08-28T20:24:30.030 回答